mirror of
https://github.com/mblanke/Dashboard.git
synced 2026-03-01 12:10:20 -05:00
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:
@@ -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),
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 · {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 · {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>
|
||||
</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 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>
|
||||
{dev.clients > 0 && <span className="text-gray-500 ml-1">{dev.clients}</span>}
|
||||
</div>
|
||||
{dev.ip && (
|
||||
<div className="text-xs text-gray-500 mt-1 ml-6">{dev.ip}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<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"
|
||||
/>
|
||||
</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 (
|
||||
<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>
|
||||
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">
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
@@ -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,78 +95,78 @@ 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="text-xs text-gray-400">Available</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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{data.disks.length > 0 && (
|
||||
<div className="pt-2 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 ${
|
||||
disk.status === "normal" ? "text-green-400" : "text-yellow-400"
|
||||
}`} />
|
||||
<span className="text-gray-400 truncate" title={disk.model}>
|
||||
{disk.name}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Usage bar per volume */}
|
||||
{data.volumes.map((vol) => {
|
||||
const pct = parseFloat(vol.percentUsed);
|
||||
return (
|
||||
<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" :
|
||||
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.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>
|
||||
|
||||
Reference in New Issue
Block a user