Files
ITWorx-Pulse-Public/apps/web/src/DiskPage.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

16 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 { presentArrayRole, presentReason, presentStatus } from './presentation';
type SmartAttribute = { id:string; name:string; rawValue:number; normalizedValue?:number; unit?:string; status:string; critical:boolean; reason?:string }; type Smart = { state:string; overall:string; observedAt?:string; attributes:SmartAttribute[]; selfTest?:{supported:boolean;result:string;completedAt?:string;ageSeconds?:number}; reasons?:string[] }; type Sample = { observedAt:string; readBytesPerSecond?:number; writeBytesPerSecond?:number; readIops?:number; writeIops?:number; readLatencyMs?:number; writeLatencyMs?:number }; type Disk = { id:string; name:string; role:string; state:string; model?:string; serialDisplay?:string; filesystem?:string; sizeBytes:number; usedBytes:number; freeBytes:number; utilizationPercent:number; capacitySeverity?:string; thermalSeverity?:string; inodes?:{total:number;used:number;free:number;utilizationPercent:number}; smart?:Smart; performance?:{state:string;current:Sample;history:Sample[]}; temperature?:{state:string;celsius?:number;status:string;observedAt?:string}; spin?:{state:string} };
type Snapshot = { source:{id:string;state:string;freshness:string;observedAt?:string;reason?:string}; disks:Disk[]; total:number; missingHistory?:Array<{diskId:string;name:string;role:string;observedAt:string;reason:string}> };
type Detail = {source:Snapshot['source'];disk:Disk};
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 date(value?:string):string{return formatDateTime(value)}
function Badge({label,ready}:{label:string;ready:boolean}){return <span className={'status-badge status-badge--'+(ready?'ready':'unknown')}><span className="status-icon" aria-hidden="true">{ready?'✓':'?'}</span>{label}</span>}
function severityLabel(value?:string):string{return value==='normal'?'Normaal':value==='attention'?'Aandacht':value==='critical'?'Kritiek':'Onbekend'}
function DiskCard({disk}:{disk:Disk}){return <article className="card"><div className="card-heading"><div><p className="card-kicker">{presentArrayRole(disk.role)}</p><h2><a className="entity-link" href={'/disks/'+encodeURIComponent(disk.id)}>{disk.name}</a></h2></div><Badge label={presentStatus(disk.state)} ready={disk.state==='online'}/></div><p className="card-copy">{disk.filesystem||copy.disks.noFilesystem} · {bytes(disk.sizeBytes)} · {disk.utilizationPercent.toLocaleString('nl-BE',{maximumFractionDigits:1})}% {copy.disks.used}</p><p className="card-copy">Capaciteit: {severityLabel(disk.capacitySeverity)} · Temperatuur: {severityLabel(disk.thermalSeverity)}</p><p className="card-copy">{disk.model||copy.disks.noModel} · {disk.serialDisplay||copy.disks.noSerial}</p></article>}
export function DiskPage(){const[state,setState]=useState<'loading'|'ready'|'error'>('loading');const[snapshot,setSnapshot]=useState<Snapshot|null>(null);useEffect(()=>{const controller=new AbortController();fetch('/api/v1/disks?limit=100',{signal:controller.signal}).then(response=>{if(!response.ok)throw new Error('disks');return response.json() as Promise<Snapshot>}).then(data=>{setSnapshot(data);setState('ready')}).catch(error=>{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.disks.loading}</h1></section>;if(state==='error'||!snapshot)return <section className="state-page" role="alert"><span className="state-icon" aria-hidden="true">×</span><h1>{copy.disks.errorTitle}</h1><p>{copy.disks.errorDetail}</p></section>;const fresh=snapshot.source?.freshness==='fresh'&&snapshot.source?.state!=='unknown';return <><header className="page-intro"><p className="eyebrow">{copy.disks.eyebrow}</p><h1>{copy.disks.title}</h1><p className="intro">{copy.disks.intro}</p></header><section className="card container-summary" aria-labelledby="disks-summary-title"><div className="card-heading"><div><p className="card-kicker">{copy.disks.source}</p><h2 id="disks-summary-title">{snapshot.source?.id||copy.disks.unknown}</h2><p className="card-copy">{snapshot.source?.reason?presentReason(snapshot.source.reason):(fresh?copy.disks.fresh:copy.disks.stale)}</p></div><Badge label={fresh?copy.disks.available:copy.disks.unknown} ready={fresh}/></div><p className="container-provenance">{copy.disks.observed}: {date(snapshot.source?.observedAt)} · {snapshot.total} {copy.disks.rows}</p></section><section className="card-grid" aria-label={copy.disks.cards}>{snapshot.disks.length?snapshot.disks.map(disk=><DiskCard key={disk.id} disk={disk}/>):<article className="card"><p className="card-copy">{copy.disks.empty}</p></article>}</section><section className="card" aria-labelledby="missing-disk-title"><div className="card-heading"><div><p className="card-kicker">{copy.disks.history}</p><h2 id="missing-disk-title">{copy.disks.missingTitle}</h2></div></div>{snapshot.missingHistory?.length?<ul className="inventory-list">{snapshot.missingHistory.map(item=><li key={item.diskId+'-'+item.observedAt}><span><strong>{item.name}</strong><small>{item.role} · {presentReason(item.reason)} · {date(item.observedAt)}</small></span><Badge label={copy.disks.missing} ready={false}/></li>)}</ul>:<p className="card-copy">{copy.disks.noMissingHistory}</p>}</section></>}
function TelemetrySection({performance,temperature,spin}:{performance?:Disk['performance'];temperature?:Disk['temperature'];spin?:Disk['spin']}){return <section className="card" aria-labelledby="disk-telemetry-title"><div className="card-heading"><div><p className="card-kicker">{copy.disks.telemetry}</p><h2 id="disk-telemetry-title">{temperature?.celsius == null ? '—' : temperature.celsius.toLocaleString('nl-BE',{maximumFractionDigits:1})+' °C'}</h2></div><Badge label={presentStatus(temperature?.status)} ready={temperature?.status==='normal'}/></div><p className="card-copy">{copy.disks.spin}: {spin?.state==='unsupported'?copy.disks.unsupported:presentStatus(spin?.state)} · {copy.disks.performance}: {performance?.state==='unsupported'?copy.disks.unsupported:presentStatus(performance?.state)}</p>{performance?.state==='available'&&<p className="card-copy">{copy.disks.read}: {Math.round(performance.current.readBytesPerSecond||0).toLocaleString('nl-BE')} B/s · {copy.disks.write}: {Math.round(performance.current.writeBytesPerSecond||0).toLocaleString('nl-BE')} B/s · {performance.history.length} {copy.disks.historyPoints}</p>}</section>}function SmartSection({smart}:{smart?:Smart}){if(!smart)return <section className="card" aria-labelledby="smart-title"><div className="card-heading"><h2 id="smart-title">SMART</h2><Badge label={copy.disks.smartUnknown} ready={false}/></div><p className="card-copy">{copy.disks.smartUnavailable}</p></section>;return <section className="card" aria-labelledby="smart-title"><div className="card-heading"><div><p className="card-kicker">SMART</p><h2 id="smart-title">{presentStatus(smart.overall)}</h2></div><Badge label={smart.state=== 'available'?copy.disks.smartAvailable:copy.disks.smartUnknown} ready={smart.state==='available'&&smart.overall==='passed'}/></div>{smart.reasons?.length?<ul className="reason-list">{smart.reasons.map(reason=><li key={reason}>{reason}</li>)}</ul>:<p className="card-copy">{copy.disks.smartNoReasons}</p>}{smart.selfTest&&<p className="card-copy">{copy.disks.selfTest}: {smart.selfTest.result} · {smart.selfTest.ageSeconds == null ? '—' : Math.floor(smart.selfTest.ageSeconds/3600)+' u'}</p>}{smart.attributes.length>0&&<div className="host-table-wrap"><table className="host-table"><thead><tr><th>{copy.disks.attribute}</th><th>{copy.disks.value}</th><th>{copy.disks.state}</th></tr></thead><tbody>{smart.attributes.map(attribute=><tr key={attribute.id}><th scope="row">{attribute.name}<small>{attribute.id}</small></th><td>{attribute.rawValue}{attribute.unit?' '+attribute.unit:''}</td><td><Badge label={presentStatus(attribute.status)} ready={!attribute.critical}/></td></tr>)}</tbody></table></div>}</section>}export function DiskDetailPage({id}:{id:string}){const[state,setState]=useState<'loading'|'ready'|'error'>('loading');const[detail,setDetail]=useState<Detail|null>(null);useEffect(()=>{const controller=new AbortController();fetch('/api/v1/disks/'+encodeURIComponent(id),{signal:controller.signal}).then(response=>{if(!response.ok)throw new Error('disk-detail');return response.json() as Promise<Detail>}).then(data=>{setDetail(data);setState('ready')}).catch(error=>{if(error instanceof DOMException&&error.name==='AbortError')return;setState('error')});return()=>controller.abort()},[id]);if(state==='loading')return <section className="state-page" role="status"><span className="loader" aria-hidden="true"/><h1>{copy.disks.loading}</h1></section>;if(state==='error'||!detail)return <section className="state-page" role="alert"><span className="state-icon" aria-hidden="true">×</span><h1>{copy.disks.errorTitle}</h1><p>{copy.disks.errorDetail}</p><a className="button" href="/disks">{copy.disks.back}</a></section>;const disk=detail.disk;return <><header className="page-intro"><p className="eyebrow">{copy.disks.detailEyebrow}</p><h1>{disk.name}</h1><p className="intro">{copy.disks.detailIntro}</p><div className="detail-actions"><a className="button button--secondary" href="/disks">{copy.disks.back}</a></div></header><section className="card container-summary" aria-labelledby="disk-detail-summary"><div className="card-heading"><div><p className="card-kicker">{copy.disks.source}</p><h2 id="disk-detail-summary">{detail.source?.id||copy.disks.unknown}</h2><p className="card-copy">{detail.source?.reason||copy.disks.readOnly}</p></div><Badge label={presentStatus(disk.state)} ready={disk.state==='online'}/></div><p className="container-provenance">{copy.disks.observed}: {date(detail.source?.observedAt)} · {detail.source?.freshness=== 'fresh'?copy.disks.fresh:copy.disks.stale}</p></section><SmartSection smart={disk.smart}/><TelemetrySection performance={disk.performance} temperature={disk.temperature} spin={disk.spin}/><section className="card-grid" aria-label={copy.disks.metrics}><article className="card"><p className="card-kicker">{copy.disks.capacity}</p><h2>{disk.utilizationPercent.toLocaleString('nl-BE',{maximumFractionDigits:1})}%</h2><p className="card-copy">{bytes(disk.usedBytes)} {copy.disks.usedOf} {bytes(disk.sizeBytes)} · {bytes(disk.freeBytes)} {copy.disks.free}</p></article><article className="card"><p className="card-kicker">{copy.disks.identity}</p><h2>{presentArrayRole(disk.role)}</h2><p className="card-copy">{disk.model||copy.disks.noModel} · {disk.serialDisplay||copy.disks.noSerial}</p></article>{disk.inodes&&<article className="card"><p className="card-kicker">{copy.disks.inodes}</p><h2>{disk.inodes.utilizationPercent.toLocaleString('nl-BE',{maximumFractionDigits:1})}%</h2><p className="card-copy">{disk.inodes.used} / {disk.inodes.total}</p></article>}</section><details className="card technical-details"><summary>{copy.disks.technical}</summary><dl className="technical-grid"><dt>ID</dt><dd>{disk.id}</dd><dt>{copy.disks.filesystem}</dt><dd>{disk.filesystem||copy.disks.noFilesystem}</dd><dt>{copy.disks.capacity}</dt><dd>{bytes(disk.sizeBytes)}</dd><dt>{copy.disks.free}</dt><dd>{bytes(disk.freeBytes)}</dd><dt>{copy.disks.serial}</dt><dd>{disk.serialDisplay||copy.disks.noSerial}</dd></dl></details></>}