diff --git a/ui/bun.lock b/ui/bun.lock index 2b2aefa..040c190 100644 --- a/ui/bun.lock +++ b/ui/bun.lock @@ -6,7 +6,7 @@ "dependencies": { "lucide-react": "^0.525.0", "react": "^19.1.0", - "react-dom": "^19.1.0", + "react-dom": "^19.1.0" }, "devDependencies": { "@eslint/js": "^9.29.0", @@ -15,18 +15,20 @@ "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.5.2", + "chart.js": "^4.5.0", "eslint": "^9.29.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.2.0", "nanostores": "^1.0.1", + "react-chartjs-2": "^5.3.0", "react-router-dom": "^7.6.3", "tailwindcss": "^4.1.11", "typescript": "~5.8.3", "typescript-eslint": "^8.34.1", - "vite": "^7.0.0", - }, - }, + "vite": "^7.0.0" + } + } }, "packages": { "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], @@ -155,6 +157,8 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.29", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ=="], + "@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="], + "@nanostores/react": ["@nanostores/react@1.0.0", "", { "peerDependencies": { "nanostores": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^1.0.0", "react": ">=18.0.0" } }, "sha512-eDduyNy+lbQJMg6XxZ/YssQqF6b4OXMFEZMYKPJCCmBevp1lg0g+4ZRi94qGHirMtsNfAWKNwsjOhC+q1gvC+A=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], @@ -297,6 +301,8 @@ "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "chart.js": ["chart.js@4.5.0", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ=="], + "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], @@ -499,6 +505,8 @@ "react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="], + "react-chartjs-2": ["react-chartjs-2@5.3.0", "", { "peerDependencies": { "chart.js": "^4.1.1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UfZZFnDsERI3c3CZGxzvNJd02SHjaSJ8kgW1djn65H1KK8rehwTjyrRKOG3VTMG8wtHZ5rgAO5oTHtHi9GCCmw=="], + "react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="], "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], @@ -595,6 +603,6 @@ "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="] } } diff --git a/ui/package.json b/ui/package.json index f2bf7bb..c0914b9 100644 --- a/ui/package.json +++ b/ui/package.json @@ -21,11 +21,13 @@ "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.5.2", + "chart.js": "^4.5.0", "eslint": "^9.29.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.2.0", "nanostores": "^1.0.1", + "react-chartjs-2": "^5.3.0", "react-router-dom": "^7.6.3", "tailwindcss": "^4.1.11", "typescript": "~5.8.3", diff --git a/ui/src/components/AgentCard.tsx b/ui/src/components/AgentCard.tsx index 79b5fe5..6de6f56 100644 --- a/ui/src/components/AgentCard.tsx +++ b/ui/src/components/AgentCard.tsx @@ -1,6 +1,6 @@ -import { UptimeMessage } from "@/services/types"; +import type { UptimeMessage } from "@/services/types"; +import { Progressbar } from "@/components/Progressbar"; import { formatRelativeTime, isAgentOnline } from "@/services/utils"; -import { Progressbar } from "./Progressbar"; type AgentOverviewCardProps = { count: number; diff --git a/ui/src/components/ConnectionStatus.tsx b/ui/src/components/ConnectionStatus.tsx index cdf5b44..1b4efca 100644 --- a/ui/src/components/ConnectionStatus.tsx +++ b/ui/src/components/ConnectionStatus.tsx @@ -50,7 +50,7 @@ export function ConnectionStatus({ status, timestamp }: ConnectionStatusProps) { {config.text} {timestamp && ( - + {new Date(timestamp).toLocaleTimeString()} )} diff --git a/ui/src/components/DonutChart.tsx b/ui/src/components/DonutChart.tsx new file mode 100644 index 0000000..fd7d1f5 --- /dev/null +++ b/ui/src/components/DonutChart.tsx @@ -0,0 +1,67 @@ +import { ArcElement, Chart, Legend, Tooltip } from "chart.js"; +import { Doughnut } from "react-chartjs-2"; + +Chart.register(ArcElement, Tooltip, Legend); + +type DonutChartProps = { + data: { + labels: string[]; + values: number[]; + colors: string[]; + }; + centerText?: string; + size?: "sm" | "md" | "lg"; +}; + +export function DonutChart({ data, centerText, size = "md" }: DonutChartProps) { + const sizeClasses = { + sm: "w-24 h-24", + md: "w-32 h-32", + lg: "w-40 h-40", + }; + + const chartData = { + labels: data.labels, + datasets: [ + { + data: data.values, + backgroundColor: data.colors, + borderWidth: 0, + cutout: "70%", + }, + ], + }; + + const options = { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + display: false, + }, + tooltip: { + backgroundColor: "rgba(17, 17, 17, 0.95)", + titleColor: "#ffffff", + bodyColor: "#ffffff", + borderColor: "rgba(64, 64, 64, 0.5)", + borderWidth: 1, + cornerRadius: 8, + }, + }, + }; + + return ( +
+
+ + {centerText && ( +
+ + {centerText} + +
+ )} +
+
+ ); +} diff --git a/ui/src/components/Header.tsx b/ui/src/components/Header.tsx index aaa55a3..9cfbea6 100644 --- a/ui/src/components/Header.tsx +++ b/ui/src/components/Header.tsx @@ -12,7 +12,7 @@ export function Header({ props }: { props: HeaderProps }) { const navigate = useNavigate(); return ( -
+
{props.hasBackButton && ( diff --git a/ui/src/components/LineChart.tsx b/ui/src/components/LineChart.tsx new file mode 100644 index 0000000..c41c5a8 --- /dev/null +++ b/ui/src/components/LineChart.tsx @@ -0,0 +1,107 @@ +import type { ChartData } from "@/hooks/useChartData"; +import { + CategoryScale, + Chart, + Filler, + Legend, + LinearScale, + LineElement, + PointElement, + Title, + Tooltip, +} from "chart.js"; +import { Line } from "react-chartjs-2"; + +Chart.register( + CategoryScale, + LinearScale, + PointElement, + LineElement, + Title, + Tooltip, + Legend, + Filler, +); + +type LineChartProps = { + data: ChartData; + height?: number; +}; + +export function LineChart({ data, height = 200 }: LineChartProps) { + const chartData = { + labels: data.labels, + datasets: data.datasets.map((dataset) => ({ + label: dataset.label, + data: dataset.data, + borderColor: dataset.color, + backgroundColor: dataset.color + "20", + fill: true, + tension: 0.4, + pointRadius: 0, + pointHoverRadius: 4, + borderWidth: 2, + })), + }; + + const options = { + responsive: true, + maintainAspectRatio: false, + scales: { + x: { + display: true, + grid: { + color: "rgba(64, 64, 64, 0.3)", + drawBorder: false, + }, + ticks: { + color: "rgba(156, 163, 175, 0.8)", + maxTicksLimit: 6, + }, + }, + y: { + display: true, + beginAtZero: true, + grid: { + color: "rgba(64, 64, 64, 0.3)", + drawBorder: false, + }, + ticks: { + color: "rgba(156, 163, 175, 0.8)", + }, + }, + }, + plugins: { + legend: { + display: data.datasets.length > 1, + position: "top" as const, + labels: { + color: "rgba(156, 163, 175, 0.8)", + usePointStyle: true, + pointStyle: "circle", + }, + }, + tooltip: { + backgroundColor: "rgba(17, 17, 17, 0.95)", + titleColor: "#ffffff", + bodyColor: "#ffffff", + borderColor: "rgba(64, 64, 64, 0.5)", + borderWidth: 1, + cornerRadius: 8, + }, + }, + interaction: { + intersect: false, + mode: "index" as const, + }, + animation: { + duration: 300, + }, + }; + + return ( +
+ +
+ ); +} diff --git a/ui/src/components/LineChartCard.tsx b/ui/src/components/LineChartCard.tsx new file mode 100644 index 0000000..b6ec21c --- /dev/null +++ b/ui/src/components/LineChartCard.tsx @@ -0,0 +1,27 @@ +import type { ChartData } from "@/hooks/useChartData"; +import { LineChart } from "@/components/LineChart"; + +export function LineChartCard({ + title, + data, +}: { + title: string; + data: ChartData; +}) { + return ( +
+ {data.labels.length > 0 ? ( + <> +

{title}

+
+ +
+ + ) : ( +
+ Waiting for data... +
+ )} +
+ ); +} diff --git a/ui/src/components/MetricCard.tsx b/ui/src/components/MetricCard.tsx index 688776e..e6c509f 100644 --- a/ui/src/components/MetricCard.tsx +++ b/ui/src/components/MetricCard.tsx @@ -31,7 +31,7 @@ export function MetricCard({ {props.value}
{props.subtitle}
-
{children}
+
{children}
); diff --git a/ui/src/components/Progressbar.tsx b/ui/src/components/Progressbar.tsx index d5d91a6..7d23695 100644 --- a/ui/src/components/Progressbar.tsx +++ b/ui/src/components/Progressbar.tsx @@ -1,5 +1,3 @@ -"use client"; - import { useEffect, useRef, useState } from "react"; export function Progressbar({ diff --git a/ui/src/components/SysinfoCard.tsx b/ui/src/components/SysinfoCard.tsx new file mode 100644 index 0000000..6d45776 --- /dev/null +++ b/ui/src/components/SysinfoCard.tsx @@ -0,0 +1,56 @@ +import type { SystemInfo } from "@/services/types"; +import { formatUptime } from "@/services/utils"; + +export function SysinfoCard({ sysinfo }: { sysinfo: SystemInfo | undefined }) { + return ( +
+ {sysinfo ? ( + <> +

+ System Information +

+
+
+ Host + + {sysinfo.host} + +
+ +
+ OS + + {sysinfo.name} + +
+ +
+ Kernel + + {sysinfo.kernel} + +
+ +
+ Uptime + + {formatUptime(sysinfo.uptime)} + +
+ +
+ Version + + {sysinfo.os_version} + +
+
+ + ) : ( +
+ Waiting for data... +
+ )} +
+ ); +} diff --git a/ui/src/hooks/useChartData.tsx b/ui/src/hooks/useChartData.tsx new file mode 100644 index 0000000..dd71cab --- /dev/null +++ b/ui/src/hooks/useChartData.tsx @@ -0,0 +1,106 @@ +import type { StatusMessage } from "@/services/types"; +import { addRealtimeData, getRealtimeData } from "@/services/data_service"; +import { formatTimestamp } from "@/services/utils"; +import { useEffect } from "react"; + +export type ChartData = { + labels: string[]; + datasets: { + label: string; + data: number[]; + color: string; + }[]; +}; + +type ChartDataReturns = { + cpuData: ChartData; + memoryData: ChartData; + networkData: ChartData; +}; + +export function useChartData(data: StatusMessage | null): ChartDataReturns { + useEffect(() => { + if (!data) return; + + addRealtimeData(data); + }, [data]); + + const cpuPoints = getRealtimeData("cpu"); + const cpuUserPoints = getRealtimeData("cpu_user"); + const cpuSystemPoints = getRealtimeData("cpu_system"); + const cpuIdlePoints = getRealtimeData("cpu_idle"); + const cpuStealPoints = getRealtimeData("cpu_steal"); + const cpuIowaitPoints = getRealtimeData("cpu_iowait"); + const memoryPoints = getRealtimeData("memory"); + const swapPoints = getRealtimeData("swap"); + const networkUpPoints = getRealtimeData("network_up"); + const networkDownPoints = getRealtimeData("network_down"); + + return { + cpuData: { + labels: cpuPoints.map((p) => formatTimestamp(p.timestamp)), + datasets: [ + { + label: "Total CPU (%)", + data: cpuPoints.map((p) => p.value), + color: "#3b82f6", + }, + { + label: "User (%)", + data: cpuUserPoints.map((p) => p.value), + color: "#10b981", + }, + { + label: "System (%)", + data: cpuSystemPoints.map((p) => p.value), + color: "#f59e0b", + }, + { + label: "I/O Wait (%)", + data: cpuIowaitPoints.map((p) => p.value), + color: "#ef4444", + }, + { + label: "Steal (%)", + data: cpuStealPoints.map((p) => p.value), + color: "#8b5cf6", + }, + { + label: "Idle (%)", + data: cpuIdlePoints.map((p) => p.value), + color: "#6b7280", + }, + ], + }, + memoryData: { + labels: memoryPoints.map((p) => formatTimestamp(p.timestamp)), + datasets: [ + { + label: "Memory Usage (%)", + data: memoryPoints.map((p) => p.value), + color: "#10b981", + }, + { + label: "Swap Usage (%)", + data: swapPoints.map((p) => p.value), + color: "#f59e0b", + }, + ], + }, + networkData: { + labels: networkUpPoints.map((p) => formatTimestamp(p.timestamp)), + datasets: [ + { + label: "Upload (B/s)", + data: networkUpPoints.map((p) => p.value), + color: "#ef4444", + }, + { + label: "Download (B/s)", + data: networkDownPoints.map((p) => p.value), + color: "#3b82f6", + }, + ], + }, + }; +} diff --git a/ui/src/pages/agent.tsx b/ui/src/pages/agent.tsx index deacea8..1833ebb 100644 --- a/ui/src/pages/agent.tsx +++ b/ui/src/pages/agent.tsx @@ -1,15 +1,19 @@ import { + type WebsocketStatus, ConnectionStatus, - WebsocketStatus, } from "@/components/ConnectionStatus"; +import type { StatusMessage } from "@/services/types"; import { Header } from "@/components/Header"; +import { DonutChart } from "@/components/DonutChart"; +import { LineChartCard } from "@/components/LineChartCard"; import { MetricCard } from "@/components/MetricCard"; +import { SysinfoCard } from "@/components/SysinfoCard"; +import { useChartData } from "@/hooks/useChartData"; import { getLastMessage, setLastMessage } from "@/services/store"; -import { StatusMessage } from "@/services/types"; import { formatBytes, - calcPercentage, formatPercentage, + breakdownMetrics, } from "@/services/utils"; import { initializeConnection } from "@/services/websocket"; import { useEffect, useState } from "react"; @@ -39,9 +43,10 @@ export function AgentPage() { }); }, [agent]); - const { metrics } = message || {}; + const { metrics } = message ?? {}; + const { cpuData, memoryData, networkData } = useChartData(message); - const getMetricsStatus = (percentage: number | undefined) => { + const getMetricStatus = (percentage: number | undefined) => { if (!percentage) return "nil"; if (percentage < 50) return "good"; @@ -49,17 +54,20 @@ export function AgentPage() { return "critical"; }; - const cpuUsage = metrics?.cpu.usage ?? 0; - const memoryUsage = calcPercentage( - metrics?.memory.used, - metrics?.memory.total, - ); - const diskUsage = calcPercentage( - (metrics?.disk.total ?? 0) - (metrics?.disk.free ?? 0), - metrics?.disk.total, - ); - const networkUsage = - (metrics?.network.up ?? 0) + (metrics?.network.down ?? 0); + const { + cpuThreads, + cpuUsage, + memoryUsage, + memoryUsed, + memoryTotal, + diskUsage, + diskFree, + diskTotal, + networkUp, + networkDown, + } = breakdownMetrics(message?.metrics); + + const networkUsage = networkUp + networkDown; return (
@@ -76,64 +84,103 @@ export function AgentPage() { ), }} /> -
-
- -

cpu chart

-
+
+
+ + + - -

memory chart

-
+ + + - -

network chart

-
-
- - -
-
+ + +
- + + +
+
+
+ + +
+ + + + + +
+
); diff --git a/ui/src/pages/home.tsx b/ui/src/pages/home.tsx index 30fa396..d1aed7b 100644 --- a/ui/src/pages/home.tsx +++ b/ui/src/pages/home.tsx @@ -1,8 +1,6 @@ -"use client"; - +import type { UptimeMessage } from "@/services/types"; import { AgentCard, AgentOverviewCard } from "@/components/AgentCard"; import { Header } from "@/components/Header"; -import { UptimeMessage } from "@/services/types"; import { isAgentOnline } from "@/services/utils"; import { Box } from "lucide-react"; import { useEffect, useState } from "react"; diff --git a/ui/src/services/data_service.ts b/ui/src/services/data_service.ts new file mode 100644 index 0000000..3130b80 --- /dev/null +++ b/ui/src/services/data_service.ts @@ -0,0 +1,82 @@ +import type { StatusMessage } from "@/services/types"; +import { breakdownMetrics } from "@/services/utils"; + +type HistoricalDataPoint = { + timestamp: string; + value: number; +}; + +/* +type HistoricalMetrics = { + cpu: HistoricalDataPoint[]; + cpu_user: HistoricalDataPoint[]; + cpu_system: HistoricalDataPoint[]; + cpu_idle: HistoricalDataPoint[]; + cpu_steal: HistoricalDataPoint[]; + cpu_iowait: HistoricalDataPoint[]; + memory: HistoricalDataPoint[]; + swap: HistoricalDataPoint[]; + network_up: HistoricalDataPoint[]; + network_down: HistoricalDataPoint[]; +}; +*/ + +//export type TimePeriod = "realtime" | "hour" | "day" | "week"; + +const realtimeData = new Map(); +const maxRealtimePoints = 50; + +function addDataPoint(metric: string, timestamp: string, value: number) { + if (!realtimeData.has(metric)) { + realtimeData.set(metric, []); + } + + const data = realtimeData.get(metric)!; + data.push({ timestamp, value }); + + if (data.length > maxRealtimePoints) { + data.shift(); + } +} + +export const addRealtimeData = (data: StatusMessage): void => { + const timestamp = data.timestamp; + + const { + cpuUsage, + cpuUser, + cpuSystem, + cpuIdle, + cpuSteal, + cpuIowait, + memoryUsage, + swapUsage, + networkUp, + networkDown, + } = breakdownMetrics(data.metrics); + + addDataPoint("cpu", timestamp, cpuUsage); + addDataPoint("cpu_user", timestamp, cpuUser); + addDataPoint("cpu_system", timestamp, cpuSystem); + addDataPoint("cpu_idle", timestamp, cpuIdle); + addDataPoint("cpu_steal", timestamp, cpuSteal); + addDataPoint("cpu_iowait", timestamp, cpuIowait); + addDataPoint("memory", timestamp, memoryUsage); + addDataPoint("swap", timestamp, swapUsage); + addDataPoint("network_up", timestamp, networkUp); + addDataPoint("network_down", timestamp, networkDown); +}; + +export function getRealtimeData(metric: string): HistoricalDataPoint[] { + return realtimeData.get(metric) || []; +} + +/* +export async function getHistoricalData(period: TimePeriod): Promise { + return ...todo +} + +function clearRealtimeData() { + realtimeData.clear(); +} +*/ diff --git a/ui/src/services/utils.ts b/ui/src/services/utils.ts index 16ca2af..5e74c73 100644 --- a/ui/src/services/utils.ts +++ b/ui/src/services/utils.ts @@ -1,4 +1,4 @@ -import { UptimeMessage } from "@/services/types"; +import type { Metrics, UptimeMessage } from "@/services/types"; export function isAgentOnline(data: UptimeMessage): boolean { const timeDiff = new Date().getTime() - new Date(data.last_seen).getTime(); @@ -6,6 +6,24 @@ export function isAgentOnline(data: UptimeMessage): boolean { return timeDiff < 10000; } +export function formatTimestamp(timestamp: string) { + return new Date(timestamp).toLocaleTimeString(); +} + +export const formatUptime = (seconds: number): string => { + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + + if (days > 0) { + return `${days}d ${hours}h`; + } else if (hours > 0) { + return `${hours}h ${minutes}m`; + } else { + return `${minutes}m`; + } +}; + export function formatRelativeTime(timestamp: string): string { const now = new Date(); const past = new Date(timestamp); @@ -48,3 +66,54 @@ export function calcPercentage( export function formatPercentage(val: number | undefined) { return `${(val ?? 0).toFixed(2)}%`; } + +export type BrokedownMetrics = { + cpuThreads: number; + cpuUsage: number; + cpuUser: number; + cpuSystem: number; + cpuIdle: number; + cpuSteal: number; + cpuIowait: number; + memoryUsage: number; + memoryUsed: number; + memoryTotal: number; + swapUsage: number; + diskUsage: number; + diskFree: number; + diskTotal: number; + networkUp: number; + networkDown: number; +}; + +export function breakdownMetrics( + metrics: Metrics | undefined, +): BrokedownMetrics { + return { + cpuThreads: metrics?.cpu.threads ?? 0, + cpuUsage: metrics?.cpu.usage ?? 0, + cpuUser: metrics?.cpu.breakdown.user ?? 0, + cpuSystem: metrics?.cpu.breakdown.system ?? 0, + cpuIdle: metrics?.cpu.breakdown.idle ?? 0, + cpuSteal: metrics?.cpu.breakdown.steal ?? 0, + cpuIowait: metrics?.cpu.breakdown.iowait ?? 0, + memoryUsage: calcPercentage( + metrics?.memory.used, + metrics?.memory.total, + ), + memoryUsed: metrics?.memory.used ?? 0, + memoryTotal: metrics?.memory.total ?? 0, + swapUsage: calcPercentage( + metrics?.memory.swap_used, + metrics?.memory.swap_total, + ), + diskUsage: calcPercentage( + (metrics?.disk.total ?? 0) - (metrics?.disk.free ?? 0), + metrics?.disk.total, + ), + diskFree: metrics?.disk.free ?? 0, + diskTotal: metrics?.disk.total ?? 0, + networkUp: (metrics?.network.up ?? 0) / 1024, + networkDown: (metrics?.network.down ?? 0) / 1024, + }; +}