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.
175 lines
5.8 KiB
TypeScript
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>
|
|
)
|
|
}
|