feat: server health gauges (CPU/RAM/Temp), condensed UniFi DMP, NAS space display

- ServerStatsWidget: 3 SVG ring gauges per server with color thresholds
- Servers API: added cpuTemp from node_hwmon_temp_celsius
- NetworkWidget: condensed DMP view (WAN/WLAN/LAN health subsystems)
- UniFi API: parallel fetch of stat/device + stat/health
- SynologyWidget: prominent total/available space display
This commit is contained in:
2026-02-13 15:18:34 -05:00
parent 304840c316
commit 9133f5bb3c
5 changed files with 510 additions and 357 deletions

View File

@@ -8,52 +8,41 @@ const INSTANCE_MAP: Record<string, { name: string; role: string }> = {
"192.168.1.51": { name: "RoadRunner", role: "GPU Node - Fast" }, "192.168.1.51": { name: "RoadRunner", role: "GPU Node - Fast" },
}; };
export const dynamic = "force-dynamic";
async function queryPrometheus(query: string): Promise<any[]> { async function queryPrometheus(query: string): Promise<any[]> {
try { try {
const url = `${PROMETHEUS_URL}/api/v1/query?query=${encodeURIComponent(query)}`; const url = `${PROMETHEUS_URL}/api/v1/query?query=${encodeURIComponent(query)}`;
const res = await fetch(url, { cache: "no-store" }); const res = await fetch(url, { cache: "no-store" });
if (!res.ok) return []; if (!res.ok) return [];
const json = await res.json(); const json = await res.json();
if (json.status === "success" && json.data?.result) { return json.status === "success" ? (json.data?.result || []) : [];
return json.data.result; } catch { return []; }
}
return [];
} catch {
return [];
}
} }
function extractByInstance(results: any[]): Record<string, number> { function extractByInstance(results: any[]): Record<string, number> {
const map: Record<string, number> = {}; const map: Record<string, number> = {};
for (const r of results) { for (const r of results) {
const instance: string = r.metric?.instance || ""; const ip = (r.metric?.instance || "").replace(/:\d+$/, "");
const ip = instance.replace(/:\d+$/, "");
const val = parseFloat(r.value?.[1] || "0"); const val = parseFloat(r.value?.[1] || "0");
if (!isNaN(val)) { if (!isNaN(val)) map[ip] = val;
map[ip] = val;
}
} }
return map; return map;
} }
export async function GET() { export async function GET() {
try { try {
const [cpuRes, memPercentRes, memTotalRes, memAvailRes, diskRes, uptimeBootRes, uptimeNowRes, loadRes] = const [cpuRes, memPercentRes, memTotalRes, memAvailRes, diskRes, uptimeBootRes, uptimeNowRes, loadRes, tempRes] =
await Promise.all([ await Promise.all([
queryPrometheus( queryPrometheus('100 - (avg by(instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)'),
'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(
"(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100"
),
queryPrometheus("node_memory_MemTotal_bytes"), queryPrometheus("node_memory_MemTotal_bytes"),
queryPrometheus("node_memory_MemAvailable_bytes"), queryPrometheus("node_memory_MemAvailable_bytes"),
queryPrometheus( queryPrometheus('100 - (node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"} * 100)'),
'100 - (node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"} * 100)'
),
queryPrometheus("node_boot_time_seconds"), queryPrometheus("node_boot_time_seconds"),
queryPrometheus("node_time_seconds"), queryPrometheus("node_time_seconds"),
queryPrometheus("node_load1"), queryPrometheus("node_load1"),
queryPrometheus('node_hwmon_temp_celsius{chip="thermal_thermal_zone0",sensor="temp0"}'),
]); ]);
const cpuMap = extractByInstance(cpuRes); const cpuMap = extractByInstance(cpuRes);
@@ -64,11 +53,11 @@ export async function GET() {
const bootMap = extractByInstance(uptimeBootRes); const bootMap = extractByInstance(uptimeBootRes);
const nowMap = extractByInstance(uptimeNowRes); const nowMap = extractByInstance(uptimeNowRes);
const loadMap = extractByInstance(loadRes); const loadMap = extractByInstance(loadRes);
const tempMap = extractByInstance(tempRes);
const servers = Object.entries(INSTANCE_MAP).map(([ip, info]) => { const servers = Object.entries(INSTANCE_MAP).map(([ip, info]) => {
const memTotalBytes = memTotalMap[ip] || 0; const memTotalBytes = memTotalMap[ip] || 0;
const memAvailBytes = memAvailMap[ip] || 0; const memAvailBytes = memAvailMap[ip] || 0;
const memUsedBytes = memTotalBytes - memAvailBytes;
const uptimeSeconds = (nowMap[ip] || 0) - (bootMap[ip] || 0); const uptimeSeconds = (nowMap[ip] || 0) - (bootMap[ip] || 0);
return { return {
@@ -76,8 +65,9 @@ export async function GET() {
role: info.role, role: info.role,
ip, ip,
cpu: parseFloat((cpuMap[ip] || 0).toFixed(1)), cpu: parseFloat((cpuMap[ip] || 0).toFixed(1)),
cpuTemp: tempMap[ip] !== undefined ? parseFloat(tempMap[ip].toFixed(0)) : null,
memoryPercent: parseFloat((memPercentMap[ip] || 0).toFixed(1)), memoryPercent: parseFloat((memPercentMap[ip] || 0).toFixed(1)),
memoryUsedGB: parseFloat((memUsedBytes / 1073741824).toFixed(1)), memoryUsedGB: parseFloat(((memTotalBytes - memAvailBytes) / 1073741824).toFixed(1)),
memoryTotalGB: parseFloat((memTotalBytes / 1073741824).toFixed(1)), memoryTotalGB: parseFloat((memTotalBytes / 1073741824).toFixed(1)),
diskPercent: parseFloat((diskMap[ip] || 0).toFixed(1)), diskPercent: parseFloat((diskMap[ip] || 0).toFixed(1)),
uptimeSeconds: Math.floor(uptimeSeconds > 0 ? uptimeSeconds : 0), uptimeSeconds: Math.floor(uptimeSeconds > 0 ? uptimeSeconds : 0),

View File

@@ -8,14 +8,9 @@ const UNIFI_PASSWORD = process.env.UNIFI_PASSWORD;
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
/** Low-level HTTPS request that ignores self-signed certs and returns cookies */
function httpsRequest( function httpsRequest(
url: string, url: string,
opts: { opts: { method?: string; headers?: Record<string, string>; body?: string } = {}
method?: string;
headers?: Record<string, string>;
body?: string;
} = {}
): Promise<{ status: number; headers: Record<string, string[]>; body: string }> { ): Promise<{ status: number; headers: Record<string, string[]>; body: string }> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const parsed = new URL(url); const parsed = new URL(url);
@@ -47,81 +42,99 @@ function httpsRequest(
export async function GET() { export async function GET() {
if (!UNIFI_USERNAME || !UNIFI_PASSWORD) { if (!UNIFI_USERNAME || !UNIFI_PASSWORD) {
return NextResponse.json( return NextResponse.json({ error: "UniFi credentials not configured" }, { status: 500 });
{ error: "UniFi credentials not configured" },
{ status: 500 }
);
} }
try { try {
const baseUrl = `https://${UNIFI_HOST}:${UNIFI_PORT}`; const baseUrl = `https://${UNIFI_HOST}:${UNIFI_PORT}`;
// Step 1: Login to UniFi OS // Login
const loginResp = await httpsRequest(`${baseUrl}/api/auth/login`, { const loginResp = await httpsRequest(`${baseUrl}/api/auth/login`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({ username: UNIFI_USERNAME, password: UNIFI_PASSWORD }),
username: UNIFI_USERNAME,
password: UNIFI_PASSWORD,
}),
}); });
if (loginResp.status !== 200) { if (loginResp.status !== 200) {
console.error("UniFi login failed:", loginResp.status, loginResp.body.slice(0, 200)); return NextResponse.json({ error: `UniFi login failed: ${loginResp.status}` }, { status: 401 });
return NextResponse.json(
{ error: `UniFi login failed: ${loginResp.status}` },
{ status: 401 }
);
} }
// Extract session cookies
const setCookies = loginResp.headers["set-cookie"] || []; const setCookies = loginResp.headers["set-cookie"] || [];
const cookieStr = setCookies const cookieStr = setCookies.map((c) => c.split(";")[0]).join("; ");
.map((c) => c.split(";")[0])
.join("; ");
// Extract CSRF token if present
const csrfToken = (loginResp.headers["x-csrf-token"] || [])[0] || ""; const csrfToken = (loginResp.headers["x-csrf-token"] || [])[0] || "";
// Step 2: Fetch devices
const reqHeaders: Record<string, string> = { Cookie: cookieStr }; const reqHeaders: Record<string, string> = { Cookie: cookieStr };
if (csrfToken) reqHeaders["x-csrf-token"] = csrfToken; if (csrfToken) reqHeaders["x-csrf-token"] = csrfToken;
const devicesResp = await httpsRequest( // Fetch devices + health in parallel
`${baseUrl}/proxy/network/api/s/default/stat/device`, const [devicesResp, healthResp] = await Promise.all([
{ headers: reqHeaders } httpsRequest(`${baseUrl}/proxy/network/api/s/default/stat/device`, { headers: reqHeaders }),
); httpsRequest(`${baseUrl}/proxy/network/api/s/default/stat/health`, { headers: reqHeaders }),
]);
if (devicesResp.status !== 200) { // Parse devices
console.error("UniFi device fetch failed:", devicesResp.status); const rawDevices = devicesResp.status === 200 ? (JSON.parse(devicesResp.body)?.data || []) : [];
return NextResponse.json( const devices = rawDevices.map((d: any) => ({
{ error: `UniFi device fetch failed: ${devicesResp.status}` }, name: d.name || d.model || "Unknown",
{ status: 502 } mac: d.mac || "",
); ip: d.ip || "",
} model: d.model || "",
type: d.type || "",
const devicesData = JSON.parse(devicesResp.body); state: d.state ?? 0,
const rawDevices = devicesData?.data || []; uptime: d.uptime || 0,
clients: d.num_sta || 0,
const devices = rawDevices.map((device: any) => ({
name: device.name || device.model || "Unknown",
mac: device.mac || "",
ip: device.ip || "",
model: device.model || "",
type: device.type || "",
state: device.state ?? 0,
uptime: device.uptime || 0,
clients: device.num_sta || 0,
satisfaction: device.satisfaction ?? null,
version: device.version || "",
})); }));
return NextResponse.json(devices); // Parse health subsystems
const rawHealth = healthResp.status === 200 ? (JSON.parse(healthResp.body)?.data || []) : [];
const health: Record<string, any> = {};
for (const h of rawHealth) {
const sub = h.subsystem;
if (sub === "wan") {
health.wan = {
status: h.status,
wanIp: h.wan_ip || "",
isp: h.isp_name || "",
txRate: h["tx_bytes-r"] || 0,
rxRate: h["rx_bytes-r"] || 0,
gateways: h.num_gw || 0,
clients: h.num_sta || 0,
};
} else if (sub === "www") {
health.www = {
status: h.status,
latency: h.latency || 0,
uptime: h.uptime || 0,
downSpeed: h.xput_down || 0,
upSpeed: h.xput_up || 0,
};
} else if (sub === "wlan") {
health.wlan = {
status: h.status,
adopted: h.num_adopted || 0,
clients: h.num_user || 0,
aps: h.num_ap || 0,
txRate: h["tx_bytes-r"] || 0,
rxRate: h["rx_bytes-r"] || 0,
};
} else if (sub === "lan") {
health.lan = {
status: h.status,
adopted: h.num_adopted || 0,
clients: h.num_user || 0,
switches: h.num_sw || 0,
txRate: h["tx_bytes-r"] || 0,
rxRate: h["rx_bytes-r"] || 0,
};
} else if (sub === "vpn") {
health.vpn = { status: h.status };
}
}
const totalClients = devices.reduce((s: number, d: any) => s + d.clients, 0);
return NextResponse.json({ devices, health, totalClients });
} catch (error: any) { } catch (error: any) {
console.error("UniFi API error:", error?.message || error); console.error("UniFi API error:", error?.message || error);
return NextResponse.json( return NextResponse.json({ error: "Failed to fetch UniFi data" }, { status: 500 });
{ error: "Failed to fetch UniFi devices" },
{ status: 500 }
);
} }
} }

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Wifi, Router, Monitor, Signal, WifiOff } from "lucide-react"; import { Wifi, Globe, Router, Monitor, Signal, WifiOff, ArrowDown, ArrowUp, Activity } from "lucide-react";
interface UnifiDevice { interface UnifiDevice {
name: string; name: string;
@@ -12,63 +12,96 @@ interface UnifiDevice {
state: number; state: number;
uptime: number; uptime: number;
clients: number; clients: number;
satisfaction: number | null;
version: string;
} }
function formatUptime(seconds: number): string { interface HealthSub {
if (!seconds) return "—"; status: string;
const d = Math.floor(seconds / 86400); wanIp?: string;
const h = Math.floor((seconds % 86400) / 3600); isp?: string;
txRate?: number;
rxRate?: number;
latency?: number;
uptime?: number;
clients?: number;
aps?: number;
switches?: number;
adopted?: number;
}
interface UnifiData {
devices: UnifiDevice[];
health: Record<string, HealthSub>;
totalClients: number;
}
function formatRate(bps: number): string {
if (bps >= 1024 * 1024) return (bps / (1024 ** 2)).toFixed(1) + " MB/s";
if (bps >= 1024) return (bps / 1024).toFixed(1) + " KB/s";
return bps.toFixed(0) + " B/s";
}
function formatUptime(sec: number): string {
if (!sec) return "—";
const d = Math.floor(sec / 86400);
const h = Math.floor((sec % 86400) / 3600);
if (d > 0) return `${d}d ${h}h`; if (d > 0) return `${d}d ${h}h`;
const m = Math.floor((seconds % 3600) / 60); const m = Math.floor((sec % 3600) / 60);
return `${h}h ${m}m`; return `${h}h ${m}m`;
} }
function deviceIcon(type: string) { function StatusDot({ status }: { status: string }) {
switch (type) { const color = status === "ok" ? "bg-green-400" : status === "unknown" ? "bg-gray-500" : "bg-yellow-400";
case "uap": return <Wifi className="w-3.5 h-3.5" />; return <span className={`inline-block w-2 h-2 rounded-full ${color}`} />;
case "usw": return <Monitor className="w-3.5 h-3.5" />;
case "ugw":
case "udm": return <Router className="w-3.5 h-3.5" />;
default: return <Signal className="w-3.5 h-3.5" />;
}
} }
export default function NetworkWidget() { export default function NetworkWidget() {
const [devices, setDevices] = useState<UnifiDevice[]>([]); const [data, setData] = useState<UnifiData | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(false); const [error, setError] = useState(false);
useEffect(() => { useEffect(() => {
fetchDevices(); fetchData();
const interval = setInterval(fetchDevices, 30000); const interval = setInterval(fetchData, 30000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, []); }, []);
const fetchDevices = async () => { const fetchData = async () => {
try { try {
const response = await fetch("/api/unifi"); const response = await fetch("/api/unifi");
if (response.ok) { if (response.ok) {
const data = await response.json(); const json = await response.json();
if (Array.isArray(data)) { if (json.devices) { setData(json); setError(false); }
setDevices(data); else setError(true);
setError(false); } else setError(true);
} else {
setError(true);
}
} else {
setError(true);
}
setLoading(false); setLoading(false);
} catch { } catch { setError(true); setLoading(false); }
setError(true);
setLoading(false);
}
}; };
const totalClients = devices.reduce((sum, d) => sum + d.clients, 0); if (loading) return (
const onlineDevices = devices.filter((d) => d.state === 1).length; <div className="bg-gray-800/40 backdrop-blur-sm rounded-lg border border-gray-700 p-6">
<h3 className="text-lg font-semibold text-white flex items-center gap-2 mb-4">
<Wifi className="w-5 h-5 text-blue-500" /> UniFi Network
</h3>
<div className="flex justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
</div>
</div>
);
if (error || !data) return (
<div className="bg-gray-800/40 backdrop-blur-sm rounded-lg border border-gray-700 p-6">
<h3 className="text-lg font-semibold text-white flex items-center gap-2 mb-4">
<Wifi className="w-5 h-5 text-blue-500" /> UniFi Network
</h3>
<div className="text-center py-8">
<WifiOff className="w-12 h-12 text-gray-600 mx-auto mb-2" />
<p className="text-sm text-gray-400">No network data</p>
</div>
</div>
);
const { wan, www, wlan, lan } = data.health;
const onlineDevices = data.devices.filter(d => d.state === 1).length;
return ( return (
<div className="bg-gray-800/40 backdrop-blur-sm rounded-lg border border-gray-700 p-6"> <div className="bg-gray-800/40 backdrop-blur-sm rounded-lg border border-gray-700 p-6">
@@ -77,56 +110,111 @@ export default function NetworkWidget() {
<Wifi className="w-5 h-5 text-blue-500" /> <Wifi className="w-5 h-5 text-blue-500" />
UniFi Network UniFi Network
</h3> </h3>
{!loading && !error && devices.length > 0 && ( <span className="text-xs text-gray-400">{onlineDevices}/{data.devices.length} devices</span>
<div className="flex items-center gap-3 text-xs text-gray-400"> </div>
<span className="flex items-center gap-1">
<Signal className="w-3 h-3 text-green-400" /> {/* Top stats row */}
{totalClients} clients <div className="grid grid-cols-3 gap-3 mb-4">
<div className="bg-gray-900/50 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-white">{data.totalClients}</div>
<div className="text-xs text-gray-400">Clients</div>
</div>
<div className="bg-gray-900/50 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-white">{wlan?.clients || 0}</div>
<div className="text-xs text-gray-400">Wireless</div>
</div>
<div className="bg-gray-900/50 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-white">{lan?.clients || 0}</div>
<div className="text-xs text-gray-400">Wired</div>
</div>
</div>
{/* WAN status */}
{wan && (
<div className="bg-gray-900/50 rounded-lg p-3 mb-3">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-200 flex items-center gap-2">
<Globe className="w-3.5 h-3.5 text-blue-400" />
WAN
<StatusDot status={wan.status} />
</span> </span>
<span>{onlineDevices}/{devices.length} online</span> <span className="text-xs text-gray-500">{wan.wanIp}</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-gray-400">{wan.isp}</span>
<div className="flex items-center gap-3">
<span className="flex items-center gap-1 text-green-400">
<ArrowDown className="w-3 h-3" />{formatRate(wan.rxRate || 0)}
</span>
<span className="flex items-center gap-1 text-blue-400">
<ArrowUp className="w-3 h-3" />{formatRate(wan.txRate || 0)}
</span>
</div>
</div>
{www && (
<div className="flex items-center gap-4 mt-2 text-xs text-gray-500">
<span className="flex items-center gap-1">
<Activity className="w-3 h-3" />{www.latency}ms latency
</span>
<span>Uptime: {formatUptime(www.uptime || 0)}</span>
</div>
)}
</div>
)}
{/* WLAN + LAN row */}
<div className="grid grid-cols-2 gap-3 mb-3">
{wlan && (
<div className="bg-gray-900/50 rounded-lg p-3">
<div className="flex items-center gap-2 mb-1">
<Wifi className="w-3.5 h-3.5 text-green-400" />
<span className="text-sm font-medium text-gray-200">WiFi</span>
<StatusDot status={wlan.status} />
</div>
<div className="text-xs text-gray-400">
{wlan.aps} APs &middot; {wlan.clients} clients
</div>
<div className="flex items-center gap-2 mt-1 text-xs">
<span className="text-green-400 flex items-center gap-0.5"><ArrowDown className="w-2.5 h-2.5" />{formatRate(wlan.rxRate || 0)}</span>
<span className="text-blue-400 flex items-center gap-0.5"><ArrowUp className="w-2.5 h-2.5" />{formatRate(wlan.txRate || 0)}</span>
</div>
</div>
)}
{lan && (
<div className="bg-gray-900/50 rounded-lg p-3">
<div className="flex items-center gap-2 mb-1">
<Monitor className="w-3.5 h-3.5 text-purple-400" />
<span className="text-sm font-medium text-gray-200">LAN</span>
<StatusDot status={lan.status} />
</div>
<div className="text-xs text-gray-400">
{lan.switches} switches &middot; {lan.clients} clients
</div>
<div className="flex items-center gap-2 mt-1 text-xs">
<span className="text-green-400 flex items-center gap-0.5"><ArrowDown className="w-2.5 h-2.5" />{formatRate(lan.rxRate || 0)}</span>
<span className="text-blue-400 flex items-center gap-0.5"><ArrowUp className="w-2.5 h-2.5" />{formatRate(lan.txRate || 0)}</span>
</div>
</div> </div>
)} )}
</div> </div>
{loading ? ( {/* Device list - compact */}
<div className="flex justify-center py-8"> <div className="border-t border-gray-700/50 pt-3">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div> <p className="text-xs text-gray-500 mb-2">Devices</p>
</div> <div className="grid grid-cols-2 gap-1">
) : error || devices.length === 0 ? ( {data.devices
<div className="text-center py-8">
<WifiOff className="w-12 h-12 text-gray-600 mx-auto mb-2" />
<p className="text-sm text-gray-400">No network data available</p>
</div>
) : (
<div className="space-y-2">
{devices
.sort((a, b) => b.clients - a.clients) .sort((a, b) => b.clients - a.clients)
.map((dev) => ( .map((dev) => (
<div key={dev.mac} className="bg-gray-900/50 rounded-lg p-3"> <div key={dev.mac} className="flex items-center justify-between text-xs py-1 px-2 rounded bg-gray-900/30">
<div className="flex justify-between items-center"> <div className="flex items-center gap-1.5 truncate">
<div className="flex items-center gap-2"> <span className={`w-1.5 h-1.5 rounded-full ${dev.state === 1 ? "bg-green-400" : "bg-red-400"}`} />
<span className={dev.state === 1 ? "text-green-400" : "text-red-400"}> <span className="text-gray-300 truncate">{dev.name}</span>
{deviceIcon(dev.type)}
</span>
<div>
<span className="text-sm font-medium text-gray-200">{dev.name}</span>
<span className="text-xs text-gray-500 ml-2">{dev.model}</span>
</div>
</div>
<div className="flex items-center gap-3 text-xs text-gray-400">
{dev.clients > 0 && (
<span className="text-blue-400">{dev.clients} clients</span>
)}
<span>{formatUptime(dev.uptime)}</span>
</div> </div>
{dev.clients > 0 && <span className="text-gray-500 ml-1">{dev.clients}</span>}
</div> </div>
{dev.ip && ( ))}
<div className="text-xs text-gray-500 mt-1 ml-6">{dev.ip}</div>
)}
</div>
))}
</div> </div>
)} </div>
</div> </div>
); );
} }

View File

@@ -1,154 +1,194 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { motion } from "framer-motion"; import { Cpu, HardDrive, Clock, Server, Thermometer, MemoryStick } from "lucide-react";
import { Cpu, HardDrive, Clock, Server } from "lucide-react";
import { ServerStats } from "@/types";
function StatBar({ interface ServerData {
label, name: string;
value, role: string;
icon, ip: string;
}: { cpu: number;
label: string; cpuTemp: number | null;
value: number; memoryPercent: number;
icon: React.ReactNode; memoryUsedGB: number;
}) { memoryTotalGB: number;
const color = diskPercent: number;
value < 50 uptimeSeconds: number;
? "from-green-500 to-green-400" load1: number;
: value < 80 }
? "from-yellow-500 to-yellow-400"
: "from-red-500 to-red-400"; function formatUptime(sec: number): string {
if (!sec) return "—";
const d = Math.floor(sec / 86400);
const h = Math.floor((sec % 86400) / 3600);
return d > 0 ? `${d}d ${h}h` : `${h}h`;
}
function GaugeRing({ value, max, color, size = 72 }: { value: number; max: number; color: string; size?: number }) {
const pct = Math.min((value / max) * 100, 100);
const radius = (size - 8) / 2;
const circumference = 2 * Math.PI * radius;
const strokeDashoffset = circumference - (pct / 100) * circumference;
return ( return (
<div className="space-y-1"> <svg width={size} height={size} className="-rotate-90">
<div className="flex items-center justify-between text-xs"> <circle cx={size/2} cy={size/2} r={radius} fill="none" stroke="rgba(255,255,255,0.05)" strokeWidth="6" />
<span className="text-gray-400 flex items-center gap-1"> <circle
{icon} cx={size/2} cy={size/2} r={radius} fill="none"
{label} stroke={color} strokeWidth="6" strokeLinecap="round"
</span> strokeDasharray={circumference}
<span className="text-gray-300 font-mono">{value.toFixed(1)}%</span> strokeDashoffset={strokeDashoffset}
</div> className="transition-all duration-1000 ease-out"
<div className="h-2 bg-gray-700 rounded-full overflow-hidden"> />
<motion.div </svg>
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 { function cpuColor(v: number): string {
if (seconds <= 0) return "N/A"; if (v >= 90) return "#ef4444";
const days = Math.floor(seconds / 86400); if (v >= 70) return "#f59e0b";
const hours = Math.floor((seconds % 86400) / 3600); return "#22c55e";
return `${days}d ${hours}h`; }
function ramColor(v: number): string {
if (v >= 90) return "#ef4444";
if (v >= 70) return "#f59e0b";
return "#3b82f6";
}
function tempColor(v: number): string {
if (v >= 90) return "#ef4444";
if (v >= 75) return "#f59e0b";
if (v >= 60) return "#eab308";
return "#22c55e";
} }
export default function ServerStatsWidget() { export default function ServerStatsWidget() {
const [servers, setServers] = useState<ServerStats[]>([]); const [servers, setServers] = useState<ServerData[]>([]);
const [loading, setLoading] = useState(true); 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(() => { useEffect(() => {
fetchStats(); fetchServers();
const interval = setInterval(fetchStats, 15000); const interval = setInterval(fetchServers, 15000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, []); }, []);
if (loading) { const fetchServers = async () => {
return ( try {
<div className="bg-gray-800/40 backdrop-blur-sm rounded-lg border border-gray-700 p-6"> const res = await fetch("/api/servers");
<div className="flex items-center gap-2 mb-4"> if (res.ok) {
<Server className="w-5 h-5 text-blue-400" /> const data = await res.json();
<h2 className="text-lg font-semibold text-white">Server Stats</h2> if (Array.isArray(data)) setServers(data);
</div> }
<div className="flex items-center justify-center h-32"> setLoading(false);
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" /> } catch { setLoading(false); }
</div> };
if (loading) return (
<div className="bg-gray-800/40 backdrop-blur-sm rounded-lg border border-gray-700 p-6">
<h3 className="text-lg font-semibold text-white flex items-center gap-2 mb-4">
<Server className="w-5 h-5 text-blue-500" /> Server Health
</h3>
<div className="flex justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
</div> </div>
); </div>
} );
return ( return (
<div className="bg-gray-800/40 backdrop-blur-sm rounded-lg border border-gray-700 overflow-hidden"> <div className="bg-gray-800/40 backdrop-blur-sm rounded-lg border border-gray-700 p-6">
<div className="px-6 py-4 border-b border-gray-700 bg-gray-800/60"> <h3 className="text-lg font-semibold text-white flex items-center gap-2 mb-6">
<h2 className="text-lg font-semibold text-white flex items-center gap-2"> <Server className="w-5 h-5 text-blue-500" />
<Server className="w-5 h-5 text-blue-400" /> Server Health
Server Stats </h3>
<span className="ml-auto text-sm text-gray-400">
{servers.length} nodes <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
</span> {servers.map((srv) => (
</h2> <div key={srv.name} className="bg-gray-900/50 rounded-xl p-4">
</div> {/* Server name */}
<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 className="flex items-center justify-between mb-4">
<div> <div>
<h3 className="text-white font-medium">{server.name}</h3> <h4 className="text-sm font-bold text-white">{srv.name}</h4>
<p className="text-xs text-gray-400">{server.role}</p> <p className="text-xs text-gray-500">{srv.role}</p>
</div> </div>
<span className="text-xs text-gray-500 font-mono"> <div className="text-right text-xs text-gray-500">
{server.ip} <div className="flex items-center gap-1">
</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" /> <Clock className="w-3 h-3" />
Uptime {formatUptime(srv.uptimeSeconds)}
</span> </div>
<span className="text-gray-300 font-mono"> <div>Load: {srv.load1}</div>
{formatUptime(server.uptimeSeconds)} </div>
</div>
{/* 3 Gauges */}
<div className="flex items-center justify-around mb-3">
{/* CPU Gauge */}
<div className="relative flex flex-col items-center">
<div className="relative">
<GaugeRing value={srv.cpu} max={100} color={cpuColor(srv.cpu)} />
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-sm font-bold text-white">{Math.round(srv.cpu)}%</span>
</div>
</div>
<span className="text-xs text-gray-400 mt-1 flex items-center gap-0.5">
<Cpu className="w-3 h-3" /> CPU
</span> </span>
</div> </div>
<div className="flex items-center justify-between text-xs">
<span className="text-gray-400">Load (1m)</span> {/* RAM Gauge */}
<span className="text-gray-300 font-mono"> <div className="relative flex flex-col items-center">
{server.load1} <div className="relative">
<GaugeRing value={srv.memoryPercent} max={100} color={ramColor(srv.memoryPercent)} />
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-sm font-bold text-white">{Math.round(srv.memoryPercent)}%</span>
</div>
</div>
<span className="text-xs text-gray-400 mt-1 flex items-center gap-0.5">
<MemoryStick className="w-3 h-3" /> RAM
</span>
</div>
{/* Temp Gauge */}
<div className="relative flex flex-col items-center">
<div className="relative">
<GaugeRing
value={srv.cpuTemp ?? 0}
max={110}
color={srv.cpuTemp !== null ? tempColor(srv.cpuTemp) : "#4b5563"}
/>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-sm font-bold text-white">
{srv.cpuTemp !== null ? `${srv.cpuTemp}°` : "—"}
</span>
</div>
</div>
<span className="text-xs text-gray-400 mt-1 flex items-center gap-0.5">
<Thermometer className="w-3 h-3" /> Temp
</span> </span>
</div> </div>
</div> </div>
</motion.div>
{/* Disk + Memory detail */}
<div className="space-y-2">
<div>
<div className="flex justify-between text-xs text-gray-400 mb-1">
<span className="flex items-center gap-1"><HardDrive className="w-3 h-3" /> Disk</span>
<span>{srv.diskPercent}%</span>
</div>
<div className="w-full bg-gray-700 rounded-full h-1.5 overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
srv.diskPercent > 90 ? "bg-red-500" : srv.diskPercent > 75 ? "bg-yellow-500" : "bg-emerald-500"
}`}
style={{ width: `${Math.min(srv.diskPercent, 100)}%` }}
/>
</div>
</div>
<div className="text-xs text-gray-500 text-center">
{srv.memoryUsedGB} / {srv.memoryTotalGB} GB RAM
</div>
</div>
</div>
))} ))}
</div> </div>
</div> </div>

View File

@@ -19,6 +19,7 @@ interface Disk {
model: string; model: string;
status: string; status: string;
isSsd: boolean; isSsd: boolean;
temp: number | null;
} }
interface SynologyData { interface SynologyData {
@@ -27,11 +28,12 @@ interface SynologyData {
utilization: { cpu: number | null; memory: number | null } | null; utilization: { cpu: number | null; memory: number | null } | null;
} }
function formatBytes(bytes: number): string { function formatTB(bytes: number): string {
if (bytes >= 1024 ** 4) return (bytes / (1024 ** 4)).toFixed(1) + " TB"; return (bytes / (1024 ** 4)).toFixed(2) + " TB";
if (bytes >= 1024 ** 3) return (bytes / (1024 ** 3)).toFixed(1) + " GB"; }
if (bytes >= 1024 ** 2) return (bytes / (1024 ** 2)).toFixed(0) + " MB";
return (bytes / 1024).toFixed(0) + " KB"; function formatTemp(t: number | null): string {
return t !== null ? `${t}°C` : "";
} }
export default function SynologyWidget() { export default function SynologyWidget() {
@@ -50,22 +52,42 @@ export default function SynologyWidget() {
const response = await fetch("/api/synology"); const response = await fetch("/api/synology");
if (response.ok) { if (response.ok) {
const json = await response.json(); const json = await response.json();
if (json.volumes) { if (json.volumes) { setData(json); setError(false); }
setData(json); else setError(true);
setError(false); } else setError(true);
} else {
setError(true);
}
} else {
setError(true);
}
setLoading(false); setLoading(false);
} catch { } catch { setError(true); setLoading(false); }
setError(true);
setLoading(false);
}
}; };
if (loading) return (
<div className="bg-gray-800/40 backdrop-blur-sm rounded-lg border border-gray-700 p-6">
<h3 className="text-lg font-semibold text-white flex items-center gap-2 mb-4">
<HardDrive className="w-5 h-5 text-purple-500" /> Synology NAS
</h3>
<div className="flex justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-500" />
</div>
</div>
);
if (error || !data) return (
<div className="bg-gray-800/40 backdrop-blur-sm rounded-lg border border-gray-700 p-6">
<h3 className="text-lg font-semibold text-white flex items-center gap-2 mb-4">
<HardDrive className="w-5 h-5 text-purple-500" /> Synology NAS
</h3>
<div className="text-center py-8">
<HardDrive className="w-12 h-12 text-gray-600 mx-auto mb-2" />
<p className="text-sm text-gray-400">Configure Synology credentials in .env</p>
</div>
</div>
);
// Aggregate across volumes
const totalSize = data.volumes.reduce((s, v) => s + v.size, 0);
const totalUsed = data.volumes.reduce((s, v) => s + v.used, 0);
const totalAvail = totalSize - totalUsed;
const overallPct = totalSize > 0 ? (totalUsed / totalSize) * 100 : 0;
return ( return (
<div className="bg-gray-800/40 backdrop-blur-sm rounded-lg border border-gray-700 p-6"> <div className="bg-gray-800/40 backdrop-blur-sm rounded-lg border border-gray-700 p-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
@@ -73,78 +95,78 @@ export default function SynologyWidget() {
<HardDrive className="w-5 h-5 text-purple-500" /> <HardDrive className="w-5 h-5 text-purple-500" />
Synology NAS Synology NAS
</h3> </h3>
{data?.utilization && ( {data.utilization && (
<div className="flex items-center gap-3 text-xs text-gray-400"> <div className="flex items-center gap-3 text-xs text-gray-400">
{data.utilization.cpu !== null && ( {data.utilization.cpu !== null && (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1"><Cpu className="w-3 h-3" />{data.utilization.cpu}%</span>
<Cpu className="w-3 h-3" /> {data.utilization.cpu}%
</span>
)} )}
{data.utilization.memory !== null && ( {data.utilization.memory !== null && (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1"><MemoryStick className="w-3 h-3" />{data.utilization.memory}%</span>
<MemoryStick className="w-3 h-3" /> {data.utilization.memory}%
</span>
)} )}
</div> </div>
)} )}
</div> </div>
{loading ? ( {/* Big total/available stats */}
<div className="flex justify-center py-8"> <div className="grid grid-cols-2 gap-3 mb-4">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-500"></div> <div className="bg-gray-900/50 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-white">{formatTB(totalSize)}</div>
<div className="text-xs text-gray-400">Total Space</div>
</div> </div>
) : error || !data ? ( <div className="bg-gray-900/50 rounded-lg p-3 text-center">
<div className="text-center py-8"> <div className={`text-2xl font-bold ${overallPct > 90 ? "text-red-400" : overallPct > 75 ? "text-yellow-400" : "text-green-400"}`}>
<HardDrive className="w-12 h-12 text-gray-600 mx-auto mb-2" /> {formatTB(totalAvail)}
<p className="text-sm text-gray-400">Configure Synology credentials in .env</p> </div>
<div className="text-xs text-gray-400">Available</div>
</div> </div>
) : ( </div>
<div className="space-y-3">
{data.volumes.map((vol) => {
const pct = parseFloat(vol.percentUsed);
return (
<div key={vol.id} className="bg-gray-900/50 rounded-lg p-3">
<div className="flex justify-between items-center mb-2">
<span className="text-sm text-gray-300">{vol.id}</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${
vol.status === "normal" ? "bg-green-900/50 text-green-400" :
vol.status === "attention" ? "bg-yellow-900/50 text-yellow-400" :
"bg-red-900/50 text-red-400"
}`}>{vol.status}</span>
</div>
<div className="w-full bg-gray-700 rounded-full h-2 overflow-hidden mb-2">
<div
className={`h-full rounded-full transition-all ${
pct > 90 ? "bg-red-500" : pct > 75 ? "bg-yellow-500" : "bg-purple-500"
}`}
style={{ width: `${Math.min(pct, 100)}%` }}
/>
</div>
<div className="flex justify-between text-xs text-gray-400">
<span>{formatBytes(vol.used)} / {formatBytes(vol.size)}</span>
<span>{vol.percentUsed}%</span>
</div>
</div>
);
})}
{data.disks.length > 0 && ( {/* Usage bar per volume */}
<div className="pt-2 border-t border-gray-700/50"> {data.volumes.map((vol) => {
<p className="text-xs text-gray-500 mb-2">{data.disks.length} drives</p> const pct = parseFloat(vol.percentUsed);
<div className="grid grid-cols-2 gap-1"> return (
{data.disks.slice(0, 8).map((disk, i) => ( <div key={vol.id} className="mb-3">
<div key={i} className="flex items-center gap-1 text-xs"> <div className="flex justify-between items-center mb-1">
<Disc className={`w-3 h-3 ${ <span className="text-sm text-gray-300">{vol.id}</span>
disk.status === "normal" ? "text-green-400" : "text-yellow-400" <span className={`text-xs px-1.5 py-0.5 rounded ${
}`} /> vol.status === "normal" ? "bg-green-900/50 text-green-400" :
<span className="text-gray-400 truncate" title={disk.model}> vol.status === "attention" ? "bg-yellow-900/50 text-yellow-400" :
{disk.name} "bg-red-900/50 text-red-400"
</span> }`}>{vol.status}</span>
</div>
))}
</div>
</div> </div>
)} <div className="w-full bg-gray-700 rounded-full h-2.5 overflow-hidden mb-1">
<div
className={`h-full rounded-full transition-all ${
pct > 90 ? "bg-red-500" : pct > 75 ? "bg-yellow-500" : "bg-purple-500"
}`}
style={{ width: `${Math.min(pct, 100)}%` }}
/>
</div>
<div className="flex justify-between text-xs text-gray-500">
<span>{formatTB(vol.used)} used / {formatTB(vol.size)}</span>
<span>{vol.percentUsed}%</span>
</div>
</div>
);
})}
{/* Disk grid */}
{data.disks.length > 0 && (
<div className="pt-3 border-t border-gray-700/50">
<p className="text-xs text-gray-500 mb-2">{data.disks.length} drives</p>
<div className="grid grid-cols-3 gap-1">
{data.disks.map((disk, i) => (
<div key={i} className="flex items-center gap-1 text-xs py-0.5">
<Disc className={`w-3 h-3 flex-shrink-0 ${
disk.status === "normal" ? "text-green-400" : "text-yellow-400"
}`} />
<span className="text-gray-400 truncate">{disk.name}</span>
{disk.temp !== null && (
<span className="text-gray-600 ml-auto">{disk.temp}°</span>
)}
</div>
))}
</div>
</div> </div>
)} )}
</div> </div>