diff --git a/src/app/api/servers/route.ts b/src/app/api/servers/route.ts index 44da8a2..0abf6ae 100644 --- a/src/app/api/servers/route.ts +++ b/src/app/api/servers/route.ts @@ -8,52 +8,41 @@ const INSTANCE_MAP: Record = { "192.168.1.51": { name: "RoadRunner", role: "GPU Node - Fast" }, }; +export const dynamic = "force-dynamic"; + async function queryPrometheus(query: string): Promise { 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 { const map: Record = {}; 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), diff --git a/src/app/api/unifi/route.ts b/src/app/api/unifi/route.ts index a929134..c4774c7 100644 --- a/src/app/api/unifi/route.ts +++ b/src/app/api/unifi/route.ts @@ -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; - body?: string; - } = {} + opts: { method?: string; headers?: Record; body?: string } = {} ): Promise<{ status: number; headers: Record; 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 = { 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 = {}; + 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 }); } } diff --git a/src/components/NetworkWidget.tsx b/src/components/NetworkWidget.tsx index 61eea19..a8f72d6 100644 --- a/src/components/NetworkWidget.tsx +++ b/src/components/NetworkWidget.tsx @@ -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; + 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 ; - case "usw": return ; - case "ugw": - case "udm": return ; - default: return ; - } +function StatusDot({ status }: { status: string }) { + const color = status === "ok" ? "bg-green-400" : status === "unknown" ? "bg-gray-500" : "bg-yellow-400"; + return ; } export default function NetworkWidget() { - const [devices, setDevices] = useState([]); + const [data, setData] = useState(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 ( +
+

+ UniFi Network +

+
+
+
+
+ ); + + if (error || !data) return ( +
+

+ UniFi Network +

+
+ +

No network data

+
+
+ ); + + const { wan, www, wlan, lan } = data.health; + const onlineDevices = data.devices.filter(d => d.state === 1).length; return (
@@ -77,56 +110,111 @@ export default function NetworkWidget() { UniFi Network - {!loading && !error && devices.length > 0 && ( -
- - - {totalClients} clients + {onlineDevices}/{data.devices.length} devices +
+ + {/* Top stats row */} +
+
+
{data.totalClients}
+
Clients
+
+
+
{wlan?.clients || 0}
+
Wireless
+
+
+
{lan?.clients || 0}
+
Wired
+
+
+ + {/* WAN status */} + {wan && ( +
+
+ + + WAN + - {onlineDevices}/{devices.length} online + {wan.wanIp} +
+
+ {wan.isp} +
+ + {formatRate(wan.rxRate || 0)} + + + {formatRate(wan.txRate || 0)} + +
+
+ {www && ( +
+ + {www.latency}ms latency + + Uptime: {formatUptime(www.uptime || 0)} +
+ )} +
+ )} + + {/* WLAN + LAN row */} +
+ {wlan && ( +
+
+ + WiFi + +
+
+ {wlan.aps} APs · {wlan.clients} clients +
+
+ {formatRate(wlan.rxRate || 0)} + {formatRate(wlan.txRate || 0)} +
+
+ )} + {lan && ( +
+
+ + LAN + +
+
+ {lan.switches} switches · {lan.clients} clients +
+
+ {formatRate(lan.rxRate || 0)} + {formatRate(lan.txRate || 0)} +
)}
- {loading ? ( -
-
-
- ) : error || devices.length === 0 ? ( -
- -

No network data available

-
- ) : ( -
- {devices + {/* Device list - compact */} +
+

Devices

+
+ {data.devices .sort((a, b) => b.clients - a.clients) .map((dev) => ( -
-
-
- - {deviceIcon(dev.type)} - -
- {dev.name} - {dev.model} -
-
-
- {dev.clients > 0 && ( - {dev.clients} clients - )} - {formatUptime(dev.uptime)} +
+
+ + {dev.name}
+ {dev.clients > 0 && {dev.clients}}
- {dev.ip && ( -
{dev.ip}
- )} -
- ))} + ))}
- )} +
); } diff --git a/src/components/ServerStatsWidget.tsx b/src/components/ServerStatsWidget.tsx index e7580c3..fa63445 100644 --- a/src/components/ServerStatsWidget.tsx +++ b/src/components/ServerStatsWidget.tsx @@ -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 ( -
-
- - {icon} - {label} - - {value.toFixed(1)}% -
-
- -
-
+ + + + ); } -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([]); + const [servers, setServers] = useState([]); 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 ( -
-
- -

Server Stats

-
-
-
-
+ 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 ( +
+

+ Server Health +

+
+
- ); - } +
+ ); return ( -
-
-

- - Server Stats - - {servers.length} nodes - -

-
-
- {servers.map((server, idx) => ( - +
+

+ + Server Health +

+ +
+ {servers.map((srv) => ( +
+ {/* Server name */}
-

{server.name}

-

{server.role}

+

{srv.name}

+

{srv.role}

- - {server.ip} - -
- -
- } - /> - } - /> - } - /> - -
- +
+
- Uptime - - - {formatUptime(server.uptimeSeconds)} + {formatUptime(srv.uptimeSeconds)} +
+
Load: {srv.load1}
+
+
+ + {/* 3 Gauges */} +
+ {/* CPU Gauge */} +
+
+ +
+ {Math.round(srv.cpu)}% +
+
+ + CPU
-
- Load (1m) - - {server.load1} + + {/* RAM Gauge */} +
+
+ +
+ {Math.round(srv.memoryPercent)}% +
+
+ + RAM + +
+ + {/* Temp Gauge */} +
+
+ +
+ + {srv.cpuTemp !== null ? `${srv.cpuTemp}°` : "—"} + +
+
+ + Temp
- + + {/* Disk + Memory detail */} +
+
+
+ Disk + {srv.diskPercent}% +
+
+
90 ? "bg-red-500" : srv.diskPercent > 75 ? "bg-yellow-500" : "bg-emerald-500" + }`} + style={{ width: `${Math.min(srv.diskPercent, 100)}%` }} + /> +
+
+
+ {srv.memoryUsedGB} / {srv.memoryTotalGB} GB RAM +
+
+
))}
diff --git a/src/components/SynologyWidget.tsx b/src/components/SynologyWidget.tsx index 16bfe5c..e0a1813 100644 --- a/src/components/SynologyWidget.tsx +++ b/src/components/SynologyWidget.tsx @@ -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 ( +
+

+ Synology NAS +

+
+
+
+
+ ); + + if (error || !data) return ( +
+

+ Synology NAS +

+
+ +

Configure Synology credentials in .env

+
+
+ ); + + // 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 (
@@ -73,78 +95,78 @@ export default function SynologyWidget() { Synology NAS - {data?.utilization && ( + {data.utilization && (
{data.utilization.cpu !== null && ( - - {data.utilization.cpu}% - + {data.utilization.cpu}% )} {data.utilization.memory !== null && ( - - {data.utilization.memory}% - + {data.utilization.memory}% )}
)}
- {loading ? ( -
-
+ {/* Big total/available stats */} +
+
+
{formatTB(totalSize)}
+
Total Space
- ) : error || !data ? ( -
- -

Configure Synology credentials in .env

+
+
90 ? "text-red-400" : overallPct > 75 ? "text-yellow-400" : "text-green-400"}`}> + {formatTB(totalAvail)} +
+
Available
- ) : ( -
- {data.volumes.map((vol) => { - const pct = parseFloat(vol.percentUsed); - return ( -
-
- {vol.id} - {vol.status} -
-
-
90 ? "bg-red-500" : pct > 75 ? "bg-yellow-500" : "bg-purple-500" - }`} - style={{ width: `${Math.min(pct, 100)}%` }} - /> -
-
- {formatBytes(vol.used)} / {formatBytes(vol.size)} - {vol.percentUsed}% -
-
- ); - })} +
- {data.disks.length > 0 && ( -
-

{data.disks.length} drives

-
- {data.disks.slice(0, 8).map((disk, i) => ( -
- - - {disk.name} - -
- ))} -
+ {/* Usage bar per volume */} + {data.volumes.map((vol) => { + const pct = parseFloat(vol.percentUsed); + return ( +
+
+ {vol.id} + {vol.status}
- )} +
+
90 ? "bg-red-500" : pct > 75 ? "bg-yellow-500" : "bg-purple-500" + }`} + style={{ width: `${Math.min(pct, 100)}%` }} + /> +
+
+ {formatTB(vol.used)} used / {formatTB(vol.size)} + {vol.percentUsed}% +
+
+ ); + })} + + {/* Disk grid */} + {data.disks.length > 0 && ( +
+

{data.disks.length} drives

+
+ {data.disks.map((disk, i) => ( +
+ + {disk.name} + {disk.temp !== null && ( + {disk.temp}° + )} +
+ ))} +
)}