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

327 lines
15 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]">{{ server.cpu_percent?.toFixed(0) || '-' }}</span>
</div>
<div class="metric-row">
<span class="metric-label">MEM</span>
<div class="progress-bar">
<div :class="['progress-fill', server.memory_level]" :style="{ width: (server.memory_percent || 0) + '%' }"></div>
</div>
<span :class="['metric-value', server.memory_level]">{{ server.memory_percent?.toFixed(0) || '-' }}</span>
</div>
<div class="metric-row">
<span class="metric-label">DISK</span>
<div class="progress-bar">
<div :class="['progress-fill', server.disk_level]" :style="{ width: (server.disk_percent || 0) + '%' }"></div>
</div>
<span :class="['metric-value', server.disk_level]">{{ server.disk_percent?.toFixed(0) || '-' }}</span>
</div>
</template>
<template v-else>
<div class="offline-info">
<div class="offline-text">오프라인</div>
<div class="offline-time">{{ formatTimeAgo(server.last_collected) }}</div>
</div>
</template>
</div>
<!-- 컨테이너 영역 (오른쪽) -->
<div class="container-area" v-if="server.level !== 'offline'">
<div
v-for="container in sortContainers(server.containers)"
:key="server.target_id + '-' + container.name"
:class="['container-card', container.level]"
@click="goToServerStatus(server.target_id, container.name)"
>
<div class="card-header">
<div class="card-name">
<span class="card-level">{{ levelIcon(container.level) }}</span>
<span class="name">{{ container.name }}</span>
</div>
<span class="card-uptime" v-if="container.status === 'running'">{{ container.uptime || '-' }}</span>
</div>
<template v-if="container.status === 'running'">
<div class="card-metrics">
<div class="card-metric">
<span class="label">CPU</span>
<div class="mini-bar">
<div :class="['mini-fill', getContainerCpuLevel(container)]" :style="{ width: (container.cpu_percent || 0) + '%' }"></div>
</div>
<span class="value">{{ container.cpu_percent?.toFixed(0) || '-' }}%</span>
</div>
<div class="card-metric">
<span class="label">MEM</span>
<div class="mini-bar">
<div :class="['mini-fill', getContainerMemLevel(container)]" :style="{ width: getMemPercent(container) + '%' }"></div>
</div>
<span :class="['value', { 'mem-highlight': isMemoryOver1GB(container.memory_usage) }]">{{ formatMemoryShort(container.memory_usage) }}</span>
</div>
<div class="card-metric">
<span class="label">RX</span>
<span class="value net">{{ formatNetworkShort(container.network_rx) }}</span>
</div>
<div class="card-metric">
<span class="label">TX</span>
<span class="value net">{{ formatNetworkShort(container.network_tx) }}</span>
</div>
</div>
</template>
<template v-else>
<div class="card-stopped">{{ container.status }}</div>
</template>
</div>
<!-- 컨테이너 없음 -->
<div class="no-container" v-if="server.containers.length === 0">
<span>컨테이너 없음</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface ContainerStatus {
name: string
status: string
level: string
cpu_percent: number | null
memory_usage: number | null
memory_limit: number | null
uptime: string | null
network_rx: number | null
network_tx: number | null
}
interface ServerStatus {
target_id: number
server_name: string
level: string
cpu_percent: number | null
cpu_level: string
memory_percent: number | null
memory_level: string
disk_percent: number | null
disk_level: string
last_collected: string | null
containers: ContainerStatus[]
container_summary: { total: number; normal: number; warning: number; critical: number; stopped: number }
}
interface Summary {
servers: { total: number; normal: number; warning: number; critical: number; danger: number; offline: number }
containers: { total: number; normal: number; warning: number; critical: number; danger: number; stopped: number }
}
const props = defineProps<{
servers: ServerStatus[]
summary: Summary
}>()
const emit = defineEmits<{
(e: 'navigate', path: string): void
}>()
const levelPriority: Record<string, number> = { stopped: 3, critical: 2, danger: 2, warning: 1, normal: 0 }
function sortContainers(containers: ContainerStatus[]) {
return [...containers].sort((a, b) => a.name.localeCompare(b.name))
}
function goToServerStatus(targetId: number, containerName?: string) {
let path = `/server/history?target=${targetId}`
if (containerName) path += `&container=${containerName}`
emit('navigate', path)
}
function levelIcon(level: string): string {
const icons: Record<string, string> = { normal: '🟢', warning: '🟡', critical: '🟠', danger: '🔴', offline: '⚫', stopped: '🔴' }
return icons[level] || '⚪'
}
function getContainerCpuLevel(c: ContainerStatus): string {
if (c.cpu_percent === null) return 'normal'
if (c.cpu_percent >= 95) return 'danger'
if (c.cpu_percent >= 90) return 'critical'
if (c.cpu_percent >= 80) return 'warning'
return 'normal'
}
function getContainerMemLevel(c: ContainerStatus): string {
const pct = getMemPercent(c)
if (pct >= 95) return 'danger'
if (pct >= 90) return 'critical'
if (pct >= 80) return 'warning'
return 'normal'
}
function getMemPercent(c: ContainerStatus): number {
if (!c.memory_limit || !c.memory_usage) return 0
return (c.memory_usage / c.memory_limit) * 100
}
function formatMemoryShort(bytes: number | null): string {
if (bytes === null || bytes === undefined) return '-'
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 '-'
if (bytes < 1024) return `${bytes}B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}K`
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)}M`
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)}G`
}
function formatTimeAgo(datetime: string | null): string {
if (!datetime) return '-'
const now = new Date()
const then = new Date(datetime.replace(' ', 'T') + '+09:00')
const diff = Math.floor((now.getTime() - then.getTime()) / 1000)
if (diff < 60) return `${diff}초 전`
if (diff < 3600) return `${Math.floor(diff / 60)}분 전`
if (diff < 86400) return `${Math.floor(diff / 3600)}시간 전`
return `${Math.floor(diff / 86400)}일 전`
}
</script>
<style scoped>
.server-portlet { background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 12px; overflow: hidden; }
.portlet-header { display: flex; justify-content: space-between; align-items: center; padding: 14px 18px; border-bottom: 1px solid var(--border-color); background: var(--bg-tertiary, #f8fafc); }
.portlet-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--text-primary); }
.summary-badges { display: flex; align-items: center; gap: 12px; font-size: 14px; color: var(--text-secondary); }
.divider { color: var(--border-color); }
.level-counts { margin-left: 8px; }
.level-counts .lv { margin-right: 6px; }
/* 서버 그리드 - flex-wrap */
.server-grid { display: flex; flex-wrap: wrap; gap: 12px; padding: 16px; align-items: flex-start; }
/* 서버 유닛 */
.server-unit { display: flex; background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 10px; overflow: hidden; }
.server-unit.warning { border-left: 3px solid #eab308; }
.server-unit.critical { border-left: 3px solid #f97316; }
.server-unit.danger { border-left: 3px solid #ef4444; }
.server-unit.offline { border-left: 3px solid #6b7280; opacity: 0.7; }
/* 서버 정보 (왼쪽) */
.server-info { width: 150px; min-width: 150px; padding: 12px; border-right: 1px solid var(--border-color); cursor: pointer; }
.server-info:hover { background: var(--bg-tertiary, #f8fafc); }
.server-name { display: flex; align-items: center; gap: 6px; margin-bottom: 10px; }
.server-name .level-icon { font-size: 12px; flex-shrink: 0; }
.server-name .name { font-size: 15px; font-weight: 600; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
.server-name .container-count { font-size: 12px; color: var(--text-muted); flex-shrink: 0; }
/* 서버 메트릭 */
.metric-row { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
.metric-label { font-size: 11px; font-weight: 500; color: var(--text-muted); width: 28px; }
.progress-bar { flex: 1; height: 6px; background: var(--bg-tertiary, #e5e7eb); border-radius: 3px; overflow: hidden; }
.progress-fill { height: 100%; border-radius: 3px; transition: width 0.3s; }
.progress-fill.normal { background: #22c55e; }
.progress-fill.warning { background: #eab308; }
.progress-fill.critical { background: #f97316; }
.progress-fill.danger { background: #ef4444; }
.metric-value { font-size: 12px; font-weight: 600; width: 24px; text-align: right; }
.metric-value.normal { color: #16a34a; }
.metric-value.warning { color: #ca8a04; }
.metric-value.critical { color: #ea580c; }
.metric-value.danger { color: #dc2626; }
.offline-info { text-align: center; padding: 16px 0; color: var(--text-muted); }
.offline-text { font-size: 14px; margin-bottom: 4px; }
.offline-time { font-size: 12px; opacity: 0.7; }
/* 컨테이너 영역 */
.container-area { display: flex; flex-wrap: wrap; gap: 8px; padding: 10px; align-content: flex-start; min-width: 100px; }
/* 컨테이너 카드 */
.container-card { width: 200px; padding: 10px; border-radius: 8px; border: 1px solid var(--border-color); background: var(--bg-secondary); cursor: pointer; transition: all 0.15s; overflow: hidden; }
.container-card:hover { transform: translateY(-1px); box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
.container-card.normal { background: 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: 4px; flex: 1; min-width: 0; overflow: hidden; }
.card-name .card-level { font-size: 10px; flex-shrink: 0; }
.card-name .name { font-size: 13px; font-weight: 600; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.card-uptime { font-size: 11px; color: var(--text-muted); flex-shrink: 0; margin-left: 8px; white-space: nowrap; }
.card-metrics { display: flex; flex-wrap: wrap; gap: 4px 8px; }
.card-metric { display: flex; align-items: center; gap: 4px; width: calc(50% - 4px); overflow: hidden; }
.card-metric .label { font-size: 10px; color: var(--text-muted); width: 22px; flex-shrink: 0; }
.mini-bar { flex: 1; height: 5px; background: rgba(0,0,0,0.1); border-radius: 2px; overflow: hidden; min-width: 20px; }
.mini-fill { height: 100%; border-radius: 2px; }
.mini-fill.normal { background: #22c55e; }
.mini-fill.warning { background: #eab308; }
.mini-fill.critical { background: #f97316; }
.mini-fill.danger { background: #ef4444; }
.card-metric .value { font-size: 11px; font-weight: 500; color: var(--text-secondary); width: 36px; text-align: right; flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; }
.card-metric .value.mem-highlight { color: #dc2626; font-weight: 700; }
.card-stopped { font-size: 12px; color: var(--text-muted); font-style: italic; text-align: center; padding: 6px 0; }
.no-container { font-size: 13px; color: var(--text-muted); padding: 12px; display: flex; align-items: center; justify-content: center; }
</style>