시스템 모니터
This commit is contained in:
317
frontend/anomaly/baseline.vue
Normal file
317
frontend/anomaly/baseline.vue
Normal file
@@ -0,0 +1,317 @@
|
||||
<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: #f0fdf4; border: 1px solid #86efac; }
|
||||
.cons { background: #fef2f2; border: 1px solid #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: #fefce8; }
|
||||
.log-item.danger { background: #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: #ca8a04; }
|
||||
.log-item.danger .log-value { color: #dc2626; }
|
||||
.log-msg { color: var(--text-muted); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
</style>
|
||||
351
frontend/anomaly/short-term.vue
Normal file
351
frontend/anomaly/short-term.vue
Normal file
@@ -0,0 +1,351 @@
|
||||
<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: #f0fdf4; border: 1px solid #86efac; }
|
||||
.cons { background: #fef2f2; border: 1px solid #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: #fefce8; }
|
||||
.log-item.danger { background: #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: #ca8a04; }
|
||||
.log-item.danger .log-value { color: #dc2626; }
|
||||
.log-msg { color: var(--text-muted); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
</style>
|
||||
318
frontend/anomaly/trend.vue
Normal file
318
frontend/anomaly/trend.vue
Normal file
@@ -0,0 +1,318 @@
|
||||
<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">
|
||||
최근 30분간 데이터의 선형 회귀(Linear Regression) 기울기를 분석하여 지속적인 증가/감소 추세를 감지합니다.<br>
|
||||
<code>기울기 = 분당 변화율 (%/min)</code> | R² ≥ 0.3 이면 유의미한 추세 | 기울기 ≥ 0.5%/min: 상승 추세
|
||||
</p>
|
||||
<div class="pros-cons">
|
||||
<div class="pros">
|
||||
<h4>✅ 장점</h4>
|
||||
<ul>
|
||||
<li>임계값 도달 전 사전 경고 가능</li>
|
||||
<li>점진적 리소스 누수 감지에 효과적</li>
|
||||
<li>R²로 추세의 신뢰도 평가 가능</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="getTrendClass(server.cpu_trend)">{{ formatTrend(server.cpu_current, server.cpu_slope, server.cpu_trend) }}</td>
|
||||
<td :class="getTrendClass(server.mem_trend)">{{ formatTrend(server.mem_current, server.mem_slope, server.mem_trend) }}</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(2) }}/분</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 ServerTrend {
|
||||
target_id: number
|
||||
server_name: string
|
||||
cpu_current: number | null
|
||||
mem_current: number | null
|
||||
cpu_slope: number | null
|
||||
mem_slope: number | null
|
||||
cpu_trend: string | null
|
||||
mem_trend: string | 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<ServerTrend[]>([])
|
||||
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/trend')
|
||||
allServers.value = statusRes.servers || []
|
||||
|
||||
const logRes = await $fetch(`/api/anomaly/logs?type=trend&period=${selectedPeriod.value}`)
|
||||
logs.value = logRes.logs || []
|
||||
|
||||
const chartRes = await $fetch(`/api/anomaly/chart?type=trend&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 getTrendClass(trend: string | null): string {
|
||||
if (!trend) return ''
|
||||
if (trend === 'rising') return 'trend-rising'
|
||||
if (trend === 'falling') return 'trend-falling'
|
||||
if (trend === 'unstable') return 'trend-unstable'
|
||||
return ''
|
||||
}
|
||||
|
||||
function formatTrend(current: number | null, slope: number | null, trend: string | null): string {
|
||||
if (current === null || slope === null || !trend) return '-'
|
||||
if (trend === 'unstable') return `${current.toFixed(0)}% (불안정)`
|
||||
const icon = slope >= 0 ? '↑' : '↓'
|
||||
return `${current.toFixed(0)}% ${icon}${Math.abs(slope).toFixed(2)}/분`
|
||||
}
|
||||
|
||||
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; }
|
||||
.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: #f0fdf4; border: 1px solid #86efac; }
|
||||
.cons { background: #fef2f2; border: 1px solid #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 320px; 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; }
|
||||
|
||||
.trend-rising { color: #dc2626 !important; font-weight: 600; }
|
||||
.trend-falling { color: #16a34a !important; font-weight: 500; }
|
||||
.trend-unstable { color: #9ca3af !important; font-style: italic; }
|
||||
|
||||
.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: #fefce8; }
|
||||
.log-item.danger { background: #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: 70px; }
|
||||
.log-item.warning .log-value { color: #ca8a04; }
|
||||
.log-item.danger .log-value { color: #dc2626; }
|
||||
.log-msg { color: var(--text-muted); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
</style>
|
||||
312
frontend/anomaly/zscore.vue
Normal file
312
frontend/anomaly/zscore.vue
Normal file
@@ -0,0 +1,312 @@
|
||||
<template>
|
||||
<div class="app-layout">
|
||||
<SidebarNav />
|
||||
<div class="main-content">
|
||||
<header class="main-header">
|
||||
<h1 class="page-title">📊 Z-Score 분석</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>📖 Z-Score 분석이란?</h3>
|
||||
<span class="collapse-icon">{{ infoCollapsed ? '▼' : '▲' }}</span>
|
||||
</div>
|
||||
<div class="info-content" v-show="!infoCollapsed">
|
||||
<p class="desc">
|
||||
최근 1시간의 평균과 표준편차를 기반으로, 현재 값이 정상 범위에서 얼마나 벗어났는지 측정합니다.<br>
|
||||
<code>Z-Score = (현재값 - 평균) / 표준편차</code> | |Z| ≥ 2.0: 주의, |Z| ≥ 3.0: 위험
|
||||
</p>
|
||||
<div class="pros-cons">
|
||||
<div class="pros">
|
||||
<h4>✅ 장점</h4>
|
||||
<ul>
|
||||
<li>통계적 근거가 있는 이상 탐지</li>
|
||||
<li>서버별 특성(평소 사용량)을 자동 반영</li>
|
||||
<li>|Z| > 2 면 이상, |Z| > 3 이면 심각 등 기준 명확</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="cons">
|
||||
<h4>❌ 단점</h4>
|
||||
<ul>
|
||||
<li>충분한 히스토리 데이터 필요 (최소 1시간)</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>📋 현재 서버 Z-Score</h3>
|
||||
<table class="data-table compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>서버</th>
|
||||
<th>CPU (Z)</th>
|
||||
<th>MEM (Z)</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="getZscoreClass(server.cpu_zscore)">{{ formatZscore(server.cpu_current, server.cpu_zscore) }}</td>
|
||||
<td :class="getZscoreClass(server.mem_zscore)">{{ formatZscore(server.mem_current, server.mem_zscore) }}</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">Z={{ log.threshold_value?.toFixed(2) }}</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 ServerZscore {
|
||||
target_id: number
|
||||
server_name: string
|
||||
cpu_current: number
|
||||
mem_current: number
|
||||
cpu_zscore: number | null
|
||||
mem_zscore: 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<ServerZscore[]>([])
|
||||
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/zscore')
|
||||
allServers.value = statusRes.servers || []
|
||||
|
||||
const logRes = await $fetch(`/api/anomaly/logs?type=zscore&period=${selectedPeriod.value}`)
|
||||
logs.value = logRes.logs || []
|
||||
|
||||
const chartRes = await $fetch(`/api/anomaly/chart?type=zscore&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 getZscoreClass(z: number | null): string {
|
||||
if (z === null) return ''
|
||||
const abs = Math.abs(z)
|
||||
if (abs >= 3) return 'zscore-danger'
|
||||
if (abs >= 2) return 'zscore-warning'
|
||||
return ''
|
||||
}
|
||||
|
||||
function formatZscore(current: number | null, z: number | null): string {
|
||||
if (current === null || z === null) return '-'
|
||||
return `${current.toFixed(0)}% (${z >= 0 ? '+' : ''}${z.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); }
|
||||
.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: #f0fdf4; border: 1px solid #86efac; }
|
||||
.cons { background: #fef2f2; border: 1px solid #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; }
|
||||
|
||||
.zscore-warning { color: #ca8a04 !important; font-weight: 500; }
|
||||
.zscore-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: #fefce8; }
|
||||
.log-item.danger { background: #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: 60px; }
|
||||
.log-item.warning .log-value { color: #ca8a04; }
|
||||
.log-item.danger .log-value { color: #dc2626; }
|
||||
.log-msg { color: var(--text-muted); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
</style>
|
||||
722
frontend/assets/css/main.css
Normal file
722
frontend/assets/css/main.css
Normal file
@@ -0,0 +1,722 @@
|
||||
/* CSS 변수 - 라이트 테마 (기본) */
|
||||
:root,
|
||||
:root[data-theme="light"] {
|
||||
--bg-primary: #f0f2f5;
|
||||
--bg-secondary: #fff;
|
||||
--bg-tertiary: #fafafa;
|
||||
--bg-hover: #e8e8e8;
|
||||
--text-primary: #1a1a1a;
|
||||
--text-secondary: #333;
|
||||
--text-muted: #666;
|
||||
--text-dim: #999;
|
||||
--border-color: #ddd;
|
||||
--border-light: #eee;
|
||||
|
||||
/* 상태 색상 */
|
||||
--success-bg: linear-gradient(135deg, #e8f5e8 0%, #fff 100%);
|
||||
--success-header: #d4edda;
|
||||
--success-border: #28a745;
|
||||
--success-text: #155724;
|
||||
|
||||
--fail-bg: linear-gradient(135deg, #f5e8e8 0%, #fff 100%);
|
||||
--fail-header: #f8d7da;
|
||||
--fail-border: #dc3545;
|
||||
--fail-text: #721c24;
|
||||
|
||||
/* 버튼 */
|
||||
--btn-active-bg: #555;
|
||||
--btn-active-border: #444;
|
||||
--btn-primary-bg: #555;
|
||||
--btn-primary-border: #444;
|
||||
--btn-primary-hover: #444;
|
||||
--btn-auto-active-bg: #1a1a1a;
|
||||
--btn-auto-active-border: #1a1a1a;
|
||||
--btn-auto-active-text: #fff;
|
||||
|
||||
/* 사이드바 */
|
||||
--sidebar-bg: #fff;
|
||||
--sidebar-active-bg: #e8e8e8;
|
||||
--sidebar-active-border: #4a90d9;
|
||||
|
||||
/* 기타 */
|
||||
--link-color: #4a90d9;
|
||||
--time-color: #4a90d9;
|
||||
--input-bg: #fff;
|
||||
|
||||
/* 테마 토글 버튼 */
|
||||
--theme-toggle-bg: #1a1a1a;
|
||||
--theme-toggle-text: #fff;
|
||||
--theme-toggle-border: #1a1a1a;
|
||||
}
|
||||
|
||||
/* 다크 테마 */
|
||||
:root[data-theme="dark"] {
|
||||
--bg-primary: #1a1a1a;
|
||||
--bg-secondary: #2d2d2d;
|
||||
--bg-tertiary: #252525;
|
||||
--bg-hover: #3a3a3a;
|
||||
--text-primary: #fff;
|
||||
--text-secondary: #e0e0e0;
|
||||
--text-muted: #b0b0b0;
|
||||
--text-dim: #909090;
|
||||
--border-color: #444;
|
||||
--border-light: #3a3a3a;
|
||||
|
||||
/* 상태 색상 */
|
||||
--success-bg: linear-gradient(135deg, #1a3d1a 0%, #2d2d2d 100%);
|
||||
--success-header: #2a4d2a;
|
||||
--success-border: #4ade80;
|
||||
--success-text: #86efac;
|
||||
|
||||
--fail-bg: linear-gradient(135deg, #3d1a1a 0%, #2d2d2d 100%);
|
||||
--fail-header: #4d2a2a;
|
||||
--fail-border: #f87171;
|
||||
--fail-text: #fca5a5;
|
||||
|
||||
/* 버튼 */
|
||||
--btn-active-bg: #555;
|
||||
--btn-active-border: #666;
|
||||
--btn-primary-bg: #555;
|
||||
--btn-primary-border: #666;
|
||||
--btn-primary-hover: #666;
|
||||
--btn-auto-active-bg: #fff;
|
||||
--btn-auto-active-border: #fff;
|
||||
--btn-auto-active-text: #1a1a1a;
|
||||
|
||||
/* 사이드바 */
|
||||
--sidebar-bg: #2d2d2d;
|
||||
--sidebar-active-bg: #3a3a3a;
|
||||
--sidebar-active-border: #6b9dc4;
|
||||
|
||||
/* 기타 */
|
||||
--link-color: #6b9dc4;
|
||||
--time-color: #6b9dc4;
|
||||
--input-bg: #1a1a1a;
|
||||
|
||||
/* 테마 토글 버튼 */
|
||||
--theme-toggle-bg: #fff;
|
||||
--theme-toggle-text: #1a1a1a;
|
||||
--theme-toggle-border: #fff;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-primary);
|
||||
transition: background 0.3s, color 0.3s;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--link-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 레이아웃 */
|
||||
.app-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* 사이드바 */
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
background: var(--sidebar-bg);
|
||||
border-right: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 20px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sidebar-logo .logo-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: block;
|
||||
padding: 10px 20px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: var(--sidebar-active-bg);
|
||||
color: var(--text-primary);
|
||||
border-left: 3px solid var(--sidebar-active-border);
|
||||
}
|
||||
|
||||
.nav-item .icon {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.nav-group-title {
|
||||
padding: 16px 20px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.nav-sub-item {
|
||||
padding-left: 40px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 메인 컨텐츠 */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
background: var(--bg-primary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.main-header {
|
||||
background: var(--bg-secondary);
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.header-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.current-time {
|
||||
font-family: 'Rajdhani', sans-serif;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--theme-toggle-border);
|
||||
background: var(--theme-toggle-bg);
|
||||
color: var(--theme-toggle-text);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.main-body {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 컨트롤 영역 */
|
||||
.control-panel {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 24px;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.control-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.control-row + .control-row {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.control-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.interval-buttons {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.interval-btn {
|
||||
padding: 6px 14px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.interval-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.interval-btn.active {
|
||||
background: var(--btn-active-bg);
|
||||
color: #fff;
|
||||
border-color: var(--btn-active-border);
|
||||
}
|
||||
|
||||
.control-divider {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
padding: 6px 16px;
|
||||
border: 1px solid var(--btn-primary-border);
|
||||
background: var(--btn-primary-bg);
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.refresh-btn:hover {
|
||||
background: var(--btn-primary-hover);
|
||||
}
|
||||
|
||||
.auto-refresh-btn {
|
||||
padding: 6px 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.auto-refresh-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.auto-refresh-btn.active {
|
||||
background: var(--btn-auto-active-bg);
|
||||
color: var(--btn-auto-active-text);
|
||||
border-color: var(--btn-auto-active-border);
|
||||
}
|
||||
|
||||
.datetime-input {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--input-bg);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
transition: background 0.3s, border 0.3s;
|
||||
}
|
||||
|
||||
.datetime-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--link-color);
|
||||
}
|
||||
|
||||
.fetch-btn {
|
||||
padding: 6px 16px;
|
||||
border: 1px solid var(--btn-primary-border);
|
||||
background: var(--btn-primary-bg);
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.fetch-btn:hover {
|
||||
background: var(--btn-primary-hover);
|
||||
}
|
||||
|
||||
.last-fetch-info {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.last-fetch-time {
|
||||
color: var(--text-muted);
|
||||
font-family: 'SF Mono', Monaco, 'Courier New', monospace;
|
||||
}
|
||||
|
||||
/* 포틀릿 그리드 */
|
||||
.portlet-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* 포틀릿 */
|
||||
.portlet {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.portlet.status-success {
|
||||
border-color: var(--success-border);
|
||||
background: var(--success-bg);
|
||||
}
|
||||
|
||||
.portlet.status-success .portlet-header {
|
||||
background: var(--success-header);
|
||||
border-bottom-color: var(--success-border);
|
||||
}
|
||||
|
||||
.portlet.status-fail {
|
||||
border-color: var(--fail-border);
|
||||
background: var(--fail-bg);
|
||||
}
|
||||
|
||||
.portlet.status-fail .portlet-header {
|
||||
background: var(--fail-header);
|
||||
border-bottom-color: var(--fail-border);
|
||||
}
|
||||
|
||||
.portlet-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-tertiary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.portlet-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.portlet-title .icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.portlet-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.portlet-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.main-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.main-status .datetime {
|
||||
color: var(--text-muted);
|
||||
font-family: 'SF Mono', Monaco, 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.main-status .status-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.main-status .target-name {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--border-color);
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.log-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
font-size: 13px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.log-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.log-item .datetime {
|
||||
color: var(--text-muted);
|
||||
font-family: 'SF Mono', Monaco, 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.log-item .status-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.log-item .target-name {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 연결 상태 */
|
||||
.connection-status {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
padding: 8px 16px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
color: var(--fail-text);
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.connection-status.connected {
|
||||
color: var(--success-text);
|
||||
border-color: var(--success-border);
|
||||
}
|
||||
|
||||
/* 목록 페이지 */
|
||||
.list-page {
|
||||
min-height: 100vh;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
background: var(--bg-secondary);
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.page-main {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.status-card {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.status-item .label {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-item .value {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.status-item .value.active { color: var(--success-text); }
|
||||
.status-item .value.inactive { color: var(--fail-text); }
|
||||
.status-item .value.healthy { color: var(--success-text); }
|
||||
.status-item .value.unhealthy { color: var(--fail-text); }
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
th {
|
||||
background: var(--bg-tertiary);
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.url-cell {
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.error-cell {
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--fail-text);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.sidebar-logo span,
|
||||
.nav-item span:not(.icon),
|
||||
.nav-group-title {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nav-item .icon {
|
||||
margin-right: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.portlet-grid {
|
||||
grid-template-columns: 1fr;
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
213
frontend/components/DashboardControl.vue
Normal file
213
frontend/components/DashboardControl.vue
Normal file
@@ -0,0 +1,213 @@
|
||||
<template>
|
||||
<div class="control-panel">
|
||||
<div class="control-row">
|
||||
<span class="control-label">조회 간격:</span>
|
||||
<div class="interval-buttons">
|
||||
<button
|
||||
v-for="min in [1, 2, 3, 4, 5]"
|
||||
:key="min"
|
||||
:class="['interval-btn', { active: autoRefresh && interval === min }]"
|
||||
@click="selectInterval(min)"
|
||||
>
|
||||
{{ min }}분
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="control-divider"></div>
|
||||
|
||||
<button
|
||||
:class="['auto-refresh-btn', { active: autoRefresh }]"
|
||||
@click="toggleAutoRefresh"
|
||||
>
|
||||
{{ autoRefresh ? '⏸ 자동갱신 ON' : '▶ 자동갱신 OFF' }}
|
||||
</button>
|
||||
|
||||
<div class="last-fetch-info">
|
||||
마지막 조회: <span class="last-fetch-time">{{ relativeTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-row">
|
||||
<span class="control-label">특정시간:</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
v-model="selectedDatetime"
|
||||
class="datetime-input"
|
||||
:disabled="autoRefresh || fetchState !== 'idle'"
|
||||
:class="{ disabled: autoRefresh || fetchState !== 'idle' }"
|
||||
/>
|
||||
<button
|
||||
class="refresh-btn"
|
||||
:disabled="autoRefresh || fetchState !== 'idle'"
|
||||
:class="{
|
||||
disabled: autoRefresh,
|
||||
loading: fetchState === 'loading',
|
||||
success: fetchState === 'success'
|
||||
}"
|
||||
@click="doRefresh"
|
||||
>
|
||||
<span v-if="fetchState === 'loading'" class="loading-spinner"></span>
|
||||
{{ buttonText }}
|
||||
</button>
|
||||
<span v-if="autoRefresh" class="hint-text">자동갱신 OFF 시 특정시간 조회 가능</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
interval: number
|
||||
autoRefresh: boolean
|
||||
lastFetchTime: Date | null
|
||||
fetchState: 'idle' | 'loading' | 'success'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:interval', value: number): void
|
||||
(e: 'update:autoRefresh', value: boolean): void
|
||||
(e: 'fetchAt', datetime: string): void
|
||||
(e: 'refresh'): void
|
||||
}>()
|
||||
|
||||
// props destructure for template
|
||||
const { interval, autoRefresh, fetchState } = toRefs(props)
|
||||
|
||||
const selectedDatetime = ref('')
|
||||
const relativeTime = ref('-')
|
||||
|
||||
const buttonText = computed(() => {
|
||||
switch (props.fetchState) {
|
||||
case 'loading': return '조회 중...'
|
||||
case 'success': return '✓ 완료'
|
||||
default: return '조회'
|
||||
}
|
||||
})
|
||||
|
||||
function getCurrentDatetimeLocal(): string {
|
||||
const now = new Date()
|
||||
const y = now.getFullYear()
|
||||
const m = String(now.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(now.getDate()).padStart(2, '0')
|
||||
const h = String(now.getHours()).padStart(2, '0')
|
||||
const min = String(now.getMinutes()).padStart(2, '0')
|
||||
return `${y}-${m}-${d}T${h}:${min}`
|
||||
}
|
||||
|
||||
function getRelativeTime(date: Date | null): string {
|
||||
if (!date) return '-'
|
||||
|
||||
const now = new Date()
|
||||
const diff = Math.floor((now.getTime() - date.getTime()) / 1000)
|
||||
|
||||
if (diff < 5) return '방금 전'
|
||||
if (diff < 60) return `${diff}초 전`
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}분 전`
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}시간 전`
|
||||
return `${Math.floor(diff / 86400)}일 전`
|
||||
}
|
||||
|
||||
function updateRelativeTime() {
|
||||
relativeTime.value = getRelativeTime(props.lastFetchTime)
|
||||
}
|
||||
|
||||
let timeTimer: ReturnType<typeof window.setInterval> | null = null
|
||||
|
||||
onMounted(() => {
|
||||
updateRelativeTime()
|
||||
timeTimer = window.setInterval(updateRelativeTime, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timeTimer) {
|
||||
window.clearInterval(timeTimer)
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.lastFetchTime, () => {
|
||||
updateRelativeTime()
|
||||
})
|
||||
|
||||
// 조회간격 선택 → 자동갱신 ON + 특정시간 초기화
|
||||
function selectInterval(min: number) {
|
||||
selectedDatetime.value = ''
|
||||
emit('update:interval', min)
|
||||
if (!props.autoRefresh) {
|
||||
emit('update:autoRefresh', true)
|
||||
}
|
||||
emit('refresh')
|
||||
}
|
||||
|
||||
function toggleAutoRefresh() {
|
||||
const newValue = !props.autoRefresh
|
||||
emit('update:autoRefresh', newValue)
|
||||
|
||||
if (newValue) {
|
||||
// 자동갱신 ON 시 특정시간 초기화 + 즉시 조회
|
||||
selectedDatetime.value = ''
|
||||
emit('refresh')
|
||||
} else {
|
||||
// 자동갱신 OFF 시 현재시간을 기본값으로 설정
|
||||
selectedDatetime.value = getCurrentDatetimeLocal()
|
||||
}
|
||||
}
|
||||
|
||||
function doRefresh() {
|
||||
// 자동갱신 OFF일 때만 동작
|
||||
if (props.autoRefresh || props.fetchState !== 'idle') return
|
||||
|
||||
// 특정시간 입력된 경우 → 해당 시간 조회
|
||||
if (selectedDatetime.value) {
|
||||
emit('fetchAt', selectedDatetime.value.replace('T', ' '))
|
||||
} else {
|
||||
// 특정시간 미입력 → 현재 시점 조회
|
||||
emit('refresh')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.datetime-input.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.refresh-btn.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.refresh-btn.loading {
|
||||
cursor: wait;
|
||||
background: var(--btn-primary-bg);
|
||||
}
|
||||
|
||||
.refresh-btn.success {
|
||||
background: var(--success-border);
|
||||
border-color: var(--success-border);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid #fff;
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
margin-left: 8px;
|
||||
}
|
||||
</style>
|
||||
84
frontend/components/NetworkPortlet.vue
Normal file
84
frontend/components/NetworkPortlet.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<div :class="['network-card', statusClass]" @click="goToList">
|
||||
<div class="card-icon">{{ icon }}</div>
|
||||
<div class="card-title">{{ title }}</div>
|
||||
<div class="card-status" v-if="status && status.last_checked_at">
|
||||
<span class="status-icon">{{ status.is_healthy ? '✅' : '❌' }}</span>
|
||||
<span class="status-text">{{ status.is_healthy ? '정상' : '오류' }}</span>
|
||||
</div>
|
||||
<div class="card-status" v-else>
|
||||
<span class="status-icon">⚪</span>
|
||||
<span class="status-text">대기</span>
|
||||
</div>
|
||||
<div class="card-time" v-if="status && status.last_checked_at">
|
||||
{{ formatTimeAgo(status.last_checked_at) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
type: 'pubnet' | 'privnet'
|
||||
title: string
|
||||
icon: string
|
||||
status: {
|
||||
is_healthy: number
|
||||
last_checked_at: string | null
|
||||
last_target_name: string | null
|
||||
} | null
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const statusClass = computed(() => {
|
||||
if (!props.status || !props.status.last_checked_at) return 'pending'
|
||||
return props.status.is_healthy ? 'healthy' : 'unhealthy'
|
||||
})
|
||||
|
||||
function goToList() {
|
||||
router.push(`/network/${props.type}`)
|
||||
}
|
||||
|
||||
function formatTimeAgo(datetime: string | null): string {
|
||||
if (!datetime) return '-'
|
||||
const now = new Date()
|
||||
const then = new Date(datetime.replace(' ', 'T') + '+09:00')
|
||||
const diff = Math.floor((now.getTime() - then.getTime()) / 1000)
|
||||
if (diff < 60) return `${diff}초 전`
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}분 전`
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}시간 전`
|
||||
return `${Math.floor(diff / 86400)}일 전`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.network-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 16px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-align: center;
|
||||
}
|
||||
.network-card:hover {
|
||||
background: var(--bg-tertiary, #f8fafc);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
|
||||
}
|
||||
.network-card.healthy { border-top: 4px solid #22c55e; }
|
||||
.network-card.unhealthy { border-top: 4px solid #ef4444; }
|
||||
.network-card.pending { border-top: 4px solid #9ca3af; }
|
||||
|
||||
.card-icon { font-size: 28px; margin-bottom: 8px; }
|
||||
.card-title { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 10px; }
|
||||
|
||||
.card-status { display: flex; align-items: center; justify-content: center; gap: 6px; margin-bottom: 4px; }
|
||||
.status-icon { font-size: 16px; }
|
||||
.status-text { font-size: 15px; font-weight: 500; color: var(--text-secondary); }
|
||||
|
||||
.card-time { font-size: 12px; color: var(--text-muted); }
|
||||
</style>
|
||||
319
frontend/components/ServerPortlet.vue
Normal file
319
frontend/components/ServerPortlet.vue
Normal file
@@ -0,0 +1,319 @@
|
||||
<template>
|
||||
<div class="server-portlet">
|
||||
<div class="portlet-header">
|
||||
<h2 class="portlet-title">🖥️ 서버 현황</h2>
|
||||
<div class="summary-badges">
|
||||
<span class="badge">
|
||||
서버 {{ summary.servers.total }}대
|
||||
<span class="level-counts">
|
||||
<span class="lv">🟢{{ summary.servers.normal }}</span>
|
||||
<span class="lv">🟡{{ summary.servers.warning }}</span>
|
||||
<span class="lv">🟠{{ summary.servers.critical }}</span>
|
||||
<span class="lv">🔴{{ summary.servers.danger }}</span>
|
||||
<span class="lv">⚫{{ summary.servers.offline }}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="divider">|</span>
|
||||
<span class="badge">
|
||||
컨테이너 {{ summary.containers.total }}개
|
||||
<span class="level-counts">
|
||||
<span class="lv">🟢{{ summary.containers.normal }}</span>
|
||||
<span class="lv">🟡{{ summary.containers.warning }}</span>
|
||||
<span class="lv">🟠{{ summary.containers.critical }}</span>
|
||||
<span class="lv">🔴{{ (summary.containers.danger || 0) + summary.containers.stopped }}</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="server-grid">
|
||||
<!-- 서버 유닛들 -->
|
||||
<div
|
||||
v-for="server in servers"
|
||||
:key="server.target_id"
|
||||
:class="['server-unit', server.level]"
|
||||
>
|
||||
<!-- 서버 정보 (왼쪽) -->
|
||||
<div class="server-info" @dblclick="goToServerStatus(server.target_id)">
|
||||
<div class="server-name">
|
||||
<span class="level-icon">{{ levelIcon(server.level) }}</span>
|
||||
<span class="name">{{ server.server_name }}</span>
|
||||
<span class="container-count" v-if="server.level !== 'offline'">📦{{ server.container_summary.total }}</span>
|
||||
</div>
|
||||
|
||||
<template v-if="server.level !== 'offline'">
|
||||
<div class="metric-row">
|
||||
<span class="metric-label">CPU</span>
|
||||
<div class="progress-bar">
|
||||
<div :class="['progress-fill', server.cpu_level]" :style="{ width: (server.cpu_percent || 0) + '%' }"></div>
|
||||
</div>
|
||||
<span :class="['metric-value', server.cpu_level]">{{ server.cpu_percent?.toFixed(0) || '-' }}</span>
|
||||
</div>
|
||||
<div class="metric-row">
|
||||
<span class="metric-label">MEM</span>
|
||||
<div class="progress-bar">
|
||||
<div :class="['progress-fill', server.memory_level]" :style="{ width: (server.memory_percent || 0) + '%' }"></div>
|
||||
</div>
|
||||
<span :class="['metric-value', server.memory_level]">{{ server.memory_percent?.toFixed(0) || '-' }}</span>
|
||||
</div>
|
||||
<div class="metric-row">
|
||||
<span class="metric-label">DISK</span>
|
||||
<div class="progress-bar">
|
||||
<div :class="['progress-fill', server.disk_level]" :style="{ width: (server.disk_percent || 0) + '%' }"></div>
|
||||
</div>
|
||||
<span :class="['metric-value', server.disk_level]">{{ server.disk_percent?.toFixed(0) || '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="offline-info">
|
||||
<div class="offline-text">오프라인</div>
|
||||
<div class="offline-time">{{ formatTimeAgo(server.last_collected) }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 컨테이너 영역 (오른쪽) -->
|
||||
<div class="container-area" v-if="server.level !== 'offline'">
|
||||
<div
|
||||
v-for="container in sortContainers(server.containers)"
|
||||
:key="server.target_id + '-' + container.name"
|
||||
:class="['container-card', container.level]"
|
||||
@click="goToServerStatus(server.target_id, container.name)"
|
||||
>
|
||||
<div class="card-header">
|
||||
<div class="card-name">
|
||||
<span class="card-level">{{ levelIcon(container.level) }}</span>
|
||||
<span class="name">{{ container.name }}</span>
|
||||
</div>
|
||||
<span class="card-uptime" v-if="container.status === 'running'">{{ container.uptime || '-' }}</span>
|
||||
</div>
|
||||
<template v-if="container.status === 'running'">
|
||||
<div class="card-metrics">
|
||||
<div class="card-metric">
|
||||
<span class="label">CPU</span>
|
||||
<div class="mini-bar">
|
||||
<div :class="['mini-fill', getContainerCpuLevel(container)]" :style="{ width: (container.cpu_percent || 0) + '%' }"></div>
|
||||
</div>
|
||||
<span class="value">{{ container.cpu_percent?.toFixed(0) || '-' }}%</span>
|
||||
</div>
|
||||
<div class="card-metric">
|
||||
<span class="label">MEM</span>
|
||||
<div class="mini-bar">
|
||||
<div :class="['mini-fill', getContainerMemLevel(container)]" :style="{ width: getMemPercent(container) + '%' }"></div>
|
||||
</div>
|
||||
<span class="value">{{ formatMemoryShort(container.memory_usage) }}</span>
|
||||
</div>
|
||||
<div class="card-metric">
|
||||
<span class="label">↓RX</span>
|
||||
<span class="value net">{{ formatNetworkShort(container.network_rx) }}</span>
|
||||
</div>
|
||||
<div class="card-metric">
|
||||
<span class="label">↑TX</span>
|
||||
<span class="value net">{{ formatNetworkShort(container.network_tx) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="card-stopped">{{ container.status }}</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 컨테이너 없음 -->
|
||||
<div class="no-container" v-if="server.containers.length === 0">
|
||||
<span>컨테이너 없음</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface ContainerStatus {
|
||||
name: string
|
||||
status: string
|
||||
level: string
|
||||
cpu_percent: number | null
|
||||
memory_usage: number | null
|
||||
memory_limit: number | null
|
||||
uptime: string | null
|
||||
network_rx: number | null
|
||||
network_tx: number | null
|
||||
}
|
||||
|
||||
interface ServerStatus {
|
||||
target_id: number
|
||||
server_name: string
|
||||
level: string
|
||||
cpu_percent: number | null
|
||||
cpu_level: string
|
||||
memory_percent: number | null
|
||||
memory_level: string
|
||||
disk_percent: number | null
|
||||
disk_level: string
|
||||
last_collected: string | null
|
||||
containers: ContainerStatus[]
|
||||
container_summary: { total: number; normal: number; warning: number; critical: number; stopped: number }
|
||||
}
|
||||
|
||||
interface Summary {
|
||||
servers: { total: number; normal: number; warning: number; critical: number; danger: number; offline: number }
|
||||
containers: { total: number; normal: number; warning: number; critical: number; danger: number; stopped: number }
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
servers: ServerStatus[]
|
||||
summary: Summary
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'navigate', path: string): void
|
||||
}>()
|
||||
|
||||
const levelPriority: Record<string, number> = { stopped: 3, critical: 2, danger: 2, warning: 1, normal: 0 }
|
||||
|
||||
function sortContainers(containers: ContainerStatus[]) {
|
||||
return [...containers].sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
function goToServerStatus(targetId: number, containerName?: string) {
|
||||
let path = `/server/history?target=${targetId}`
|
||||
if (containerName) path += `&container=${containerName}`
|
||||
emit('navigate', path)
|
||||
}
|
||||
|
||||
function levelIcon(level: string): string {
|
||||
const icons: Record<string, string> = { normal: '🟢', warning: '🟡', critical: '🟠', danger: '🔴', offline: '⚫', stopped: '🔴' }
|
||||
return icons[level] || '⚪'
|
||||
}
|
||||
|
||||
function getContainerCpuLevel(c: ContainerStatus): string {
|
||||
if (c.cpu_percent === null) return 'normal'
|
||||
if (c.cpu_percent >= 95) return 'danger'
|
||||
if (c.cpu_percent >= 90) return 'critical'
|
||||
if (c.cpu_percent >= 80) return 'warning'
|
||||
return 'normal'
|
||||
}
|
||||
|
||||
function getContainerMemLevel(c: ContainerStatus): string {
|
||||
const pct = getMemPercent(c)
|
||||
if (pct >= 95) return 'danger'
|
||||
if (pct >= 90) return 'critical'
|
||||
if (pct >= 80) return 'warning'
|
||||
return 'normal'
|
||||
}
|
||||
|
||||
function getMemPercent(c: ContainerStatus): number {
|
||||
if (!c.memory_limit || !c.memory_usage) return 0
|
||||
return (c.memory_usage / c.memory_limit) * 100
|
||||
}
|
||||
|
||||
function formatMemoryShort(bytes: number | null): string {
|
||||
if (bytes === null || bytes === undefined) return '-'
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}K`
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(0)}M`
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)}G`
|
||||
}
|
||||
|
||||
function formatNetworkShort(bytes: number | null): string {
|
||||
if (bytes === null || bytes === undefined) return '-'
|
||||
if (bytes < 1024) return `${bytes}B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}K`
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)}M`
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)}G`
|
||||
}
|
||||
|
||||
function formatTimeAgo(datetime: string | null): string {
|
||||
if (!datetime) return '-'
|
||||
const now = new Date()
|
||||
const then = new Date(datetime.replace(' ', 'T') + '+09:00')
|
||||
const diff = Math.floor((now.getTime() - then.getTime()) / 1000)
|
||||
if (diff < 60) return `${diff}초 전`
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}분 전`
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}시간 전`
|
||||
return `${Math.floor(diff / 86400)}일 전`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.server-portlet { background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 12px; overflow: hidden; }
|
||||
|
||||
.portlet-header { display: flex; justify-content: space-between; align-items: center; padding: 14px 18px; border-bottom: 1px solid var(--border-color); background: var(--bg-tertiary, #f8fafc); }
|
||||
.portlet-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--text-primary); }
|
||||
.summary-badges { display: flex; align-items: center; gap: 12px; font-size: 14px; color: var(--text-secondary); }
|
||||
.divider { color: var(--border-color); }
|
||||
.level-counts { margin-left: 8px; }
|
||||
.level-counts .lv { margin-right: 6px; }
|
||||
|
||||
/* 서버 그리드 - flex-wrap */
|
||||
.server-grid { display: flex; flex-wrap: wrap; gap: 12px; padding: 16px; align-items: flex-start; }
|
||||
|
||||
/* 서버 유닛 */
|
||||
.server-unit { display: flex; background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 10px; overflow: hidden; }
|
||||
.server-unit.warning { border-left: 3px solid #eab308; }
|
||||
.server-unit.critical { border-left: 3px solid #f97316; }
|
||||
.server-unit.danger { border-left: 3px solid #ef4444; }
|
||||
.server-unit.offline { border-left: 3px solid #6b7280; opacity: 0.7; }
|
||||
|
||||
/* 서버 정보 (왼쪽) */
|
||||
.server-info { width: 150px; min-width: 150px; padding: 12px; border-right: 1px solid var(--border-color); cursor: pointer; }
|
||||
.server-info:hover { background: var(--bg-tertiary, #f8fafc); }
|
||||
|
||||
.server-name { display: flex; align-items: center; gap: 6px; margin-bottom: 10px; }
|
||||
.server-name .level-icon { font-size: 12px; flex-shrink: 0; }
|
||||
.server-name .name { font-size: 15px; font-weight: 600; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
|
||||
.server-name .container-count { font-size: 12px; color: var(--text-muted); flex-shrink: 0; }
|
||||
|
||||
/* 서버 메트릭 */
|
||||
.metric-row { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
|
||||
.metric-label { font-size: 11px; font-weight: 500; color: var(--text-muted); width: 28px; }
|
||||
.progress-bar { flex: 1; height: 6px; background: var(--bg-tertiary, #e5e7eb); border-radius: 3px; overflow: hidden; }
|
||||
.progress-fill { height: 100%; border-radius: 3px; transition: width 0.3s; }
|
||||
.progress-fill.normal { background: #22c55e; }
|
||||
.progress-fill.warning { background: #eab308; }
|
||||
.progress-fill.critical { background: #f97316; }
|
||||
.progress-fill.danger { background: #ef4444; }
|
||||
.metric-value { font-size: 12px; font-weight: 600; width: 24px; text-align: right; }
|
||||
.metric-value.normal { color: #16a34a; }
|
||||
.metric-value.warning { color: #ca8a04; }
|
||||
.metric-value.critical { color: #ea580c; }
|
||||
.metric-value.danger { color: #dc2626; }
|
||||
|
||||
.offline-info { text-align: center; padding: 16px 0; color: var(--text-muted); }
|
||||
.offline-text { font-size: 14px; margin-bottom: 4px; }
|
||||
.offline-time { font-size: 12px; opacity: 0.7; }
|
||||
|
||||
/* 컨테이너 영역 */
|
||||
.container-area { display: flex; flex-wrap: wrap; gap: 8px; padding: 10px; align-content: flex-start; min-width: 100px; }
|
||||
|
||||
/* 컨테이너 카드 */
|
||||
.container-card { width: 200px; padding: 10px; border-radius: 8px; border: 1px solid var(--border-color); background: var(--bg-secondary); cursor: pointer; transition: all 0.15s; overflow: hidden; }
|
||||
.container-card:hover { transform: translateY(-1px); box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
|
||||
.container-card.normal { background: #f0fdf4; border-color: #86efac; }
|
||||
.container-card.warning { background: #fefce8; border-color: #fde047; }
|
||||
.container-card.critical { background: #fff7ed; border-color: #fdba74; }
|
||||
.container-card.danger { background: #fef2f2; border-color: #fca5a5; }
|
||||
.container-card.stopped { background: #fef2f2; border-color: #fca5a5; }
|
||||
|
||||
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; overflow: hidden; }
|
||||
.card-name { display: flex; align-items: center; gap: 4px; flex: 1; min-width: 0; overflow: hidden; }
|
||||
.card-name .card-level { font-size: 10px; flex-shrink: 0; }
|
||||
.card-name .name { font-size: 13px; font-weight: 600; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.card-uptime { font-size: 11px; color: var(--text-muted); flex-shrink: 0; margin-left: 8px; white-space: nowrap; }
|
||||
|
||||
.card-metrics { display: flex; flex-wrap: wrap; gap: 4px 8px; }
|
||||
.card-metric { display: flex; align-items: center; gap: 4px; width: calc(50% - 4px); overflow: hidden; }
|
||||
.card-metric .label { font-size: 10px; color: var(--text-muted); width: 22px; flex-shrink: 0; }
|
||||
.mini-bar { flex: 1; height: 5px; background: rgba(0,0,0,0.1); border-radius: 2px; overflow: hidden; min-width: 20px; }
|
||||
.mini-fill { height: 100%; border-radius: 2px; }
|
||||
.mini-fill.normal { background: #22c55e; }
|
||||
.mini-fill.warning { background: #eab308; }
|
||||
.mini-fill.critical { background: #f97316; }
|
||||
.mini-fill.danger { background: #ef4444; }
|
||||
.card-metric .value { font-size: 11px; font-weight: 500; color: var(--text-secondary); width: 36px; text-align: right; flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.card-stopped { font-size: 12px; color: var(--text-muted); font-style: italic; text-align: center; padding: 6px 0; }
|
||||
|
||||
.no-container { font-size: 13px; color: var(--text-muted); padding: 12px; display: flex; align-items: center; justify-content: center; }
|
||||
</style>
|
||||
74
frontend/components/SidebarNav.vue
Normal file
74
frontend/components/SidebarNav.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<div class="sidebar-logo">
|
||||
<span class="logo-icon">📡</span>
|
||||
<span>OSOLIT Monitor</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<NuxtLink to="/" class="nav-item" :class="{ active: route.path === '/' }">
|
||||
<span class="icon">📊</span>
|
||||
<span>대시보드</span>
|
||||
</NuxtLink>
|
||||
|
||||
<div class="nav-group-title">네트워크</div>
|
||||
|
||||
<NuxtLink to="/network/pubnet" class="nav-item nav-sub-item" :class="{ active: route.path === '/network/pubnet' }">
|
||||
<span class="icon">🌐</span>
|
||||
<span>Public Network</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink to="/network/privnet" class="nav-item nav-sub-item" :class="{ active: route.path === '/network/privnet' }">
|
||||
<span class="icon">🔒</span>
|
||||
<span>Private Network</span>
|
||||
</NuxtLink>
|
||||
|
||||
<div class="nav-group-title">서버</div>
|
||||
|
||||
<NuxtLink to="/server/list" class="nav-item nav-sub-item" :class="{ active: route.path === '/server/list' }">
|
||||
<span class="icon">🖥️</span>
|
||||
<span>Server Targets</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink to="/server/history" class="nav-item nav-sub-item" :class="{ active: route.path === '/server/history' }">
|
||||
<span class="icon">📈</span>
|
||||
<span>Server Status</span>
|
||||
</NuxtLink>
|
||||
|
||||
<div class="nav-group-title">이상감지</div>
|
||||
|
||||
<NuxtLink to="/anomaly/short-term" class="nav-item nav-sub-item" :class="{ active: route.path === '/anomaly/short-term' }">
|
||||
<span class="icon">⚡</span>
|
||||
<span>단기 변화율</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink to="/anomaly/zscore" class="nav-item nav-sub-item" :class="{ active: route.path === '/anomaly/zscore' }">
|
||||
<span class="icon">📊</span>
|
||||
<span>Z-Score 분석</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink to="/anomaly/baseline" class="nav-item nav-sub-item" :class="{ active: route.path === '/anomaly/baseline' }">
|
||||
<span class="icon">🕐</span>
|
||||
<span>시간대별 베이스라인</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink to="/anomaly/trend" class="nav-item nav-sub-item" :class="{ active: route.path === '/anomaly/trend' }">
|
||||
<span class="icon">📉</span>
|
||||
<span>추세 분석</span>
|
||||
</NuxtLink>
|
||||
|
||||
<div class="nav-group-title">설정</div>
|
||||
|
||||
<NuxtLink to="/settings/thresholds" class="nav-item nav-sub-item" :class="{ active: route.path === '/settings/thresholds' }">
|
||||
<span class="icon">⚙️</span>
|
||||
<span>임계값 설정</span>
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
</script>
|
||||
112
frontend/components/ThemeToggle.vue
Normal file
112
frontend/components/ThemeToggle.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<div
|
||||
class="theme-switch"
|
||||
:class="{ dark: theme === 'dark' }"
|
||||
@click="toggleTheme"
|
||||
:title="theme === 'dark' ? '라이트 모드로 전환' : '다크 모드로 전환'"
|
||||
>
|
||||
<div class="switch-track">
|
||||
<span class="switch-icon sun">☀️</span>
|
||||
<span class="switch-icon moon">🌙</span>
|
||||
</div>
|
||||
<div class="switch-thumb"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { theme, toggleTheme, initTheme } = useTheme()
|
||||
|
||||
onMounted(() => {
|
||||
initTheme()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.theme-switch {
|
||||
position: relative;
|
||||
width: 56px;
|
||||
height: 28px;
|
||||
background: #e0e0e0;
|
||||
border: 2px solid #ccc;
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s ease, border-color 0.3s ease;
|
||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.theme-switch.dark {
|
||||
background: #3a3a3a;
|
||||
border-color: #666;
|
||||
}
|
||||
|
||||
.switch-track {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.switch-icon {
|
||||
font-size: 14px;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.switch-icon.sun {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.switch-icon.moon {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.theme-switch.dark .switch-icon.sun {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.theme-switch.dark .switch-icon.moon {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.switch-thumb {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: #1a1a1a;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), background 0.3s ease;
|
||||
}
|
||||
|
||||
.theme-switch.dark .switch-thumb {
|
||||
transform: translateX(28px);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* 호버 효과 */
|
||||
.theme-switch:hover {
|
||||
border-color: #999;
|
||||
}
|
||||
|
||||
.theme-switch.dark:hover {
|
||||
border-color: #888;
|
||||
}
|
||||
|
||||
.theme-switch:hover .switch-thumb {
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.theme-switch:active .switch-thumb {
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
.theme-switch.dark:active .switch-thumb {
|
||||
transform: translateX(24px);
|
||||
}
|
||||
</style>
|
||||
35
frontend/composables/useTheme.ts
Normal file
35
frontend/composables/useTheme.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export type Theme = 'dark' | 'light'
|
||||
|
||||
const THEME_KEY = 'osolit-theme'
|
||||
|
||||
export function useTheme() {
|
||||
const theme = useState<Theme>('theme', () => 'light')
|
||||
|
||||
function setTheme(newTheme: Theme) {
|
||||
theme.value = newTheme
|
||||
if (import.meta.client) {
|
||||
document.documentElement.setAttribute('data-theme', newTheme)
|
||||
localStorage.setItem(THEME_KEY, newTheme)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const newTheme = theme.value === 'dark' ? 'light' : 'dark'
|
||||
setTheme(newTheme)
|
||||
}
|
||||
|
||||
function initTheme() {
|
||||
if (import.meta.client) {
|
||||
const saved = localStorage.getItem(THEME_KEY) as Theme | null
|
||||
const initial = saved || 'light'
|
||||
setTheme(initial)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
theme,
|
||||
setTheme,
|
||||
toggleTheme,
|
||||
initTheme
|
||||
}
|
||||
}
|
||||
246
frontend/index.vue
Normal file
246
frontend/index.vue
Normal file
@@ -0,0 +1,246 @@
|
||||
<template>
|
||||
<div class="app-layout">
|
||||
<SidebarNav />
|
||||
|
||||
<div class="main-content">
|
||||
<header class="main-header">
|
||||
<h1 class="page-title">📊 대시보드</h1>
|
||||
<div class="header-info">
|
||||
<span class="current-time">{{ currentTime }}</span>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main-body">
|
||||
<DashboardControl
|
||||
:interval="interval"
|
||||
:auto-refresh="autoRefresh"
|
||||
:last-fetch-time="lastFetchTime"
|
||||
:fetch-state="fetchState"
|
||||
@update:interval="updateInterval"
|
||||
@update:auto-refresh="updateAutoRefresh"
|
||||
@fetch-at="fetchAt"
|
||||
@refresh="refresh"
|
||||
/>
|
||||
|
||||
<div class="dashboard-layout">
|
||||
<!-- 서버 현황 (좌측 90%) -->
|
||||
<div class="server-section">
|
||||
<ServerPortlet
|
||||
v-if="serverDashboard"
|
||||
:servers="serverDashboard.servers"
|
||||
:summary="serverDashboard.summary"
|
||||
@navigate="navigateTo"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 네트워크 상태 (우측 10%) -->
|
||||
<div class="network-section">
|
||||
<NetworkPortlet
|
||||
type="pubnet"
|
||||
title="Public"
|
||||
icon="🌐"
|
||||
:status="pubnetStatus"
|
||||
/>
|
||||
<NetworkPortlet
|
||||
type="privnet"
|
||||
title="Private"
|
||||
icon="🔒"
|
||||
:status="privnetStatus"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div class="connection-status" :class="{ connected: wsConnected }">
|
||||
{{ wsConnected ? '🟢 연결됨' : '🔴 연결 끊김' }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const router = useRouter()
|
||||
|
||||
// 초기값 상수
|
||||
const DEFAULT_INTERVAL = 1
|
||||
const DEFAULT_AUTO_REFRESH = true
|
||||
const MIN_LOADING_TIME = 500
|
||||
const SUCCESS_DISPLAY_TIME = 800
|
||||
|
||||
// 상태
|
||||
const interval = ref(DEFAULT_INTERVAL)
|
||||
const autoRefresh = ref(DEFAULT_AUTO_REFRESH)
|
||||
const wsConnected = ref(false)
|
||||
const currentTime = ref('')
|
||||
const lastFetchTime = ref<Date | null>(null)
|
||||
const fetchState = ref<'idle' | 'loading' | 'success'>('idle')
|
||||
|
||||
// 로딩 타이밍 관리
|
||||
let loadingStartTime = 0
|
||||
|
||||
// 데이터
|
||||
const pubnetStatus = ref<any>(null)
|
||||
const privnetStatus = ref<any>(null)
|
||||
const serverDashboard = ref<any>(null)
|
||||
|
||||
// WebSocket
|
||||
let ws: WebSocket | null = null
|
||||
let timeInterval: ReturnType<typeof window.setInterval> | null = null
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(date.getDate()).padStart(2, '0')
|
||||
const h = String(date.getHours()).padStart(2, '0')
|
||||
const min = String(date.getMinutes()).padStart(2, '0')
|
||||
const s = String(date.getSeconds()).padStart(2, '0')
|
||||
return `${y}-${m}-${d} ${h}:${min}:${s}`
|
||||
}
|
||||
|
||||
function updateCurrentTime() {
|
||||
currentTime.value = formatTime(new Date())
|
||||
}
|
||||
|
||||
function sendMessage(msg: object) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
console.log('[WS] Sending:', JSON.stringify(msg))
|
||||
ws.send(JSON.stringify(msg))
|
||||
}
|
||||
}
|
||||
|
||||
function handleDataReceived(data: any) {
|
||||
lastFetchTime.value = new Date()
|
||||
|
||||
if (data.pubnet) {
|
||||
pubnetStatus.value = data.pubnet.status
|
||||
}
|
||||
|
||||
if (data.privnet) {
|
||||
privnetStatus.value = data.privnet.status
|
||||
}
|
||||
}
|
||||
|
||||
function finishLoading() {
|
||||
fetchState.value = 'success'
|
||||
setTimeout(() => {
|
||||
fetchState.value = 'idle'
|
||||
}, SUCCESS_DISPLAY_TIME)
|
||||
}
|
||||
|
||||
function connectWebSocket() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const wsUrl = `${protocol}//${window.location.host}/_ws`
|
||||
|
||||
ws = new WebSocket(wsUrl)
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('[WS] Connected')
|
||||
wsConnected.value = true
|
||||
|
||||
ws!.send(JSON.stringify({ type: 'set_interval', interval: DEFAULT_INTERVAL }))
|
||||
ws!.send(JSON.stringify({ type: 'set_auto_refresh', enabled: DEFAULT_AUTO_REFRESH }))
|
||||
ws!.send(JSON.stringify({ type: 'refresh' }))
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
|
||||
if (msg.type === 'status' || msg.type === 'historical') {
|
||||
handleDataReceived(msg.data)
|
||||
|
||||
if (fetchState.value === 'loading') {
|
||||
const elapsed = Date.now() - loadingStartTime
|
||||
const remaining = Math.max(0, MIN_LOADING_TIME - elapsed)
|
||||
|
||||
setTimeout(() => {
|
||||
finishLoading()
|
||||
}, remaining)
|
||||
}
|
||||
}
|
||||
|
||||
if (msg.type === 'server') {
|
||||
serverDashboard.value = msg.data
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[WS] Parse error:', err)
|
||||
fetchState.value = 'idle'
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('[WS] Disconnected')
|
||||
wsConnected.value = false
|
||||
fetchState.value = 'idle'
|
||||
|
||||
setTimeout(() => {
|
||||
if (autoRefresh.value) {
|
||||
connectWebSocket()
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('[WS] Error:', error)
|
||||
fetchState.value = 'idle'
|
||||
}
|
||||
}
|
||||
|
||||
function updateInterval(min: number) {
|
||||
interval.value = min
|
||||
sendMessage({ type: 'set_interval', interval: min })
|
||||
}
|
||||
|
||||
function updateAutoRefresh(enabled: boolean) {
|
||||
autoRefresh.value = enabled
|
||||
sendMessage({ type: 'set_auto_refresh', enabled })
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
sendMessage({ type: 'refresh' })
|
||||
}
|
||||
|
||||
function fetchAt(datetime: string) {
|
||||
fetchState.value = 'loading'
|
||||
loadingStartTime = Date.now()
|
||||
sendMessage({ type: 'fetch_at', datetime })
|
||||
}
|
||||
|
||||
function navigateTo(path: string) {
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
connectWebSocket()
|
||||
updateCurrentTime()
|
||||
timeInterval = window.setInterval(updateCurrentTime, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (ws) {
|
||||
ws.close()
|
||||
}
|
||||
if (timeInterval) {
|
||||
window.clearInterval(timeInterval)
|
||||
}
|
||||
})
|
||||
</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-info { display: flex; align-items: center; gap: 16px; }
|
||||
.current-time { font-size: 14px; color: var(--text-muted); font-family: monospace; }
|
||||
.main-body { flex: 1; padding: 20px 24px; overflow-y: auto; }
|
||||
|
||||
.dashboard-layout { display: flex; gap: 16px; height: 100%; }
|
||||
.server-section { flex: 9; min-width: 0; }
|
||||
.network-section { flex: 1; min-width: 130px; max-width: 160px; display: flex; flex-direction: column; gap: 12px; }
|
||||
|
||||
.connection-status { position: fixed; bottom: 16px; right: 16px; padding: 8px 12px; background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 8px; font-size: 12px; color: var(--text-muted); }
|
||||
.connection-status.connected { color: #16a34a; }
|
||||
</style>
|
||||
1069
frontend/network/privnet.vue
Normal file
1069
frontend/network/privnet.vue
Normal file
File diff suppressed because it is too large
Load Diff
1074
frontend/network/pubnet.vue
Normal file
1074
frontend/network/pubnet.vue
Normal file
File diff suppressed because it is too large
Load Diff
791
frontend/server/history.vue
Normal file
791
frontend/server/history.vue
Normal file
@@ -0,0 +1,791 @@
|
||||
<template>
|
||||
<div class="app-layout">
|
||||
<SidebarNav />
|
||||
|
||||
<div class="main-content">
|
||||
<header class="main-header">
|
||||
<h1 class="page-title">📈 Server Status</h1>
|
||||
<div class="header-info">
|
||||
<span class="current-time">{{ currentTime }}</span>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main-body">
|
||||
<!-- 상단 고정 영역 -->
|
||||
<div class="fixed-top">
|
||||
<!-- 첫 번째 줄: 조회 기간 -->
|
||||
<section class="filter-row">
|
||||
<span class="filter-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>
|
||||
</section>
|
||||
|
||||
<!-- 두 번째 줄: 서버 목록 -->
|
||||
<section class="server-row">
|
||||
<span class="filter-label">서버</span>
|
||||
<div class="server-buttons">
|
||||
<button
|
||||
v-for="target in targets"
|
||||
:key="target.target_id"
|
||||
:class="['server-btn', { active: selectedTargetId === target.target_id }]"
|
||||
@click="selectServer(target.target_id)"
|
||||
>
|
||||
<span class="server-status" :class="{ online: target.is_active }"></span>
|
||||
{{ target.server_name }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="server-nav">
|
||||
<button class="nav-btn" @click="prevServer">◀</button>
|
||||
<button class="nav-btn" @click="nextServer">▶</button>
|
||||
</div>
|
||||
<div class="auto-rotate">
|
||||
<label class="rotate-checkbox">
|
||||
<input type="checkbox" v-model="autoRotate" @change="onAutoRotateChange">
|
||||
<span>자동순환</span>
|
||||
</label>
|
||||
<div class="rotate-intervals">
|
||||
<button
|
||||
:class="['rotate-btn', { active: rotateInterval === 1/6 }]"
|
||||
:disabled="!autoRotate"
|
||||
@click="changeRotateInterval(1/6)"
|
||||
>
|
||||
10초
|
||||
</button>
|
||||
<button
|
||||
:class="['rotate-btn', { active: rotateInterval === 0.5 }]"
|
||||
:disabled="!autoRotate"
|
||||
@click="changeRotateInterval(0.5)"
|
||||
>
|
||||
30초
|
||||
</button>
|
||||
<button
|
||||
v-for="min in [1, 3, 5]"
|
||||
:key="min"
|
||||
:class="['rotate-btn', { active: rotateInterval === min }]"
|
||||
:disabled="!autoRotate"
|
||||
@click="changeRotateInterval(min)"
|
||||
>
|
||||
{{ min }}분
|
||||
</button>
|
||||
</div>
|
||||
<span class="rotate-remaining" v-if="autoRotate">{{ formatRemaining(rotateRemaining) }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 스냅샷 정보 -->
|
||||
<section class="snapshot-info" v-if="latestSnapshot">
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<label>호스트명</label>
|
||||
<span>{{ latestSnapshot.host_name || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>OS</label>
|
||||
<span>{{ latestSnapshot.os_name }} {{ latestSnapshot.os_version }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>IP</label>
|
||||
<span>{{ latestSnapshot.ip_address || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>CPU</label>
|
||||
<span>{{ latestSnapshot.cpu_name || '-' }} ({{ latestSnapshot.cpu_count || '-' }} cores)</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>Memory</label>
|
||||
<span>{{ formatBytes(latestSnapshot.memory_total) }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>Swap</label>
|
||||
<span>{{ formatBytes(latestSnapshot.swap_total) }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>Uptime</label>
|
||||
<span>{{ latestSnapshot.uptime_str || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>컨테이너</label>
|
||||
<span>{{ containerData.length }}개</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>수집시간</label>
|
||||
<span>{{ latestSnapshot.collected_at }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="disk-list" v-if="diskList.length > 0">
|
||||
<label>Disks</label>
|
||||
<div class="disk-items">
|
||||
<span v-for="disk in diskList" :key="disk.mount_point" class="disk-item">
|
||||
{{ disk.mount_point }} ({{ formatBytes(disk.disk_total) }}, {{ disk.disk_percent?.toFixed(1) }}%)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- 하단 스크롤 영역 -->
|
||||
<div class="scroll-area">
|
||||
<!-- 시스템 차트 (4개) -->
|
||||
<section class="chart-row">
|
||||
<div class="chart-box">
|
||||
<div class="chart-header">
|
||||
<h4>CPU / 온도 / Load</h4>
|
||||
<span class="chart-avg" v-if="cpuAvg">{{ latestSnapshot?.cpu_name || '-' }} ({{ latestSnapshot?.cpu_count || '-' }} cores) | 평균: CPU <span class="val-cpu">{{ cpuAvg.cpu }}%</span> | 온도 <span class="val-temp">{{ cpuAvg.temp }}°C</span> | Load <span class="val-load">{{ cpuAvg.load }}%</span></span>
|
||||
</div>
|
||||
<div class="chart-container"><canvas ref="cpuChartRef"></canvas></div>
|
||||
</div>
|
||||
<div class="chart-box">
|
||||
<div class="chart-header">
|
||||
<h4>Memory / Swap</h4>
|
||||
<span class="chart-avg" v-if="memAvg">평균: Mem <span class="val-mem">{{ memAvg.mem }}% ({{ memAvg.used }}/{{ memAvg.total }} GB)</span> | Swap <span class="val-swap">{{ memAvg.swap }}% ({{ memAvg.swapUsed }}/{{ memAvg.swapTotal }} GB)</span></span>
|
||||
</div>
|
||||
<div class="chart-container"><canvas ref="memChartRef"></canvas></div>
|
||||
</div>
|
||||
<div class="chart-box">
|
||||
<div class="chart-header">
|
||||
<h4>Disk 사용률</h4>
|
||||
<span class="chart-avg" v-if="diskAvg">평균: <span class="val-disk">{{ diskAvg.percent }}% ({{ diskAvg.used }}/{{ diskAvg.total }} GB)</span></span>
|
||||
</div>
|
||||
<div class="chart-container"><canvas ref="diskChartRef"></canvas></div>
|
||||
</div>
|
||||
<div class="chart-box">
|
||||
<div class="chart-header">
|
||||
<h4>Network I/O</h4>
|
||||
<span class="chart-avg" v-if="networkAvg">평균: RX <span class="val-rx">{{ networkAvg.rx }}</span> | TX <span class="val-tx">{{ networkAvg.tx }}</span></span>
|
||||
</div>
|
||||
<div class="chart-container"><canvas ref="networkMainChartRef"></canvas></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 컨테이너 영역 -->
|
||||
<section class="container-section">
|
||||
<h3 class="section-title">🐳 컨테이너</h3>
|
||||
<div class="container-cards">
|
||||
<div v-for="container in containerData" :key="container.name" class="container-card" :class="container.status">
|
||||
<div class="container-header">
|
||||
<span class="container-name">{{ container.name }}</span>
|
||||
<span class="container-status" :class="container.status">{{ container.status }}</span>
|
||||
<span class="container-uptime">{{ container.uptime || '-' }}</span>
|
||||
</div>
|
||||
<div class="container-charts">
|
||||
<div class="container-chart-box">
|
||||
<div class="chart-header">
|
||||
<span class="chart-title">CPU</span>
|
||||
<span class="chart-avg">평균: <span class="val-cpu">{{ container.cpuAvg }}%</span></span>
|
||||
</div>
|
||||
<div class="container-chart"><canvas :ref="el => setContainerChartRef(container.name, 'cpu', el)"></canvas></div>
|
||||
</div>
|
||||
<div class="container-chart-box">
|
||||
<div class="chart-header">
|
||||
<span class="chart-title">Memory</span>
|
||||
<span class="chart-avg">평균: <span class="val-mem">{{ container.memAvg }}</span> / {{ container.memLimit }}</span>
|
||||
</div>
|
||||
<div class="container-chart"><canvas :ref="el => setContainerChartRef(container.name, 'mem', el)"></canvas></div>
|
||||
</div>
|
||||
<div class="container-chart-box">
|
||||
<div class="chart-header">
|
||||
<span class="chart-title">Network</span>
|
||||
<span class="chart-avg">RX <span class="val-rx">{{ container.rxAvg }}</span> | TX <span class="val-tx">{{ container.txAvg }}</span></span>
|
||||
</div>
|
||||
<div class="container-chart"><canvas :ref="el => setContainerChartRef(container.name, 'net', el)"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Chart, registerables } from 'chart.js'
|
||||
Chart.register(...registerables)
|
||||
|
||||
interface ServerTarget {
|
||||
target_id: number
|
||||
server_name: string
|
||||
server_ip: string
|
||||
is_active: number
|
||||
}
|
||||
|
||||
const targets = ref<ServerTarget[]>([])
|
||||
const selectedTargetId = ref<number | null>(null)
|
||||
const selectedPeriod = ref('1h')
|
||||
const currentTime = ref('')
|
||||
const latestSnapshot = ref<any>(null)
|
||||
const diskList = ref<any[]>([])
|
||||
|
||||
// 자동 순환 (rotateInterval은 분 단위, 10초 = 1/6분)
|
||||
const autoRotate = ref(true)
|
||||
const rotateInterval = ref(1/6)
|
||||
const rotateRemaining = ref(10)
|
||||
let rotateTimer: ReturnType<typeof setInterval> | null = null
|
||||
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const periods = [
|
||||
{ value: '1h', label: '1시간' },
|
||||
{ value: '2h', label: '2시간' },
|
||||
{ value: '3h', label: '3시간' },
|
||||
{ value: '4h', label: '4시간' },
|
||||
{ value: '5h', label: '5시간' },
|
||||
{ value: '6h', label: '6시간' },
|
||||
{ value: '12h', label: '12시간' },
|
||||
{ value: '18h', label: '18시간' },
|
||||
{ value: '24h', label: '24시간' },
|
||||
{ value: '7d', label: '7일' },
|
||||
{ value: '30d', label: '30일' }
|
||||
]
|
||||
|
||||
const cpuChartRef = ref<HTMLCanvasElement | null>(null)
|
||||
const memChartRef = ref<HTMLCanvasElement | null>(null)
|
||||
const diskChartRef = ref<HTMLCanvasElement | null>(null)
|
||||
const networkMainChartRef = ref<HTMLCanvasElement | null>(null)
|
||||
|
||||
let cpuChart: Chart | null = null
|
||||
let memChart: Chart | null = null
|
||||
let diskChart: Chart | null = null
|
||||
let networkMainChart: Chart | null = null
|
||||
|
||||
// 컨테이너 차트 관리
|
||||
interface ContainerInfo {
|
||||
name: string
|
||||
status: string
|
||||
uptime: string
|
||||
cpuAvg: string
|
||||
memAvg: string
|
||||
memLimit: string
|
||||
rxAvg: string
|
||||
txAvg: string
|
||||
}
|
||||
const containerData = ref<ContainerInfo[]>([])
|
||||
const containerChartRefs: Record<string, Record<string, HTMLCanvasElement | null>> = {}
|
||||
const containerCharts: Record<string, Record<string, Chart | null>> = {}
|
||||
|
||||
function setContainerChartRef(name: string, type: string, el: any) {
|
||||
if (!containerChartRefs[name]) containerChartRefs[name] = {}
|
||||
containerChartRefs[name][type] = el as HTMLCanvasElement | null
|
||||
}
|
||||
|
||||
// 평균값
|
||||
const cpuAvg = ref<{ cpu: string; temp: string; load: string } | null>(null)
|
||||
const memAvg = ref<{ mem: string; swap: string; used: string; total: string; swapUsed: string; swapTotal: string } | null>(null)
|
||||
const diskAvg = ref<{ percent: string; used: string; total: string } | null>(null)
|
||||
const networkAvg = ref<{ rx: string; tx: string } | null>(null)
|
||||
|
||||
const chartColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4', '#ec4899', '#14b8a6', '#f97316', '#6366f1']
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
return date.toLocaleString('sv-SE', { timeZone: 'Asia/Seoul' }).replace('T', ' ')
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number | null): string {
|
||||
if (!seconds) return '-'
|
||||
const days = Math.floor(seconds / 86400)
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
const mins = Math.floor((seconds % 3600) / 60)
|
||||
if (days > 0) return `${days}일 ${hours}시간`
|
||||
if (hours > 0) return `${hours}시간 ${mins}분`
|
||||
return `${mins}분`
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number | null): string {
|
||||
if (!bytes) return '-'
|
||||
const gb = bytes / (1024 * 1024 * 1024)
|
||||
if (gb >= 1024) return `${(gb / 1024).toFixed(1)} TB`
|
||||
if (gb >= 1) return `${gb.toFixed(1)} GB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(0)} MB`
|
||||
}
|
||||
|
||||
function formatBytesPerSec(bytes: number): string {
|
||||
if (bytes >= 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB/s`
|
||||
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB/s`
|
||||
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB/s`
|
||||
return `${bytes.toFixed(0)} B/s`
|
||||
}
|
||||
|
||||
function createLineChart(canvas: HTMLCanvasElement, labels: string[], datasets: { label: string; data: number[]; borderColor: string }[], maxY: number | null = 100): Chart {
|
||||
return new Chart(canvas, {
|
||||
type: 'line',
|
||||
data: { labels, datasets: datasets.map(ds => ({ ...ds, fill: false, tension: 0.3, pointRadius: 1, borderWidth: 2 })) },
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { position: 'top', labels: { boxWidth: 12, font: { size: 11 } } } },
|
||||
scales: {
|
||||
x: { ticks: { maxTicksLimit: 8, font: { size: 10 } } },
|
||||
y: { beginAtZero: true, max: maxY || undefined }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function createCpuTempLoadChart(canvas: HTMLCanvasElement, labels: string[], cpuData: number[], tempData: number[], loadData: number[]): Chart {
|
||||
return new Chart(canvas, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{ label: 'CPU %', data: cpuData, borderColor: chartColors[0], fill: false, tension: 0.3, pointRadius: 1, borderWidth: 2, yAxisID: 'y' },
|
||||
{ label: 'Load %', data: loadData, borderColor: chartColors[4], fill: false, tension: 0.3, pointRadius: 1, borderWidth: 2, yAxisID: 'y' },
|
||||
{ label: '온도 °C', data: tempData, borderColor: chartColors[3], fill: false, tension: 0.3, pointRadius: 1, borderWidth: 2, yAxisID: 'y1' }
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { position: 'top', labels: { boxWidth: 12, font: { size: 11 } } } },
|
||||
scales: {
|
||||
x: { ticks: { maxTicksLimit: 8, font: { size: 10 } } },
|
||||
y: { type: 'linear', position: 'left', beginAtZero: true, max: 100, title: { display: true, text: '%', font: { size: 10 } } },
|
||||
y1: { type: 'linear', position: 'right', beginAtZero: true, max: 100, grid: { drawOnChartArea: false }, title: { display: true, text: '°C', font: { size: 10 } } }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchTargets() {
|
||||
try {
|
||||
const res = await $fetch('/api/server/targets')
|
||||
targets.value = (res as ServerTarget[]) || []
|
||||
if (targets.value.length > 0 && !selectedTargetId.value) {
|
||||
selectedTargetId.value = targets.value[0].target_id
|
||||
}
|
||||
} catch (err) { console.error('Failed to fetch targets:', err) }
|
||||
}
|
||||
|
||||
async function fetchLatestSnapshot() {
|
||||
if (!selectedTargetId.value) return
|
||||
try {
|
||||
latestSnapshot.value = await $fetch('/api/server/history/latest', { query: { target_id: selectedTargetId.value } })
|
||||
} catch (err) { console.error('Failed to fetch latest snapshot:', err) }
|
||||
}
|
||||
|
||||
async function fetchDiskList() {
|
||||
if (!selectedTargetId.value) return
|
||||
try {
|
||||
const res = await $fetch('/api/server/history/disk-list', { query: { target_id: selectedTargetId.value } }) as any[]
|
||||
diskList.value = res || []
|
||||
} catch (err) { console.error('Failed to fetch disk list:', err) }
|
||||
}
|
||||
|
||||
async function fetchSnapshots() {
|
||||
if (!selectedTargetId.value || !cpuChartRef.value) return
|
||||
try {
|
||||
const res = await $fetch('/api/server/history/snapshots', { query: { target_id: selectedTargetId.value, period: selectedPeriod.value } }) as any
|
||||
const data = res.data || []
|
||||
const labels = data.map((d: any) => d.collected_at?.substring(11, 16) || '')
|
||||
|
||||
cpuChart?.destroy()
|
||||
memChart?.destroy()
|
||||
|
||||
// CPU + 온도 + Load 차트 (듀얼 Y축: 왼쪽 %, 오른쪽 °C)
|
||||
const cpuData = data.map((d: any) => d.cpu_percent || 0)
|
||||
const tempData = data.map((d: any) => d.cpu_temp || 0)
|
||||
const loadData = data.map((d: any) => d.load_percent || 0)
|
||||
cpuChart = createCpuTempLoadChart(cpuChartRef.value!, labels, cpuData, tempData, loadData)
|
||||
|
||||
// 평균 계산 (CPU, 온도, Load)
|
||||
const validCpu = cpuData.filter((v: number) => v > 0)
|
||||
const validTemp = tempData.filter((v: number) => v > 0)
|
||||
const validLoad = loadData.filter((v: number) => v > 0)
|
||||
cpuAvg.value = {
|
||||
cpu: validCpu.length ? (validCpu.reduce((a: number, b: number) => a + b, 0) / validCpu.length).toFixed(1) : '-',
|
||||
temp: validTemp.length ? (validTemp.reduce((a: number, b: number) => a + b, 0) / validTemp.length).toFixed(1) : '-',
|
||||
load: validLoad.length ? (validLoad.reduce((a: number, b: number) => a + b, 0) / validLoad.length).toFixed(1) : '-'
|
||||
}
|
||||
|
||||
// Memory/Swap 라인 차트
|
||||
const memData = data.map((d: any) => d.memory_percent || 0)
|
||||
const swapData = data.map((d: any) => d.swap_percent || 0)
|
||||
memChart = createLineChart(memChartRef.value!, labels, [
|
||||
{ label: 'Memory %', data: memData, borderColor: chartColors[1] },
|
||||
{ label: 'Swap %', data: swapData, borderColor: chartColors[2] }
|
||||
])
|
||||
|
||||
// 평균 계산 (Memory, Swap) + 사용량/전체용량
|
||||
const validMem = memData.filter((v: number) => v > 0)
|
||||
const validSwap = swapData.filter((v: number) => v >= 0)
|
||||
const memUsedData = data.map((d: any) => d.memory_used || 0).filter((v: number) => v > 0)
|
||||
const avgMemUsedGB = memUsedData.length ? (memUsedData.reduce((a: number, b: number) => a + b, 0) / memUsedData.length / (1024 * 1024 * 1024)).toFixed(1) : '-'
|
||||
const memTotalGB = data[0]?.memory_total ? (data[0].memory_total / (1024 * 1024 * 1024)).toFixed(1) : '-'
|
||||
const swapUsedData = data.map((d: any) => d.swap_used || 0).filter((v: number) => v >= 0)
|
||||
const avgSwapUsedGB = swapUsedData.length ? (swapUsedData.reduce((a: number, b: number) => a + b, 0) / swapUsedData.length / (1024 * 1024 * 1024)).toFixed(1) : '0'
|
||||
const swapTotalGB = data[0]?.swap_total ? (data[0].swap_total / (1024 * 1024 * 1024)).toFixed(1) : '0'
|
||||
memAvg.value = {
|
||||
mem: validMem.length ? (validMem.reduce((a: number, b: number) => a + b, 0) / validMem.length).toFixed(1) : '-',
|
||||
swap: validSwap.length ? (validSwap.reduce((a: number, b: number) => a + b, 0) / validSwap.length).toFixed(1) : '-',
|
||||
used: avgMemUsedGB,
|
||||
total: memTotalGB,
|
||||
swapUsed: avgSwapUsedGB,
|
||||
swapTotal: swapTotalGB
|
||||
}
|
||||
|
||||
} catch (err) { console.error('Failed to fetch snapshots:', err) }
|
||||
}
|
||||
|
||||
async function fetchDisks() {
|
||||
if (!selectedTargetId.value || !diskChartRef.value) return
|
||||
try {
|
||||
const res = await $fetch('/api/server/history/disks', { query: { target_id: selectedTargetId.value, period: selectedPeriod.value } }) as any
|
||||
const data = (res.data || []).filter((d: any) => d.device_name && !d.device_name.includes('loop') && !d.mount_point?.includes('/snap') && d.fs_type !== 'tmpfs' && d.fs_type !== 'squashfs')
|
||||
const mountPoints = [...new Set(data.map((d: any) => d.mount_point))]
|
||||
const timeLabels = [...new Set(data.map((d: any) => d.collected_at?.substring(11, 16)))] as string[]
|
||||
const datasets = mountPoints.map((mp, idx) => ({
|
||||
label: mp as string,
|
||||
data: timeLabels.map(time => data.find((d: any) => d.mount_point === mp && d.collected_at?.substring(11, 16) === time)?.disk_percent || 0),
|
||||
borderColor: chartColors[idx % chartColors.length]
|
||||
}))
|
||||
diskChart?.destroy()
|
||||
diskChart = createLineChart(diskChartRef.value!, timeLabels, datasets)
|
||||
|
||||
// 평균 계산 (전체 디스크) + 사용량/전체용량
|
||||
const allPercents = data.map((d: any) => d.disk_percent || 0).filter((v: number) => v > 0)
|
||||
const allUsed = data.map((d: any) => d.disk_used || 0).filter((v: number) => v > 0)
|
||||
const allTotal = data.map((d: any) => d.disk_total || 0).filter((v: number) => v > 0)
|
||||
const avgUsedGB = allUsed.length ? (allUsed.reduce((a: number, b: number) => a + b, 0) / allUsed.length / (1024 * 1024 * 1024)).toFixed(1) : '-'
|
||||
const avgTotalGB = allTotal.length ? (allTotal.reduce((a: number, b: number) => a + b, 0) / allTotal.length / (1024 * 1024 * 1024)).toFixed(1) : '-'
|
||||
diskAvg.value = {
|
||||
percent: allPercents.length ? (allPercents.reduce((a: number, b: number) => a + b, 0) / allPercents.length).toFixed(1) : '-',
|
||||
used: avgUsedGB,
|
||||
total: avgTotalGB
|
||||
}
|
||||
} catch (err) { console.error('Failed to fetch disks:', err) }
|
||||
}
|
||||
|
||||
async function fetchContainers() {
|
||||
if (!selectedTargetId.value) return
|
||||
try {
|
||||
const res = await $fetch('/api/server/history/containers', { query: { target_id: selectedTargetId.value, period: selectedPeriod.value } }) as any
|
||||
const data = res.data || []
|
||||
const names = [...new Set(data.map((d: any) => d.container_name))] as string[]
|
||||
|
||||
// 기존 차트 정리
|
||||
Object.values(containerCharts).forEach(charts => {
|
||||
Object.values(charts).forEach(chart => chart?.destroy())
|
||||
})
|
||||
|
||||
// 컨테이너별 데이터 구성
|
||||
const containers: ContainerInfo[] = []
|
||||
|
||||
for (const name of names) {
|
||||
const containerRows = data.filter((d: any) => d.container_name === name)
|
||||
if (containerRows.length === 0) continue
|
||||
|
||||
const latest = containerRows[containerRows.length - 1]
|
||||
const timeLabels = containerRows.map((d: any) => d.collected_at?.substring(11, 16))
|
||||
|
||||
// CPU 평균
|
||||
const cpuValues = containerRows.map((d: any) => d.cpu_percent || 0)
|
||||
const cpuAvgVal = cpuValues.length ? (cpuValues.reduce((a: number, b: number) => a + b, 0) / cpuValues.length).toFixed(1) : '0'
|
||||
|
||||
// Memory 평균 (bytes -> MB)
|
||||
const memValues = containerRows.map((d: any) => (d.memory_usage || 0) / 1024 / 1024)
|
||||
const memAvgVal = memValues.length ? (memValues.reduce((a: number, b: number) => a + b, 0) / memValues.length) : 0
|
||||
const memLimit = latest.memory_limit ? (latest.memory_limit / 1024 / 1024 / 1024).toFixed(1) + ' GB' : '-'
|
||||
|
||||
// Network 평균 (bytes/s -> KB/s)
|
||||
const rxValues = containerRows.map((d: any) => (d.network_rx || 0) / 1024)
|
||||
const txValues = containerRows.map((d: any) => (d.network_tx || 0) / 1024)
|
||||
const rxAvgVal = rxValues.length ? (rxValues.reduce((a: number, b: number) => a + b, 0) / rxValues.length) : 0
|
||||
const txAvgVal = txValues.length ? (txValues.reduce((a: number, b: number) => a + b, 0) / txValues.length) : 0
|
||||
|
||||
containers.push({
|
||||
name,
|
||||
status: latest.container_status || 'unknown',
|
||||
uptime: latest.uptime || '-',
|
||||
cpuAvg: cpuAvgVal,
|
||||
memAvg: formatBytes(memAvgVal * 1024 * 1024),
|
||||
memLimit,
|
||||
rxAvg: formatBytesPerSec(rxAvgVal * 1024),
|
||||
txAvg: formatBytesPerSec(txAvgVal * 1024)
|
||||
})
|
||||
}
|
||||
|
||||
containerData.value = containers
|
||||
|
||||
// 다음 틱에서 차트 생성 (DOM 업데이트 후)
|
||||
await nextTick()
|
||||
|
||||
for (const name of names) {
|
||||
const containerRows = data.filter((d: any) => d.container_name === name)
|
||||
const timeLabels = containerRows.map((d: any) => d.collected_at?.substring(11, 16))
|
||||
|
||||
const refs = containerChartRefs[name]
|
||||
if (!refs) continue
|
||||
|
||||
if (!containerCharts[name]) containerCharts[name] = {}
|
||||
|
||||
// CPU 차트 (0-100%)
|
||||
if (refs.cpu) {
|
||||
const cpuData = containerRows.map((d: any) => d.cpu_percent || 0)
|
||||
containerCharts[name].cpu = createLineChart(refs.cpu, timeLabels, [{ label: 'CPU %', data: cpuData, borderColor: '#3b82f6' }], 100)
|
||||
}
|
||||
|
||||
// Memory 차트 (MB 단위) - 자동 스케일
|
||||
if (refs.mem) {
|
||||
const memData = containerRows.map((d: any) => (d.memory_usage || 0) / 1024 / 1024)
|
||||
containerCharts[name].mem = createLineChart(refs.mem, timeLabels, [{ label: 'Memory MB', data: memData, borderColor: '#22c55e' }], null)
|
||||
}
|
||||
|
||||
// Network 차트 (KB/s 단위) - 자동 스케일
|
||||
if (refs.net) {
|
||||
const rxData = containerRows.map((d: any) => (d.network_rx || 0) / 1024)
|
||||
const txData = containerRows.map((d: any) => (d.network_tx || 0) / 1024)
|
||||
containerCharts[name].net = createLineChart(refs.net, timeLabels, [
|
||||
{ label: 'RX KB/s', data: rxData, borderColor: '#06b6d4' },
|
||||
{ label: 'TX KB/s', data: txData, borderColor: '#f59e0b' }
|
||||
], null)
|
||||
}
|
||||
}
|
||||
} catch (err) { console.error('Failed to fetch containers:', err) }
|
||||
}
|
||||
|
||||
async function fetchNetworkMain() {
|
||||
if (!selectedTargetId.value || !networkMainChartRef.value) return
|
||||
try {
|
||||
const res = await $fetch('/api/server/history/networks', { query: { target_id: selectedTargetId.value, period: selectedPeriod.value } }) as any
|
||||
const data = res.data || []
|
||||
const ifaces = [...new Set(data.map((d: any) => d.interface_name))]
|
||||
const timeLabels = [...new Set(data.map((d: any) => d.collected_at?.substring(11, 16)))] as string[]
|
||||
const datasets: any[] = []
|
||||
ifaces.forEach((iface, idx) => {
|
||||
datasets.push({ label: `${iface} RX`, data: timeLabels.map(t => data.find((d: any) => d.interface_name === iface && d.collected_at?.substring(11, 16) === t)?.speed_recv || 0), borderColor: chartColors[idx * 2 % chartColors.length] })
|
||||
datasets.push({ label: `${iface} TX`, data: timeLabels.map(t => data.find((d: any) => d.interface_name === iface && d.collected_at?.substring(11, 16) === t)?.speed_sent || 0), borderColor: chartColors[(idx * 2 + 1) % chartColors.length] })
|
||||
})
|
||||
networkMainChart?.destroy()
|
||||
networkMainChart = createLineChart(networkMainChartRef.value!, timeLabels, datasets, null)
|
||||
|
||||
// 평균 계산 (전체 RX/TX)
|
||||
const allRx = data.map((d: any) => d.speed_recv || 0).filter((v: number) => v > 0)
|
||||
const allTx = data.map((d: any) => d.speed_sent || 0).filter((v: number) => v > 0)
|
||||
const avgRx = allRx.length ? allRx.reduce((a: number, b: number) => a + b, 0) / allRx.length : 0
|
||||
const avgTx = allTx.length ? allTx.reduce((a: number, b: number) => a + b, 0) / allTx.length : 0
|
||||
networkAvg.value = {
|
||||
rx: formatBytesPerSec(avgRx),
|
||||
tx: formatBytesPerSec(avgTx)
|
||||
}
|
||||
} catch (err) { console.error('Failed to fetch network main:', err) }
|
||||
}
|
||||
|
||||
async function fetchAllData() {
|
||||
await Promise.all([fetchLatestSnapshot(), fetchSnapshots(), fetchDisks(), fetchDiskList(), fetchNetworkMain()])
|
||||
await fetchContainers()
|
||||
}
|
||||
|
||||
async function selectServer(targetId: number) {
|
||||
selectedTargetId.value = targetId
|
||||
// 자동순환 중이면 카운트다운 리셋
|
||||
if (autoRotate.value) {
|
||||
rotateRemaining.value = rotateInterval.value * 60
|
||||
}
|
||||
await fetchAllData()
|
||||
}
|
||||
|
||||
function prevServer() {
|
||||
if (targets.value.length === 0) return
|
||||
const idx = targets.value.findIndex(t => t.target_id === selectedTargetId.value)
|
||||
const newIdx = idx <= 0 ? targets.value.length - 1 : idx - 1
|
||||
selectServer(targets.value[newIdx].target_id)
|
||||
}
|
||||
|
||||
function nextServer() {
|
||||
if (targets.value.length === 0) return
|
||||
const idx = targets.value.findIndex(t => t.target_id === selectedTargetId.value)
|
||||
const newIdx = idx >= targets.value.length - 1 ? 0 : idx + 1
|
||||
selectServer(targets.value[newIdx].target_id)
|
||||
}
|
||||
|
||||
// 자동 순환 기능
|
||||
function startRotateTimer() {
|
||||
stopRotateTimer()
|
||||
if (!autoRotate.value) return
|
||||
|
||||
// 남은 시간 초기화
|
||||
rotateRemaining.value = rotateInterval.value * 60
|
||||
|
||||
// 1초마다 카운트다운
|
||||
countdownTimer = setInterval(() => {
|
||||
rotateRemaining.value--
|
||||
if (rotateRemaining.value <= 0) {
|
||||
nextServer()
|
||||
rotateRemaining.value = rotateInterval.value * 60
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function stopRotateTimer() {
|
||||
if (rotateTimer) {
|
||||
clearInterval(rotateTimer)
|
||||
rotateTimer = null
|
||||
}
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer)
|
||||
countdownTimer = null
|
||||
}
|
||||
rotateRemaining.value = 0
|
||||
}
|
||||
|
||||
function onAutoRotateChange() {
|
||||
if (autoRotate.value) {
|
||||
rotateInterval.value = 1/6
|
||||
startRotateTimer()
|
||||
} else {
|
||||
stopRotateTimer()
|
||||
}
|
||||
}
|
||||
|
||||
function changeRotateInterval(min: number) {
|
||||
rotateInterval.value = min
|
||||
startRotateTimer()
|
||||
}
|
||||
|
||||
function formatRemaining(seconds: number): string {
|
||||
const min = Math.floor(seconds / 60)
|
||||
const sec = seconds % 60
|
||||
return `${min}:${sec.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function changePeriod(period: string) {
|
||||
selectedPeriod.value = period
|
||||
fetchAllData()
|
||||
}
|
||||
|
||||
let timeInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
currentTime.value = formatTime(new Date())
|
||||
timeInterval = setInterval(() => { currentTime.value = formatTime(new Date()) }, 1000)
|
||||
await fetchTargets()
|
||||
if (selectedTargetId.value) await fetchAllData()
|
||||
// 자동 순환 기본 활성화
|
||||
if (autoRotate.value) startRotateTimer()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timeInterval) clearInterval(timeInterval)
|
||||
stopRotateTimer()
|
||||
cpuChart?.destroy()
|
||||
memChart?.destroy()
|
||||
diskChart?.destroy()
|
||||
networkMainChart?.destroy()
|
||||
// 컨테이너 차트 정리
|
||||
Object.values(containerCharts).forEach(charts => {
|
||||
Object.values(charts).forEach(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; height: 100vh; overflow: hidden; }
|
||||
.main-header { display: flex; justify-content: space-between; align-items: center; padding: 16px 24px; background: var(--bg-secondary); border-bottom: 1px solid var(--border-color); flex-shrink: 0; }
|
||||
.page-title { font-size: 20px; font-weight: 600; color: var(--text-primary); margin: 0; }
|
||||
.header-info { display: flex; align-items: center; gap: 16px; }
|
||||
.current-time { font-family: monospace; color: var(--text-muted); }
|
||||
.main-body { flex: 1; padding: 16px; display: flex; flex-direction: column; gap: 12px; overflow: hidden; }
|
||||
.fixed-top { flex-shrink: 0; display: flex; flex-direction: column; gap: 12px; }
|
||||
.scroll-area { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 12px; padding-bottom: 16px; }
|
||||
|
||||
/* 필터 행 */
|
||||
.filter-row, .server-row { display: flex; align-items: center; gap: 12px; background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 8px; padding: 10px 16px; }
|
||||
.filter-label { font-size: 13px; font-weight: 600; color: var(--text-muted); min-width: 60px; }
|
||||
.period-buttons { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.server-buttons { display: flex; gap: 6px; flex-wrap: wrap; flex: 1; }
|
||||
.period-btn, .server-btn { padding: 6px 14px; border: 1px solid var(--border-color); border-radius: 6px; background: var(--bg-primary); color: var(--text-muted); font-size: 13px; cursor: pointer; transition: all 0.2s; }
|
||||
.period-btn:hover, .server-btn:hover { border-color: var(--text-muted); }
|
||||
.period-btn.active, .server-btn.active { background: var(--btn-primary-bg); border-color: var(--btn-primary-bg); color: #fff; }
|
||||
.server-btn { display: flex; align-items: center; gap: 6px; }
|
||||
.server-status { width: 8px; height: 8px; border-radius: 50%; background: var(--text-muted); }
|
||||
.server-status.online { background: #22c55e; }
|
||||
.server-nav { display: flex; gap: 6px; margin-left: auto; }
|
||||
.nav-btn { width: 36px; height: 32px; border: 1px solid var(--border-color); border-radius: 6px; background: var(--bg-primary); color: var(--text-primary); font-size: 14px; cursor: pointer; transition: all 0.2s; display: flex; align-items: center; justify-content: center; }
|
||||
.nav-btn:hover { background: var(--btn-primary-bg); border-color: var(--btn-primary-bg); color: #fff; }
|
||||
|
||||
/* 자동 순환 */
|
||||
.auto-rotate { display: flex; align-items: center; gap: 10px; margin-left: 12px; padding-left: 12px; border-left: 1px solid var(--border-color); }
|
||||
.rotate-checkbox { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-primary); cursor: pointer; white-space: nowrap; }
|
||||
.rotate-checkbox input { width: 16px; height: 16px; cursor: pointer; }
|
||||
.rotate-intervals { display: flex; gap: 4px; }
|
||||
.rotate-btn { padding: 4px 10px; border: 1px solid var(--border-color); border-radius: 4px; background: var(--bg-primary); color: var(--text-muted); font-size: 12px; cursor: pointer; transition: all 0.2s; }
|
||||
.rotate-btn:hover:not(:disabled) { border-color: var(--text-muted); }
|
||||
.rotate-btn.active { background: var(--btn-primary-bg); border-color: var(--btn-primary-bg); color: #fff; }
|
||||
.rotate-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.rotate-remaining { font-size: 13px; font-weight: 600; color: var(--btn-primary-bg); font-family: monospace; min-width: 40px; }
|
||||
|
||||
/* 스냅샷 정보 */
|
||||
.snapshot-info { background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 8px; padding: 10px 16px; }
|
||||
.info-grid { display: flex; flex-wrap: wrap; gap: 12px 24px; }
|
||||
.info-item { display: flex; flex-direction: column; gap: 2px; }
|
||||
.info-item label { font-size: 10px; color: var(--text-muted); text-transform: uppercase; }
|
||||
.info-item span { font-size: 13px; color: var(--text-primary); font-weight: 500; }
|
||||
.disk-list { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border-color); display: flex; align-items: flex-start; gap: 12px; }
|
||||
.disk-list label { font-size: 10px; color: var(--text-muted); text-transform: uppercase; min-width: 40px; }
|
||||
.disk-items { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.disk-item { font-size: 12px; color: var(--text-primary); background: var(--bg-primary); padding: 4px 8px; border-radius: 4px; border: 1px solid var(--border-color); }
|
||||
|
||||
/* 차트 행 */
|
||||
.chart-row { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
|
||||
.chart-box { background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 8px; padding: 12px; }
|
||||
.chart-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
.chart-header h4 { margin: 0; font-size: 13px; font-weight: 600; color: var(--text-primary); }
|
||||
.chart-avg { font-size: 13px; color: #1e293b; font-weight: 500; background: #e2e8f0; padding: 4px 10px; border-radius: 4px; }
|
||||
.val-cpu { color: #2563eb; font-weight: 600; }
|
||||
.val-temp { color: #dc2626; font-weight: 600; }
|
||||
.val-mem { color: #16a34a; font-weight: 600; }
|
||||
.val-swap { color: #d97706; font-weight: 600; }
|
||||
.val-disk { color: #3b82f6; font-weight: 600; }
|
||||
.val-load { color: #8b5cf6; font-weight: 600; }
|
||||
.val-rx { color: #0891b2; font-weight: 600; }
|
||||
.val-tx { color: #ea580c; font-weight: 600; }
|
||||
.chart-container { height: 360px; position: relative; }
|
||||
|
||||
/* 컨테이너 섹션 */
|
||||
.container-section { background: var(--bg-tertiary, #f1f5f9); border-radius: 12px; padding: 16px; margin-top: 16px; }
|
||||
.section-title { margin: 0 0 16px 0; font-size: 16px; font-weight: 700; color: var(--text-primary); }
|
||||
.container-cards { display: flex; flex-direction: column; gap: 16px; }
|
||||
.container-card { background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 10px; padding: 14px; }
|
||||
.container-card.running { background: #f0fdf4; border-color: #86efac; }
|
||||
.container-card.exited { background: #fef2f2; border-color: #fca5a5; }
|
||||
.container-card.paused { background: #fffbeb; border-color: #fcd34d; }
|
||||
.container-card.restarting { background: #fef3c7; border-color: #f59e0b; }
|
||||
.container-header { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; padding-bottom: 10px; border-bottom: 1px solid var(--border-color); }
|
||||
.container-card.running .container-header { border-color: #86efac; }
|
||||
.container-card.exited .container-header { border-color: #fca5a5; }
|
||||
.container-card.paused .container-header { border-color: #fcd34d; }
|
||||
.container-card.restarting .container-header { border-color: #f59e0b; }
|
||||
.container-name { font-size: 16px; font-weight: 700; color: var(--btn-primary-bg); background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
|
||||
.container-card.exited .container-name { background: linear-gradient(135deg, #dc2626 0%, #f97316 100%); -webkit-background-clip: text; background-clip: text; }
|
||||
.container-card.paused .container-name { background: linear-gradient(135deg, #d97706 0%, #eab308 100%); -webkit-background-clip: text; background-clip: text; }
|
||||
.container-card.restarting .container-name { background: linear-gradient(135deg, #ea580c 0%, #facc15 100%); -webkit-background-clip: text; background-clip: text; }
|
||||
.container-status { font-size: 12px; padding: 3px 10px; border-radius: 12px; font-weight: 500; }
|
||||
.container-status.running { background: #dcfce7; color: #166534; }
|
||||
.container-status.exited { background: #fee2e2; color: #991b1b; }
|
||||
.container-status.paused { background: #fef3c7; color: #92400e; }
|
||||
.container-status.restarting { background: #ffedd5; color: #c2410c; }
|
||||
.container-status.unknown { background: #e2e8f0; color: #475569; }
|
||||
.container-uptime { font-size: 12px; color: var(--text-muted); margin-left: auto; }
|
||||
.container-charts { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
|
||||
.container-chart-box { background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 8px; padding: 10px; }
|
||||
.container-card.running .container-chart-box { background: #ffffff; border-color: #bbf7d0; }
|
||||
.container-card.exited .container-chart-box { background: #ffffff; border-color: #fecaca; }
|
||||
.container-card.paused .container-chart-box { background: #ffffff; border-color: #fde68a; }
|
||||
.container-card.restarting .container-chart-box { background: #ffffff; border-color: #fed7aa; }
|
||||
.container-chart-box .chart-header { margin-bottom: 6px; }
|
||||
.container-chart-box .chart-title { font-size: 12px; font-weight: 600; color: var(--text-primary); }
|
||||
.container-chart-box .chart-avg { font-size: 11px; padding: 2px 8px; }
|
||||
.container-chart { height: 180px; position: relative; }
|
||||
|
||||
@media (max-width: 1200px) { .container-charts { grid-template-columns: repeat(2, 1fr); } }
|
||||
@media (max-width: 900px) { .chart-row { grid-template-columns: 1fr; } .container-charts { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
515
frontend/server/list.vue
Normal file
515
frontend/server/list.vue
Normal file
@@ -0,0 +1,515 @@
|
||||
<template>
|
||||
<div class="app-layout">
|
||||
<SidebarNav />
|
||||
|
||||
<div class="main-content">
|
||||
<header class="main-header">
|
||||
<h1 class="page-title">🖥️ Server Targets</h1>
|
||||
<div class="header-info">
|
||||
<span class="current-time">{{ currentTime }}</span>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main-body">
|
||||
<!-- 스케줄러 상태 -->
|
||||
<section class="section">
|
||||
<h2>스케줄러 상태</h2>
|
||||
<div class="scheduler-card">
|
||||
<div class="status-item">
|
||||
<span class="label">상태</span>
|
||||
<span :class="['value', schedulerStatus.is_running ? 'active' : 'inactive']">
|
||||
{{ schedulerStatus.is_running ? '실행 중' : '중지됨' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="label">활성 타이머</span>
|
||||
<span class="value">{{ schedulerStatus.active_timers }} / {{ schedulerStatus.total_targets }}</span>
|
||||
</div>
|
||||
<div class="scheduler-controls">
|
||||
<button class="btn btn-start" @click="startScheduler" :disabled="schedulerStatus.is_running">
|
||||
▶ 시작
|
||||
</button>
|
||||
<button class="btn btn-stop" @click="stopScheduler" :disabled="!schedulerStatus.is_running">
|
||||
⏹ 중지
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 서버 목록 관리 -->
|
||||
<section class="section">
|
||||
<h2>서버 목록 관리</h2>
|
||||
|
||||
<!-- 추가 폼 -->
|
||||
<div class="add-form">
|
||||
<input
|
||||
v-model="newTarget.server_name"
|
||||
type="text"
|
||||
placeholder="서버명"
|
||||
class="input-field"
|
||||
style="width: 120px;"
|
||||
/>
|
||||
<input
|
||||
v-model="newTarget.server_ip"
|
||||
type="text"
|
||||
placeholder="IP"
|
||||
class="input-field"
|
||||
style="width: 130px;"
|
||||
/>
|
||||
<input
|
||||
v-model="newTarget.glances_url"
|
||||
type="text"
|
||||
placeholder="Glances URL"
|
||||
class="input-field url-input"
|
||||
/>
|
||||
<input
|
||||
v-model.number="newTarget.collect_interval"
|
||||
type="number"
|
||||
placeholder="주기(초)"
|
||||
class="input-field"
|
||||
style="width: 80px;"
|
||||
min="10"
|
||||
/>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="newTarget.is_active" />
|
||||
활성
|
||||
</label>
|
||||
<button class="btn btn-add" @click="addTarget" :disabled="!canAdd">
|
||||
추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 목록 -->
|
||||
<div v-if="targets.length === 0" class="no-data">
|
||||
등록된 서버가 없습니다.
|
||||
</div>
|
||||
<div v-else>
|
||||
<p class="total-count">총 {{ targets.length }}개</p>
|
||||
<table class="target-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 50px;">ID</th>
|
||||
<th style="width: 100px;">서버명</th>
|
||||
<th style="width: 120px;">IP</th>
|
||||
<th>Glances URL</th>
|
||||
<th style="width: 70px;">주기(초)</th>
|
||||
<th style="width: 50px;">상태</th>
|
||||
<th style="width: 120px;">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="target in targets" :key="target.target_id">
|
||||
<td>{{ target.target_id }}</td>
|
||||
<td>
|
||||
<input v-if="editingId === target.target_id" v-model="editTarget.server_name" type="text" class="edit-input" />
|
||||
<span v-else>{{ target.server_name }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<input v-if="editingId === target.target_id" v-model="editTarget.server_ip" type="text" class="edit-input" />
|
||||
<span v-else>{{ target.server_ip }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<input v-if="editingId === target.target_id" v-model="editTarget.glances_url" type="text" class="edit-input" />
|
||||
<span v-else class="url-cell">{{ target.glances_url }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<input v-if="editingId === target.target_id" v-model.number="editTarget.collect_interval" type="number" class="edit-input" min="10" />
|
||||
<span v-else>{{ target.collect_interval }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<label v-if="editingId === target.target_id" class="checkbox-label">
|
||||
<input type="checkbox" v-model="editTarget.is_active" />
|
||||
</label>
|
||||
<span v-else>{{ target.is_active ? '✅' : '❌' }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div v-if="editingId === target.target_id" class="action-buttons">
|
||||
<button class="btn btn-save" @click="saveEdit">저장</button>
|
||||
<button class="btn btn-cancel" @click="cancelEdit">취소</button>
|
||||
</div>
|
||||
<div v-else class="action-buttons">
|
||||
<button class="btn btn-edit" @click="startEdit(target)">수정</button>
|
||||
<button class="btn btn-delete" @click="deleteTarget(target.target_id)">삭제</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface ServerTarget {
|
||||
target_id: number
|
||||
server_name: string
|
||||
server_ip: string
|
||||
glances_url: string
|
||||
is_active: number
|
||||
collect_interval: number
|
||||
}
|
||||
|
||||
interface SchedulerStatus {
|
||||
is_running: boolean
|
||||
active_timers: number
|
||||
total_targets: number
|
||||
}
|
||||
|
||||
const targets = ref<ServerTarget[]>([])
|
||||
const currentTime = ref('')
|
||||
const editingId = ref<number | null>(null)
|
||||
const schedulerStatus = ref<SchedulerStatus>({
|
||||
is_running: false,
|
||||
active_timers: 0,
|
||||
total_targets: 0
|
||||
})
|
||||
|
||||
const newTarget = ref({
|
||||
server_name: '',
|
||||
server_ip: '',
|
||||
glances_url: '',
|
||||
is_active: true,
|
||||
collect_interval: 60
|
||||
})
|
||||
|
||||
const editTarget = ref({
|
||||
server_name: '',
|
||||
server_ip: '',
|
||||
glances_url: '',
|
||||
is_active: true,
|
||||
collect_interval: 60
|
||||
})
|
||||
|
||||
const canAdd = computed(() =>
|
||||
newTarget.value.server_name.trim() &&
|
||||
newTarget.value.server_ip.trim() &&
|
||||
newTarget.value.glances_url.trim()
|
||||
)
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
const y = date.getFullYear()
|
||||
const M = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(date.getDate()).padStart(2, '0')
|
||||
const h = String(date.getHours()).padStart(2, '0')
|
||||
const m = String(date.getMinutes()).padStart(2, '0')
|
||||
const s = String(date.getSeconds()).padStart(2, '0')
|
||||
return `${y}-${M}-${d} ${h}:${m}:${s}`
|
||||
}
|
||||
|
||||
async function fetchSchedulerStatus() {
|
||||
try {
|
||||
const res = await $fetch('/api/server/status')
|
||||
schedulerStatus.value = res as SchedulerStatus
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch scheduler status:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function startScheduler() {
|
||||
try {
|
||||
await $fetch('/api/server/scheduler/start', { method: 'POST' })
|
||||
await fetchSchedulerStatus()
|
||||
} catch (err) {
|
||||
console.error('Failed to start scheduler:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function stopScheduler() {
|
||||
try {
|
||||
await $fetch('/api/server/scheduler/stop', { method: 'POST' })
|
||||
await fetchSchedulerStatus()
|
||||
} catch (err) {
|
||||
console.error('Failed to stop scheduler:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTargets() {
|
||||
try {
|
||||
const res = await $fetch('/api/server/targets')
|
||||
targets.value = (res as ServerTarget[]) || []
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch targets:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function addTarget() {
|
||||
if (!canAdd.value) return
|
||||
try {
|
||||
await $fetch('/api/server/targets', {
|
||||
method: 'POST',
|
||||
body: newTarget.value
|
||||
})
|
||||
newTarget.value = { server_name: '', server_ip: '', glances_url: '', is_active: true, collect_interval: 60 }
|
||||
await fetchTargets()
|
||||
await fetchSchedulerStatus()
|
||||
} catch (err) {
|
||||
console.error('Failed to add target:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(target: ServerTarget) {
|
||||
editingId.value = target.target_id
|
||||
editTarget.value = {
|
||||
server_name: target.server_name,
|
||||
server_ip: target.server_ip,
|
||||
glances_url: target.glances_url,
|
||||
is_active: !!target.is_active,
|
||||
collect_interval: target.collect_interval || 60
|
||||
}
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingId.value = null
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!editingId.value) return
|
||||
try {
|
||||
await $fetch(`/api/server/targets/${editingId.value}`, {
|
||||
method: 'PUT',
|
||||
body: editTarget.value
|
||||
})
|
||||
editingId.value = null
|
||||
await fetchTargets()
|
||||
await fetchSchedulerStatus()
|
||||
} catch (err) {
|
||||
console.error('Failed to save target:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTarget(id: number) {
|
||||
if (!confirm('정말 삭제하시겠습니까?')) return
|
||||
try {
|
||||
await $fetch(`/api/server/targets/${id}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
await fetchTargets()
|
||||
await fetchSchedulerStatus()
|
||||
} catch (err) {
|
||||
console.error('Failed to delete target:', err)
|
||||
}
|
||||
}
|
||||
|
||||
let timeInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
onMounted(() => {
|
||||
currentTime.value = formatTime(new Date())
|
||||
timeInterval = setInterval(() => {
|
||||
currentTime.value = formatTime(new Date())
|
||||
}, 1000)
|
||||
|
||||
fetchTargets()
|
||||
fetchSchedulerStatus()
|
||||
|
||||
// 스케줄러 상태 주기적 갱신
|
||||
setInterval(fetchSchedulerStatus, 5000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timeInterval) clearInterval(timeInterval)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-layout {
|
||||
display: flex;
|
||||
min-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;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.current-time {
|
||||
font-family: monospace;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.main-body {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 16px 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.scheduler-card {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.status-item .label {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.status-item .value {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.status-item .value.active { color: #22c55e; }
|
||||
.status-item .value.inactive { color: #ef4444; }
|
||||
|
||||
.scheduler-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.add-form {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.input-field.url-input {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.total-count {
|
||||
margin-bottom: 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
th {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
td {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.url-cell {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.edit-input {
|
||||
width: 100%;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-add { background: var(--btn-primary-bg); color: #fff; }
|
||||
.btn-add:hover:not(:disabled) { background: var(--btn-primary-hover); }
|
||||
.btn-start { background: #22c55e; color: #fff; }
|
||||
.btn-stop { background: #ef4444; color: #fff; }
|
||||
.btn-edit { background: #3b82f6; color: #fff; }
|
||||
.btn-save { background: #22c55e; color: #fff; }
|
||||
.btn-cancel { background: #6b7280; color: #fff; }
|
||||
.btn-delete { background: #ef4444; color: #fff; }
|
||||
</style>
|
||||
240
frontend/settings/thresholds.vue
Normal file
240
frontend/settings/thresholds.vue
Normal file
@@ -0,0 +1,240 @@
|
||||
<template>
|
||||
<div class="app-layout">
|
||||
<SidebarNav />
|
||||
|
||||
<div class="main-content">
|
||||
<header class="main-header">
|
||||
<h1 class="page-title">⚙️ 임계값 설정</h1>
|
||||
<div class="header-info">
|
||||
<span class="current-time">{{ currentTime }}</span>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main-body">
|
||||
<div class="settings-container">
|
||||
<!-- 서버 임계값 -->
|
||||
<section class="threshold-section">
|
||||
<h2 class="section-title">🖥️ 서버 임계값</h2>
|
||||
<p class="section-desc">서버의 CPU, Memory, Disk 사용률에 대한 경고 기준을 설정합니다.</p>
|
||||
|
||||
<div class="threshold-table">
|
||||
<div class="table-header">
|
||||
<div class="col-metric">지표</div>
|
||||
<div class="col-value">🟡 주의 (Warning)</div>
|
||||
<div class="col-value">🟠 경고 (Critical)</div>
|
||||
<div class="col-value">🔴 위험 (Danger)</div>
|
||||
</div>
|
||||
<div class="table-row" v-for="metric in serverMetrics" :key="metric.key">
|
||||
<div class="col-metric">
|
||||
<span class="metric-icon">{{ metric.icon }}</span>
|
||||
<span>{{ metric.label }}</span>
|
||||
</div>
|
||||
<div class="col-value">
|
||||
<input type="number" v-model.number="thresholds.server[metric.key].warning" min="0" max="100" />
|
||||
<span class="unit">%</span>
|
||||
</div>
|
||||
<div class="col-value">
|
||||
<input type="number" v-model.number="thresholds.server[metric.key].critical" min="0" max="100" />
|
||||
<span class="unit">%</span>
|
||||
</div>
|
||||
<div class="col-value">
|
||||
<input type="number" v-model.number="thresholds.server[metric.key].danger" min="0" max="100" />
|
||||
<span class="unit">%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 컨테이너 임계값 -->
|
||||
<section class="threshold-section">
|
||||
<h2 class="section-title">🐳 컨테이너 임계값</h2>
|
||||
<p class="section-desc">컨테이너의 CPU, Memory 사용률에 대한 경고 기준을 설정합니다.</p>
|
||||
|
||||
<div class="threshold-table">
|
||||
<div class="table-header">
|
||||
<div class="col-metric">지표</div>
|
||||
<div class="col-value">🟡 주의 (Warning)</div>
|
||||
<div class="col-value">🟠 경고 (Critical)</div>
|
||||
<div class="col-value">🔴 위험 (Danger)</div>
|
||||
</div>
|
||||
<div class="table-row" v-for="metric in containerMetrics" :key="metric.key">
|
||||
<div class="col-metric">
|
||||
<span class="metric-icon">{{ metric.icon }}</span>
|
||||
<span>{{ metric.label }}</span>
|
||||
</div>
|
||||
<div class="col-value">
|
||||
<input type="number" v-model.number="thresholds.container[metric.key].warning" min="0" max="100" />
|
||||
<span class="unit">%</span>
|
||||
</div>
|
||||
<div class="col-value">
|
||||
<input type="number" v-model.number="thresholds.container[metric.key].critical" min="0" max="100" />
|
||||
<span class="unit">%</span>
|
||||
</div>
|
||||
<div class="col-value">
|
||||
<input type="number" v-model.number="thresholds.container[metric.key].danger" min="0" max="100" />
|
||||
<span class="unit">%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 레벨 설명 -->
|
||||
<section class="level-guide">
|
||||
<h3>📋 레벨 설명</h3>
|
||||
<div class="level-items">
|
||||
<div class="level-item normal">🟢 정상: 모든 지표가 주의 기준 미만</div>
|
||||
<div class="level-item warning">🟡 주의: 하나 이상의 지표가 주의 기준 이상</div>
|
||||
<div class="level-item critical">🟠 경고: 하나 이상의 지표가 경고 기준 이상</div>
|
||||
<div class="level-item danger">🔴 위험: 하나 이상의 지표가 위험 기준 이상</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 저장 버튼 -->
|
||||
<div class="actions">
|
||||
<button class="btn-reset" @click="resetDefaults" :disabled="saving">기본값 복원</button>
|
||||
<button class="btn-save" @click="saveThresholds" :disabled="saving">
|
||||
{{ saving ? '저장 중...' : '저장' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="message" :class="['message', messageType]">{{ message }}</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const currentTime = ref('')
|
||||
const saving = ref(false)
|
||||
const message = ref('')
|
||||
const messageType = ref<'success' | 'error'>('success')
|
||||
|
||||
const serverMetrics = [
|
||||
{ key: 'cpu', label: 'CPU 사용률', icon: '💻' },
|
||||
{ key: 'memory', label: 'Memory 사용률', icon: '🧠' },
|
||||
{ key: 'disk', label: 'Disk 사용률', icon: '💾' }
|
||||
]
|
||||
|
||||
const containerMetrics = [
|
||||
{ key: 'cpu', label: 'CPU 사용률', icon: '💻' },
|
||||
{ key: 'memory', label: 'Memory 사용률', icon: '🧠' }
|
||||
]
|
||||
|
||||
const defaultThresholds = {
|
||||
server: {
|
||||
cpu: { warning: 70, critical: 85, danger: 95 },
|
||||
memory: { warning: 80, critical: 90, danger: 95 },
|
||||
disk: { warning: 80, critical: 90, danger: 95 }
|
||||
},
|
||||
container: {
|
||||
cpu: { warning: 80, critical: 90, danger: 95 },
|
||||
memory: { warning: 80, critical: 90, danger: 95 }
|
||||
}
|
||||
}
|
||||
|
||||
const thresholds = ref(JSON.parse(JSON.stringify(defaultThresholds)))
|
||||
|
||||
async function fetchThresholds() {
|
||||
try {
|
||||
const data = await $fetch('/api/settings/thresholds')
|
||||
if (data) {
|
||||
thresholds.value = data as typeof defaultThresholds
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch thresholds:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveThresholds() {
|
||||
saving.value = true
|
||||
message.value = ''
|
||||
|
||||
try {
|
||||
await $fetch('/api/settings/thresholds', {
|
||||
method: 'PUT',
|
||||
body: thresholds.value
|
||||
})
|
||||
message.value = '✅ 저장되었습니다.'
|
||||
messageType.value = 'success'
|
||||
} catch (err: any) {
|
||||
message.value = `❌ 저장 실패: ${err.data?.message || err.message}`
|
||||
messageType.value = 'error'
|
||||
} finally {
|
||||
saving.value = false
|
||||
setTimeout(() => { message.value = '' }, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
function resetDefaults() {
|
||||
thresholds.value = JSON.parse(JSON.stringify(defaultThresholds))
|
||||
message.value = '기본값으로 복원되었습니다. 저장 버튼을 눌러 적용하세요.'
|
||||
messageType.value = 'success'
|
||||
setTimeout(() => { message.value = '' }, 3000)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const updateTime = () => {
|
||||
currentTime.value = new Date().toLocaleString('ko-KR', {
|
||||
timeZone: 'Asia/Seoul',
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit'
|
||||
})
|
||||
}
|
||||
updateTime()
|
||||
setInterval(updateTime, 1000)
|
||||
fetchThresholds()
|
||||
})
|
||||
</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-info { display: flex; align-items: center; gap: 16px; }
|
||||
.current-time { font-size: 14px; color: var(--text-muted); font-family: monospace; }
|
||||
.main-body { flex: 1; padding: 24px; overflow-y: auto; }
|
||||
|
||||
.settings-container { }
|
||||
|
||||
.threshold-section { background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 12px; padding: 20px; margin-bottom: 20px; }
|
||||
.section-title { margin: 0 0 8px 0; font-size: 18px; font-weight: 600; color: var(--text-primary); }
|
||||
.section-desc { margin: 0 0 16px 0; font-size: 13px; color: var(--text-muted); }
|
||||
|
||||
.threshold-table { border: 1px solid var(--border-color); border-radius: 8px; overflow: hidden; }
|
||||
.table-header { display: grid; grid-template-columns: 180px repeat(3, 1fr); background: var(--bg-tertiary, #f1f5f9); border-bottom: 1px solid var(--border-color); }
|
||||
.table-header > div { padding: 12px 16px; font-size: 13px; font-weight: 600; color: var(--text-primary); }
|
||||
.table-row { display: grid; grid-template-columns: 180px repeat(3, 1fr); border-bottom: 1px solid var(--border-color); }
|
||||
.table-row:last-child { border-bottom: none; }
|
||||
.table-row:hover { background: var(--bg-tertiary, #f8fafc); }
|
||||
|
||||
.col-metric { display: flex; align-items: center; gap: 8px; padding: 12px 16px; font-size: 14px; color: var(--text-primary); }
|
||||
.metric-icon { font-size: 16px; }
|
||||
.col-value { display: flex; align-items: center; gap: 6px; padding: 10px 16px; }
|
||||
.col-value input { width: 70px; padding: 8px 10px; border: 1px solid var(--border-color); border-radius: 6px; font-size: 14px; text-align: center; background: var(--bg-primary); color: var(--text-primary); }
|
||||
.col-value input:focus { outline: none; border-color: var(--btn-primary-bg); }
|
||||
.col-value .unit { font-size: 13px; color: var(--text-muted); }
|
||||
|
||||
.level-guide { background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 12px; padding: 20px; margin-bottom: 20px; }
|
||||
.level-guide h3 { margin: 0 0 12px 0; font-size: 15px; font-weight: 600; color: var(--text-primary); }
|
||||
.level-items { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||
.level-item { padding: 8px 14px; border-radius: 6px; font-size: 13px; }
|
||||
.level-item.normal { background: #f0fdf4; color: #166534; border: 1px solid #86efac; }
|
||||
.level-item.warning { background: #fefce8; color: #854d0e; border: 1px solid #fde047; }
|
||||
.level-item.critical { background: #fff7ed; color: #c2410c; border: 1px solid #fdba74; }
|
||||
.level-item.danger { background: #fef2f2; color: #991b1b; border: 1px solid #fca5a5; }
|
||||
|
||||
.actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 20px; }
|
||||
.btn-reset { padding: 10px 20px; border: 1px solid var(--border-color); border-radius: 8px; background: var(--bg-primary); color: var(--text-primary); font-size: 14px; cursor: pointer; transition: all 0.2s; }
|
||||
.btn-reset:hover:not(:disabled) { background: var(--bg-secondary); }
|
||||
.btn-reset:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-save { padding: 10px 24px; border: none; border-radius: 8px; background: var(--btn-primary-bg); color: #fff; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.2s; }
|
||||
.btn-save:hover:not(:disabled) { opacity: 0.9; }
|
||||
.btn-save:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.message { margin-top: 16px; padding: 12px 16px; border-radius: 8px; font-size: 14px; text-align: center; }
|
||||
.message.success { background: #f0fdf4; color: #166534; border: 1px solid #86efac; }
|
||||
.message.error { background: #fef2f2; color: #991b1b; border: 1px solid #fca5a5; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user