Files
system-monitor/frontend/components/ServerPortlet.vue
2025-12-28 17:55:55 +09:00

436 lines
19 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>
<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, { '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: (server.memory_percent || 0) + '%' }"></div>
</div>
<span :class="['metric-value', server.memory_level, { 'value-changed': isChanged(server.target_id, 'mem') }]">
{{ 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, { 'value-changed': isChanged(server.target_id, 'disk') }]">
{{ 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: Math.min(container.cpu_percent || 0, 100) + '%' }"></div>
</div>
<span :class="['value', { 'value-changed': isContainerChanged(server.target_id, container.name, 'cpu') }]">
{{ 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', { '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
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 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}`)
}
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 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: 200px; min-width: 200px; 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 { font-size: 19px; font-weight: 700; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
.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: 36px; 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; }
.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: 12px; padding: 14px; align-content: flex-start; min-width: 140px; }
.container-card { width: 260px; padding: 14px; border-radius: 10px; 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: 12px; overflow: hidden; }
.card-name { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; overflow: hidden; }
.card-name .card-level { font-size: 14px; flex-shrink: 0; }
.card-name .name { font-size: 17px; font-weight: 700; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.card-uptime { font-size: 14px; color: var(--text-muted); flex-shrink: 0; margin-left: 12px; white-space: nowrap; }
.card-metrics { display: flex; flex-wrap: wrap; gap: 8px 12px; }
.card-metric { display: flex; align-items: center; gap: 8px; width: calc(50% - 6px); overflow: hidden; }
.card-metric .label { font-size: 13px; color: var(--text-muted); width: 28px; flex-shrink: 0; font-weight: 500; }
.mini-bar { flex: 1; height: 8px; background: rgba(0,0,0,0.1); border-radius: 4px; overflow: hidden; min-width: 28px; }
.mini-fill { height: 100%; border-radius: 4px; }
.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: 15px; font-weight: 700; color: var(--text-secondary); width: 50px; text-align: right; flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; transition: all 0.3s; padding: 2px 4px; border-radius: 4px; }
.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% {
background-color: rgba(59, 130, 246, 0.6);
transform: scale(1.1);
}
50% {
background-color: rgba(59, 130, 246, 0.3);
transform: scale(1.05);
}
100% {
background-color: transparent;
transform: scale(1);
}
}
.value-changed {
animation: valueFlash 1.5s ease-out;
border-radius: 4px;
}
</style>