Files
ITWorx-Pulse-Public/apps/web/src/HostPage.tsx
T
ITWorx Pulse release export bd774932d5
Public source validation / validate (push) Failing after 3m8s
Publish ITWorx Pulse source
2026-09-03 02:09:19 +02:00

91 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}
</>;
}