Home: one time range across the health block, and failures that follow it

The health screen was fixed at 24 hours everywhere except its lists, which
were fixed at nothing: `failuresQueryOptions` read the newest 100 rows and
`HealthActivity` filtered them client-side, so on a busy engine the failures
list covered whatever few minutes 100 rows happened to span while the chart
beside it spanned a day — and pinning an older minute showed an empty card.

One `RangePicker` now sits on the Health heading and governs the whole block:
the tiles, the flow table, both charts and both lists. Presets are 1h / 6h /
24h / 7d — the collector prunes at `OBS_RETENTION_DAYS` (30), so a week is
behind the last one. The longer windows ask for coarser buckets, since a week
of minute rollups is ten thousand points nobody can see.

Failures get the escape hatch the runs already had: a pinned minute is asked
for with `since`/`until` rather than filtered out of what is held, and the
list itself is bound to the selected range. `RUN_DEPTH`/`EVENT_DEPTH` become
one `LIST_DEPTH`, which now buys coverage of the window on screen instead of
a fixed newest-N — narrowing the range is what makes the same rows reach the
whole of it. The "Failures (24h)" tile counts errors over the selected window
from the rollups the table is drawn from, so tile, column and chart agree.

The node-panel and edge trend curves get no picker. They are a Redis ring of
the last 120 values per message with no window to ask for, so hovering one
reveals what it actually shows — how many readings, and the span they cover.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XC2jX6Hdj7pxGGKzBTrbqB
This commit is contained in:
2026-08-17 11:51:42 +02:00
co-authored by Claude Opus 5
parent 3d73e42313
commit 683c25b26d
7 changed files with 245 additions and 62 deletions
@@ -2,6 +2,7 @@ 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"
@@ -37,9 +38,11 @@ function Tile({
/**
* A flow's execution trend, drawn from the 60 slices the rollup carries.
*
* The same curve the node panel and the edge popover draw, in the chart ramp
* this page's other graphs use. No live dot: the rollups are polled, so the
* right edge is the last completed slice rather than this instant.
* 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, in the chart ramp this page's other graphs use. No live
* dot: the rollups are polled, so the right edge is the last completed slice
* rather than this instant.
*/
function Spark({ counts }: { counts: number[] }) {
const points: HistoryPoint[] = counts.map((value, index) => ({
@@ -61,28 +64,40 @@ function Spark({ counts }: { counts: number[] }) {
}
/**
* How the engine is doing, and how each flow has been doing for a day.
* 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 day the charts
* cover, one row per flow.
* 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() {
export function HealthOverview({
range,
onRangeChange,
}: {
range: Range
onRangeChange: (range: Range) => void
}) {
const { data: summary } = useQuery(summaryQueryOptions())
const { data: flows } = useQuery(flowRollupsQueryOptions())
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())
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 items-center 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">
@@ -112,8 +127,12 @@ export function HealthOverview() {
}
/>
<Tile
label="Failures (24h)"
value={si(summary?.failures_24h ?? 0)}
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)}`
@@ -134,7 +153,7 @@ export function HealthOverview() {
</section>
<section className="grid gap-3">
<h2 className={PANEL_SECTION}>Flow activity (24h)</h2>
<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. */}
@@ -197,7 +216,7 @@ export function HealthOverview() {
colSpan={6}
className="py-6 text-center text-muted-foreground"
>
No flow has run in the last day.
No flow has run in the last {range.label}.
</td>
</tr>
) : null}