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;
|
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 { Link, Navigate, Route, Routes, useMatch, useNavigate, useParams } from 'react-router-dom'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
import NodeConnections from './NodeConnections'
|
import NodeConnections from './NodeConnections'
|
||||||
|
import NodeHardware from './NodeHardware'
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL
|
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') {
|
if (activePage === 'clusters') {
|
||||||
void loadClusters()
|
void loadClusters()
|
||||||
}
|
}
|
||||||
if (activePage === 'nodes' || activePage === 'clusters') {
|
if (activePage === 'dashboard' || activePage === 'nodes' || activePage === 'clusters') {
|
||||||
void loadNodeInterfaces()
|
void loadNodeInterfaces()
|
||||||
}
|
}
|
||||||
}, [activePage])
|
}, [activePage])
|
||||||
@@ -654,14 +655,20 @@ function Workspace({ page: activePage }: { page: PageId | 'node-details' }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activePage === 'dashboard' && (
|
{activePage === 'dashboard' && (
|
||||||
<section className="content-panel" id="dashboard">
|
<section className="content-panel hardware-dashboard" id="dashboard">
|
||||||
<p className="eyebrow">Dashboard</p>
|
<p className="eyebrow">Dashboard</p>
|
||||||
<h1>Welcome back</h1>
|
<h1>Hardware usage</h1>
|
||||||
<p>
|
<p>Live CPU, memory, and disk usage for each registered node.</p>
|
||||||
Your main workspace is ready. The left navigation can collapse
|
{(nodesStatus === 'idle' || nodesStatus === 'loading') && <p role="status">Loading nodes…</p>}
|
||||||
now, and more sections can be added to the nav list as the app
|
{nodesStatus === 'error' && <div>
|
||||||
grows.
|
<p className="status-message error" role="alert">{nodesError}</p>
|
||||||
</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>
|
</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