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

352 lines
14 KiB
Vue
Raw Permalink 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">
<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">
최근 5 평균과 직전 5 평균을 비교하여 급격한 변화를 감지합니다.<br>
<code>변화율 = (현재구간 - 이전구간) / 이전구간 × 100</code>
</p>
<div class="pros-cons">
<div class="pros">
<h4> 장점</h4>
<ul>
<li>구현이 간단하고 직관적</li>
<li>즉각적인 변화 감지 가능</li>
<li>임계값과 무관하게 이상 탐지</li>
</ul>
</div>
<div class="cons">
<h4> 단점</h4>
<ul>
<li>정상적인 변동도 이상으로 감지될 있음</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="getChangeClass(server.cpu_change)">{{ formatChange(server.cpu_change) }}</td>
<td :class="getChangeClass(server.mem_change)">{{ formatChange(server.mem_change) }}</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">{{ formatChange(log.threshold_value) }}</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 ServerChange {
target_id: number
server_name: string
cpu_change: number | null
mem_change: number | null
status: string
}
interface AnomalyLog {
id: number
target_id: number
server_name: string
metric: string
level: string
current_value: number
threshold_value: number
message: string
detected_at: string
}
interface ChartData {
time: string
warning: number
danger: number
}
const loading = ref(false)
const infoCollapsed = ref(true)
const allServers = ref<ServerChange[]>([])
const logs = ref<AnomalyLog[]>([])
const chartRef = ref<HTMLCanvasElement | null>(null)
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/short-term')
allServers.value = statusRes.servers || []
// 로그 조회
const logRes = await $fetch(`/api/anomaly/logs?type=short-term&period=${selectedPeriod.value}`)
logs.value = logRes.logs || []
// 차트 데이터 조회
const chartRes = await $fetch(`/api/anomaly/chart?type=short-term&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: ChartData[]) {
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 getChangeClass(change: number | null): string {
if (change === null) return ''
const abs = Math.abs(change)
if (abs >= 100) return 'change-danger'
if (abs >= 50) return 'change-warning'
if (abs >= 30) return 'change-caution'
return ''
}
function formatChange(change: number | null): string {
if (change === null) return '-'
const sign = change >= 0 ? '+' : ''
return `${sign}${change.toFixed(0)}%`
}
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); }
.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); }
.pros li, .cons li { margin-bottom: 2px; }
/* 조회기간 + 차트/테이블 */
.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 280px; 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: 100px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.change-danger { color: #dc2626 !important; font-weight: 600; }
.change-warning { color: #ea580c !important; font-weight: 500; }
.change-caution { color: #ca8a04 !important; }
.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; }
/* 로그 섹션 */
.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>