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')
This commit is contained in:
2026-02-13 13:08:39 -05:00
parent d6debe51b1
commit b14489ff59
8 changed files with 918 additions and 435 deletions

View File

@@ -34,4 +34,4 @@ services:
networks: networks:
traefik: traefik:
external: true external: true

87
src/app/api/gpu/route.ts Normal file
View File

@@ -0,0 +1,87 @@
import { NextResponse } from "next/server";
const PROMETHEUS_URL = "http://prometheus:9090";
const INSTANCE_MAP: Record<string, string> = {
"192.168.1.50": "Wile",
"192.168.1.51": "RoadRunner",
};
async function queryPrometheus(query: string): Promise<any[]> {
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<string, number> {
const map: Record<string, number> = {};
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<Record<string, number>> {
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([]);
}
}

View File

@@ -0,0 +1,93 @@
import { NextResponse } from "next/server";
const PROMETHEUS_URL = "http://prometheus:9090";
const INSTANCE_MAP: Record<string, { name: string; role: string }> = {
"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<any[]> {
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<string, number> {
const map: Record<string, number> = {};
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([]);
}
}

View File

@@ -1,279 +1,202 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Search, Server, Activity } from "lucide-react"; import { Server, Activity } from "lucide-react";
import ContainerGroup from "@/components/ContainerGroup"; import ContainerGroup from "@/components/ContainerGroup";
import SearchBar from "@/components/SearchBar"; import SearchBar from "@/components/SearchBar";
import GrafanaWidget from "@/components/GrafanaWidget"; import GrafanaWidget from "@/components/GrafanaWidget";
import UnifiWidget from "@/components/UnifiWidget"; import UnifiWidget from "@/components/UnifiWidget";
import SynologyWidget from "@/components/SynologyWidget"; import SynologyWidget from "@/components/SynologyWidget";
import { Container } from "@/types"; import ServerStatsWidget from "@/components/ServerStatsWidget";
import GPUStatsWidget from "@/components/GPUStatsWidget";
export default function Home() { import { Container } from "@/types";
const [containers, setContainers] = useState<Container[]>([]);
const [searchQuery, setSearchQuery] = useState(""); export default function Home() {
const [loading, setLoading] = useState(true); const [containers, setContainers] = useState<Container[]>([]);
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => { const [loading, setLoading] = useState(true);
fetchContainers();
const interval = setInterval(fetchContainers, 10000); // Refresh every 10s useEffect(() => {
return () => clearInterval(interval); fetchContainers();
}, []); const interval = setInterval(fetchContainers, 10000);
return () => clearInterval(interval);
const fetchContainers = async () => { }, []);
try {
const response = await fetch("/api/containers"); const fetchContainers = async () => {
const data = await response.json(); try {
setContainers(data); const response = await fetch("/api/containers");
setLoading(false); const data = await response.json();
} catch (error) { setContainers(data);
console.error("Failed to fetch containers:", error); setLoading(false);
setLoading(false); } catch (error) {
} console.error("Failed to fetch containers:", error);
}; setLoading(false);
}
const groupContainers = (containers: Container[]) => { };
return {
media: containers.filter((c) => const groupContainers = (containers: Container[]) => {
[ return {
"sonarr", media: containers.filter((c) =>
"radarr", [
"lidarr", "sonarr", "radarr", "lidarr", "whisparr", "prowlarr", "bazarr",
"whisparr", "tautulli", "overseerr", "ombi", "jellyfin", "plex", "audiobookshelf",
"prowlarr", "lazylibrarian",
"bazarr", ].some((app) => c.name.toLowerCase().includes(app))
"tautulli", ),
"overseerr", download: containers.filter((c) =>
"ombi", [
"jellyfin", "qbittorrent", "transmission", "sabnzbd", "nzbget", "deluge",
"plex", "gluetun", "flaresolverr",
"audiobookshelf", ].some((app) => c.name.toLowerCase().includes(app))
"lazylibrarian", ),
].some((app) => c.name.toLowerCase().includes(app)) infrastructure: containers.filter((c) =>
), [
download: containers.filter((c) => "traefik", "portainer", "heimdall", "homepage", "nginx", "caddy",
[ "pihole", "adguard", "unbound", "mosquitto",
"qbittorrent", ].some((app) => c.name.toLowerCase().includes(app))
"transmission", ),
"sabnzbd", monitoring: containers.filter((c) =>
"nzbget", [
"deluge", "grafana", "prometheus", "cadvisor", "node-exporter", "dozzle",
"gluetun", "uptime-kuma", "beszel", "dockmon", "docker-stats-exporter", "diun",
"flaresolverr", "container-census",
].some((app) => c.name.toLowerCase().includes(app)) ].some((app) => c.name.toLowerCase().includes(app))
), ),
infrastructure: containers.filter((c) => automation: containers.filter((c) =>
[ [
"traefik", "homeassistant", "home-assistant", "n8n", "nodered", "node-red",
"portainer", "duplicati",
"heimdall", ].some((app) => c.name.toLowerCase().includes(app))
"homepage", ),
"nginx", productivity: containers.filter((c) =>
"caddy", [
"pihole", "nextcloud", "openproject", "gitea", "gitlab", "code-server", "vscode",
"adguard", ].some((app) => c.name.toLowerCase().includes(app))
"unbound", ),
"mosquitto", media_processing: containers.filter((c) =>
].some((app) => c.name.toLowerCase().includes(app)) ["tdarr"].some((app) => c.name.toLowerCase().includes(app))
), ),
monitoring: containers.filter((c) => ai: containers.filter((c) =>
[ ["openwebui", "open-webui", "ollama", "stable-diffusion", "mcp"].some(
"grafana", (app) => c.name.toLowerCase().includes(app)
"prometheus", )
"cadvisor", ),
"node-exporter", photos: containers.filter((c) =>
"dozzle", ["immich"].some((app) => c.name.toLowerCase().includes(app))
"uptime-kuma", ),
"beszel", databases: containers.filter((c) =>
"dockmon", ["postgres", "mariadb", "mysql", "mongo", "redis", "db"].some((app) =>
"docker-stats-exporter", c.name.toLowerCase().includes(app)
"diun", )
"container-census", ),
].some((app) => c.name.toLowerCase().includes(app)) };
), };
automation: containers.filter((c) =>
[ const filteredContainers = containers.filter(
"homeassistant", (c) =>
"home-assistant", c.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
"n8n", c.image.toLowerCase().includes(searchQuery.toLowerCase())
"nodered", );
"node-red",
"duplicati", const grouped = groupContainers(
].some((app) => c.name.toLowerCase().includes(app)) searchQuery ? filteredContainers : containers
), );
productivity: containers.filter((c) =>
[ return (
"nextcloud", <div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
"openproject", <header className="border-b border-gray-700 bg-gray-900/50 backdrop-blur-lg sticky top-0 z-50">
"gitea", <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
"gitlab", <div className="flex items-center justify-between">
"code-server", <div className="flex items-center space-x-3">
"vscode", <Server className="w-8 h-8 text-blue-500" />
].some((app) => c.name.toLowerCase().includes(app)) <h1 className="text-2xl font-bold text-white">Atlas Dashboard</h1>
), </div>
media_processing: containers.filter((c) => <div className="flex items-center space-x-4">
["tdarr"].some((app) => c.name.toLowerCase().includes(app)) <div className="flex items-center space-x-2 text-sm text-gray-400">
), <Activity className="w-4 h-4" />
ai: containers.filter((c) => <span>{containers.length} containers</span>
["openwebui", "open-webui", "ollama", "stable-diffusion", "mcp"].some( </div>
(app) => c.name.toLowerCase().includes(app) </div>
) </div>
), <div className="mt-4">
photos: containers.filter((c) => <SearchBar value={searchQuery} onChange={setSearchQuery} />
["immich"].some((app) => c.name.toLowerCase().includes(app)) </div>
), </div>
databases: containers.filter((c) => </header>
["postgres", "mariadb", "mysql", "mongo", "redis", "db"].some((app) =>
c.name.toLowerCase().includes(app) <main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
) <div className="space-y-6 mb-8">
), <ServerStatsWidget />
}; <GPUStatsWidget />
}; </div>
const filteredContainers = containers.filter( <div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
(c) => <UnifiWidget />
c.name.toLowerCase().includes(searchQuery.toLowerCase()) || <SynologyWidget />
c.image.toLowerCase().includes(searchQuery.toLowerCase()) <GrafanaWidget
); title="Server Stats"
dashboardId="server-overview"
const grouped = groupContainers( panelId={1}
searchQuery ? filteredContainers : containers />
); </div>
return ( <div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900"> <GrafanaWidget
{/* Header */} title="Docker Stats"
<header className="border-b border-gray-700 bg-gray-900/50 backdrop-blur-lg sticky top-0 z-50"> dashboardId="docker-monitoring"
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4"> panelId={2}
<div className="flex items-center justify-between"> />
<div className="flex items-center space-x-3"> <GrafanaWidget
<Server className="w-8 h-8 text-blue-500" /> title="LLM Metrics"
<h1 className="text-2xl font-bold text-white">Atlas Dashboard</h1> dashboardId="llm-monitoring"
</div> panelId={3}
<div className="flex items-center space-x-4"> />
<div className="flex items-center space-x-2 text-sm text-gray-400"> <GrafanaWidget
<Activity className="w-4 h-4" /> title="System Load"
<span>{containers.length} containers</span> dashboardId="system-metrics"
</div> panelId={4}
</div> />
</div> </div>
<div className="mt-4">
<SearchBar value={searchQuery} onChange={setSearchQuery} /> {loading ? (
</div> <div className="flex items-center justify-center h-64">
</div> <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
</header> </div>
) : (
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div className="space-y-6">
{/* Widgets Section */} {grouped.ai.length > 0 && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8"> <ContainerGroup title="AI Services" containers={grouped.ai} icon="🤖" />
<UnifiWidget /> )}
<SynologyWidget /> {grouped.media.length > 0 && (
<GrafanaWidget <ContainerGroup title="Media Management" containers={grouped.media} icon="📺" />
title="Server Stats" )}
dashboardId="server-overview" {grouped.download.length > 0 && (
panelId={1} <ContainerGroup title="Download Clients" containers={grouped.download} icon="⬇️" />
/> )}
</div> {grouped.photos.length > 0 && (
<ContainerGroup title="Photo Management" containers={grouped.photos} icon="📷" />
{/* Grafana Dashboards */} )}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8"> {grouped.media_processing.length > 0 && (
<GrafanaWidget <ContainerGroup title="Media Processing" containers={grouped.media_processing} icon="🎬" />
title="Docker Stats" )}
dashboardId="docker-monitoring" {grouped.automation.length > 0 && (
panelId={2} <ContainerGroup title="Automation" containers={grouped.automation} icon="⚡" />
/> )}
<GrafanaWidget {grouped.productivity.length > 0 && (
title="LLM Metrics" <ContainerGroup title="Productivity" containers={grouped.productivity} icon="💼" />
dashboardId="llm-monitoring" )}
panelId={3} {grouped.infrastructure.length > 0 && (
/> <ContainerGroup title="Infrastructure" containers={grouped.infrastructure} icon="🔧" />
<GrafanaWidget )}
title="System Load" {grouped.monitoring.length > 0 && (
dashboardId="system-metrics" <ContainerGroup title="Monitoring" containers={grouped.monitoring} icon="📊" />
panelId={4} )}
/> {grouped.databases.length > 0 && (
</div> <ContainerGroup title="Databases" containers={grouped.databases} icon="🗄️" />
)}
{/* Container Groups */} </div>
{loading ? ( )}
<div className="flex items-center justify-center h-64"> </main>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div> </div>
</div> );
) : ( }
<div className="space-y-6">
{grouped.media.length > 0 && (
<ContainerGroup
title="Media Management"
containers={grouped.media}
icon="📺"
/>
)}
{grouped.download.length > 0 && (
<ContainerGroup
title="Download Clients"
containers={grouped.download}
icon="⬇️"
/>
)}
{grouped.ai.length > 0 && (
<ContainerGroup
title="AI Services"
containers={grouped.ai}
icon="🤖"
/>
)}
{grouped.photos.length > 0 && (
<ContainerGroup
title="Photo Management"
containers={grouped.photos}
icon="📷"
/>
)}
{grouped.media_processing.length > 0 && (
<ContainerGroup
title="Media Processing"
containers={grouped.media_processing}
icon="🎬"
/>
)}
{grouped.automation.length > 0 && (
<ContainerGroup
title="Automation"
containers={grouped.automation}
icon="⚡"
/>
)}
{grouped.productivity.length > 0 && (
<ContainerGroup
title="Productivity"
containers={grouped.productivity}
icon="💼"
/>
)}
{grouped.infrastructure.length > 0 && (
<ContainerGroup
title="Infrastructure"
containers={grouped.infrastructure}
icon="🔧"
/>
)}
{grouped.monitoring.length > 0 && (
<ContainerGroup
title="Monitoring"
containers={grouped.monitoring}
icon="📊"
/>
)}
{grouped.databases.length > 0 && (
<ContainerGroup
title="Databases"
containers={grouped.databases}
icon="🗄️"
/>
)}
</div>
)}
</main>
</div>
);
}

View File

@@ -1,115 +1,154 @@
"use client"; "use client";
import { Container } from "@/types"; import { Container } from "@/types";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { ExternalLink, Power, Circle } from "lucide-react"; import { ExternalLink, Circle } from "lucide-react";
interface ContainerGroupProps { interface ContainerGroupProps {
title: string; title: string;
containers: Container[]; containers: Container[];
icon: string; icon: string;
} }
export default function ContainerGroup({ export default function ContainerGroup({
title, title,
containers, containers,
icon, icon,
}: ContainerGroupProps) { }: ContainerGroupProps) {
const getStatusColor = (state: string) => { const getStatusColor = (state: string) => {
switch (state.toLowerCase()) { switch (state.toLowerCase()) {
case "running": case "running":
return "text-green-500"; return "text-green-500";
case "paused": case "paused":
return "text-yellow-500"; return "text-yellow-500";
case "exited": case "exited":
return "text-red-500"; return "text-red-500";
default: default:
return "text-gray-500"; return "text-gray-500";
} }
}; };
const getTraefikUrl = (labels: Record<string, string>) => { const getTraefikUrl = (labels: Record<string, string>) => {
const host = labels["traefik.http.routers.https.rule"]; for (const key of Object.keys(labels)) {
if (host) { if (key.includes("traefik.http.routers") && key.endsWith(".rule")) {
const match = host.match(/Host\(`([^`]+)`\)/); const match = labels[key].match(/Host\(`([^`]+)`\)/);
if (match) return `https://${match[1]}`; if (match) return `https://${match[1]}`;
} }
return null; }
}; return null;
};
return (
<div className="bg-gray-800/40 backdrop-blur-sm rounded-lg border border-gray-700 overflow-hidden"> const parseCpuPercent = (cpu?: string): number => {
<div className="px-6 py-4 border-b border-gray-700 bg-gray-800/60"> if (!cpu) return 0;
<h2 className="text-lg font-semibold text-white flex items-center gap-2"> const val = parseFloat(cpu.replace("%", ""));
<span className="text-2xl">{icon}</span> return isNaN(val) ? 0 : val;
{title} };
<span className="ml-auto text-sm text-gray-400">
{containers.length}{" "} const getCpuBarColor = (val: number): string => {
{containers.length === 1 ? "container" : "containers"} if (val < 25) return "bg-green-500";
</span> if (val < 60) return "bg-yellow-500";
</h2> return "bg-red-500";
</div> };
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 p-6">
{containers.map((container, idx) => { return (
const url = getTraefikUrl(container.labels); <div className="bg-gray-800/40 backdrop-blur-sm rounded-lg border border-gray-700 overflow-hidden">
return ( <div className="px-6 py-4 border-b border-gray-700 bg-gray-800/60">
<motion.div <h2 className="text-lg font-semibold text-white flex items-center gap-2">
key={container.id} <span className="text-2xl">{icon}</span>
initial={{ opacity: 0, y: 20 }} {title}
animate={{ opacity: 1, y: 0 }} <span className="ml-auto text-sm text-gray-400">
transition={{ delay: idx * 0.05 }} {containers.length}{" "}
className="bg-gray-900/50 rounded-lg border border-gray-700 p-4 hover:border-blue-500/50 transition-all duration-200 group" {containers.length === 1 ? "container" : "containers"}
> </span>
<div className="flex items-start justify-between mb-3"> </h2>
<div className="flex-1 min-w-0"> </div>
<h3 className="text-white font-medium truncate"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 p-6">
{container.name} {containers.map((container, idx) => {
</h3> const url = getTraefikUrl(container.labels);
<p className="text-xs text-gray-400 truncate"> const cpuVal = parseCpuPercent(container.cpu);
{container.image} return (
</p> <motion.div
</div> key={container.id}
<Circle initial={{ opacity: 0, y: 20 }}
className={`w-3 h-3 fill-current ${getStatusColor( animate={{ opacity: 1, y: 0 }}
container.state transition={{ delay: idx * 0.05 }}
)} flex-shrink-0`} className="bg-gray-900/50 rounded-lg border border-gray-700 p-4 hover:border-blue-500/50 transition-all duration-200 group"
/> >
</div> <div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<div className="space-y-2"> <h3 className="text-white font-medium truncate">
<div className="flex items-center justify-between text-xs"> {container.name}
<span className="text-gray-400">Status</span> </h3>
<span className="text-gray-300">{container.status}</span> <p className="text-xs text-gray-400 truncate">
</div> {container.image}
</p>
{container.ports.length > 0 && ( </div>
<div className="flex items-center justify-between text-xs"> <Circle
<span className="text-gray-400">Ports</span> className={`w-3 h-3 fill-current ${getStatusColor(
<span className="text-gray-300"> container.state
{container.ports )} flex-shrink-0`}
.filter((p) => p.publicPort) />
.map((p) => p.publicPort) </div>
.join(", ") || "Internal"}
</span> <div className="space-y-2">
</div> <div className="flex items-center justify-between text-xs">
)} <span className="text-gray-400">Status</span>
<span className="text-gray-300">{container.status}</span>
{url && ( </div>
<a
href={url} {container.ports.length > 0 && (
target="_blank" <div className="flex items-center justify-between text-xs">
rel="noopener noreferrer" <span className="text-gray-400">Ports</span>
className="flex items-center justify-center gap-2 mt-3 py-2 px-3 bg-blue-600/20 hover:bg-blue-600/30 text-blue-400 rounded text-xs font-medium transition-colors" <span className="text-gray-300">
> {container.ports
<ExternalLink className="w-3 h-3" /> .filter((p) => p.publicPort)
Open .map((p) => p.publicPort)
</a> .join(", ") || "Internal"}
)} </span>
</div> </div>
</motion.div> )}
);
})} {container.state === "running" && container.cpu && (
</div> <div className="pt-2 border-t border-gray-700/50 space-y-1.5">
</div> <div className="flex items-center justify-between text-xs">
); <span className="text-gray-400">CPU</span>
} <span className="text-gray-300 font-mono text-[11px]">
{container.cpu}
</span>
</div>
<div className="h-1.5 bg-gray-700 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${getCpuBarColor(cpuVal)}`}
style={{ width: `${Math.min(cpuVal, 100)}%` }}
/>
</div>
{container.memory && (
<div className="flex items-center justify-between text-xs">
<span className="text-gray-400">Memory</span>
<span className="text-gray-300 font-mono text-[11px]">
{container.memory}
</span>
</div>
)}
</div>
)}
{url && (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 mt-3 py-2 px-3 bg-blue-600/20 hover:bg-blue-600/30 text-blue-400 rounded text-xs font-medium transition-colors"
>
<ExternalLink className="w-3 h-3" />
Open
</a>
)}
</div>
</motion.div>
);
})}
</div>
</div>
);
}

View File

@@ -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 (
<div className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="text-gray-400 flex items-center gap-1">
{icon}
{label}
</span>
<span className="text-gray-300 font-mono">{value}%</span>
</div>
<div className="h-2 bg-gray-700 rounded-full overflow-hidden">
<motion.div
className={`h-full rounded-full bg-gradient-to-r ${color}`}
initial={{ width: 0 }}
animate={{ width: `${Math.min(value, 100)}%` }}
transition={{ duration: 0.8, ease: "easeOut" }}
/>
</div>
</div>
);
}
export default function GPUStatsWidget() {
const [gpus, setGpus] = useState<GPUStats[]>([]);
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 (
<div className="bg-gray-800/40 backdrop-blur-sm rounded-lg border border-gray-700 p-6">
<div className="flex items-center gap-2 mb-4">
<Cpu className="w-5 h-5 text-purple-400" />
<h2 className="text-lg font-semibold text-white">GPU Stats</h2>
</div>
<div className="flex items-center justify-center h-32">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-500" />
</div>
</div>
);
}
if (gpus.length === 0) return null;
return (
<div className="bg-gray-800/40 backdrop-blur-sm rounded-lg border border-gray-700 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-700 bg-gray-800/60">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<Cpu className="w-5 h-5 text-purple-400" />
GPU Stats
<span className="ml-auto text-sm text-gray-400">
{gpus.length} GPUs
</span>
</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 p-6">
{gpus.map((gpu, idx) => (
<motion.div
key={gpu.name}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: idx * 0.1 }}
className="bg-gray-900/50 rounded-lg border border-gray-700 p-4 hover:border-purple-500/50 transition-all duration-200"
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-white font-medium">{gpu.name}</h3>
<span className="text-xs text-gray-500">NVIDIA Jetson</span>
</div>
<div className="space-y-3">
<UtilBar
label="GPU Utilization"
value={gpu.gpu_util}
icon={<Cpu className="w-3 h-3" />}
/>
<UtilBar
label="Memory Utilization"
value={gpu.mem_util}
icon={<Cpu className="w-3 h-3" />}
/>
<UtilBar
label="Temperature"
value={Math.min((gpu.temp / 100) * 100, 100)}
icon={<Thermometer className="w-3 h-3" />}
colorOverride={getTempBarColor(gpu.temp)}
/>
<div className="flex items-center justify-between pt-1 border-t border-gray-700/50">
<div className="flex items-center gap-2 text-xs">
<Thermometer className="w-3 h-3 text-gray-400" />
<span className={`font-mono ${getTempColor(gpu.temp)}`}>
{gpu.temp}°C
</span>
</div>
<div className="flex items-center gap-2 text-xs">
<Zap className="w-3 h-3 text-yellow-400" />
<span className="text-gray-300 font-mono">
{gpu.power_watts}W
</span>
</div>
</div>
</div>
</motion.div>
))}
</div>
</div>
);
}

View File

@@ -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 (
<div className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="text-gray-400 flex items-center gap-1">
{icon}
{label}
</span>
<span className="text-gray-300 font-mono">{value.toFixed(1)}%</span>
</div>
<div className="h-2 bg-gray-700 rounded-full overflow-hidden">
<motion.div
className={`h-full rounded-full bg-gradient-to-r ${color}`}
initial={{ width: 0 }}
animate={{ width: `${Math.min(value, 100)}%` }}
transition={{ duration: 0.8, ease: "easeOut" }}
/>
</div>
</div>
);
}
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<ServerStats[]>([]);
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 (
<div className="bg-gray-800/40 backdrop-blur-sm rounded-lg border border-gray-700 p-6">
<div className="flex items-center gap-2 mb-4">
<Server className="w-5 h-5 text-blue-400" />
<h2 className="text-lg font-semibold text-white">Server Stats</h2>
</div>
<div className="flex items-center justify-center h-32">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
</div>
</div>
);
}
return (
<div className="bg-gray-800/40 backdrop-blur-sm rounded-lg border border-gray-700 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-700 bg-gray-800/60">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<Server className="w-5 h-5 text-blue-400" />
Server Stats
<span className="ml-auto text-sm text-gray-400">
{servers.length} nodes
</span>
</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 p-6">
{servers.map((server, idx) => (
<motion.div
key={server.name}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: idx * 0.1 }}
className="bg-gray-900/50 rounded-lg border border-gray-700 p-4 hover:border-blue-500/50 transition-all duration-200"
>
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-white font-medium">{server.name}</h3>
<p className="text-xs text-gray-400">{server.role}</p>
</div>
<span className="text-xs text-gray-500 font-mono">
{server.ip}
</span>
</div>
<div className="space-y-3">
<StatBar
label="CPU"
value={server.cpu}
icon={<Cpu className="w-3 h-3" />}
/>
<StatBar
label={`RAM (${server.memoryUsedGB}/${server.memoryTotalGB} GB)`}
value={server.memoryPercent}
icon={<Server className="w-3 h-3" />}
/>
<StatBar
label="Disk"
value={server.diskPercent}
icon={<HardDrive className="w-3 h-3" />}
/>
<div className="flex items-center justify-between text-xs pt-1 border-t border-gray-700/50">
<span className="text-gray-400 flex items-center gap-1">
<Clock className="w-3 h-3" />
Uptime
</span>
<span className="text-gray-300 font-mono">
{formatUptime(server.uptimeSeconds)}
</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-gray-400">Load (1m)</span>
<span className="text-gray-300 font-mono">
{server.load1}
</span>
</div>
</div>
</motion.div>
))}
</div>
</div>
);
}

View File

@@ -1,40 +1,64 @@
export interface Container { export interface Container {
id: string; id: string;
name: string; name: string;
image: string; image: string;
state: string; state: string;
status: string; status: string;
created: number; created: number;
ports: Port[]; ports: Port[];
labels: Record<string, string>; labels: Record<string, string>;
} cpu?: string;
memory?: string;
export interface Port { stats?: string;
ip?: string; }
privatePort: number;
publicPort?: number; export interface Port {
type: string; ip?: string;
} privatePort: number;
publicPort?: number;
export interface UnifiDevice { type: string;
name: string; }
mac: string;
ip: string; export interface UnifiDevice {
model: string; name: string;
state: number; mac: string;
uptime: number; ip: string;
} model: string;
state: number;
export interface SynologyStorage { uptime: number;
volume: string; }
size: number;
used: number; export interface SynologyStorage {
available: number; volume: string;
percentUsed: number; size: number;
} used: number;
available: number;
export interface GrafanaDashboard { percentUsed: number;
uid: string; }
title: string;
url: string; 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;
}