Files
ClusterCanvas/webui/src/OverviewPanel.tsx
T
Squid 6eef839526 Add Overview page with kind-grouped node cards and RAM/disk metrics.
Batch-fetch action widgets so the dashboard can show free/total memory and disk usage without N+1 requests.
2026-07-20 21:25:55 +02:00

260 lines
6.8 KiB
TypeScript

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<ActionWidgetSnapshot>,
): MemoryWidgetData | null {
for (const widget of widgets) {
if (widget.widget_type === 'memory' && widget.memory) {
return widget.memory
}
}
return null
}
function pickDiskWidget(
widgets: ReadonlyArray<ActionWidgetSnapshot>,
): 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<ActionWidgetSnapshot>,
): Map<string, ActionWidgetSnapshot[]> {
const byNode = new Map<string, ActionWidgetSnapshot[]>()
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<ActionWidgetSnapshot>
section: ResourceSectionId
}) {
const memory = pickMemoryWidget(widgets)
const disk = pickDiskWidget(widgets)
return (
<button
type="button"
className="overview-node-card"
onClick={() => {
navigateTo(pathFor(section, 'overview', 'overview', node.id, 'overview'))
}}
>
<div className="overview-node-card-heading">
<span
className={healthDotClass(node.health_ok)}
title={healthDotLabel(node.health_ok)}
aria-label={healthDotLabel(node.health_ok)}
role="img"
/>
<strong className="overview-node-card-title">{node.name}</strong>
</div>
{memory ? (
<p className="overview-node-metric">
<span className="overview-node-metric-label">RAM</span>
{formatMemoryLine(memory)}
</p>
) : null}
{disk ? (
<p className="overview-node-metric">
<span className="overview-node-metric-label">Disk</span>
{formatDiskLine(disk)}
</p>
) : null}
</button>
)
}
export function OverviewPanel() {
const [nodes, setNodes] = useState<ReadonlyArray<Node> | null>(null)
const [widgetsByNode, setWidgetsByNode] = useState<
Map<string, ActionWidgetSnapshot[]>
>(() => new Map())
const [errorMessage, setErrorMessage] = useState<string | null>(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 <p className="config-hint">Loading overview</p>
}
if (errorMessage) {
return <p className="form-error" role="alert">{errorMessage}</p>
}
const allNodes = nodes ?? []
if (allNodes.length === 0) {
return <p className="config-hint">No nodes yet.</p>
}
return (
<div className="overview-panel">
{KIND_SECTIONS.map(({ kind, section }) => {
const sectionNodes = allNodes.filter((node) => node.kind === kind)
if (sectionNodes.length === 0) {
return null
}
return (
<section
key={kind}
className="overview-kind-section"
aria-label={SECTION_TITLES[section]}
>
<h3 className="overview-kind-heading">{SECTION_TITLES[section]}</h3>
<div className="overview-node-grid">
{sectionNodes.map((node) => (
<OverviewNodeCard
key={node.id}
node={node}
widgets={widgetsByNode.get(node.id) ?? []}
section={section}
/>
))}
</div>
</section>
)
})}
</div>
)
}