Files
system-monitor/frontend/components/ServerPortlet.vue
2025-12-28 23:54:56 +09:00

513 lines
22 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>
<div class="name-wrap">
<span class="name">{{ server.server_name }}</span>
<span class="server-ip">{{ server.server_ip }}</span>
</div>
<span class="container-count" v-if="server.level !== 'offline'">📦{{ server.container_summary.total }}</span>
</div>
<template v-if="server.level !== 'offline'">
<!-- 업타임 -->
<div class="info-row">
<span class="info-label">UP</span>
<span class="info-value">{{ server.uptime_str || '-' }}</span>
</div>
<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, { 'value-changed': isChanged(server.target_id, 'cpu') }]">
{{ 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: calcMemPercent(server) + '%' }"></div>
</div>
<span :class="['metric-value', server.memory_level, { 'value-changed': isChanged(server.target_id, 'mem') }]">
{{ calcMemPercent(server).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, { 'value-changed': isChanged(server.target_id, 'disk') }]">
{{ server.disk_percent?.toFixed(0) || '-' }}%
</span>
</div>
<!-- 추가 정보: 온도, Load, 메모리용량, 디스크용량 -->
<div class="info-row">
<span class="info-label">TEMP</span>
<span class="info-value">{{ server.cpu_temp ? server.cpu_temp + '°C' : '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">LOAD</span>
<span class="info-value">{{ server.load_percent ? server.load_percent.toFixed(1) + '%' : '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">RAM</span>
<span class="info-value">{{ formatServerMem(server) }}</span>
</div>
<div class="info-row">
<span class="info-label">HDD</span>
<span class="info-value">{{ formatServerDisk(server) }}</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: Math.min(container.cpu_percent || 0, 100) + '%' }"></div>
</div>
<span :class="['value', { 'value-changed': isContainerChanged(server.target_id, container.name, 'cpu') }]">
{{ formatCpuPercent(container.cpu_percent) }}
</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', { 'mem-highlight': isMemoryOver1GB(container.memory_usage), 'value-changed': isContainerChanged(server.target_id, container.name, 'mem') }]">
{{ formatMemoryShort(container.memory_usage) }}
</span>
</div>
<div class="card-metric">
<span class="label">RX</span>
<span :class="['value', 'net', { 'value-changed': isContainerChanged(server.target_id, container.name, 'rx') }]">
{{ formatNetworkShort(container.network_rx) }}
</span>
</div>
<div class="card-metric">
<span class="label">TX</span>
<span :class="['value', 'net', { 'value-changed': isContainerChanged(server.target_id, container.name, 'tx') }]">
{{ 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">
import { ref, watch } from 'vue'
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
server_ip: string
level: string
cpu_percent: number | null
cpu_level: string
memory_percent: number | null
memory_level: string
memory_total: number | null
memory_free: number | null
memory_used: number | null
disk_percent: number | null
disk_level: string
disk_used: number | null
disk_total: number | null
cpu_temp: number | null
load_percent: number | null
uptime_str: string | null
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 prevServerValues = ref<Record<string, Record<string, number | null>>>({})
const prevContainerValues = ref<Record<string, Record<string, number | null>>>({})
const changedKeys = ref<Set<string>>(new Set())
// 값 변경 감지
watch(() => props.servers, (newServers, oldServers) => {
if (!oldServers || oldServers.length === 0) {
// 초기 로드 시 이전 값 저장
newServers.forEach(server => {
prevServerValues.value[server.target_id] = {
cpu: server.cpu_percent,
mem: server.memory_percent,
disk: server.disk_percent
}
server.containers.forEach(c => {
const key = `${server.target_id}-${c.name}`
prevContainerValues.value[key] = {
cpu: c.cpu_percent,
mem: c.memory_usage,
rx: c.network_rx,
tx: c.network_tx
}
})
})
return
}
const newChangedKeys = new Set<string>()
newServers.forEach(server => {
const prev = prevServerValues.value[server.target_id] || {}
// 서버 값 비교
if (prev.cpu !== server.cpu_percent) newChangedKeys.add(`server-${server.target_id}-cpu`)
if (prev.mem !== server.memory_percent) newChangedKeys.add(`server-${server.target_id}-mem`)
if (prev.disk !== server.disk_percent) newChangedKeys.add(`server-${server.target_id}-disk`)
// 현재 값 저장
prevServerValues.value[server.target_id] = {
cpu: server.cpu_percent,
mem: server.memory_percent,
disk: server.disk_percent
}
// 컨테이너 값 비교
server.containers.forEach(c => {
const key = `${server.target_id}-${c.name}`
const prevC = prevContainerValues.value[key] || {}
if (prevC.cpu !== c.cpu_percent) newChangedKeys.add(`container-${key}-cpu`)
if (prevC.mem !== c.memory_usage) newChangedKeys.add(`container-${key}-mem`)
if (prevC.rx !== c.network_rx) newChangedKeys.add(`container-${key}-rx`)
if (prevC.tx !== c.network_tx) newChangedKeys.add(`container-${key}-tx`)
prevContainerValues.value[key] = {
cpu: c.cpu_percent,
mem: c.memory_usage,
rx: c.network_rx,
tx: c.network_tx
}
})
})
changedKeys.value = newChangedKeys
// 1.5초 후 하이라이트 제거
if (newChangedKeys.size > 0) {
setTimeout(() => {
changedKeys.value = new Set()
}, 1500)
}
}, { deep: true })
function isChanged(serverId: number, metric: string): boolean {
return changedKeys.value.has(`server-${serverId}-${metric}`)
}
function isContainerChanged(serverId: number, containerName: string, metric: string): boolean {
return changedKeys.value.has(`container-${serverId}-${containerName}-${metric}`)
}
// 서버 메모리 퍼센트: memory_percent 직접 사용
function calcMemPercent(server: ServerStatus): number {
return server.memory_percent || 0
}
// 서버 메모리 용량 포맷: used/total GB (memory_used는 DB에서 계산된 값)
function formatServerMem(server: ServerStatus): string {
const used = Number(server.memory_used) || 0
const total = Number(server.memory_total) || 0
if (total === 0) return '-'
const usedGB = (used / (1024 * 1024 * 1024)).toFixed(1)
const totalGB = (total / (1024 * 1024 * 1024)).toFixed(1)
return `${usedGB}/${totalGB}G`
}
// 서버 디스크 용량 포맷: used/total GB
function formatServerDisk(server: ServerStatus): string {
const used = Number(server.disk_used) || 0
const total = Number(server.disk_total) || 0
if (total === 0) return '-'
const usedGB = (used / (1024 * 1024 * 1024)).toFixed(0)
const totalGB = (total / (1024 * 1024 * 1024)).toFixed(0)
return `${usedGB}/${totalGB}G`
}
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 (Number(c.memory_usage) / Number(c.memory_limit)) * 100
}
function formatCpuPercent(value: number | null): string {
if (value === null || value === undefined) return '-'
// 10% 이상이면 정수로, 미만이면 소수점 1자리
if (value >= 10) return `${value.toFixed(0)}%`
if (value >= 1) return `${value.toFixed(1)}%`
if (value >= 0.1) return `${value.toFixed(1)}%`
return `${value.toFixed(2)}%`
}
function formatMemoryShort(bytes: number | null): string {
if (bytes === null || bytes === undefined) return '-'
const numBytes = Number(bytes)
if (numBytes < 1024 * 1024) return `${(numBytes / 1024).toFixed(0)}K`
if (numBytes < 1024 * 1024 * 1024) return `${(numBytes / 1024 / 1024).toFixed(0)}M`
return `${(numBytes / 1024 / 1024 / 1024).toFixed(1)}G`
}
function isMemoryOver1GB(bytes: number | null): boolean {
if (bytes === null || bytes === undefined) return false
return Number(bytes) >= 1024 * 1024 * 1024
}
function formatNetworkShort(bytes: number | null): string {
if (bytes === null || bytes === undefined) return '-'
const numBytes = Number(bytes)
if (numBytes < 1024) return `${numBytes}B`
if (numBytes < 1024 * 1024) return `${(numBytes / 1024).toFixed(0)}K`
if (numBytes < 1024 * 1024 * 1024) return `${(numBytes / 1024 / 1024).toFixed(1)}M`
return `${(numBytes / 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: 18px 24px; border-bottom: 1px solid var(--border-color); background: var(--bg-tertiary, #f8fafc); }
.portlet-title { margin: 0; font-size: 22px; font-weight: 600; color: var(--text-primary); }
.summary-badges { display: flex; align-items: center; gap: 16px; font-size: 17px; color: var(--text-secondary); }
.divider { color: var(--border-color); }
.level-counts { margin-left: 12px; }
.level-counts .lv { margin-right: 10px; font-size: 16px; }
.server-grid { display: flex; flex-wrap: wrap; gap: 16px; padding: 20px; align-items: flex-start; }
.server-unit { display: flex; background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 12px; overflow: hidden; }
.server-unit.warning { border-left: 5px solid #eab308; }
.server-unit.critical { border-left: 5px solid #f97316; }
.server-unit.danger { border-left: 5px solid #ef4444; }
.server-unit.offline { border-left: 5px solid #6b7280; opacity: 0.7; }
.server-info { width: 220px; min-width: 220px; padding: 16px; 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: 10px; margin-bottom: 14px; }
.server-name .level-icon { font-size: 16px; flex-shrink: 0; }
.server-name .name-wrap { flex: 1; min-width: 0; overflow: hidden; }
.server-name .name { display: block; font-size: 19px; font-weight: 700; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.server-name .server-ip { display: block; font-size: 12px; color: var(--text-muted); font-family: monospace; margin-top: 2px; }
.server-name .container-count { font-size: 15px; color: var(--text-muted); flex-shrink: 0; }
.metric-row { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
.metric-label { font-size: 14px; font-weight: 600; color: var(--text-muted); width: 42px; }
.progress-bar { flex: 1; height: 10px; background: var(--bg-tertiary, #e5e7eb); border-radius: 5px; overflow: hidden; }
.progress-fill { height: 100%; border-radius: 5px; 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: 17px; font-weight: 700; width: 44px; text-align: right; transition: all 0.3s; padding: 2px 4px; border-radius: 4px; }
.metric-value.normal { color: #16a34a; }
.metric-value.warning { color: #ca8a04; }
.metric-value.critical { color: #ea580c; }
.metric-value.danger { color: #dc2626; }
.info-row { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.info-label { font-size: 14px; font-weight: 600; color: var(--text-muted); width: 42px; }
.info-value { flex: 1; font-size: 14px; font-weight: 600; color: var(--text-secondary); font-family: monospace; text-align: right; }
.offline-info { text-align: center; padding: 24px 0; color: var(--text-muted); }
.offline-text { font-size: 18px; margin-bottom: 8px; }
.offline-time { font-size: 15px; opacity: 0.7; }
.container-area { display: flex; flex-wrap: wrap; gap: 10px; padding: 12px; align-content: flex-start; min-width: 140px; }
.container-card { width: 208px; 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(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.container-card.normal { background: var(--container-normal-bg, #f0fdf4); border-color: var(--container-normal-border, #86efac); }
.container-card.warning { background: var(--container-warning-bg, #fefce8); border-color: var(--container-warning-border, #fde047); }
.container-card.critical { background: var(--container-critical-bg, #fff7ed); border-color: var(--container-critical-border, #fdba74); }
.container-card.danger { background: var(--container-danger-bg, #fef2f2); border-color: var(--container-danger-border, #fca5a5); }
.container-card.stopped { background: var(--container-danger-bg, #fef2f2); border-color: var(--container-danger-border, #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: 6px; flex: 1; min-width: 0; overflow: hidden; }
.card-name .card-level { font-size: 12px; flex-shrink: 0; }
.card-name .name { font-size: 15px; font-weight: 700; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.card-uptime { font-size: 12px; color: var(--text-muted); flex-shrink: 0; margin-left: 8px; white-space: nowrap; }
.card-metrics { display: flex; flex-wrap: wrap; gap: 5px 8px; }
.card-metric { display: flex; align-items: center; gap: 4px; width: calc(50% - 4px); overflow: visible; }
.card-metric .label { font-size: 11px; color: var(--text-muted); width: 22px; flex-shrink: 0; font-weight: 500; }
.mini-bar { flex: 1; height: 6px; background: rgba(0,0,0,0.1); border-radius: 3px; overflow: hidden; min-width: 20px; }
.mini-fill { height: 100%; border-radius: 3px; }
.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: 13px; font-weight: 700; color: var(--text-secondary); min-width: 44px; text-align: right; flex-shrink: 0; transition: all 0.3s; padding: 1px 2px; border-radius: 3px; }
.card-metric .value.mem-highlight { color: #dc2626; font-weight: 800; }
.card-metric .value.net { color: var(--text-muted); font-weight: 600; }
.card-stopped { font-size: 15px; color: var(--text-muted); font-style: italic; text-align: center; padding: 10px 0; }
.no-container { font-size: 16px; color: var(--text-muted); padding: 16px; display: flex; align-items: center; justify-content: center; }
/* 값 변경 시 하이라이트 애니메이션 - 글자색 변경 */
@keyframes valueFlash {
0% {
color: #3b82f6;
text-shadow: 0 0 8px rgba(59, 130, 246, 0.8);
transform: scale(1.15);
}
50% {
color: #60a5fa;
text-shadow: 0 0 4px rgba(59, 130, 246, 0.5);
transform: scale(1.08);
}
100% {
text-shadow: none;
transform: scale(1);
}
}
.value-changed {
animation: valueFlash 1.5s ease-out;
}
</style>