diff --git a/backend/api/server/location-stats.get.ts b/backend/api/server/location-stats.get.ts new file mode 100644 index 0000000..c12c4eb --- /dev/null +++ b/backend/api/server/location-stats.get.ts @@ -0,0 +1,28 @@ +import { query } from '../../utils/db' + +export default defineEventHandler(async () => { + const stats = await query(` + SELECT + t.physical_location, + COUNT(DISTINCT t.target_id) as server_count, + ROUND(AVG(s.cpu_temp)::numeric, 1) as avg_temp, + ROUND(SUM(n.speed_recv)::numeric, 0) as total_rx, + ROUND(SUM(n.speed_sent)::numeric, 0) as total_tx + FROM server_targets t + LEFT JOIN server_snapshots s ON t.target_id = s.target_id + AND s.collected_at::timestamp >= NOW() - INTERVAL '10 minutes' + LEFT JOIN server_networks n ON t.target_id = n.target_id + AND n.collected_at::timestamp >= NOW() - INTERVAL '10 minutes' + WHERE t.is_active = 1 + GROUP BY t.physical_location + ORDER BY t.physical_location + `) + + return stats.map((row: any) => ({ + location: row.physical_location || '미지정', + serverCount: Number(row.server_count) || 0, + avgTemp: row.avg_temp ? parseFloat(row.avg_temp) : null, + totalRx: Number(row.total_rx) || 0, + totalTx: Number(row.total_tx) || 0 + })) +}) diff --git a/backend/routes/_ws.ts b/backend/routes/_ws.ts index 9774ada..a4b2bba 100644 --- a/backend/routes/_ws.ts +++ b/backend/routes/_ws.ts @@ -183,6 +183,17 @@ async function getServerDashboard() { LIMIT 1 `, [server.target_id]) + // 최신 디스크 사용률 (최대값) + const diskData = await queryOne(` + SELECT MAX(disk_percent) as disk_percent + FROM server_disks + WHERE target_id = $1 + AND collected_at = (SELECT MAX(collected_at) FROM server_disks WHERE target_id = $1) + AND device_name NOT LIKE '%loop%' + AND mount_point NOT LIKE '%/snap%' + AND fs_type NOT IN ('tmpfs', 'squashfs') + `, [server.target_id]) + // 오프라인 체크 let isOffline = true let lastCollected = null @@ -199,7 +210,7 @@ async function getServerDashboard() { if (!isOffline && snapshot) { cpuLevel = getLevel(Number(snapshot.cpu_percent), thresholds.server?.cpu || { warning: 70, critical: 85, danger: 95 }) memLevel = getLevel(Number(snapshot.memory_percent), thresholds.server?.memory || { warning: 80, critical: 90, danger: 95 }) - diskLevel = getLevel(Number(snapshot.disk_percent), thresholds.server?.disk || { warning: 80, critical: 90, danger: 95 }) + diskLevel = getLevel(Number(diskData?.disk_percent), thresholds.server?.disk || { warning: 80, critical: 90, danger: 95 }) serverLevel = getHighestLevel([cpuLevel, memLevel, diskLevel]) } @@ -280,7 +291,7 @@ async function getServerDashboard() { cpu_level: cpuLevel, memory_percent: snapshot?.memory_percent ?? null, memory_level: memLevel, - disk_percent: snapshot?.disk_percent ?? null, + disk_percent: diskData?.disk_percent ?? null, disk_level: diskLevel, last_collected: lastCollected, containers: containers, diff --git a/frontend/components/LocationStatsPortlet.vue b/frontend/components/LocationStatsPortlet.vue new file mode 100644 index 0000000..cb630f5 --- /dev/null +++ b/frontend/components/LocationStatsPortlet.vue @@ -0,0 +1,160 @@ + + + + + diff --git a/frontend/components/NetworkPortlet.vue b/frontend/components/NetworkPortlet.vue index bb8a52e..d2c380f 100644 --- a/frontend/components/NetworkPortlet.vue +++ b/frontend/components/NetworkPortlet.vue @@ -56,10 +56,10 @@ function formatTimeAgo(datetime: string | null): string { display: flex; flex-direction: column; align-items: center; - padding: 16px 12px; + padding: 10px 8px; background: var(--bg-secondary); border: 1px solid var(--border-color); - border-radius: 12px; + border-radius: 10px; cursor: pointer; transition: all 0.2s; text-align: center; @@ -73,12 +73,12 @@ function formatTimeAgo(datetime: string | null): string { .network-card.unhealthy { border-top: 4px solid #ef4444; } .network-card.pending { border-top: 4px solid #9ca3af; } -.card-icon { font-size: 28px; margin-bottom: 8px; } -.card-title { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 10px; } +.card-icon { font-size: 22px; margin-bottom: 4px; } +.card-title { font-size: 12px; font-weight: 600; color: var(--text-primary); margin-bottom: 6px; } -.card-status { display: flex; align-items: center; justify-content: center; gap: 6px; margin-bottom: 4px; } -.status-icon { font-size: 16px; } -.status-text { font-size: 15px; font-weight: 500; color: var(--text-secondary); } +.card-status { display: flex; align-items: center; justify-content: center; gap: 4px; margin-bottom: 2px; } +.status-icon { font-size: 14px; } +.status-text { font-size: 13px; font-weight: 500; color: var(--text-secondary); } -.card-time { font-size: 12px; color: var(--text-muted); } +.card-time { font-size: 10px; color: var(--text-muted); } diff --git a/frontend/components/ServerPortlet.vue b/frontend/components/ServerPortlet.vue index ec8d317..91f12b9 100644 --- a/frontend/components/ServerPortlet.vue +++ b/frontend/components/ServerPortlet.vue @@ -102,7 +102,7 @@
- {{ formatMemoryShort(container.memory_usage) }} + {{ formatMemoryShort(container.memory_usage) }}
↓RX @@ -211,9 +211,15 @@ function getMemPercent(c: ContainerStatus): number { function formatMemoryShort(bytes: number | null): string { if (bytes === null || bytes === undefined) return '-' - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}K` - if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(0)}M` - return `${(bytes / 1024 / 1024 / 1024).toFixed(1)}G` + 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 { @@ -312,6 +318,7 @@ function formatTimeAgo(datetime: string | null): string { .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; } diff --git a/frontend/index.vue b/frontend/index.vue index 0fee105..cdf838a 100644 --- a/frontend/index.vue +++ b/frontend/index.vue @@ -48,6 +48,9 @@ icon="🔒" :status="privnetStatus" /> +
@@ -83,11 +86,21 @@ let loadingStartTime = 0 const pubnetStatus = ref(null) const privnetStatus = ref(null) const serverDashboard = ref(null) +const locationStats = ref([]) // WebSocket let ws: WebSocket | null = null let timeInterval: ReturnType | null = null +async function fetchLocationStats() { + try { + const data = await $fetch('/api/server/location-stats') + locationStats.value = data as any[] + } catch (err) { + console.error('Failed to fetch location stats:', err) + } +} + function formatTime(date: Date): string { const y = date.getFullYear() const m = String(date.getMonth() + 1).padStart(2, '0') @@ -162,6 +175,7 @@ function connectWebSocket() { if (msg.type === 'server') { serverDashboard.value = msg.data + fetchLocationStats() } } catch (err) { console.error('[WS] Parse error:', err) @@ -214,6 +228,7 @@ function navigateTo(path: string) { onMounted(() => { connectWebSocket() updateCurrentTime() + fetchLocationStats() timeInterval = window.setInterval(updateCurrentTime, 1000) }) @@ -239,7 +254,7 @@ onUnmounted(() => { .dashboard-layout { display: flex; gap: 16px; height: 100%; } .server-section { flex: 9; min-width: 0; } -.network-section { flex: 1; min-width: 130px; max-width: 160px; display: flex; flex-direction: column; gap: 12px; } +.network-section { flex: 1; min-width: 140px; max-width: 180px; display: flex; flex-direction: column; gap: 10px; } .connection-status { position: fixed; bottom: 16px; right: 16px; padding: 8px 12px; background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 8px; font-size: 12px; color: var(--text-muted); } .connection-status.connected { color: #16a34a; }