Files
system-monitor/frontend/anomaly/baseline.vue
2025-12-28 17:08:12 +09:00

318 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="app-layout">
<SidebarNav />
<div class="main-content">
<header class="main-header">
<h1 class="page-title">🕐 시간대별 베이스라인</h1>
<div class="header-actions">
<span class="context-badge">{{ context.day_type_label }} {{ context.current_hour }}</span>
<button class="btn btn-primary" @click="refresh" :disabled="loading">
{{ loading ? '분석 중...' : '🔄 새로고침' }}
</button>
</div>
</header>
<main class="main-body">
<!-- 접히는 설명 카드 -->
<div class="info-card collapsible" :class="{ collapsed: infoCollapsed }">
<div class="info-header" @click="infoCollapsed = !infoCollapsed">
<h3>📖 시간대별 베이스라인이란?</h3>
<span class="collapse-icon">{{ infoCollapsed ? '▼' : '▲' }}</span>
</div>
<div class="info-content" v-show="!infoCollapsed">
<p class="desc">
과거 2주간 동일 시간대(평일/주말 구분) 데이터를 기반으로 베이스라인을 구축하고, 현재 값이 정상 범위를 벗어났는지 감지합니다.<br>
<code>편차 = (현재값 - 베이스라인평균) / 표준편차</code> | |편차| 2.0: 주의, |편차| 3.0: 위험
</p>
<div class="pros-cons">
<div class="pros">
<h4> 장점</h4>
<ul>
<li>시간대별 패턴을 반영 (업무시간 vs 야간)</li>
<li>평소보다 "낮은" 값도 감지 가능</li>
<li>배치 작업 정기 패턴 오탐 방지</li>
</ul>
</div>
<div class="cons">
<h4> 단점</h4>
<ul>
<li>최소 1~2 데이터 필요</li>
<li>운영 패턴 변경 재학습 필요</li>
<li>구현 유지보수 복잡</li>
</ul>
</div>
</div>
</div>
</div>
<!-- 조회기간 + 차트/테이블 영역 -->
<div class="content-section">
<div class="period-row">
<span class="period-label">조회 기간</span>
<div class="period-buttons">
<button
v-for="p in periods"
:key="p.value"
:class="['period-btn', { active: selectedPeriod === p.value }]"
@click="changePeriod(p.value)"
>{{ p.label }}</button>
</div>
</div>
<div class="chart-table-grid">
<div class="chart-section">
<h3>📈 이상감지 추이</h3>
<div class="chart-container">
<canvas ref="chartRef"></canvas>
</div>
</div>
<div class="table-section">
<h3>📋 현재 베이스라인 비교</h3>
<table class="data-table compact">
<thead>
<tr>
<th>서버</th>
<th>CPU (σ)</th>
<th>MEM (σ)</th>
<th>상태</th>
</tr>
</thead>
<tbody>
<tr v-for="server in allServers" :key="server.target_id">
<td class="server-name">{{ server.server_name }}</td>
<td :class="getDeviationClass(server.cpu_deviation)">{{ formatDeviation(server.cpu_current, server.cpu_deviation) }}</td>
<td :class="getDeviationClass(server.mem_deviation)">{{ formatDeviation(server.mem_current, server.mem_deviation) }}</td>
<td><span :class="['status-dot', server.status]"></span></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- 이상감지 로그 -->
<div class="log-section">
<div class="log-header">
<h3>📜 이상감지 로그</h3>
<span class="log-count" v-if="logs.length">{{ logs.length }}</span>
</div>
<div v-if="logs.length === 0" class="no-logs">
이상감지 기록이 없습니다
</div>
<div v-else class="log-list">
<div
v-for="log in logs"
:key="log.id"
:class="['log-item', log.level]"
>
<span class="log-time">{{ formatLogTime(log.detected_at) }}</span>
<span :class="['log-level', log.level]">{{ log.level === 'danger' ? '🔴' : '🟡' }}</span>
<span class="log-server">{{ log.server_name }}</span>
<span class="log-metric">{{ log.metric }}</span>
<span class="log-value">σ={{ log.threshold_value?.toFixed(1) }}</span>
<span class="log-msg">{{ log.message }}</span>
</div>
</div>
</div>
</main>
</div>
</div>
</template>
<script setup lang="ts">
import { Chart, registerables } from 'chart.js'
Chart.register(...registerables)
interface ServerBaseline {
target_id: number
server_name: string
cpu_current: number | null
mem_current: number | null
cpu_deviation: number | null
mem_deviation: number | null
status: string
}
interface AnomalyLog {
id: number
server_name: string
metric: string
level: string
threshold_value: number
message: string
detected_at: string
}
const loading = ref(false)
const infoCollapsed = ref(true)
const allServers = ref<ServerBaseline[]>([])
const logs = ref<AnomalyLog[]>([])
const chartRef = ref<HTMLCanvasElement | null>(null)
const context = ref({ current_hour: 0, day_type: 'weekday', day_type_label: '평일' })
let chart: Chart | null = null
const selectedPeriod = ref('24h')
const periods = [
{ value: '1h', label: '1시간' },
{ value: '6h', label: '6시간' },
{ value: '12h', label: '12시간' },
{ value: '24h', label: '24시간' },
{ value: '7d', label: '7일' },
{ value: '30d', label: '30일' }
]
async function refresh() {
loading.value = true
try {
const statusRes = await $fetch('/api/anomaly/baseline')
allServers.value = statusRes.servers || []
context.value = statusRes.context || { current_hour: 0, day_type: 'weekday', day_type_label: '평일' }
const logRes = await $fetch(`/api/anomaly/logs?type=baseline&period=${selectedPeriod.value}`)
logs.value = logRes.logs || []
const chartRes = await $fetch(`/api/anomaly/chart?type=baseline&period=${selectedPeriod.value}`)
updateChart(chartRes.data || [])
} catch (e) {
console.error('Failed to fetch data:', e)
} finally {
loading.value = false
}
}
function changePeriod(period: string) {
selectedPeriod.value = period
refresh()
}
function updateChart(data: any[]) {
if (!chartRef.value) return
if (chart) chart.destroy()
chart = new Chart(chartRef.value, {
type: 'bar',
data: {
labels: data.map(d => d.time),
datasets: [
{ label: 'Warning', data: data.map(d => d.warning), backgroundColor: '#fbbf24', borderRadius: 4 },
{ label: 'Danger', data: data.map(d => d.danger), backgroundColor: '#ef4444', borderRadius: 4 }
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { position: 'top', labels: { boxWidth: 12, font: { size: 11 } } } },
scales: {
x: { stacked: true, grid: { display: false }, ticks: { font: { size: 10 } } },
y: { stacked: true, beginAtZero: true, ticks: { stepSize: 1, font: { size: 10 } } }
}
}
})
}
function getDeviationClass(dev: number | null): string {
if (dev === null) return ''
const abs = Math.abs(dev)
if (abs >= 3) return 'dev-danger'
if (abs >= 2) return 'dev-warning'
return ''
}
function formatDeviation(current: number | null, dev: number | null): string {
if (current === null || dev === null) return '-'
return `${current.toFixed(0)}% (${dev >= 0 ? '+' : ''}${dev.toFixed(1)})`
}
function formatLogTime(dateStr: string): string {
const date = new Date(dateStr)
return date.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
onMounted(() => { refresh() })
onUnmounted(() => { if (chart) chart.destroy() })
</script>
<style scoped>
.app-layout { display: flex; height: 100vh; background: var(--bg-primary); }
.main-content { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.main-header { display: flex; justify-content: space-between; align-items: center; padding: 16px 24px; border-bottom: 1px solid var(--border-color); background: var(--bg-secondary); }
.page-title { margin: 0; font-size: 20px; font-weight: 600; color: var(--text-primary); }
.header-actions { display: flex; align-items: center; gap: 12px; }
.context-badge { background: #dbeafe; color: #1e40af; padding: 6px 12px; border-radius: 6px; font-size: 13px; font-weight: 500; }
.main-body { flex: 1; padding: 20px 24px; overflow-y: auto; display: flex; flex-direction: column; gap: 16px; }
.btn { padding: 8px 16px; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; }
.btn-primary { background: #3b82f6; color: white; }
.btn-primary:hover { background: #2563eb; }
.btn-primary:disabled { background: #93c5fd; cursor: not-allowed; }
.info-card { background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 12px; overflow: hidden; }
.info-header { display: flex; justify-content: space-between; align-items: center; padding: 14px 20px; cursor: pointer; }
.info-header:hover { background: var(--bg-tertiary); }
.info-header h3 { margin: 0; font-size: 15px; color: var(--text-primary); }
.collapse-icon { color: var(--text-muted); font-size: 12px; }
.info-content { padding: 0 20px 16px; }
.info-content .desc { color: var(--text-secondary); line-height: 1.6; margin-bottom: 12px; font-size: 13px; }
.info-content code { background: var(--bg-tertiary); padding: 2px 6px; border-radius: 4px; font-size: 12px; }
.pros-cons { display: flex; gap: 16px; }
.pros, .cons { flex: 1; padding: 12px; border-radius: 8px; }
.pros { background: var(--container-normal-bg, #f0fdf4); border: 1px solid var(--container-normal-border, #86efac); }
.cons { background: var(--container-danger-bg, #fef2f2); border: 1px solid var(--container-danger-border, #fca5a5); }
.pros h4, .cons h4 { margin: 0 0 8px 0; font-size: 13px; }
.pros ul, .cons ul { margin: 0; padding-left: 18px; font-size: 12px; color: var(--text-secondary); }
.content-section { background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 12px; padding: 16px; }
.period-row { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; padding-bottom: 12px; border-bottom: 1px solid var(--border-color); }
.period-label { font-size: 13px; font-weight: 500; color: var(--text-muted); }
.period-buttons { display: flex; gap: 6px; }
.period-btn { padding: 6px 12px; border: 1px solid var(--border-color); border-radius: 6px; background: var(--bg-primary); color: var(--text-secondary); font-size: 12px; cursor: pointer; }
.period-btn:hover { background: var(--bg-tertiary); }
.period-btn.active { background: #3b82f6; color: white; border-color: #3b82f6; }
.chart-table-grid { display: grid; grid-template-columns: 1fr 300px; gap: 16px; }
.chart-section, .table-section { background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 8px; padding: 12px; }
.chart-section h3, .table-section h3 { margin: 0 0 12px 0; font-size: 14px; color: var(--text-primary); }
.chart-container { height: 200px; }
.data-table.compact { width: 100%; border-collapse: collapse; font-size: 12px; }
.data-table.compact th, .data-table.compact td { padding: 8px 6px; text-align: left; border-bottom: 1px solid var(--border-color); }
.data-table.compact th { font-size: 11px; font-weight: 600; color: var(--text-muted); background: var(--bg-tertiary); }
.data-table.compact .server-name { font-weight: 500; max-width: 90px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dev-warning { color: #ca8a04 !important; font-weight: 500; }
.dev-danger { color: #dc2626 !important; font-weight: 600; }
.status-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; }
.status-dot.normal { background: #22c55e; }
.status-dot.warning { background: #f59e0b; }
.status-dot.danger { background: #ef4444; }
.status-dot.insufficient { background: #9ca3af; }
.log-section { background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 12px; padding: 16px; flex: 1; min-height: 200px; }
.log-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.log-header h3 { margin: 0; font-size: 14px; color: var(--text-primary); }
.log-count { font-size: 12px; color: var(--text-muted); background: var(--bg-tertiary); padding: 2px 8px; border-radius: 10px; }
.no-logs { text-align: center; padding: 40px; color: var(--text-muted); font-size: 13px; }
.log-list { max-height: 300px; overflow-y: auto; }
.log-item { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 6px; margin-bottom: 4px; font-size: 12px; }
.log-item.warning { background: var(--log-warning-bg, #fefce8); }
.log-item.danger { background: var(--log-danger-bg, #fef2f2); }
.log-time { color: var(--text-muted); font-family: monospace; min-width: 70px; }
.log-level { font-size: 14px; }
.log-server { font-weight: 600; color: var(--text-primary); min-width: 80px; }
.log-metric { color: var(--text-secondary); min-width: 50px; }
.log-value { font-weight: 600; min-width: 50px; }
.log-item.warning .log-value { color: var(--log-warning-text, #ca8a04); }
.log-item.danger .log-value { color: var(--log-danger-text, #dc2626); }
.log-msg { color: var(--text-muted); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
</style>