Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XC2jX6Hdj7pxGGKzBTrbqB
235 lines
8.4 KiB
TypeScript
235 lines
8.4 KiB
TypeScript
import { useQuery } from "@tanstack/react-query"
|
|
import { Link } from "@tanstack/react-router"
|
|
|
|
import type { FlowRollup, HistoryPoint } from "@/client"
|
|
import { type Range, RangePicker } from "@/components/Common/RangePicker"
|
|
import { Sparkline } from "@/components/Common/Sparkline"
|
|
import { PANEL_SECTION } from "@/components/Flow/SidePanel"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { si } from "@/lib/utils"
|
|
import {
|
|
ago,
|
|
CARD,
|
|
failuresQueryOptions,
|
|
flowRollupsQueryOptions,
|
|
summaryQueryOptions,
|
|
} from "./queries"
|
|
|
|
function Tile({
|
|
label,
|
|
value,
|
|
note,
|
|
}: {
|
|
label: string
|
|
value: string
|
|
note?: string
|
|
}) {
|
|
return (
|
|
<div className={CARD}>
|
|
<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}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* A flow's execution trend, drawn from the 60 slices the rollup carries.
|
|
*
|
|
* Sixty slices of whatever window is selected, so the curve stays the same
|
|
* width and only its resolution moves. The same curve the node panel and the
|
|
* edge popover draw, dot included, in the chart ramp this page's other graphs
|
|
* use. The dot marks the newest slice rather than this instant — the rollups
|
|
* are polled, so that slice is the last complete one.
|
|
*/
|
|
function Spark({ counts }: { counts: number[] }) {
|
|
const points: HistoryPoint[] = counts.map((value, index) => ({
|
|
ts: index,
|
|
value,
|
|
}))
|
|
if (points.every((point) => point.value === 0)) {
|
|
return <span className="text-xs text-muted-foreground">nothing yet</span>
|
|
}
|
|
return (
|
|
<Sparkline
|
|
points={points}
|
|
color="var(--chart-1)"
|
|
height="h-6"
|
|
readout={false}
|
|
/>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* How the engine is doing, and how each flow has been doing over the window.
|
|
*
|
|
* The tiles are the standing state; the table below is the same window the
|
|
* charts cover, one row per flow. The range control sits on this heading
|
|
* because it governs the whole health block, the activity below included —
|
|
* 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))
|
|
|
|
const queue = (summary?.queue ?? {}) as Record<string, number>
|
|
const degraded = summary?.status === "degraded"
|
|
const errors = (flows ?? []).reduce((total, row) => total + row.errors, 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 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"
|
|
}
|
|
/>
|
|
<Tile
|
|
label="Flows running"
|
|
value={`${summary?.flows.running ?? 0}/${summary?.flows.total ?? 0}`}
|
|
// Worst first: the engine gave up on a quarantined flow, a flow
|
|
// validation blocks never started, and a paused one is deliberate.
|
|
note={
|
|
summary?.flows.quarantined
|
|
? `${summary.flows.quarantined} quarantined`
|
|
: summary?.flows.invalid
|
|
? `${summary.flows.invalid} cannot run`
|
|
: summary?.flows.paused
|
|
? `${summary.flows.paused} paused`
|
|
: "none paused"
|
|
}
|
|
/>
|
|
<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"
|
|
}
|
|
/>
|
|
<Tile
|
|
label="Queue in flight"
|
|
value={String(queue.pending ?? 0)}
|
|
note={`${queue.delayed ?? 0} waiting · ${queue.parked ?? 0} parked`}
|
|
/>
|
|
<Tile
|
|
label="Loop lag"
|
|
value={`${si(summary?.loop_lag.ewma ?? 0)} ms`}
|
|
note={`peak ${si(summary?.loop_lag.max_60s ?? 0)} ms in the last minute`}
|
|
/>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="grid gap-3">
|
|
<h2 className={PANEL_SECTION}>Flow activity ({range.label})</h2>
|
|
<div className={`${CARD} overflow-x-auto`}>
|
|
{/* The name anchors the left, the numbers read down their own
|
|
centre, and the trend closes the row on the right. */}
|
|
<table className="w-full text-sm">
|
|
<thead className="text-xs text-muted-foreground">
|
|
<tr>
|
|
<th className="pb-2 text-left font-medium">Flow</th>
|
|
<th className="px-3 pb-2 text-center font-medium">
|
|
Executions
|
|
</th>
|
|
<th className="px-3 pb-2 text-center font-medium">Errors</th>
|
|
<th className="px-3 pb-2 text-center font-medium">Avg</th>
|
|
<th className="px-3 pb-2 text-center font-medium">Lag</th>
|
|
{/* A bounded share rather than all the slack: the columns
|
|
beside it grow with their own content, so the numbers
|
|
spread across the middle instead of huddling on the left. */}
|
|
<th className="w-1/3 min-w-32 pb-2 pl-3 text-right font-medium">
|
|
Trend
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{(flows ?? []).map((row: FlowRollup) => (
|
|
<tr key={row.flow} className="border-t border-border">
|
|
<td className="py-2 pr-3">
|
|
<Link
|
|
to="/flows/$flowName"
|
|
params={{ flowName: row.flow }}
|
|
className="font-mono hover:underline"
|
|
>
|
|
{row.flow || "—"}
|
|
</Link>
|
|
</td>
|
|
<td className="px-3 py-2 text-center">
|
|
{si(row.executions)}
|
|
</td>
|
|
<td className="px-3 py-2 text-center">
|
|
{row.errors ? (
|
|
<Badge variant="destructive">
|
|
{si(row.errors)} failed
|
|
</Badge>
|
|
) : (
|
|
<span className="text-muted-foreground">none</span>
|
|
)}
|
|
</td>
|
|
<td className="whitespace-nowrap px-3 py-2 text-center">
|
|
{si(row.avg_ms)} ms
|
|
</td>
|
|
<td className="whitespace-nowrap px-3 py-2 text-center">
|
|
{si(row.avg_lag_ms)} ms
|
|
</td>
|
|
{/* The dot straddles the curve's right edge, so the cell
|
|
keeps a little room for the half that hangs out. */}
|
|
<td className="py-2 pr-2 pl-3 text-right">
|
|
<Spark counts={row.spark} />
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{flows?.length === 0 ? (
|
|
<tr>
|
|
<td
|
|
colSpan={6}
|
|
className="py-6 text-center text-muted-foreground"
|
|
>
|
|
No flow has run in the last {range.label}.
|
|
</td>
|
|
</tr>
|
|
) : null}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
</>
|
|
)
|
|
}
|