Public source validation / validate (push) Failing after 3m8s
51 lines
6.4 KiB
TypeScript
51 lines
6.4 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
import { copy } from './copy';
|
||
import { StorageMapWidget, TemperatureHeatmap, type HeatmapPoint, type StorageMapNode } from './StorageVisuals';
|
||
import { SourceStatusDetails, type SourceStatus } from './SourceStatusDetails';
|
||
|
||
type Severity = 'normal' | 'attention' | 'critical' | 'unknown';
|
||
type Disk = { id: string; name: string; role: string; state: string; utilizationPercent: number; capacitySeverity?: Severity; thermalSeverity?: Severity; temperature?: { celsius?: number; status: string; observedAt?: string } };
|
||
type DiskSnapshot = { source: SourceStatus; disks: Disk[] };
|
||
type Pool = { id: string; name: string; filesystem: string; state: string; utilizationPercent: number; capacitySeverity?: Severity };
|
||
type PoolSnapshot = { source: SourceStatus; pools: Pool[] };
|
||
type ArrayMember = { id: string; name: string; role: string; state: string };
|
||
type ArraySnapshot = { source: SourceStatus; state: string; members: ArrayMember[] };
|
||
export type StorageData = { array: ArraySnapshot; disks: DiskSnapshot; pools: PoolSnapshot };
|
||
|
||
function availability(value: string): string { return value === 'online' || value === 'healthy' || value === 'operational' ? 'healthy' : value === 'unknown' ? 'unknown' : 'degraded'; }
|
||
function visualSeverity(...values: Array<string | undefined>): string {
|
||
if (values.includes('critical')) return 'critical';
|
||
if (values.some((value) => value === 'attention' || value === 'degraded' || value === 'faulted')) return 'degraded';
|
||
if (values.includes('unknown')) return 'unknown';
|
||
return 'healthy';
|
||
}
|
||
function signalLabel(value: string): string { return value === 'normal' || value === 'healthy' || value === 'online' ? 'normaal' : value === 'critical' ? 'kritiek' : value === 'attention' || value === 'degraded' ? 'aandacht' : 'onbekend'; }
|
||
|
||
export function buildStorageNodes(data: StorageData): StorageMapNode[] {
|
||
const members = new Map((data.array.members ?? []).map((member) => [member.id.toLowerCase(), member]));
|
||
const diskNodes = (data.disks.disks ?? []).slice(0, 64).map((disk) => {
|
||
const member = members.get(disk.id.toLowerCase());
|
||
if (member) members.delete(disk.id.toLowerCase());
|
||
const capacity = disk.capacitySeverity ?? 'unknown';
|
||
const thermal = disk.thermalSeverity ?? disk.temperature?.status ?? 'unknown';
|
||
return { id: 'disk-' + disk.id, label: disk.name, kind: member?.role ?? disk.role, state: visualSeverity(availability(disk.state), capacity, thermal), detail: `Beschikbaarheid ${signalLabel(disk.state)} · capaciteit ${signalLabel(capacity)} · temperatuur ${signalLabel(thermal)}`, href: '/disks/' + encodeURIComponent(disk.id) };
|
||
});
|
||
const unmatchedMembers = [...members.values()].slice(0, 64).map((member) => ({ id: 'array-' + member.id, label: member.name, kind: member.role, state: availability(member.state), detail: `Beschikbaarheid ${signalLabel(member.state)} · disktelemetrie onbekend`, href: '/array' }));
|
||
const poolNodes = (data.pools.pools ?? []).slice(0, 64).map((pool) => {
|
||
const capacity = pool.capacitySeverity ?? 'unknown';
|
||
return { id: 'pool-' + pool.id, label: pool.name, kind: `pool · ${pool.filesystem}`, state: visualSeverity(availability(pool.state), capacity), detail: `Device-health ${signalLabel(pool.state)} · capaciteit ${signalLabel(capacity)} (${pool.utilizationPercent.toLocaleString('nl-BE', { maximumFractionDigits: 1 })}% gebruikt)`, href: '/pools/' + encodeURIComponent(pool.id) };
|
||
});
|
||
return [...unmatchedMembers, ...poolNodes, ...diskNodes];
|
||
}
|
||
|
||
export function StoragePage() {
|
||
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
|
||
const [data, setData] = useState<StorageData | null>(null);
|
||
useEffect(() => { const controller = new AbortController(); Promise.all([fetch('/api/v1/array', { signal: controller.signal }).then((response) => response.json() as Promise<ArraySnapshot>), fetch('/api/v1/disks?limit=100', { signal: controller.signal }).then((response) => response.json() as Promise<DiskSnapshot>), fetch('/api/v1/pools?limit=100', { signal: controller.signal }).then((response) => response.json() as Promise<PoolSnapshot>)]).then(([array, disks, pools]) => { setData({ array, disks, pools }); 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.storage.loading}</h1></section>;
|
||
if (state === 'error' || !data) return <section className="state-page" role="alert"><span className="state-icon" aria-hidden="true">×</span><h1>{copy.storage.errorTitle}</h1><p>{copy.storage.errorDetail}</p></section>;
|
||
const nodes = buildStorageNodes(data);
|
||
const heatmap: HeatmapPoint[] = (data.disks.disks ?? []).slice(0, 64).map((disk) => ({ id: disk.id, label: disk.name, observedAt: disk.temperature?.observedAt || '', value: disk.temperature?.celsius ?? null, status: visualSeverity(disk.thermalSeverity ?? disk.temperature?.status ?? 'unknown'), href: '/disks/' + encodeURIComponent(disk.id) }));
|
||
return <><header className="page-intro"><p className="eyebrow">{copy.storage.eyebrow}</p><h1>{copy.storage.title}</h1><p className="intro">{copy.storage.intro}</p></header><section className="card container-summary" aria-labelledby="storage-summary-title"><div className="card-heading"><div><p className="card-kicker">{copy.storage.source}</p><h2 id="storage-summary-title">{copy.storage.sourceTitle}</h2><p className="card-copy">{copy.storage.accessible}</p></div></div><div className="storage-source-grid"><article><h3>{copy.storage.arraySource}</h3><SourceStatusDetails source={{ ...data.array.source, id: data.array.source?.id || 'array' }} /></article><article><h3>{copy.storage.diskSource}</h3><SourceStatusDetails source={{ ...data.disks.source, id: data.disks.source?.id || 'disks' }} /></article><article><h3>{copy.storage.poolSource}</h3><SourceStatusDetails source={{ ...data.pools.source, id: data.pools.source?.id || 'pools' }} /></article></div><p className="container-provenance">{nodes.length} {copy.storage.mapNodes} · {heatmap.length} {copy.storage.heatmapPoints}</p></section><StorageMapWidget nodes={nodes} title={copy.storage.mapTitle} description={copy.storage.mapDescription} /><TemperatureHeatmap points={heatmap} title={copy.storage.heatmapTitle} description={copy.storage.heatmapDescription} /></>;
|
||
}
|