Files
app/frontend/src/components/Health/HealthOverview.tsx
T
stroblme 6c8a101d24 Rename Installation to Instance
Follows the portal: the noun is "instance" everywhere the app says it —
UI strings, CLI output, error details, docs and comments. The wire keys
(`instance_id`, `instance_token`) and the hub route this calls move with it.

An existing cloud.json is adopted rather than refused: without the key
alias the dataclass fails to parse, which the caller swallows and reads as
"never enrolled" instead of "reconnect".

`instance_key` on a node type becomes `target_key`. It means the outside
thing a node points at, which is a different sense of the word, and keeping
both would put two meanings of "instance" in one codebase.
2026-08-31 10:12:01 +02:00

175 lines
5.8 KiB
TypeScript

import { useQuery } from "@tanstack/react-query"
import { Link } from "@tanstack/react-router"
import { type Range, RangePicker } from "@/components/Common/RangePicker"
import { PANEL_SECTION } from "@/components/Flow/SidePanel"
import { runOverviewQueryOptions } from "@/components/Runs/queries"
import { Badge } from "@/components/ui/badge"
import { cn, dur, si } from "@/lib/utils"
import {
ago,
CARD,
failuresQueryOptions,
flowRollupsQueryOptions,
summaryQueryOptions,
} from "./queries"
function Tile({
label,
value,
note,
to,
hash,
}: {
label: string
value: string
note?: string
/** Where this figure is explained further, if anywhere obvious. */
to?: string
hash?: string
}) {
const body = (
<>
<div className={PANEL_SECTION}>{label}</div>
<div className="mt-1 text-2xl">{value}</div>
{note ? (
<div className="text-xs text-muted-foreground">{note}</div>
) : null}
</>
)
return to ? (
<Link
to={to}
hash={hash}
className={cn(CARD, "transition-colors hover:bg-accent/50")}
>
{body}
</Link>
) : (
<div className={CARD}>{body}</div>
)
}
/**
* How the engine is doing: the standing state, as tiles.
*
* The range control sits on this heading because it governs everything under
* it — the flow table, the charts and the lists — rather than this block
* alone. One window, not one per card.
*/
export function HealthOverview({
range,
onRangeChange,
}: {
range: Range
onRangeChange: (range: Range) => void
}) {
const { data: summary } = useQuery(summaryQueryOptions())
const { data: flows } = useQuery(flowRollupsQueryOptions(range))
// Shares the list below's cache entry, for the one tile that dates them.
const { data: failures } = useQuery(failuresQueryOptions(range))
// Shares the Runs screen's cache entry, which already counts by status in
// the database rather than over a page of rows.
const { data: runs } = useQuery(runOverviewQueryOptions())
const queue = (summary?.queue ?? {}) as Record<string, number>
const degraded = summary?.status === "degraded"
const errors = (flows ?? []).reduce((total, row) => total + row.errors, 0)
// An instance nobody has run a batch flow on has no runs tile at all —
// the same "nothing has been run yet" the Runs screen itself goes by.
const running = (runs ?? []).reduce((total, row) => total + row.running, 0)
const queued = (runs ?? []).reduce((total, row) => total + row.queued, 0)
return (
<section className="grid gap-3">
<div className="flex flex-wrap items-center gap-3">
<h2 className={PANEL_SECTION}>Health</h2>
<Badge variant={degraded ? "destructive" : "secondary"}>
{degraded ? "Degraded" : "Running normally"}
</Badge>
<div className="ml-auto">
<RangePicker value={range} onChange={onRangeChange} />
</div>
</div>
{summary?.problems.length ? (
<p className="text-sm text-muted-foreground">
{summary.problems.join(" · ")}
</p>
) : null}
<div
className={`grid gap-3 sm:grid-cols-2 ${runs?.length ? "lg:grid-cols-6" : "lg:grid-cols-5"}`}
>
<Tile
label="Nodes"
value={String(summary?.nodes.total ?? 0)}
note={
summary?.nodes.error
? `${summary.nodes.error} failed to load`
: "all loaded"
}
// Nodes belong to flows, not a list of their own — the flows table
// is where a count and a load error are read node by node.
to="/flows"
/>
<Tile
label="Flows running"
value={`${summary?.flows.running ?? 0}/${summary?.flows.total ?? 0}`}
to="/flows"
// Worst first, but all of them: a quarantine used to hide the
// flows validation blocks, so the tile disagreed with the count
// beside it. Zero and undefined both drop out of the filter.
note={
[
summary?.flows.quarantined &&
`${summary.flows.quarantined} quarantined`,
summary?.flows.invalid && `${summary.flows.invalid} cannot run`,
summary?.flows.paused && `${summary.flows.paused} paused`,
]
.filter(Boolean)
.join(" · ") || "none paused"
}
/>
{runs?.length ? (
<Tile
label="Runs running"
value={String(running)}
note={queued ? `${queued} queued` : "none queued"}
to="/runs"
/>
) : null}
<Tile
label={`Failures (${range.label})`}
// Summed from the rollups the table below is drawn from, so the
// tile, the Errors column and the chart's error line all count the
// same thing over the same window. Narrower than the list beside
// it, which also carries quarantines and crashed tasks.
value={si(errors)}
note={
failures?.length
? `latest ${ago(failures[0].ts)}`
: "nothing recorded"
}
// Down to the failures list further down this same page, rather
// than a route of its own.
to="/"
hash="failures"
/>
{/* Backlog leads: what is waiting is what says the engine is
behind. `pending` is work already running, which reads as idle
on an engine hours behind. */}
<Tile
label="Queue backlog"
value={si(queue.backlog ?? 0)}
note={`${queue.pending ?? 0} in flight · ${queue.delayed ?? 0} delayed · ${queue.parked ?? 0} parked`}
/>
<Tile
label="Loop lag"
value={dur(summary?.loop_lag.ewma ?? 0)}
note={`peak ${dur(summary?.loop_lag.max_60s ?? 0)} in the last minute`}
/>
</div>
</section>
)
}