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

792 lines
38 KiB
Vue

<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: var(--container-normal-bg, #f0fdf4); border-color: var(--container-normal-border, #86efac); }
.container-card.exited { background: var(--container-danger-bg, #fef2f2); border-color: var(--container-danger-border, #fca5a5); }
.container-card.paused { background: var(--container-warning-bg, #fffbeb); border-color: var(--container-warning-border, #fcd34d); }
.container-card.restarting { background: var(--container-critical-bg, #fef3c7); border-color: var(--container-critical-border, #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: var(--container-normal-border, #86efac); }
.container-card.exited .container-header { border-color: var(--container-danger-border, #fca5a5); }
.container-card.paused .container-header { border-color: var(--container-warning-border, #fcd34d); }
.container-card.restarting .container-header { border-color: var(--container-critical-border, #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: var(--container-normal-bg, #dcfce7); color: var(--container-status-running, #166534); }
.container-status.exited { background: var(--container-danger-bg, #fee2e2); color: var(--container-status-exited, #991b1b); }
.container-status.paused { background: var(--container-warning-bg, #fef3c7); color: var(--container-status-paused, #92400e); }
.container-status.restarting { background: var(--container-critical-bg, #ffedd5); color: var(--container-status-restarting, #c2410c); }
.container-status.unknown { background: var(--bg-tertiary, #e2e8f0); color: var(--text-muted); }
.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: var(--bg-primary); border-color: var(--container-normal-border, #bbf7d0); }
.container-card.exited .container-chart-box { background: var(--bg-primary); border-color: var(--container-danger-border, #fecaca); }
.container-card.paused .container-chart-box { background: var(--bg-primary); border-color: var(--container-warning-border, #fde68a); }
.container-card.restarting .container-chart-box { background: var(--bg-primary); border-color: var(--container-critical-border, #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>