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" },
};
export const dynamic = "force-dynamic";
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 [];
}
return json.status === "success" ? (json.data?.result || []) : [];
} 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 ip = (r.metric?.instance || "").replace(/:\d+$/, "");
const val = parseFloat(r.value?.[1] || "0");
if (!isNaN(val)) {
map[ip] = val;
}
if (!isNaN(val)) map[ip] = val;
}
return map;
}
export async function GET() {
try {
const [cpuRes, memPercentRes, memTotalRes, memAvailRes, diskRes, uptimeBootRes, uptimeNowRes, loadRes] =
const [cpuRes, memPercentRes, memTotalRes, memAvailRes, diskRes, uptimeBootRes, uptimeNowRes, loadRes, tempRes] =
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('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('100 - (node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"} * 100)'),
queryPrometheus("node_boot_time_seconds"),
queryPrometheus("node_time_seconds"),
queryPrometheus("node_load1"),
queryPrometheus('node_hwmon_temp_celsius{chip="thermal_thermal_zone0",sensor="temp0"}'),
]);
const cpuMap = extractByInstance(cpuRes);
@@ -64,11 +53,11 @@ export async function GET() {
const bootMap = extractByInstance(uptimeBootRes);
const nowMap = extractByInstance(uptimeNowRes);
const loadMap = extractByInstance(loadRes);
const tempMap = extractByInstance(tempRes);
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 {
@@ -76,8 +65,9 @@ export async function GET() {
role: info.role,
ip,
cpu: parseFloat((cpuMap[ip] || 0).toFixed(1)),
cpuTemp: tempMap[ip] !== undefined ? parseFloat(tempMap[ip].toFixed(0)) : null,
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)),
diskPercent: parseFloat((diskMap[ip] || 0).toFixed(1)),
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";
/** Low-level HTTPS request that ignores self-signed certs and returns cookies */
function httpsRequest(
url: string,
opts: {
method?: string;
headers?: Record<string, string>;
body?: string;
} = {}
opts: { method?: string; headers?: Record<string, string>; body?: string } = {}
): Promise<{ status: number; headers: Record<string, string[]>; body: string }> {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
@@ -47,81 +42,99 @@ function httpsRequest(
export async function GET() {
if (!UNIFI_USERNAME || !UNIFI_PASSWORD) {
return NextResponse.json(
{ error: "UniFi credentials not configured" },
{ status: 500 }
);
return NextResponse.json({ error: "UniFi credentials not configured" }, { status: 500 });
}
try {
const baseUrl = `https://${UNIFI_HOST}:${UNIFI_PORT}`;
// Step 1: Login to UniFi OS
// Login
const loginResp = await httpsRequest(`${baseUrl}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: UNIFI_USERNAME,
password: UNIFI_PASSWORD,
}),
body: JSON.stringify({ username: UNIFI_USERNAME, password: UNIFI_PASSWORD }),
});
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 cookieStr = setCookies
.map((c) => c.split(";")[0])
.join("; ");
// Extract CSRF token if present
const cookieStr = setCookies.map((c) => c.split(";")[0]).join("; ");
const csrfToken = (loginResp.headers["x-csrf-token"] || [])[0] || "";
// Step 2: Fetch devices
const reqHeaders: Record<string, string> = { Cookie: cookieStr };
if (csrfToken) reqHeaders["x-csrf-token"] = csrfToken;
const devicesResp = await httpsRequest(
`${baseUrl}/proxy/network/api/s/default/stat/device`,
{ headers: reqHeaders }
);
// Fetch devices + health in parallel
const [devicesResp, healthResp] = await Promise.all([
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) {
console.error("UniFi device fetch failed:", devicesResp.status);
return NextResponse.json(
{ error: `UniFi device fetch failed: ${devicesResp.status}` },
{ status: 502 }
);
}
const devicesData = JSON.parse(devicesResp.body);
const rawDevices = devicesData?.data || [];
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 || "",
// Parse devices
const rawDevices = devicesResp.status === 200 ? (JSON.parse(devicesResp.body)?.data || []) : [];
const devices = rawDevices.map((d: any) => ({
name: d.name || d.model || "Unknown",
mac: d.mac || "",
ip: d.ip || "",
model: d.model || "",
type: d.type || "",
state: d.state ?? 0,
uptime: d.uptime || 0,
clients: d.num_sta || 0,
}));
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) {
console.error("UniFi API error:", error?.message || error);
return NextResponse.json(
{ error: "Failed to fetch UniFi devices" },
{ status: 500 }
);
return NextResponse.json({ error: "Failed to fetch UniFi data" }, { status: 500 });
}
}

View File

@@ -1,7 +1,7 @@
"use client";
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 {
name: string;
@@ -12,63 +12,96 @@ interface UnifiDevice {
state: number;
uptime: number;
clients: number;
satisfaction: number | null;
version: string;
}
function formatUptime(seconds: number): string {
if (!seconds) return "—";
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
interface HealthSub {
status: string;
wanIp?: string;
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`;
const m = Math.floor((seconds % 3600) / 60);
const m = Math.floor((sec % 3600) / 60);
return `${h}h ${m}m`;
}
function deviceIcon(type: string) {
switch (type) {
case "uap": return <Wifi className="w-3.5 h-3.5" />;
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" />;
}
function StatusDot({ status }: { status: string }) {
const color = status === "ok" ? "bg-green-400" : status === "unknown" ? "bg-gray-500" : "bg-yellow-400";
return <span className={`inline-block w-2 h-2 rounded-full ${color}`} />;
}
export default function NetworkWidget() {
const [devices, setDevices] = useState<UnifiDevice[]>([]);
const [data, setData] = useState<UnifiData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
fetchDevices();
const interval = setInterval(fetchDevices, 30000);
fetchData();
const interval = setInterval(fetchData, 30000);
return () => clearInterval(interval);
}, []);
const fetchDevices = async () => {
const fetchData = async () => {
try {
const response = await fetch("/api/unifi");
if (response.ok) {
const data = await response.json();
if (Array.isArray(data)) {
setDevices(data);
setError(false);
} else {
setError(true);
}
} else {
setError(true);
}
const json = await response.json();
if (json.devices) { setData(json); setError(false); }
else setError(true);
} else setError(true);
setLoading(false);
} catch {
setError(true);
setLoading(false);
}
} catch { setError(true); setLoading(false); }
};
const totalClients = devices.reduce((sum, d) => sum + d.clients, 0);
const onlineDevices = devices.filter((d) => d.state === 1).length;
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">
<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 (
<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" />
UniFi Network
</h3>
{!loading && !error && devices.length > 0 && (
<div className="flex items-center gap-3 text-xs text-gray-400">
<span className="flex items-center gap-1">
<Signal className="w-3 h-3 text-green-400" />
{totalClients} clients
<span className="text-xs text-gray-400">{onlineDevices}/{data.devices.length} devices</span>
</div>
{/* Top stats row */}
<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>{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>
{loading ? (
<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>
) : error || devices.length === 0 ? (
<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
{/* Device list - compact */}
<div className="border-t border-gray-700/50 pt-3">
<p className="text-xs text-gray-500 mb-2">Devices</p>
<div className="grid grid-cols-2 gap-1">
{data.devices
.sort((a, b) => b.clients - a.clients)
.map((dev) => (
<div key={dev.mac} className="bg-gray-900/50 rounded-lg p-3">
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<span className={dev.state === 1 ? "text-green-400" : "text-red-400"}>
{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 key={dev.mac} className="flex items-center justify-between text-xs py-1 px-2 rounded bg-gray-900/30">
<div className="flex items-center gap-1.5 truncate">
<span className={`w-1.5 h-1.5 rounded-full ${dev.state === 1 ? "bg-green-400" : "bg-red-400"}`} />
<span className="text-gray-300 truncate">{dev.name}</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.ip && (
<div className="text-xs text-gray-500 mt-1 ml-6">{dev.ip}</div>
)}
{dev.clients > 0 && <span className="text-gray-500 ml-1">{dev.clients}</span>}
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -1,154 +1,194 @@
"use client";
import { useEffect, useState } from "react";
import { motion } from "framer-motion";
import { Cpu, HardDrive, Clock, Server } from "lucide-react";
import { ServerStats } from "@/types";
import { Cpu, HardDrive, Clock, Server, Thermometer, MemoryStick } from "lucide-react";
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";
interface ServerData {
name: string;
role: string;
ip: string;
cpu: number;
cpuTemp: number | null;
memoryPercent: number;
memoryUsedGB: number;
memoryTotalGB: number;
diskPercent: number;
uptimeSeconds: number;
load1: number;
}
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 (
<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" }}
<svg width={size} height={size} className="-rotate-90">
<circle cx={size/2} cy={size/2} r={radius} fill="none" stroke="rgba(255,255,255,0.05)" strokeWidth="6" />
<circle
cx={size/2} cy={size/2} r={radius} fill="none"
stroke={color} strokeWidth="6" strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
className="transition-all duration-1000 ease-out"
/>
</div>
</div>
</svg>
);
}
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`;
function cpuColor(v: number): string {
if (v >= 90) return "#ef4444";
if (v >= 70) return "#f59e0b";
return "#22c55e";
}
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() {
const [servers, setServers] = useState<ServerStats[]>([]);
const [servers, setServers] = useState<ServerData[]>([]);
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);
fetchServers();
const interval = setInterval(fetchServers, 15000);
return () => clearInterval(interval);
}, []);
if (loading) {
return (
const fetchServers = async () => {
try {
const res = await fetch("/api/servers");
if (res.ok) {
const data = await res.json();
if (Array.isArray(data)) setServers(data);
}
setLoading(false);
} catch { setLoading(false); }
};
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">
<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>
);
}
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="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-6">
<Server className="w-5 h-5 text-blue-500" />
Server Health
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{servers.map((srv) => (
<div key={srv.name} className="bg-gray-900/50 rounded-xl p-4">
{/* Server name */}
<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>
<h4 className="text-sm font-bold text-white">{srv.name}</h4>
<p className="text-xs text-gray-500">{srv.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">
<div className="text-right text-xs text-gray-500">
<div className="flex items-center gap-1">
<Clock className="w-3 h-3" />
Uptime
</span>
<span className="text-gray-300 font-mono">
{formatUptime(server.uptimeSeconds)}
{formatUptime(srv.uptimeSeconds)}
</div>
<div>Load: {srv.load1}</div>
</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>
</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}
{/* RAM Gauge */}
<div className="relative flex flex-col items-center">
<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>
</motion.div>
<span className="text-xs text-gray-400 mt-1 flex items-center gap-0.5">
<Thermometer className="w-3 h-3" /> Temp
</span>
</div>
</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>

View File

@@ -19,6 +19,7 @@ interface Disk {
model: string;
status: string;
isSsd: boolean;
temp: number | null;
}
interface SynologyData {
@@ -27,11 +28,12 @@ interface SynologyData {
utilization: { cpu: number | null; memory: number | null } | null;
}
function formatBytes(bytes: number): string {
if (bytes >= 1024 ** 4) return (bytes / (1024 ** 4)).toFixed(1) + " 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 formatTB(bytes: number): string {
return (bytes / (1024 ** 4)).toFixed(2) + " TB";
}
function formatTemp(t: number | null): string {
return t !== null ? `${t}°C` : "";
}
export default function SynologyWidget() {
@@ -50,22 +52,42 @@ export default function SynologyWidget() {
const response = await fetch("/api/synology");
if (response.ok) {
const json = await response.json();
if (json.volumes) {
setData(json);
setError(false);
} else {
setError(true);
}
} else {
setError(true);
}
if (json.volumes) { setData(json); setError(false); }
else setError(true);
} else setError(true);
setLoading(false);
} catch {
setError(true);
setLoading(false);
}
} catch { 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 (
<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">
@@ -73,38 +95,38 @@ export default function SynologyWidget() {
<HardDrive className="w-5 h-5 text-purple-500" />
Synology NAS
</h3>
{data?.utilization && (
{data.utilization && (
<div className="flex items-center gap-3 text-xs text-gray-400">
{data.utilization.cpu !== null && (
<span className="flex items-center gap-1">
<Cpu className="w-3 h-3" /> {data.utilization.cpu}%
</span>
<span className="flex items-center gap-1"><Cpu className="w-3 h-3" />{data.utilization.cpu}%</span>
)}
{data.utilization.memory !== null && (
<span className="flex items-center gap-1">
<MemoryStick className="w-3 h-3" /> {data.utilization.memory}%
</span>
<span className="flex items-center gap-1"><MemoryStick className="w-3 h-3" />{data.utilization.memory}%</span>
)}
</div>
)}
</div>
{loading ? (
<div className="flex justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-500"></div>
{/* Big total/available stats */}
<div className="grid grid-cols-2 gap-3 mb-4">
<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>
) : error || !data ? (
<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 className="bg-gray-900/50 rounded-lg p-3 text-center">
<div className={`text-2xl font-bold ${overallPct > 90 ? "text-red-400" : overallPct > 75 ? "text-yellow-400" : "text-green-400"}`}>
{formatTB(totalAvail)}
</div>
) : (
<div className="space-y-3">
<div className="text-xs text-gray-400">Available</div>
</div>
</div>
{/* Usage bar per volume */}
{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">
<div key={vol.id} className="mb-3">
<div className="flex justify-between items-center mb-1">
<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" :
@@ -112,7 +134,7 @@ export default function SynologyWidget() {
"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="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"
@@ -120,33 +142,33 @@ export default function SynologyWidget() {
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>
<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-2 border-t border-gray-700/50">
<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-2 gap-1">
{data.disks.slice(0, 8).map((disk, i) => (
<div key={i} className="flex items-center gap-1 text-xs">
<Disc className={`w-3 h-3 ${
<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" title={disk.model}>
{disk.name}
</span>
<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>
);
}