import { useEffect, useState } from 'react' import { type ActionWidgetSnapshot, type DiskWidgetData, type MemoryWidgetData, type Node, type NodeKind, fetchAllActionWidgets, fetchNodes, } from './api/client' import { SECTION_TITLES } from './documentTitle' import { navigateTo, pathFor, type ResourceSectionId, } from './navigation' const KIND_SECTIONS: ReadonlyArray<{ kind: NodeKind section: ResourceSectionId }> = [ { kind: 'container', section: 'containers' }, { kind: 'docker', section: 'docker' }, { kind: 'vm', section: 'vms' }, ] /** Format a MiB quantity with auto MiB / GiB / TiB units. */ export function formatMemoryAmount(mib: number): string { const absolute = Math.abs(mib) if (absolute >= 1024 * 1024) { return `${(mib / (1024 * 1024)).toFixed(1)} TiB` } if (absolute >= 1024) { return `${(mib / 1024).toFixed(1)} GiB` } return `${Math.round(mib)} MiB` } /** Format free / total RAM from a memory widget snapshot. */ export function formatMemoryLine(memory: MemoryWidgetData): string { return `${formatMemoryAmount(memory.free)} / ${formatMemoryAmount(memory.total)}` } /** Parse df Use% into a free percent integer, or null if unparsable. */ export function freePercentFromUsePercent(usePercent: string): number | null { const match = usePercent.trim().match(/^(\d+(?:\.\d+)?)\s*%?$/) if (!match) { return null } const used = Number(match[1]) if (!Number.isFinite(used)) { return null } return Math.max(0, Math.min(100, Math.round(100 - used))) } /** Format used / total (free%) from a disk widget snapshot. */ export function formatDiskLine(disk: DiskWidgetData): string { const freePercent = freePercentFromUsePercent(disk.use_percent) const base = `${disk.used} / ${disk.size}` if (freePercent === null) { return base } return `${base} (${freePercent}% free)` } function pickMemoryWidget( widgets: ReadonlyArray, ): MemoryWidgetData | null { for (const widget of widgets) { if (widget.widget_type === 'memory' && widget.memory) { return widget.memory } } return null } function pickDiskWidget( widgets: ReadonlyArray, ): DiskWidgetData | null { const diskWidgets = widgets.filter( (widget) => widget.widget_type === 'disk' && widget.disk, ) if (diskWidgets.length === 0) { return null } const root = diskWidgets.find((widget) => widget.disk?.mounted_on === '/') return (root ?? diskWidgets[0]).disk ?? null } function healthDotClass(healthOk: boolean | null | undefined): string { if (healthOk === true) { return 'node-health-dot node-health-dot-ok' } if (healthOk === false) { return 'node-health-dot node-health-dot-error' } return 'node-health-dot node-health-dot-unknown' } function healthDotLabel(healthOk: boolean | null | undefined): string { if (healthOk === true) { return 'Healthy' } if (healthOk === false) { return 'Unhealthy' } return 'Health unknown' } function groupWidgetsByNode( widgets: ReadonlyArray, ): Map { const byNode = new Map() for (const widget of widgets) { const existing = byNode.get(widget.node_id) if (existing) { existing.push(widget) } else { byNode.set(widget.node_id, [widget]) } } return byNode } function OverviewNodeCard({ node, widgets, section, }: { node: Node widgets: ReadonlyArray section: ResourceSectionId }) { const memory = pickMemoryWidget(widgets) const disk = pickDiskWidget(widgets) return ( ) } export function OverviewPanel() { const [nodes, setNodes] = useState | null>(null) const [widgetsByNode, setWidgetsByNode] = useState< Map >(() => new Map()) const [errorMessage, setErrorMessage] = useState(null) const [isLoading, setIsLoading] = useState(true) useEffect(() => { let isCancelled = false async function loadOverview() { setIsLoading(true) setErrorMessage(null) try { const [nodesResponse, widgetsResponse] = await Promise.all([ fetchNodes(), fetchAllActionWidgets(), ]) if (isCancelled) { return } setNodes(nodesResponse.nodes) setWidgetsByNode(groupWidgetsByNode(widgetsResponse.widgets ?? [])) } catch (error) { if (!isCancelled) { setErrorMessage( error instanceof Error ? error.message : 'Failed to load overview', ) setNodes([]) setWidgetsByNode(new Map()) } } finally { if (!isCancelled) { setIsLoading(false) } } } void loadOverview() return () => { isCancelled = true } }, []) if (isLoading) { return

Loading overview…

} if (errorMessage) { return

{errorMessage}

} const allNodes = nodes ?? [] if (allNodes.length === 0) { return

No nodes yet.

} return (
{KIND_SECTIONS.map(({ kind, section }) => { const sectionNodes = allNodes.filter((node) => node.kind === kind) if (sectionNodes.length === 0) { return null } return (

{SECTION_TITLES[section]}

{sectionNodes.map((node) => ( ))}
) })}
) }