feat:
--ADDED CPU,MEMORY and DISK METRICS
This commit is contained in:
1 parent
3b8e1dca3e
commit
08d79770cf
3 files changed
+165
-8
No files matched your search
+41
@@ -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; }
|
||||
+15
-8
@@ -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' && (
|
||||
<section className="content-panel" id="dashboard">
|
||||
<section className="content-panel hardware-dashboard" id="dashboard">
|
||||
<p className="eyebrow">Dashboard</p>
|
||||
<h1>Welcome back</h1>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<h1>Hardware usage</h1>
|
||||
<p>Live CPU, memory, and disk usage for each registered node.</p>
|
||||
{(nodesStatus === 'idle' || nodesStatus === 'loading') && <p role="status">Loading nodes…</p>}
|
||||
{nodesStatus === 'error' && <div>
|
||||
<p className="status-message error" role="alert">{nodesError}</p>
|
||||
<button className="secondary-button" onClick={() => void loadNodeInterfaces()}>Retry loading nodes</button>
|
||||
</div>}
|
||||
{nodesStatus === 'success' && (nodes.length === 0
|
||||
? <p>No registered nodes. Add a node from the Nodes page to monitor hardware usage.</p>
|
||||
: <div className="hardware-nodes">
|
||||
{nodes.map((node) => <NodeHardware key={`${node.uuid}:${node.host}`} name={node.if_name} host={node.host} />)}
|
||||
</div>)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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<string, unknown>
|
||||
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 (
|
||||
<div className="hardware-metric">
|
||||
<h3>{label}</h3>
|
||||
<strong>{value === undefined ? '—' : `${value.toFixed(1)}%`}</strong>
|
||||
{value !== undefined && <meter aria-label={`${label} usage`} min={0} max={100} value={value} />}
|
||||
<p>{detail ?? 'Awaiting live data'}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function NodeHardware({ name, host }: { name: string; host: string }) {
|
||||
const [data, setData] = useState<HardwareStatus | null>(null)
|
||||
const [status, setStatus] = useState('Connecting…')
|
||||
const [updated, setUpdated] = useState<Date | null>(null)
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
let socket: WebSocket | undefined
|
||||
let timeout: ReturnType<typeof setTimeout> | 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 (
|
||||
<article className="hardware-node">
|
||||
<header className="hardware-node-header">
|
||||
<div><h2>{name}</h2><p>{host}</p></div>
|
||||
<div>
|
||||
<p role="status" className={data ? 'connection-status connected' : ''}>{status}</p>
|
||||
{!data && <button className="secondary-button" onClick={() => {
|
||||
setData(null)
|
||||
setUpdated(null)
|
||||
setStatus('Connecting…')
|
||||
setAttempt((value) => value + 1)
|
||||
}}>Reconnect</button>}
|
||||
</div>
|
||||
</header>
|
||||
<div className="hardware-metrics">
|
||||
<Metric label="CPU" value={data?.cpu_usage_percent} detail={data ? 'Current processor usage' : undefined} />
|
||||
<Metric label="Memory" value={data?.memory_usage_percent} detail={data ? `${data.memory_used_mb.toFixed(2)} / ${data.memory_total_mb.toFixed(2)} MB used · ${data.memory_available_mb.toFixed(2)} MB available` : undefined} />
|
||||
<Metric label="Disk" value={data?.disk_usage_percent} detail={data ? `${data.disk_used_gb.toFixed(2)} / ${data.disk_total_gb.toFixed(2)} GB used · ${data.disk_free_gb.toFixed(2)} GB free` : undefined} />
|
||||
</div>
|
||||
{updated && <p className="hardware-updated">Last received: {updated.toLocaleTimeString()}</p>}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user