From b14489ff599b6d6f8b7692e05bef510d2de5f338 Mon Sep 17 00:00:00 2001 From: mblanke Date: Fri, 13 Feb 2026 13:08:39 -0500 Subject: [PATCH] feat: add server stats, GPU stats, container CPU/memory display - Add /api/servers endpoint querying Prometheus for CPU, RAM, disk, uptime, load - Add /api/gpu endpoint for NVIDIA Jetson GPU utilization, temp, power - Add ServerStatsWidget with animated bars for Atlas, Wile, RoadRunner - Add GPUStatsWidget with GPU util, memory, temp color-coding, power draw - Update ContainerGroup to show CPU bar and memory for running containers - Fix docker-compose.yml: traefik network external: true - Fix getTraefikUrl to scan all router labels (not just 'https') --- docker-compose.yml | 2 +- src/app/api/gpu/route.ts | 87 +++++ src/app/api/servers/route.ts | 93 ++++++ src/app/page.tsx | 481 +++++++++++---------------- src/components/ContainerGroup.tsx | 269 ++++++++------- src/components/GPUStatsWidget.tsx | 161 +++++++++ src/components/ServerStatsWidget.tsx | 156 +++++++++ src/types/index.ts | 104 +++--- 8 files changed, 918 insertions(+), 435 deletions(-) create mode 100644 src/app/api/gpu/route.ts create mode 100644 src/app/api/servers/route.ts create mode 100644 src/components/GPUStatsWidget.tsx create mode 100644 src/components/ServerStatsWidget.tsx diff --git a/docker-compose.yml b/docker-compose.yml index c2f6ce5..51e4aa0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,4 +34,4 @@ services: networks: traefik: - external: true \ No newline at end of file + external: true diff --git a/src/app/api/gpu/route.ts b/src/app/api/gpu/route.ts new file mode 100644 index 0000000..d2a49bc --- /dev/null +++ b/src/app/api/gpu/route.ts @@ -0,0 +1,87 @@ +import { NextResponse } from "next/server"; + +const PROMETHEUS_URL = "http://prometheus:9090"; + +const INSTANCE_MAP: Record = { + "192.168.1.50": "Wile", + "192.168.1.51": "RoadRunner", +}; + +async function queryPrometheus(query: string): Promise { + try { + const url = `${PROMETHEUS_URL}/api/v1/query?query=${encodeURIComponent(query)}`; + const res = await fetch(url, { cache: "no-store" }); + if (!res.ok) return []; + const json = await res.json(); + if (json.status === "success" && json.data?.result) { + return json.data.result; + } + return []; + } catch { + return []; + } +} + +function extractByInstance(results: any[]): Record { + const map: Record = {}; + for (const r of results) { + const instance: string = r.metric?.instance || ""; + const ip = instance.replace(/:\d+$/, ""); + const val = parseFloat(r.value?.[1] || "0"); + if (!isNaN(val)) { + map[ip] = val; + } + } + return map; +} + +async function queryWithFallback( + primaryMetric: string, + ...fallbacks: string[] +): Promise> { + const primary = await queryPrometheus(primaryMetric); + if (primary.length > 0) return extractByInstance(primary); + + for (const fb of fallbacks) { + const res = await queryPrometheus(fb); + if (res.length > 0) return extractByInstance(res); + } + return {}; +} + +export async function GET() { + try { + const [gpuUtilMap, memUtilMap, tempMap, powerMap] = await Promise.all([ + queryWithFallback( + "DCGM_FI_DEV_GPU_UTIL", + "nvidia_gpu_utilization_gpu", + "gpu_utilization_percentage" + ), + queryWithFallback( + "DCGM_FI_DEV_MEM_COPY_UTIL", + "nvidia_gpu_memory_used_bytes / nvidia_gpu_memory_total_bytes * 100" + ), + queryWithFallback( + "DCGM_FI_DEV_GPU_TEMP", + "nvidia_gpu_temperature_gpu" + ), + queryWithFallback( + "DCGM_FI_DEV_POWER_USAGE", + "nvidia_gpu_power_draw_watts" + ), + ]); + + const gpus = Object.entries(INSTANCE_MAP).map(([ip, name]) => ({ + name, + gpu_util: parseFloat((gpuUtilMap[ip] || 0).toFixed(1)), + mem_util: parseFloat((memUtilMap[ip] || 0).toFixed(1)), + temp: parseFloat((tempMap[ip] || 0).toFixed(0)), + power_watts: parseFloat((powerMap[ip] || 0).toFixed(1)), + })); + + return NextResponse.json(gpus); + } catch (error) { + console.error("GPU API error:", error); + return NextResponse.json([]); + } +} diff --git a/src/app/api/servers/route.ts b/src/app/api/servers/route.ts new file mode 100644 index 0000000..44da8a2 --- /dev/null +++ b/src/app/api/servers/route.ts @@ -0,0 +1,93 @@ +import { NextResponse } from "next/server"; + +const PROMETHEUS_URL = "http://prometheus:9090"; + +const INSTANCE_MAP: Record = { + "192.168.1.21": { name: "Atlas", role: "Control Node" }, + "192.168.1.50": { name: "Wile", role: "GPU Node - Heavy" }, + "192.168.1.51": { name: "RoadRunner", role: "GPU Node - Fast" }, +}; + +async function queryPrometheus(query: string): Promise { + try { + const url = `${PROMETHEUS_URL}/api/v1/query?query=${encodeURIComponent(query)}`; + const res = await fetch(url, { cache: "no-store" }); + if (!res.ok) return []; + const json = await res.json(); + if (json.status === "success" && json.data?.result) { + return json.data.result; + } + return []; + } catch { + return []; + } +} + +function extractByInstance(results: any[]): Record { + const map: Record = {}; + for (const r of results) { + const instance: string = r.metric?.instance || ""; + const ip = instance.replace(/:\d+$/, ""); + const val = parseFloat(r.value?.[1] || "0"); + if (!isNaN(val)) { + map[ip] = val; + } + } + return map; +} + +export async function GET() { + try { + const [cpuRes, memPercentRes, memTotalRes, memAvailRes, diskRes, uptimeBootRes, uptimeNowRes, loadRes] = + await Promise.all([ + queryPrometheus( + '100 - (avg by(instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)' + ), + queryPrometheus( + "(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100" + ), + queryPrometheus("node_memory_MemTotal_bytes"), + queryPrometheus("node_memory_MemAvailable_bytes"), + queryPrometheus( + '100 - (node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"} * 100)' + ), + queryPrometheus("node_boot_time_seconds"), + queryPrometheus("node_time_seconds"), + queryPrometheus("node_load1"), + ]); + + const cpuMap = extractByInstance(cpuRes); + const memPercentMap = extractByInstance(memPercentRes); + const memTotalMap = extractByInstance(memTotalRes); + const memAvailMap = extractByInstance(memAvailRes); + const diskMap = extractByInstance(diskRes); + const bootMap = extractByInstance(uptimeBootRes); + const nowMap = extractByInstance(uptimeNowRes); + const loadMap = extractByInstance(loadRes); + + const servers = Object.entries(INSTANCE_MAP).map(([ip, info]) => { + const memTotalBytes = memTotalMap[ip] || 0; + const memAvailBytes = memAvailMap[ip] || 0; + const memUsedBytes = memTotalBytes - memAvailBytes; + const uptimeSeconds = (nowMap[ip] || 0) - (bootMap[ip] || 0); + + return { + name: info.name, + role: info.role, + ip, + cpu: parseFloat((cpuMap[ip] || 0).toFixed(1)), + memoryPercent: parseFloat((memPercentMap[ip] || 0).toFixed(1)), + memoryUsedGB: parseFloat((memUsedBytes / 1073741824).toFixed(1)), + memoryTotalGB: parseFloat((memTotalBytes / 1073741824).toFixed(1)), + diskPercent: parseFloat((diskMap[ip] || 0).toFixed(1)), + uptimeSeconds: Math.floor(uptimeSeconds > 0 ? uptimeSeconds : 0), + load1: parseFloat((loadMap[ip] || 0).toFixed(2)), + }; + }); + + return NextResponse.json(servers); + } catch (error) { + console.error("Servers API error:", error); + return NextResponse.json([]); + } +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 804a91a..9127232 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,279 +1,202 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { Search, Server, Activity } from "lucide-react"; -import ContainerGroup from "@/components/ContainerGroup"; -import SearchBar from "@/components/SearchBar"; -import GrafanaWidget from "@/components/GrafanaWidget"; -import UnifiWidget from "@/components/UnifiWidget"; -import SynologyWidget from "@/components/SynologyWidget"; -import { Container } from "@/types"; - -export default function Home() { - const [containers, setContainers] = useState([]); - const [searchQuery, setSearchQuery] = useState(""); - const [loading, setLoading] = useState(true); - - useEffect(() => { - fetchContainers(); - const interval = setInterval(fetchContainers, 10000); // Refresh every 10s - return () => clearInterval(interval); - }, []); - - const fetchContainers = async () => { - try { - const response = await fetch("/api/containers"); - const data = await response.json(); - setContainers(data); - setLoading(false); - } catch (error) { - console.error("Failed to fetch containers:", error); - setLoading(false); - } - }; - - const groupContainers = (containers: Container[]) => { - return { - media: containers.filter((c) => - [ - "sonarr", - "radarr", - "lidarr", - "whisparr", - "prowlarr", - "bazarr", - "tautulli", - "overseerr", - "ombi", - "jellyfin", - "plex", - "audiobookshelf", - "lazylibrarian", - ].some((app) => c.name.toLowerCase().includes(app)) - ), - download: containers.filter((c) => - [ - "qbittorrent", - "transmission", - "sabnzbd", - "nzbget", - "deluge", - "gluetun", - "flaresolverr", - ].some((app) => c.name.toLowerCase().includes(app)) - ), - infrastructure: containers.filter((c) => - [ - "traefik", - "portainer", - "heimdall", - "homepage", - "nginx", - "caddy", - "pihole", - "adguard", - "unbound", - "mosquitto", - ].some((app) => c.name.toLowerCase().includes(app)) - ), - monitoring: containers.filter((c) => - [ - "grafana", - "prometheus", - "cadvisor", - "node-exporter", - "dozzle", - "uptime-kuma", - "beszel", - "dockmon", - "docker-stats-exporter", - "diun", - "container-census", - ].some((app) => c.name.toLowerCase().includes(app)) - ), - automation: containers.filter((c) => - [ - "homeassistant", - "home-assistant", - "n8n", - "nodered", - "node-red", - "duplicati", - ].some((app) => c.name.toLowerCase().includes(app)) - ), - productivity: containers.filter((c) => - [ - "nextcloud", - "openproject", - "gitea", - "gitlab", - "code-server", - "vscode", - ].some((app) => c.name.toLowerCase().includes(app)) - ), - media_processing: containers.filter((c) => - ["tdarr"].some((app) => c.name.toLowerCase().includes(app)) - ), - ai: containers.filter((c) => - ["openwebui", "open-webui", "ollama", "stable-diffusion", "mcp"].some( - (app) => c.name.toLowerCase().includes(app) - ) - ), - photos: containers.filter((c) => - ["immich"].some((app) => c.name.toLowerCase().includes(app)) - ), - databases: containers.filter((c) => - ["postgres", "mariadb", "mysql", "mongo", "redis", "db"].some((app) => - c.name.toLowerCase().includes(app) - ) - ), - }; - }; - - const filteredContainers = containers.filter( - (c) => - c.name.toLowerCase().includes(searchQuery.toLowerCase()) || - c.image.toLowerCase().includes(searchQuery.toLowerCase()) - ); - - const grouped = groupContainers( - searchQuery ? filteredContainers : containers - ); - - return ( -
- {/* Header */} -
-
-
-
- -

Atlas Dashboard

-
-
-
- - {containers.length} containers -
-
-
-
- -
-
-
- -
- {/* Widgets Section */} -
- - - -
- - {/* Grafana Dashboards */} -
- - - -
- - {/* Container Groups */} - {loading ? ( -
-
-
- ) : ( -
- {grouped.media.length > 0 && ( - - )} - {grouped.download.length > 0 && ( - - )} - {grouped.ai.length > 0 && ( - - )} - {grouped.photos.length > 0 && ( - - )} - {grouped.media_processing.length > 0 && ( - - )} - {grouped.automation.length > 0 && ( - - )} - {grouped.productivity.length > 0 && ( - - )} - {grouped.infrastructure.length > 0 && ( - - )} - {grouped.monitoring.length > 0 && ( - - )} - {grouped.databases.length > 0 && ( - - )} -
- )} -
-
- ); -} +"use client"; + +import { useEffect, useState } from "react"; +import { Server, Activity } from "lucide-react"; +import ContainerGroup from "@/components/ContainerGroup"; +import SearchBar from "@/components/SearchBar"; +import GrafanaWidget from "@/components/GrafanaWidget"; +import UnifiWidget from "@/components/UnifiWidget"; +import SynologyWidget from "@/components/SynologyWidget"; +import ServerStatsWidget from "@/components/ServerStatsWidget"; +import GPUStatsWidget from "@/components/GPUStatsWidget"; +import { Container } from "@/types"; + +export default function Home() { + const [containers, setContainers] = useState([]); + const [searchQuery, setSearchQuery] = useState(""); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetchContainers(); + const interval = setInterval(fetchContainers, 10000); + return () => clearInterval(interval); + }, []); + + const fetchContainers = async () => { + try { + const response = await fetch("/api/containers"); + const data = await response.json(); + setContainers(data); + setLoading(false); + } catch (error) { + console.error("Failed to fetch containers:", error); + setLoading(false); + } + }; + + const groupContainers = (containers: Container[]) => { + return { + media: containers.filter((c) => + [ + "sonarr", "radarr", "lidarr", "whisparr", "prowlarr", "bazarr", + "tautulli", "overseerr", "ombi", "jellyfin", "plex", "audiobookshelf", + "lazylibrarian", + ].some((app) => c.name.toLowerCase().includes(app)) + ), + download: containers.filter((c) => + [ + "qbittorrent", "transmission", "sabnzbd", "nzbget", "deluge", + "gluetun", "flaresolverr", + ].some((app) => c.name.toLowerCase().includes(app)) + ), + infrastructure: containers.filter((c) => + [ + "traefik", "portainer", "heimdall", "homepage", "nginx", "caddy", + "pihole", "adguard", "unbound", "mosquitto", + ].some((app) => c.name.toLowerCase().includes(app)) + ), + monitoring: containers.filter((c) => + [ + "grafana", "prometheus", "cadvisor", "node-exporter", "dozzle", + "uptime-kuma", "beszel", "dockmon", "docker-stats-exporter", "diun", + "container-census", + ].some((app) => c.name.toLowerCase().includes(app)) + ), + automation: containers.filter((c) => + [ + "homeassistant", "home-assistant", "n8n", "nodered", "node-red", + "duplicati", + ].some((app) => c.name.toLowerCase().includes(app)) + ), + productivity: containers.filter((c) => + [ + "nextcloud", "openproject", "gitea", "gitlab", "code-server", "vscode", + ].some((app) => c.name.toLowerCase().includes(app)) + ), + media_processing: containers.filter((c) => + ["tdarr"].some((app) => c.name.toLowerCase().includes(app)) + ), + ai: containers.filter((c) => + ["openwebui", "open-webui", "ollama", "stable-diffusion", "mcp"].some( + (app) => c.name.toLowerCase().includes(app) + ) + ), + photos: containers.filter((c) => + ["immich"].some((app) => c.name.toLowerCase().includes(app)) + ), + databases: containers.filter((c) => + ["postgres", "mariadb", "mysql", "mongo", "redis", "db"].some((app) => + c.name.toLowerCase().includes(app) + ) + ), + }; + }; + + const filteredContainers = containers.filter( + (c) => + c.name.toLowerCase().includes(searchQuery.toLowerCase()) || + c.image.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + const grouped = groupContainers( + searchQuery ? filteredContainers : containers + ); + + return ( +
+
+
+
+
+ +

Atlas Dashboard

+
+
+
+ + {containers.length} containers +
+
+
+
+ +
+
+
+ +
+
+ + +
+ +
+ + + +
+ +
+ + + +
+ + {loading ? ( +
+
+
+ ) : ( +
+ {grouped.ai.length > 0 && ( + + )} + {grouped.media.length > 0 && ( + + )} + {grouped.download.length > 0 && ( + + )} + {grouped.photos.length > 0 && ( + + )} + {grouped.media_processing.length > 0 && ( + + )} + {grouped.automation.length > 0 && ( + + )} + {grouped.productivity.length > 0 && ( + + )} + {grouped.infrastructure.length > 0 && ( + + )} + {grouped.monitoring.length > 0 && ( + + )} + {grouped.databases.length > 0 && ( + + )} +
+ )} +
+
+ ); +} diff --git a/src/components/ContainerGroup.tsx b/src/components/ContainerGroup.tsx index ff6506b..4f6d788 100644 --- a/src/components/ContainerGroup.tsx +++ b/src/components/ContainerGroup.tsx @@ -1,115 +1,154 @@ -"use client"; - -import { Container } from "@/types"; -import { motion } from "framer-motion"; -import { ExternalLink, Power, Circle } from "lucide-react"; - -interface ContainerGroupProps { - title: string; - containers: Container[]; - icon: string; -} - -export default function ContainerGroup({ - title, - containers, - icon, -}: ContainerGroupProps) { - const getStatusColor = (state: string) => { - switch (state.toLowerCase()) { - case "running": - return "text-green-500"; - case "paused": - return "text-yellow-500"; - case "exited": - return "text-red-500"; - default: - return "text-gray-500"; - } - }; - - const getTraefikUrl = (labels: Record) => { - const host = labels["traefik.http.routers.https.rule"]; - if (host) { - const match = host.match(/Host\(`([^`]+)`\)/); - if (match) return `https://${match[1]}`; - } - return null; - }; - - return ( -
-
-

- {icon} - {title} - - {containers.length}{" "} - {containers.length === 1 ? "container" : "containers"} - -

-
-
- {containers.map((container, idx) => { - const url = getTraefikUrl(container.labels); - return ( - -
-
-

- {container.name} -

-

- {container.image} -

-
- -
- -
-
- Status - {container.status} -
- - {container.ports.length > 0 && ( -
- Ports - - {container.ports - .filter((p) => p.publicPort) - .map((p) => p.publicPort) - .join(", ") || "Internal"} - -
- )} - - {url && ( - - - Open - - )} -
-
- ); - })} -
-
- ); -} +"use client"; + +import { Container } from "@/types"; +import { motion } from "framer-motion"; +import { ExternalLink, Circle } from "lucide-react"; + +interface ContainerGroupProps { + title: string; + containers: Container[]; + icon: string; +} + +export default function ContainerGroup({ + title, + containers, + icon, +}: ContainerGroupProps) { + const getStatusColor = (state: string) => { + switch (state.toLowerCase()) { + case "running": + return "text-green-500"; + case "paused": + return "text-yellow-500"; + case "exited": + return "text-red-500"; + default: + return "text-gray-500"; + } + }; + + const getTraefikUrl = (labels: Record) => { + for (const key of Object.keys(labels)) { + if (key.includes("traefik.http.routers") && key.endsWith(".rule")) { + const match = labels[key].match(/Host\(`([^`]+)`\)/); + if (match) return `https://${match[1]}`; + } + } + return null; + }; + + const parseCpuPercent = (cpu?: string): number => { + if (!cpu) return 0; + const val = parseFloat(cpu.replace("%", "")); + return isNaN(val) ? 0 : val; + }; + + const getCpuBarColor = (val: number): string => { + if (val < 25) return "bg-green-500"; + if (val < 60) return "bg-yellow-500"; + return "bg-red-500"; + }; + + return ( +
+
+

+ {icon} + {title} + + {containers.length}{" "} + {containers.length === 1 ? "container" : "containers"} + +

+
+
+ {containers.map((container, idx) => { + const url = getTraefikUrl(container.labels); + const cpuVal = parseCpuPercent(container.cpu); + return ( + +
+
+

+ {container.name} +

+

+ {container.image} +

+
+ +
+ +
+
+ Status + {container.status} +
+ + {container.ports.length > 0 && ( +
+ Ports + + {container.ports + .filter((p) => p.publicPort) + .map((p) => p.publicPort) + .join(", ") || "Internal"} + +
+ )} + + {container.state === "running" && container.cpu && ( +
+
+ CPU + + {container.cpu} + +
+
+
+
+ {container.memory && ( +
+ Memory + + {container.memory} + +
+ )} +
+ )} + + {url && ( + + + Open + + )} +
+ + ); + })} +
+
+ ); +} diff --git a/src/components/GPUStatsWidget.tsx b/src/components/GPUStatsWidget.tsx new file mode 100644 index 0000000..5957f8c --- /dev/null +++ b/src/components/GPUStatsWidget.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { motion } from "framer-motion"; +import { Zap, Thermometer, Cpu } from "lucide-react"; +import { GPUStats } from "@/types"; + +function getTempColor(temp: number): string { + if (temp < 60) return "text-green-400"; + if (temp < 80) return "text-yellow-400"; + return "text-red-400"; +} + +function getTempBarColor(temp: number): string { + if (temp < 60) return "from-green-500 to-green-400"; + if (temp < 80) return "from-yellow-500 to-yellow-400"; + return "from-red-500 to-red-400"; +} + +function UtilBar({ + label, + value, + icon, + colorOverride, +}: { + label: string; + value: number; + icon: React.ReactNode; + colorOverride?: string; +}) { + const color = + colorOverride || + (value < 50 + ? "from-green-500 to-green-400" + : value < 80 + ? "from-yellow-500 to-yellow-400" + : "from-red-500 to-red-400"); + + return ( +
+
+ + {icon} + {label} + + {value}% +
+
+ +
+
+ ); +} + +export default function GPUStatsWidget() { + const [gpus, setGpus] = useState([]); + const [loading, setLoading] = useState(true); + + const fetchGPU = async () => { + try { + const res = await fetch("/api/gpu"); + const data = await res.json(); + if (Array.isArray(data)) setGpus(data); + } catch (err) { + console.error("Failed to fetch GPU stats:", err); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchGPU(); + const interval = setInterval(fetchGPU, 15000); + return () => clearInterval(interval); + }, []); + + if (loading) { + return ( +
+
+ +

GPU Stats

+
+
+
+
+
+ ); + } + + if (gpus.length === 0) return null; + + return ( +
+
+

+ + GPU Stats + + {gpus.length} GPUs + +

+
+
+ {gpus.map((gpu, idx) => ( + +
+

{gpu.name}

+ NVIDIA Jetson +
+ +
+ } + /> + } + /> + } + colorOverride={getTempBarColor(gpu.temp)} + /> + +
+
+ + + {gpu.temp}°C + +
+
+ + + {gpu.power_watts}W + +
+
+
+
+ ))} +
+
+ ); +} diff --git a/src/components/ServerStatsWidget.tsx b/src/components/ServerStatsWidget.tsx new file mode 100644 index 0000000..e7580c3 --- /dev/null +++ b/src/components/ServerStatsWidget.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { motion } from "framer-motion"; +import { Cpu, HardDrive, Clock, Server } from "lucide-react"; +import { ServerStats } from "@/types"; + +function StatBar({ + label, + value, + icon, +}: { + label: string; + value: number; + icon: React.ReactNode; +}) { + const color = + value < 50 + ? "from-green-500 to-green-400" + : value < 80 + ? "from-yellow-500 to-yellow-400" + : "from-red-500 to-red-400"; + + return ( +
+
+ + {icon} + {label} + + {value.toFixed(1)}% +
+
+ +
+
+ ); +} + +function formatUptime(seconds: number): string { + if (seconds <= 0) return "N/A"; + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + return `${days}d ${hours}h`; +} + +export default function ServerStatsWidget() { + const [servers, setServers] = useState([]); + const [loading, setLoading] = useState(true); + + const fetchStats = async () => { + try { + const res = await fetch("/api/servers"); + const data = await res.json(); + if (Array.isArray(data)) setServers(data); + } catch (err) { + console.error("Failed to fetch server stats:", err); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchStats(); + const interval = setInterval(fetchStats, 15000); + return () => clearInterval(interval); + }, []); + + if (loading) { + return ( +
+
+ +

Server Stats

+
+
+
+
+
+ ); + } + + return ( +
+
+

+ + Server Stats + + {servers.length} nodes + +

+
+
+ {servers.map((server, idx) => ( + +
+
+

{server.name}

+

{server.role}

+
+ + {server.ip} + +
+ +
+ } + /> + } + /> + } + /> + +
+ + + Uptime + + + {formatUptime(server.uptimeSeconds)} + +
+
+ Load (1m) + + {server.load1} + +
+
+
+ ))} +
+
+ ); +} diff --git a/src/types/index.ts b/src/types/index.ts index f732926..ab13f92 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,40 +1,64 @@ -export interface Container { - id: string; - name: string; - image: string; - state: string; - status: string; - created: number; - ports: Port[]; - labels: Record; -} - -export interface Port { - ip?: string; - privatePort: number; - publicPort?: number; - type: string; -} - -export interface UnifiDevice { - name: string; - mac: string; - ip: string; - model: string; - state: number; - uptime: number; -} - -export interface SynologyStorage { - volume: string; - size: number; - used: number; - available: number; - percentUsed: number; -} - -export interface GrafanaDashboard { - uid: string; - title: string; - url: string; -} +export interface Container { + id: string; + name: string; + image: string; + state: string; + status: string; + created: number; + ports: Port[]; + labels: Record; + cpu?: string; + memory?: string; + stats?: string; +} + +export interface Port { + ip?: string; + privatePort: number; + publicPort?: number; + type: string; +} + +export interface UnifiDevice { + name: string; + mac: string; + ip: string; + model: string; + state: number; + uptime: number; +} + +export interface SynologyStorage { + volume: string; + size: number; + used: number; + available: number; + percentUsed: number; +} + +export interface GrafanaDashboard { + uid: string; + title: string; + url: string; +} + +export interface ServerStats { + name: string; + role: string; + ip: string; + cpu: number; + memoryPercent: number; + memoryUsedGB: number; + memoryTotalGB: number; + diskPercent: number; + uptimeSeconds: number; + load1: number; +} + +export interface GPUStats { + name: string; + gpu_util: number; + mem_util: number; + temp: number; + power_watts: number; +}