Files
app/frontend/src/components/Health/HealthActivity.tsx
T
stroblme ca15481f5c Home: widen the brain layout, fix its entrance, add a clear button to pinned charts
The force layout settled roughly square, so in a wide band the graph sat as a
small island. Vertical centring now pulls nearly three times as hard as
horizontal and the seed sits on an ellipse, which settles at about 2.2:1
without stretching the circles or the edges.

The graph also slid in on load: the rebuild fit ran with a 300ms duration, so
React Flow animated the pan from its default viewport to the content. The fit
is instant now, and the canvas grows in from the centre with `scaleIn` once it
has a layout.

The pinned chart header said "Esc clears", which a phone cannot act on. It
carries the same "× Clear" button as the list header instead; Escape still
works.
2026-08-17 08:33:27 +02:00

394 lines
13 KiB
TypeScript

import { useQuery, useQueryClient } from "@tanstack/react-query"
import { ChevronDown, ChevronRight, X } from "lucide-react"
import { useEffect, useState } from "react"
import type { EventRow, HistoryPoint } from "@/client"
import { UplotChart } from "@/components/Common/UplotChart"
import { useEngineEvents } from "@/components/Flow/liveStore"
import { PANEL_SECTION } from "@/components/Flow/SidePanel"
import { Button } from "@/components/ui/button"
import { cn, compact } from "@/lib/utils"
import {
ago,
auditQueryOptions,
CARD,
clock,
deadLetterQueryOptions,
failuresQueryOptions,
healthKeys,
minuteOf,
minuteRunsQueryOptions,
runsQueryOptions,
timeseriesQueryOptions,
} from "./queries"
/** How much of each list is shown while it is not tied to a moment. */
const RUNS_SHOWN = 15
const FAILURES_SHOWN = 25
/**
* A moment picked off a chart.
*
* Hovering previews it, so the list under the pointer scrubs along with the
* cursor. A click holds it: reading the list means moving the pointer off the
* chart, and a held moment ignores the cursor until it is released.
*/
function useMoment() {
const [hovered, setHovered] = useState<number | null>(null)
const [pinned, setPinned] = useState<number | null>(null)
useEffect(() => {
if (pinned === null) return
const release = (event: KeyboardEvent) => {
if (event.key === "Escape") setPinned(null)
}
window.addEventListener("keydown", release)
return () => window.removeEventListener("keydown", release)
}, [pinned])
return {
/** The minute the paired list is showing, or null for all of it. */
at: pinned ?? hovered,
pinned,
onCursor: setHovered,
onSelect: (ts: number | null) =>
setPinned((held) => (ts === null || ts === held ? null : ts)),
release: () => setPinned(null),
}
}
type Moment = ReturnType<typeof useMoment>
/**
* The way out of a held moment, on both the chart that set it and the list it
* drives — the same control in both places, because it is the same action.
*
* A control rather than the Escape hint it replaces on the chart: a phone has
* no Escape key. The shortcut still works, it is just no longer the only way.
*/
function ClearPin({ moment }: { moment: Moment }) {
return (
<Button
variant="ghost"
size="sm"
className="-my-1 gap-1 px-2 text-xs"
onClick={moment.release}
aria-label="Clear the pinned moment"
>
<X className="size-3" />
Clear
</Button>
)
}
/** One chart, and the moment it hands to its list. */
function Chart({
title,
labels,
plots,
moment,
}: {
title: string
labels: string[]
plots: HistoryPoint[][]
moment: Moment
}) {
return (
<div
className={cn(
CARD,
"flex h-64 flex-col",
moment.pinned !== null && "border-primary",
)}
>
<div className="flex items-baseline justify-between gap-2">
<h2 className={PANEL_SECTION}>{title}</h2>
{moment.pinned !== null ? (
<ClearPin moment={moment} />
) : (
<span className="text-xs text-muted-foreground">click to pin</span>
)}
</div>
<UplotChart
labels={labels}
plots={plots}
empty="Nothing has run yet."
onCursor={moment.onCursor}
onSelect={moment.onSelect}
/>
</div>
)
}
/** What a list is showing, and the way back to all of it. */
function ListHeader({ title, moment }: { title: string; moment: Moment }) {
return (
<div className="flex min-h-6 flex-wrap items-center gap-2">
<h2 className={PANEL_SECTION}>{title}</h2>
{moment.at !== null ? (
<span className="text-xs text-muted-foreground">
{moment.pinned !== null ? "pinned to" : "showing"} {clock(moment.at)}
</span>
) : null}
{moment.pinned !== null ? <ClearPin moment={moment} /> : null}
</div>
)
}
function Failure({ event }: { event: EventRow }) {
const [open, setOpen] = useState(false)
const [first, ...rest] = event.detail.split("\n")
return (
<div className="border-b border-border py-2 last:border-0">
<button
type="button"
className="flex w-full items-start gap-2 text-left"
onClick={() => setOpen(!open)}
disabled={rest.length === 0}
>
{rest.length ? (
open ? (
<ChevronDown className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
)
) : (
<span className="size-4 shrink-0" />
)}
<span className="min-w-0 flex-1">
<span className="font-mono text-sm">
{event.node || event.flow || "engine"}
</span>
<span className="ml-2 text-sm text-muted-foreground">{first}</span>
</span>
<span className="shrink-0 text-xs text-muted-foreground">
{ago(event.ts)}
</span>
</button>
{open && rest.length ? (
<pre className="mt-2 max-h-64 overflow-auto rounded-md border border-border bg-muted/40 p-3 font-mono text-xs">
{rest.join("\n")}
</pre>
) : null}
</div>
)
}
/**
* What the engine has been doing, as charts over a day and the records behind
* them.
*
* The two charts each drive the list beside them: throughput picks the runs of
* a minute, failures and timing picks that minute's failures.
*/
export function HealthActivity() {
const live = useEngineEvents()
const queryClient = useQueryClient()
const runsAt = useMoment()
const failuresAt = useMoment()
const { data: series } = useQuery(timeseriesQueryOptions())
const { data: runs } = useQuery(runsQueryOptions())
const { data: pinnedRuns } = useQuery(minuteRunsQueryOptions(runsAt.pinned))
const { data: failures } = useQuery(failuresQueryOptions())
const { data: audit } = useQuery(auditQueryOptions())
const { data: dead } = useQuery(deadLetterQueryOptions())
// Something just went wrong on the socket. The row for it is written on the
// collector's next flush, so the refetch waits that out rather than asking
// for a failure the database does not have yet.
//
// The newest event's minute, not the count: the count stops changing once the
// ring is full, and a per-event key would let a flapping node restart the
// timer forever without it ever firing.
const seen = live.length ? Math.floor(live[live.length - 1].ts / 60) : 0
useEffect(() => {
if (!seen) return
const timer = setTimeout(() => {
queryClient.invalidateQueries({ queryKey: healthKeys.events })
queryClient.invalidateQueries({ queryKey: healthKeys.summary })
}, 16000)
return () => clearTimeout(timer)
}, [seen, queryClient])
const points = series ?? []
const at = (
pick: (point: (typeof points)[number]) => number,
): HistoryPoint[] =>
points.map((point) => ({ ts: point.ts, value: pick(point) }))
// A pin is read back from the server, so it reaches a minute the recent list
// is nowhere near deep enough to hold. A hover stays the client-side preview
// it is: scrubbing a day's chart would otherwise be a request per minute the
// pointer rests on.
const shownRuns =
runsAt.pinned !== null
? (pinnedRuns ?? [])
: runsAt.at === null
? (runs ?? []).slice(0, RUNS_SHOWN)
: (runs ?? []).filter((run) => minuteOf(run.started_at) === runsAt.at)
const shownFailures =
failuresAt.at === null
? (failures ?? []).slice(0, FAILURES_SHOWN)
: (failures ?? []).filter((event) => minuteOf(event.ts) === failuresAt.at)
return (
<>
<section className="grid gap-4 lg:grid-cols-2">
<Chart
title="Throughput per minute"
labels={["messages", "executions"]}
plots={[
at((point) => point.messages),
at((point) => point.executions),
]}
moment={runsAt}
/>
<Chart
title="Failures and timing (ms)"
labels={["errors", "avg", "lag"]}
plots={[
at((point) => point.errors),
at((point) => point.avg_ms),
at((point) => point.avg_lag_ms),
]}
moment={failuresAt}
/>
</section>
<section className="grid content-start gap-4 lg:grid-cols-2">
<div className="grid content-start gap-3">
<ListHeader title="Recent runs" moment={runsAt} />
<div
className={cn(
CARD,
// Capped rather than as long as it happens to be: the two lists
// sit side by side, and a filtered one is meant to be scrolled.
"max-h-96 overflow-y-auto",
runsAt.pinned !== null && "border-primary",
)}
data-testid="recent-runs"
>
{shownRuns.length ? (
shownRuns.map((run) => (
<div
key={run.id}
className="flex items-baseline gap-3 border-b border-border py-2 text-sm last:border-0"
>
<span className="min-w-0 flex-1 truncate font-mono">
{run.flow}
</span>
<span className="text-xs text-muted-foreground">
{run.source}
</span>
<span
className={
run.status === "error"
? "text-destructive"
: run.status === "ok"
? "text-status-success"
: "text-muted-foreground"
}
>
{run.status}
</span>
<span className="w-16 text-right text-muted-foreground">
{compact(run.duration_ms)} ms
</span>
<span className="w-16 text-right text-xs text-muted-foreground">
{ago(run.started_at)}
</span>
</div>
))
) : (
<p className="text-sm text-muted-foreground">
{runsAt.at === null
? "No runs recorded yet."
: runsAt.pinned !== null
? "Nothing ran in this minute."
: "No run from this minute is in the recent list."}
</p>
)}
</div>
</div>
<div className="grid content-start gap-3">
<ListHeader title="Failures" moment={failuresAt} />
<div
className={cn(
CARD,
"max-h-96 overflow-y-auto",
failuresAt.pinned !== null && "border-primary",
)}
data-testid="failures"
>
{shownFailures.length ? (
shownFailures.map((event) => (
<Failure key={event.id} event={event} />
))
) : (
<p className="text-sm text-muted-foreground">
{failuresAt.at === null
? "Nothing has failed in the last day."
: "Nothing failed in this minute."}
</p>
)}
</div>
</div>
</section>
{dead?.length ? (
<section className="grid gap-3">
<h2 className={PANEL_SECTION}>Given up on</h2>
<div className={CARD}>
{dead.map((item) => (
<div
key={item.id}
className="flex items-baseline gap-3 border-b border-border py-2 text-sm last:border-0"
>
<span className="min-w-0 flex-1 truncate font-mono">
{item.node}
</span>
<span className="text-muted-foreground">{item.reason}</span>
<span className="text-xs text-muted-foreground">
{ago(item.ts)}
</span>
</div>
))}
</div>
</section>
) : null}
<section className="grid gap-3">
<h2 className={PANEL_SECTION}>Changes</h2>
<div className={CARD}>
{audit?.length ? (
audit.map((event) => (
<div
key={event.id}
className="flex items-baseline gap-3 border-b border-border py-2 text-sm last:border-0"
>
<span className="min-w-0 flex-1 truncate">
{event.actor} {event.detail}
{event.flow ? (
<span className="ml-1 font-mono text-muted-foreground">
{event.flow}
</span>
) : null}
</span>
<span className="shrink-0 text-xs text-muted-foreground">
{ago(event.ts)}
</span>
</div>
))
) : (
<p className="text-sm text-muted-foreground">
Nothing has changed yet.
</p>
)}
</div>
</section>
</>
)
}