This commit is contained in:
commit
bd774932d5
614 files changed
+77116
No files matched your search
@@ -0,0 +1,90 @@
|
||||
import { formatDateTime } from './locale';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { copy } from './copy';
|
||||
import { presentReason, presentStatus } from './presentation';
|
||||
import { SourceStatusDetails } from './SourceStatusDetails';
|
||||
|
||||
type HostSnapshot = {
|
||||
source: { id: string; type: string; observedAt?: string; freshness: string; state: string; reason?: string };
|
||||
identity: { name: string; version?: string; kernel?: string; architecture?: string };
|
||||
uptimeSeconds: number;
|
||||
bootTime?: string;
|
||||
cpu: { totalPercent?: number; perCore?: number[]; iowaitPercent?: number };
|
||||
load: { one: number; five: number; fifteen: number };
|
||||
memory: { totalBytes: number; availableBytes: number; usedBytes: number; utilizationPercent: number; swapTotalBytes: number; swapUsedBytes: number; swapUtilizationPercent: number };
|
||||
filesystems: Array<{ mount: string; filesystem?: string; capacityBytes: number; usedBytes: number; utilizationPercent: number; inodes?: { total: number; used: number } }>;
|
||||
network: Array<{ name: string; state?: string; rxBytes: number; txBytes: number; rxErrors: number; txErrors: number; rxDrops: number; txDrops: number }>;
|
||||
time: { synchronized: boolean; offsetSeconds: number; stratum?: number; state: string };
|
||||
hardware?: { capabilities: Array<{ id: string; version: string; state: string; reason?: string }>; temperatures: Array<{ id: string; name: string; celsius: number }>; fans: Array<{ id: string; name: string; rpm: number }>; gpus: Array<{ id: string; name: string; vendor?: string; utilizationPercent?: number; memoryUsedBytes: number; memoryTotalBytes: number; memoryUtilizationPercent?: number; temperatureCelsius?: number }>; status: { state: string; reasons?: Array<{ code: string; message: string }> } };
|
||||
status: { state: string; reasons?: Array<{ code: string; message: string }> };
|
||||
observedAt?: string;
|
||||
receivedAt: string;
|
||||
warnings?: string[];
|
||||
};
|
||||
|
||||
function getStatusLabel(state: string): string {
|
||||
return state === 'healthy' ? 'Gezond' : state === 'degraded' ? 'Aandacht' : 'Onbekend';
|
||||
}
|
||||
function Badge({ state }: { state: string }) {
|
||||
const ready = state === 'healthy';
|
||||
return <span className={'status-badge status-badge--' + (ready ? 'ready' : 'unknown')}><span className="status-icon" aria-hidden="true">{ready ? '✓' : '?'}</span>{getStatusLabel(state)}</span>;
|
||||
}
|
||||
function bytes(value: number): string {
|
||||
if (!Number.isFinite(value) || value < 0) return '—';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let scaled = value;
|
||||
let index = 0;
|
||||
while (scaled >= 1024 && index < units.length - 1) { scaled /= 1024; index += 1; }
|
||||
return scaled.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + ' ' + units[index];
|
||||
}
|
||||
function duration(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return '—';
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
return days > 0 ? days + ' d ' + hours + ' u' : hours + ' u ' + minutes + ' min';
|
||||
}
|
||||
function when(value?: string): string { return formatDateTime(value); }
|
||||
|
||||
export function HostPage() {
|
||||
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
|
||||
const [snapshot, setSnapshot] = useState<HostSnapshot | null>(null);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
fetch('/api/v1/host', { signal: controller.signal }).then((response) => {
|
||||
if (!response.ok) throw new Error('host');
|
||||
return response.json() as Promise<HostSnapshot>;
|
||||
}).then((data) => { setSnapshot(data); setState('ready'); }).catch((error: unknown) => {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') return;
|
||||
setState('error');
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
if (state === 'loading') return <section className="state-page" role="status"><span className="loader" aria-hidden="true" /><h1>{copy.host.loading}</h1></section>;
|
||||
if (state === 'error' || !snapshot) return <section className="state-page" role="alert"><span className="state-icon" aria-hidden="true">×</span><h1>{copy.host.errorTitle}</h1><p>{copy.host.errorDetail}</p><button className="button" type="button" onClick={() => window.location.reload()}>{copy.host.retry}</button></section>;
|
||||
const status = snapshot.status?.state ?? 'unknown';
|
||||
const observed = snapshot.observedAt ?? snapshot.source?.observedAt;
|
||||
return <>
|
||||
<header className="page-intro"><p className="eyebrow">{copy.host.eyebrow}</p><h1>{copy.host.title}</h1><p className="intro">{copy.host.intro}</p></header>
|
||||
<section className="host-summary card" aria-labelledby="host-summary-title">
|
||||
<div className="card-heading"><div><p className="card-kicker">{copy.host.identity}</p><h2 id="host-summary-title">{snapshot.identity?.name || copy.host.unknown}</h2><p className="card-copy">{[snapshot.identity?.version, snapshot.identity?.kernel, snapshot.identity?.architecture].filter(Boolean).join(' · ') || copy.host.noIdentityDetails}</p></div><Badge state={status} /></div>
|
||||
<SourceStatusDetails source={{ ...snapshot.source, observedAt: observed }} fallbackReason={snapshot.source?.freshness === 'fresh' ? copy.host.fresh : copy.host.stale} />
|
||||
{snapshot.status?.reasons?.map((reason) => <p className="host-reason" key={reason.code}>{presentReason(reason.code)}</p>)}
|
||||
</section>
|
||||
<section className="host-metric-grid" aria-label={copy.host.metrics}>
|
||||
<article className="card"><p className="card-kicker">{copy.host.uptime}</p><h2>{duration(snapshot.uptimeSeconds)}</h2><p className="card-copy">{snapshot.bootTime ? copy.host.boot + ' ' + when(snapshot.bootTime) : copy.host.noBoot}</p></article>
|
||||
<article className="card"><p className="card-kicker">{copy.host.cpu}</p><h2>{snapshot.cpu?.totalPercent == null ? '—' : snapshot.cpu.totalPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + '%'}</h2><p className="card-copy">{snapshot.cpu?.perCore?.length ?? 0} {copy.host.cores} · iowait {snapshot.cpu?.iowaitPercent == null ? '—' : snapshot.cpu.iowaitPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + '%'}</p></article>
|
||||
<article className="card"><p className="card-kicker">{copy.host.load}</p><h2>{snapshot.load?.one?.toLocaleString('nl-BE', { maximumFractionDigits: 2 }) ?? '—'}</h2><p className="card-copy">1 / 5 / 15 min: {snapshot.load?.one ?? '—'} · {snapshot.load?.five ?? '—'} · {snapshot.load?.fifteen ?? '—'}</p></article>
|
||||
<article className="card"><p className="card-kicker">{copy.host.memory}</p><h2>{snapshot.memory?.utilizationPercent?.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) ?? '—'}%</h2><p className="card-copy">{bytes(snapshot.memory?.usedBytes ?? 0)} / {bytes(snapshot.memory?.totalBytes ?? 0)} · {copy.host.available}: {bytes(snapshot.memory?.availableBytes ?? 0)}</p></article>
|
||||
<article className="card"><p className="card-kicker">{copy.host.time}</p><h2>{snapshot.time?.synchronized ? copy.host.synchronized : copy.host.notSynchronized}</h2><p className="card-copy">Offset {snapshot.time?.offsetSeconds?.toLocaleString('nl-BE', { maximumFractionDigits: 3 }) ?? '—'} s · stratum {snapshot.time?.stratum ?? '—'}</p></article>
|
||||
</section>
|
||||
<section className="host-detail-grid">
|
||||
<article className="card"><div className="card-heading"><div><p className="card-kicker">{copy.host.filesystems}</p><h2>{snapshot.filesystems?.length ?? 0}</h2></div></div>{snapshot.filesystems?.length ? <div className="host-table-wrap"><table className="host-table"><thead><tr><th>{copy.host.mount}</th><th>{copy.host.used}</th><th>{copy.host.inodes}</th></tr></thead><tbody>{snapshot.filesystems.map((item) => <tr key={item.mount}><th scope="row">{item.mount}</th><td>{item.utilizationPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 })}%<small>{bytes(item.usedBytes)} / {bytes(item.capacityBytes)}</small></td><td>{item.inodes ? ((item.inodes.used / item.inodes.total) * 100).toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + '%' : '—'}</td></tr>)}</tbody></table></div> : <p className="card-copy">{copy.host.noData}</p>}</article>
|
||||
<article className="card"><div className="card-heading"><div><p className="card-kicker">{copy.host.network}</p><h2>{snapshot.network?.length ?? 0}</h2></div></div>{snapshot.network?.length ? <div className="host-table-wrap"><table className="host-table"><thead><tr><th>{copy.host.interface}</th><th>RX / TX</th><th>{copy.host.errors}</th></tr></thead><tbody>{snapshot.network.map((item) => <tr key={item.name}><th scope="row">{item.name}<small>{item.state || copy.host.unknown}</small></th><td>{bytes(item.rxBytes)} / {bytes(item.txBytes)}</td><td>{item.rxErrors + item.txErrors + item.rxDrops + item.txDrops}</td></tr>)}</tbody></table></div> : <p className="card-copy">{copy.host.noData}</p>}</article>
|
||||
</section>
|
||||
<section className="host-detail-grid">
|
||||
<article className="card"><div className="card-heading"><div><p className="card-kicker">{copy.host.hardware}</p><h2>{snapshot.hardware?.temperatures?.length ?? 0} · {snapshot.hardware?.fans?.length ?? 0} {copy.host.sensors}</h2></div><Badge state={snapshot.hardware?.status?.state ?? 'unknown'} /></div>{snapshot.hardware?.status?.reasons?.map((reason) => <p className="host-reason" key={reason.code}>{presentReason(reason.code)}</p>)}{snapshot.hardware?.capabilities?.length ? <ul className="host-capabilities">{snapshot.hardware.capabilities.map((capability) => <li key={capability.id}><span>{capability.id}</span><small>{presentStatus(capability.state)} · {presentReason(capability.reason)}</small></li>)}</ul> : null}{snapshot.hardware?.temperatures?.length ? <div className="host-table-wrap"><table className="host-table"><thead><tr><th>{copy.host.sensor}</th><th>{copy.host.temperature}</th></tr></thead><tbody>{snapshot.hardware.temperatures.map((item) => <tr key={item.id}><th scope="row">{item.name}<small>{item.id}</small></th><td>{item.celsius.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} °C</td></tr>)}</tbody></table></div> : <p className="card-copy">{copy.host.noSensors}</p>}</article>
|
||||
<article className="card"><div className="card-heading"><div><p className="card-kicker">{copy.host.gpu}</p><h2>{snapshot.hardware?.gpus?.length ?? 0}</h2></div></div>{snapshot.hardware?.gpus?.length ? <div className="host-table-wrap"><table className="host-table"><thead><tr><th>{copy.host.device}</th><th>{copy.host.gpuUsage}</th><th>{copy.host.gpuMemory}</th></tr></thead><tbody>{snapshot.hardware.gpus.map((item) => <tr key={item.id}><th scope="row">{item.name}<small>{item.vendor || copy.host.unknown}</small></th><td>{item.utilizationPercent == null ? '—' : item.utilizationPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + '%'}</td><td>{item.memoryTotalBytes ? item.memoryUtilizationPercent?.toLocaleString('nl-BE', { maximumFractionDigits: 1 }) + '%' : '—'}</td></tr>)}</tbody></table></div> : <p className="card-copy">{copy.host.noGpu}</p>}</article>
|
||||
</section> {snapshot.warnings?.length ? <section className="card host-warnings" aria-label={copy.host.warnings}><p className="card-kicker">{copy.host.warnings}</p>{snapshot.warnings.map((warning) => <p key={warning}>{presentReason(warning)}</p>)}</section> : null}
|
||||
</>;
|
||||
}
|
||||
Reference in new issue
Block a user