diff --git a/src/App.css b/src/App.css index a4486bb..5a5c160 100644 --- a/src/App.css +++ b/src/App.css @@ -755,3 +755,44 @@ grid-template-columns: 1fr; } } + +.hardware-dashboard { max-width: 1200px; } +.hardware-nodes { display: grid; gap: 24px; margin-top: 24px; } +.hardware-node { + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg); + padding: 20px; + min-width: 0; +} +.hardware-node-header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 20px; + overflow-wrap: anywhere; +} +.hardware-node-header h2, .hardware-node-header p { margin: 0 0 6px; } +.hardware-metrics { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr)); + gap: 16px; +} +.hardware-metric { + min-width: 0; + padding: 18px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); +} +.hardware-metric h3 { font-size: 15px; margin: 0 0 12px; } +.hardware-metric strong { + display: block; + color: var(--text-h); + font-size: 30px; + font-variant-numeric: tabular-nums; +} +.hardware-metric meter { width: 100%; height: 16px; margin-top: 12px; } +.hardware-metric p, .hardware-updated { font-size: 13px; margin: 12px 0 0; } diff --git a/src/App.tsx b/src/App.tsx index d0c5a44..0eb44da 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import { Link, Navigate, Route, Routes, useMatch, useNavigate, useParams } from 'react-router-dom' import './App.css' import NodeConnections from './NodeConnections' +import NodeHardware from './NodeHardware' const API_BASE_URL = import.meta.env.VITE_API_BASE_URL @@ -379,7 +380,7 @@ function Workspace({ page: activePage }: { page: PageId | 'node-details' }) { if (activePage === 'clusters') { void loadClusters() } - if (activePage === 'nodes' || activePage === 'clusters') { + if (activePage === 'dashboard' || activePage === 'nodes' || activePage === 'clusters') { void loadNodeInterfaces() } }, [activePage]) @@ -654,14 +655,20 @@ function Workspace({ page: activePage }: { page: PageId | 'node-details' }) { )} {activePage === 'dashboard' && ( -
+

Dashboard

-

Welcome back

-

- Your main workspace is ready. The left navigation can collapse - now, and more sections can be added to the nav list as the app - grows. -

+

Hardware usage

+

Live CPU, memory, and disk usage for each registered node.

+ {(nodesStatus === 'idle' || nodesStatus === 'loading') &&

Loading nodes…

} + {nodesStatus === 'error' &&
+

{nodesError}

+ +
} + {nodesStatus === 'success' && (nodes.length === 0 + ?

No registered nodes. Add a node from the Nodes page to monitor hardware usage.

+ :
+ {nodes.map((node) => )} +
)}
)} diff --git a/src/NodeHardware.tsx b/src/NodeHardware.tsx new file mode 100644 index 0000000..665a017 --- /dev/null +++ b/src/NodeHardware.tsx @@ -0,0 +1,109 @@ +import { useEffect, useState } from 'react' + +const fields = [ + 'cpu_usage_percent', 'memory_usage_percent', 'memory_total_mb', + 'memory_used_mb', 'memory_available_mb', 'disk_usage_percent', + 'disk_total_gb', 'disk_used_gb', 'disk_free_gb', +] as const +type HardwareStatus = Record<(typeof fields)[number], number> + +function isHardwareStatus(value: unknown): value is HardwareStatus { + if (!value || typeof value !== 'object') return false + const data = value as Record + return fields.every((field) => { + const number = data[field] + return typeof number === 'number' && Number.isFinite(number) && number >= 0 + && (!field.endsWith('_percent') || number <= 100) + }) +} + +function Metric({ label, value, detail }: { label: string; value?: number; detail?: string }) { + return ( +
+

{label}

+ {value === undefined ? '—' : `${value.toFixed(1)}%`} + {value !== undefined && } +

{detail ?? 'Awaiting live data'}

+
+ ) +} + +export default function NodeHardware({ name, host }: { name: string; host: string }) { + const [data, setData] = useState(null) + const [status, setStatus] = useState('Connecting…') + const [updated, setUpdated] = useState(null) + const [attempt, setAttempt] = useState(0) + + useEffect(() => { + let active = true + let socket: WebSocket | undefined + let timeout: ReturnType | undefined + const armTimeout = () => { + clearTimeout(timeout) + timeout = setTimeout(() => { + if (!active) return + setData(null) + setStatus('No recent hardware data. Reconnect to try again.') + }, 30000) + } + try { + socket = new WebSocket(`ws://${host}:9100/ws/hardware/status`) + armTimeout() + socket.onopen = () => { + if (active) setStatus('Waiting for hardware data…') + } + socket.onmessage = (event) => { + if (!active) return + try { + const message: unknown = JSON.parse(event.data) + if (!isHardwareStatus(message)) throw new Error('Invalid hardware data') + setData(message) + setUpdated(new Date()) + setStatus('Live') + armTimeout() + } catch { + setData(null) + setStatus('Invalid hardware data received.') + } + } + const disconnected = () => { + if (!active) return + clearTimeout(timeout) + setData(null) + setStatus('Hardware feed disconnected. Reconnect to try again.') + } + socket.onerror = disconnected + socket.onclose = disconnected + } catch { + setStatus('Unable to connect to the hardware feed.') + } + return () => { + active = false + clearTimeout(timeout) + socket?.close() + } + }, [host, attempt]) + + return ( +
+
+

{name}

{host}

+
+

{status}

+ {!data && } +
+
+
+ + + +
+ {updated &&

Last received: {updated.toLocaleTimeString()}

} +
+ ) +}