792 lines
38 KiB
Vue
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: #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>
|