fix: UniFi, Synology, Network widgets + Grafana HTTPS

- Rewrite UniFi API: port 443, /api/auth/login, /proxy/network/ prefix,
  native https module for self-signed cert, cookie-based session
- Update credentials to Vault-Admin (view-only local account)
- Rewrite NetworkWidget to show UniFi devices with clients/uptime
- Fix Synology API: correct field mappings (size.total/size.used),
  add CPU/memory utilization endpoint
- Fix network API: use prometheus:9090 container DNS
- Add NODE_TLS_REJECT_UNAUTHORIZED=0 for UniFi self-signed cert
- Expand AI container group (rag, litellm, qdrant)
- Add force-dynamic to API routes
This commit is contained in:
2026-02-13 13:54:48 -05:00
parent e00c6efcda
commit 1102f27f45
8 changed files with 635 additions and 255 deletions

View File

@@ -10,6 +10,7 @@ services:
- "3000:3000"
environment:
- NODE_ENV=production
- NODE_TLS_REJECT_UNAUTHORIZED=0
- UNIFI_HOST=${UNIFI_HOST}
- UNIFI_PORT=${UNIFI_PORT}
- UNIFI_USERNAME=${UNIFI_USERNAME}

View File

@@ -0,0 +1,65 @@
import { NextResponse } from "next/server";
const PROM = "http://prometheus:9090";
const INSTANCE_MAP: Record<string, string> = {
"192.168.1.21:9100": "Atlas",
"192.168.1.50:9100": "Wile",
"192.168.1.51:9100": "RoadRunner",
};
async function promQuery(query: string) {
const resp = await fetch(
`${PROM}/api/v1/query?query=${encodeURIComponent(query)}`,
{ cache: "no-store" }
);
const data = await resp.json();
return data.data?.result || [];
}
function mapInstance(instance: string): string {
return INSTANCE_MAP[instance] || instance;
}
export async function GET() {
try {
const deviceFilter = 'device=~"eth0|eno1|enp.*|end0|bond0"';
const [rxRate, txRate, rxTotal, txTotal] = await Promise.all([
promQuery(`sum by (instance) (rate(node_network_receive_bytes_total{${deviceFilter}}[5m]))`),
promQuery(`sum by (instance) (rate(node_network_transmit_bytes_total{${deviceFilter}}[5m]))`),
promQuery(`sum by (instance) (node_network_receive_bytes_total{${deviceFilter}})`),
promQuery(`sum by (instance) (node_network_transmit_bytes_total{${deviceFilter}})`),
]);
const servers: Record<string, any> = {};
for (const r of rxRate) {
const name = mapInstance(r.metric.instance);
if (!servers[name]) servers[name] = { name };
servers[name].rxBytesPerSec = parseFloat(r.value[1]);
}
for (const r of txRate) {
const name = mapInstance(r.metric.instance);
if (!servers[name]) servers[name] = { name };
servers[name].txBytesPerSec = parseFloat(r.value[1]);
}
for (const r of rxTotal) {
const name = mapInstance(r.metric.instance);
if (!servers[name]) servers[name] = { name };
servers[name].rxBytesTotal = parseFloat(r.value[1]);
}
for (const r of txTotal) {
const name = mapInstance(r.metric.instance);
if (!servers[name]) servers[name] = { name };
servers[name].txBytesTotal = parseFloat(r.value[1]);
}
const result = Object.values(servers).sort((a: any, b: any) => a.name.localeCompare(b.name));
return NextResponse.json(result);
} catch (error: any) {
console.error("Network API error:", error?.message || error);
return NextResponse.json({ error: "Failed to fetch network stats" }, { status: 500 });
}
}

View File

@@ -1,11 +1,12 @@
import { NextResponse } from "next/server";
import axios from "axios";
const SYNOLOGY_HOST = process.env.SYNOLOGY_HOST;
const SYNOLOGY_PORT = process.env.SYNOLOGY_PORT || "5001";
const SYNOLOGY_PORT = process.env.SYNOLOGY_PORT || "5000";
const SYNOLOGY_USERNAME = process.env.SYNOLOGY_USERNAME;
const SYNOLOGY_PASSWORD = process.env.SYNOLOGY_PASSWORD;
export const dynamic = "force-dynamic";
export async function GET() {
if (!SYNOLOGY_HOST || !SYNOLOGY_USERNAME || !SYNOLOGY_PASSWORD) {
return NextResponse.json(
@@ -15,53 +16,89 @@ export async function GET() {
}
try {
const protocol = SYNOLOGY_PORT === "5000" ? "http" : "https";
const baseUrl = `${protocol}://${SYNOLOGY_HOST}:${SYNOLOGY_PORT}`;
const protocol = SYNOLOGY_PORT === "5001" ? "https" : "http";
const baseUrl = `${protocol}://${SYNOLOGY_HOST}:${SYNOLOGY_PORT}/webapi`;
// Login to Synology
const loginResponse = await axios.get(`${baseUrl}/webapi/auth.cgi`, {
params: {
api: "SYNO.API.Auth",
version: 3,
method: "login",
account: SYNOLOGY_USERNAME,
passwd: SYNOLOGY_PASSWORD,
session: "FileStation",
format: "sid",
},
httpsAgent: new (require("https").Agent)({
rejectUnauthorized: false,
}),
});
// Login
const loginUrl = `${baseUrl}/auth.cgi?api=SYNO.API.Auth&version=3&method=login&account=${encodeURIComponent(
SYNOLOGY_USERNAME
)}&passwd=${encodeURIComponent(SYNOLOGY_PASSWORD)}&session=FileStation&format=sid`;
const sid = loginResponse.data.data.sid;
const loginResp = await fetch(loginUrl, { cache: "no-store" });
const loginData = await loginResp.json();
if (!loginData.success) {
console.error("Synology login failed:", JSON.stringify(loginData));
return NextResponse.json(
{ error: "Synology login failed" },
{ status: 401 }
);
}
const sid = loginData.data.sid;
// Get storage info
const storageResponse = await axios.get(`${baseUrl}/webapi/entry.cgi`, {
params: {
api: "SYNO.Storage.CGI.Storage",
version: 1,
method: "load_info",
_sid: sid,
},
httpsAgent: new (require("https").Agent)({
rejectUnauthorized: false,
}),
const storageResp = await fetch(
`${baseUrl}/entry.cgi?api=SYNO.Storage.CGI.Storage&version=1&method=load_info&_sid=${sid}`,
{ cache: "no-store" }
);
const storageData = await storageResp.json();
// Get system utilization
let utilization = null;
try {
const utilResp = await fetch(
`${baseUrl}/entry.cgi?api=SYNO.Core.System.Utilization&version=1&method=get&_sid=${sid}`,
{ cache: "no-store" }
);
const utilData = await utilResp.json();
if (utilData.success) {
utilization = {
cpu: utilData.data?.cpu?.user_load ?? null,
memory: utilData.data?.memory?.real_usage ?? null,
};
}
} catch (e) {
console.error("Synology utilization error:", e);
}
// Parse volumes - Synology returns size.total / size.used as string byte counts
const volumes = (storageData.data?.volumes || []).map((vol: any) => {
const total = parseInt(vol.size?.total || "0", 10);
const used = parseInt(vol.size?.used || "0", 10);
const free = total - used;
return {
volume: vol.vol_path || vol.id || "unknown",
id: vol.id || vol.vol_path || "unknown",
size: total,
used: used,
available: free,
percentUsed: total > 0 ? ((used / total) * 100).toFixed(1) : "0",
status: vol.status || "unknown",
fsType: vol.fs_type || "unknown",
};
});
const volumes = storageResponse.data.data.volumes.map((vol: any) => ({
volume: vol.volume_path,
size: vol.size_total_byte,
used: vol.size_used_byte,
available: vol.size_free_byte,
percentUsed: ((vol.size_used_byte / vol.size_total_byte) * 100).toFixed(
2
),
// Parse disks
const disks = (storageData.data?.disks || []).map((disk: any) => ({
name: disk.longName || disk.name || disk.id || "Unknown",
model: disk.model || "Unknown",
serial: disk.serial || "",
sizeBytes: parseInt(disk.size_total || "0", 10),
status: disk.smart_status || disk.overview_status || "unknown",
isSsd: disk.isSsd || false,
temp: disk.temp || null,
}));
return NextResponse.json(volumes);
} catch (error) {
console.error("Synology API error:", error);
// Logout (non-blocking)
fetch(
`${baseUrl}/auth.cgi?api=SYNO.API.Auth&version=3&method=logout&session=FileStation&_sid=${sid}`,
{ cache: "no-store" }
).catch(() => {});
return NextResponse.json({ volumes, disks, utilization });
} catch (error: any) {
console.error("Synology API error:", error?.message || error);
return NextResponse.json(
{ error: "Failed to fetch Synology storage" },
{ status: 500 }

View File

@@ -1,13 +1,52 @@
import { NextResponse } from "next/server";
import axios from "axios";
import https from "https";
const UNIFI_HOST = process.env.UNIFI_HOST;
const UNIFI_PORT = process.env.UNIFI_PORT || "8443";
const UNIFI_HOST = process.env.UNIFI_HOST || "192.168.1.1";
const UNIFI_PORT = process.env.UNIFI_PORT || "443";
const UNIFI_USERNAME = process.env.UNIFI_USERNAME;
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;
} = {}
): Promise<{ status: number; headers: Record<string, string[]>; body: string }> {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const agent = new https.Agent({ rejectUnauthorized: false });
const reqOpts: https.RequestOptions = {
hostname: parsed.hostname,
port: parseInt(parsed.port || "443", 10),
path: parsed.pathname + parsed.search,
method: opts.method || "GET",
headers: opts.headers || {},
agent,
};
const req = https.request(reqOpts, (res) => {
let data = "";
res.on("data", (chunk: Buffer) => (data += chunk.toString()));
res.on("end", () => {
const hdrs: Record<string, string[]> = {};
for (const [k, v] of Object.entries(res.headers)) {
hdrs[k.toLowerCase()] = Array.isArray(v) ? v : v ? [v] : [];
}
resolve({ status: res.statusCode || 0, headers: hdrs, body: data });
});
});
req.on("error", reject);
if (opts.body) req.write(opts.body);
req.end();
});
}
export async function GET() {
if (!UNIFI_HOST || !UNIFI_USERNAME || !UNIFI_PASSWORD) {
if (!UNIFI_USERNAME || !UNIFI_PASSWORD) {
return NextResponse.json(
{ error: "UniFi credentials not configured" },
{ status: 500 }
@@ -15,42 +54,71 @@ export async function GET() {
}
try {
// Login to UniFi Controller
const loginUrl = `https://${UNIFI_HOST}:${UNIFI_PORT}/api/login`;
await axios.post(
loginUrl,
{
const baseUrl = `https://${UNIFI_HOST}:${UNIFI_PORT}`;
// Step 1: Login to UniFi OS
const loginResp = await httpsRequest(`${baseUrl}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: UNIFI_USERNAME,
password: UNIFI_PASSWORD,
},
{
httpsAgent: new (require("https").Agent)({
rejectUnauthorized: false,
}),
}
);
// Get device list
const devicesUrl = `https://${UNIFI_HOST}:${UNIFI_PORT}/api/s/default/stat/device`;
const response = await axios.get(devicesUrl, {
httpsAgent: new (require("https").Agent)({
rejectUnauthorized: false,
}),
});
const devices = response.data.data.map((device: any) => ({
name: device.name || device.model,
mac: device.mac,
ip: device.ip,
model: device.model,
state: device.state,
uptime: device.uptime,
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 }
);
}
// Extract session cookies
const setCookies = loginResp.headers["set-cookie"] || [];
const cookieStr = setCookies
.map((c) => c.split(";")[0])
.join("; ");
// Extract CSRF token if present
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 }
);
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 || "",
}));
return NextResponse.json(devices);
} catch (error) {
console.error("UniFi API error:", error);
} catch (error: any) {
console.error("UniFi API error:", error?.message || error);
return NextResponse.json(
{ error: "Failed to fetch UniFi devices" },
{ status: 500 }

View File

@@ -5,7 +5,7 @@ import { Server, Activity } from "lucide-react";
import ContainerGroup from "@/components/ContainerGroup";
import SearchBar from "@/components/SearchBar";
import GrafanaWidget from "@/components/GrafanaWidget";
import UnifiWidget from "@/components/UnifiWidget";
import NetworkWidget from "@/components/NetworkWidget";
import SynologyWidget from "@/components/SynologyWidget";
import ServerStatsWidget from "@/components/ServerStatsWidget";
import GPUStatsWidget from "@/components/GPUStatsWidget";
@@ -77,7 +77,7 @@ export default function Home() {
["tdarr"].some((app) => c.name.toLowerCase().includes(app))
),
ai: containers.filter((c) =>
["openwebui", "open-webui", "ollama", "stable-diffusion", "mcp"].some(
["openwebui", "open-webui", "ollama", "stable-diffusion", "mcp", "rag", "litellm", "llm-router", "qdrant", "chromadb"].some(
(app) => c.name.toLowerCase().includes(app)
)
),
@@ -125,36 +125,39 @@ export default function Home() {
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Server & GPU Stats */}
<div className="space-y-6 mb-8">
<ServerStatsWidget />
<GPUStatsWidget />
</div>
{/* Network, NAS & Server Overview */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
<UnifiWidget />
<NetworkWidget />
<SynologyWidget />
<GrafanaWidget
title="Server Stats"
dashboardId="server-overview"
panelId={1}
title="Server Health"
dashboardUid="server-health"
panelId={5}
/>
</div>
{/* Grafana Dashboards */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
<GrafanaWidget
title="Docker Stats"
dashboardId="docker-monitoring"
panelId={2}
title="Docker Containers"
dashboardUid="docker-containers"
panelId={8}
/>
<GrafanaWidget
title="LLM Metrics"
dashboardId="llm-monitoring"
panelId={3}
title="RAG Pipeline"
dashboardUid="rag-pipeline"
panelId={1}
/>
<GrafanaWidget
title="System Load"
dashboardId="system-metrics"
panelId={4}
title="Web Traffic"
dashboardUid="traefik-traffic"
panelId={7}
/>
</div>

View File

@@ -1,47 +1,60 @@
"use client";
import { BarChart3 } from "lucide-react";
import { useState } from "react";
import { BarChart3, ExternalLink, AlertCircle } from "lucide-react";
interface GrafanaWidgetProps {
title: string;
dashboardId: string;
dashboardUid: string;
panelId: number;
}
export default function GrafanaWidget({
title,
dashboardId,
dashboardUid,
panelId,
}: GrafanaWidgetProps) {
const grafanaHost =
process.env.NEXT_PUBLIC_GRAFANA_HOST || "http://100.104.196.38:3000";
const iframeUrl = `${grafanaHost}/d-solo/${dashboardId}?orgId=1&panelId=${panelId}&theme=dark`;
const [loadError, setLoadError] = useState(false);
const grafanaHost = process.env.NEXT_PUBLIC_GRAFANA_HOST || "https://grafana.guapo613.beer";
const iframeUrl = `${grafanaHost}/d-solo/${dashboardUid}?orgId=1&panelId=${panelId}&theme=dark&refresh=30s`;
const dashUrl = `${grafanaHost}/d/${dashboardUid}`;
return (
<div className="bg-gray-800/40 backdrop-blur-sm rounded-lg border border-gray-700 overflow-hidden">
<div className="px-4 py-3 border-b border-gray-700 bg-gray-800/60">
<div className="px-4 py-3 border-b border-gray-700 bg-gray-800/60 flex items-center justify-between">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<BarChart3 className="w-4 h-4 text-orange-500" />
{title}
</h3>
</div>
<div className="relative h-48">
<iframe
src={iframeUrl}
className="w-full h-full"
frameBorder="0"
title={title}
/>
<div className="absolute top-2 right-2">
<a
href={`${grafanaHost}/d/${dashboardId}`}
href={dashUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-gray-400 hover:text-white transition-colors"
className="text-xs text-gray-400 hover:text-white transition-colors flex items-center gap-1"
>
Open in Grafana
<ExternalLink className="w-3 h-3" />
Open
</a>
</div>
<div className="relative h-48">
{loadError ? (
<div className="flex flex-col items-center justify-center h-full text-gray-500">
<AlertCircle className="w-8 h-8 mb-2" />
<p className="text-xs">Panel unavailable</p>
<a href={dashUrl} target="_blank" rel="noopener noreferrer"
className="text-xs text-blue-400 hover:text-blue-300 mt-1">
View in Grafana
</a>
</div>
) : (
<iframe
src={iframeUrl}
className="w-full h-full border-0"
title={title}
loading="lazy"
onError={() => setLoadError(true)}
/>
)}
</div>
</div>
);

View File

@@ -0,0 +1,132 @@
"use client";
import { useEffect, useState } from "react";
import { Wifi, Router, Monitor, Signal, WifiOff } from "lucide-react";
interface UnifiDevice {
name: string;
mac: string;
ip: string;
model: string;
type: string;
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);
if (d > 0) return `${d}d ${h}h`;
const m = Math.floor((seconds % 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" />;
}
}
export default function NetworkWidget() {
const [devices, setDevices] = useState<UnifiDevice[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
fetchDevices();
const interval = setInterval(fetchDevices, 30000);
return () => clearInterval(interval);
}, []);
const fetchDevices = 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);
}
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;
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">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<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>
<span>{onlineDevices}/{devices.length} online</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-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
.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>
</div>
{dev.ip && (
<div className="text-xs text-gray-500 mt-1 ml-6">{dev.ip}</div>
)}
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -1,10 +1,41 @@
"use client";
import { useEffect, useState } from "react";
import { HardDrive } from "lucide-react";
import { HardDrive, Disc, Cpu, MemoryStick } from "lucide-react";
interface Volume {
volume: string;
id: string;
size: number;
used: number;
available: number;
percentUsed: string;
status: string;
fsType: string;
}
interface Disk {
name: string;
model: string;
status: string;
isSsd: boolean;
}
interface SynologyData {
volumes: Volume[];
disks: Disk[];
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";
}
export default function SynologyWidget() {
const [volumes, setVolumes] = useState<any[]>([]);
const [data, setData] = useState<SynologyData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
@@ -18,73 +49,103 @@ export default function SynologyWidget() {
try {
const response = await fetch("/api/synology");
if (response.ok) {
const data = await response.json();
setVolumes(data);
const json = await response.json();
if (json.volumes) {
setData(json);
setError(false);
} else {
setError(true);
}
} else {
setError(true);
}
setLoading(false);
} catch (err) {
} catch {
setError(true);
setLoading(false);
}
};
const formatBytes = (bytes: number) => {
const tb = bytes / 1024 ** 4;
return tb.toFixed(2) + " TB";
};
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">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<HardDrive className="w-5 h-5 text-purple-500" />
Synology Storage
Synology NAS
</h3>
{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>
)}
{data.utilization.memory !== null && (
<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>
</div>
) : error ? (
) : 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>
<p className="text-sm text-gray-400">Configure Synology credentials in .env</p>
</div>
) : (
<div className="space-y-3">
{volumes.map((vol, idx) => (
<div key={idx} className="bg-gray-900/50 rounded-lg p-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.volume}</span>
<span className="text-xs text-gray-400">
{vol.percentUsed}%
</span>
<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">
<div className="w-full bg-gray-700 rounded-full h-2 overflow-hidden mb-2">
<div
className={`h-full rounded-full transition-all ${
parseFloat(vol.percentUsed) > 90
? "bg-red-500"
: parseFloat(vol.percentUsed) > 75
? "bg-yellow-500"
: "bg-green-500"
pct > 90 ? "bg-red-500" : pct > 75 ? "bg-yellow-500" : "bg-purple-500"
}`}
style={{ width: `${vol.percentUsed}%` }}
></div>
style={{ width: `${Math.min(pct, 100)}%` }}
/>
</div>
<div className="flex justify-between mt-2 text-xs text-gray-400">
<span>{formatBytes(vol.used)} used</span>
<span>{formatBytes(vol.available)} free</span>
<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 && (
<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>
</div>
)}
</div>
)}
</div>
);