mirror of
https://github.com/mblanke/Dashboard.git
synced 2026-03-01 12:10:20 -05:00
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:
@@ -34,4 +34,4 @@ services:
|
|||||||
|
|
||||||
networks:
|
networks:
|
||||||
traefik:
|
traefik:
|
||||||
external: true
|
external: true
|
||||||
|
|||||||
87
src/app/api/gpu/route.ts
Normal file
87
src/app/api/gpu/route.ts
Normal 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([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
93
src/app/api/servers/route.ts
Normal file
93
src/app/api/servers/route.ts
Normal 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([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
481
src/app/page.tsx
481
src/app/page.tsx
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
161
src/components/GPUStatsWidget.tsx
Normal file
161
src/components/GPUStatsWidget.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
156
src/components/ServerStatsWidget.tsx
Normal file
156
src/components/ServerStatsWidget.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user