Batch-fetch action widgets so the dashboard can show free/total memory and disk usage without N+1 requests.
260 lines
6.8 KiB
TypeScript
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>
|
|
)
|
|
}
|