From 31e101abbd3ed82c22770d840a4f7fd20de1c936 Mon Sep 17 00:00:00 2001 From: kevin Date: Tue, 10 Mar 2026 15:54:45 +0100 Subject: [PATCH] feat: add system metrics dashboard --- components/SystemMetricsChart.vue | 333 ++++++++++++++++++++++++++++++ components/SystemResources.vue | 199 ++++++++++++++++++ pages/index.vue | 61 +++++- server/api/system.get.ts | 165 +++++++++++++++ 4 files changed, 755 insertions(+), 3 deletions(-) create mode 100644 components/SystemMetricsChart.vue create mode 100644 components/SystemResources.vue create mode 100644 server/api/system.get.ts diff --git a/components/SystemMetricsChart.vue b/components/SystemMetricsChart.vue new file mode 100644 index 0000000..1ff1d98 --- /dev/null +++ b/components/SystemMetricsChart.vue @@ -0,0 +1,333 @@ + + + + + diff --git a/components/SystemResources.vue b/components/SystemResources.vue new file mode 100644 index 0000000..ee9a0bd --- /dev/null +++ b/components/SystemResources.vue @@ -0,0 +1,199 @@ + + + + + diff --git a/pages/index.vue b/pages/index.vue index 7d3d125..9b55384 100644 --- a/pages/index.vue +++ b/pages/index.vue @@ -36,6 +36,21 @@ + +
+ + +
@@ -48,7 +63,7 @@ @@ -229,6 +275,14 @@ onMounted(() => { align-items: start; } +.metrics-row { + margin-top: 1.5rem; + display: grid; + grid-template-columns: minmax(0, 1fr) 280px; + gap: 1.5rem; + align-items: start; +} + .grid-left, .grid-middle, .grid-right { @@ -260,7 +314,8 @@ onMounted(() => { .storage-grid, .content-grid, - .dashboard-grid { + .dashboard-grid, + .metrics-row { grid-template-columns: 1fr; } diff --git a/server/api/system.get.ts b/server/api/system.get.ts new file mode 100644 index 0000000..7ce0fb1 --- /dev/null +++ b/server/api/system.get.ts @@ -0,0 +1,165 @@ +import fs from "node:fs" +import os from "node:os" + +type CpuTimesSnapshot = { + idle: number + total: number +} + +type NetworkSnapshot = { + rxBytes: number + txBytes: number + timestamp: number +} + +let previousNetworkSnapshot: NetworkSnapshot | null = null + +function getCpuSnapshot(): CpuTimesSnapshot { + const cpus = os.cpus() + + return cpus.reduce( + (snapshot, cpu) => { + const total = Object.values(cpu.times).reduce((sum, value) => sum + value, 0) + + snapshot.idle += cpu.times.idle + snapshot.total += total + + return snapshot + }, + {idle: 0, total: 0} + ) +} + +function wait(durationMs: number) { + return new Promise((resolve) => { + setTimeout(resolve, durationMs) + }) +} + +async function getCpuUsagePercent(sampleMs: number) { + const start = getCpuSnapshot() + await wait(sampleMs) + const end = getCpuSnapshot() + + const idleDelta = end.idle - start.idle + const totalDelta = end.total - start.total + + if (totalDelta <= 0) { + return 0 + } + + return Math.max(0, Math.min(100, Math.round((1 - idleDelta / totalDelta) * 100))) +} + +function getNetworkTotals() { + try { + const content = fs.readFileSync("/proc/net/dev", "utf8") + const totals = content + .split("\n") + .slice(2) + .map((line) => line.trim()) + .filter(Boolean) + .reduce( + (accumulator, line) => { + const [namePart, valuesPart] = line.split(":") + const interfaceName = namePart?.trim() + + if (!interfaceName || interfaceName === "lo" || !valuesPart) { + return accumulator + } + + const values = valuesPart.trim().split(/\s+/) + const rxBytes = Number.parseInt(values[0] || "0", 10) + const txBytes = Number.parseInt(values[8] || "0", 10) + + if (Number.isFinite(rxBytes)) { + accumulator.rxBytes += rxBytes + } + + if (Number.isFinite(txBytes)) { + accumulator.txBytes += txBytes + } + + return accumulator + }, + {rxBytes: 0, txBytes: 0} + ) + + return totals + } catch { + return null + } +} + +function getNetworkRatesMbps() { + const totals = getNetworkTotals() + const now = Date.now() + + if (!totals) { + previousNetworkSnapshot = null + return { + incomingMbps: 0, + outgoingMbps: 0 + } + } + + const currentSnapshot: NetworkSnapshot = { + ...totals, + timestamp: now + } + + if (!previousNetworkSnapshot) { + previousNetworkSnapshot = currentSnapshot + + return { + incomingMbps: 0, + outgoingMbps: 0 + } + } + + const elapsedSeconds = (currentSnapshot.timestamp - previousNetworkSnapshot.timestamp) / 1000 + + if (elapsedSeconds <= 0) { + previousNetworkSnapshot = currentSnapshot + + return { + incomingMbps: 0, + outgoingMbps: 0 + } + } + + const incomingMbps = Math.max( + 0, + Number((((currentSnapshot.rxBytes - previousNetworkSnapshot.rxBytes) * 8) / elapsedSeconds / 1000000).toFixed(2)) + ) + const outgoingMbps = Math.max( + 0, + Number((((currentSnapshot.txBytes - previousNetworkSnapshot.txBytes) * 8) / elapsedSeconds / 1000000).toFixed(2)) + ) + + previousNetworkSnapshot = currentSnapshot + + return { + incomingMbps, + outgoingMbps + } +} + +export default defineEventHandler(async () => { + const totalMemory = os.totalmem() + const freeMemory = os.freemem() + const usedMemory = totalMemory - freeMemory + const memoryPercent = totalMemory > 0 ? Math.round((usedMemory / totalMemory) * 100) : 0 + const cpuPercent = await getCpuUsagePercent(200) + const {incomingMbps, outgoingMbps} = getNetworkRatesMbps() + + return { + cpuPercent, + memoryPercent, + totalMemory, + usedMemory, + incomingMbps, + outgoingMbps, + sampledAt: new Date().toISOString() + } +})