Act on a run, and read Home top-down
Docs / docs (push) Successful in 23s
Playwright Tests / test-playwright (1, 2) (push) Successful in 3m18s
Playwright Tests / test-playwright (2, 2) (push) Successful in 1m49s
pre-commit / pre-commit (push) Failing after 2m5s
Test Backend / test-backend (push) Successful in 2m39s
Compose Smoke Test / test-compose (push) Successful in 33s
Playwright Tests / merge-reports (push) Successful in 1m11s

Runs: a run can now be deleted (DELETE /runs/{id}, cancelling a live one
first), exported as csv from the screen's own filters, and its flow label
opens the flow. Its "Parameters" panel became "Inputs" and lists every
input the flow declares, marking the ones that took the flow's own value
rather than the run's — the comparison table resolves the same defaults
instead of printing "unset".

Home reads brain, dashboards, health, flows: the mosaic is one full-width
scrolling strip, and the flows list and the flow-activity rollups merged
into a single left-joined table so a flow's state and its numbers sit on
one row.

Charts take a drag to narrow the x window and a double click or tap to
come back out. UplotChart holds the scale and passes resetScales:false
while a window is held, which is what the old comment said made this
impossible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LrWVRguqbk33YzfEeUx5W
This commit is contained in:
2026-08-29 08:37:11 +02:00
co-authored by Claude Opus 5
parent 4215e057d1
commit 7e506b26c0
18 changed files with 1082 additions and 365 deletions
@@ -0,0 +1,224 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { Link } from "@tanstack/react-router"
import { AlertCircle, Workflow } from "lucide-react"
import { type FlowRollup, type FlowSummary, FlowsService } from "@/client"
import { byRecency } from "@/components/Common/DashboardMosaic"
import type { Range } from "@/components/Common/RangePicker"
import { flowKeys, flowsQueryOptions } from "@/components/Flow/queries"
import { PANEL_SECTION } from "@/components/Flow/SidePanel"
import { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton"
import { Switch } from "@/components/ui/switch"
import useCustomToast from "@/hooks/useCustomToast"
import { dur, si } from "@/lib/utils"
import { CARD, flowRollupsQueryOptions } from "./queries"
import { Spark } from "./Spark"
/** Another tab can stop a flow, and the engine can fail one on its own. */
const REFRESH_INTERVAL = 10_000
/** About six rows. Past that the table scrolls rather than the page. */
const HEIGHT = "max-h-96"
/**
* Every flow, with what it has been doing.
*
* One table rather than a list beside a rollup table: they are two halves of
* the same question and were previously read by matching rows up by eye. The
* join is a left one — the rollups only carry flows that ran inside the
* window, and a flow that has never run is still a flow.
*/
export function FlowTable({ range }: { range: Range }) {
const { data, isPending } = useQuery({
...flowsQueryOptions(),
refetchInterval: REFRESH_INTERVAL,
})
const { data: rollups } = useQuery(flowRollupsQueryOptions(range))
const flows = [...(data?.data ?? [])].sort(byRecency)
const activity = new Map<string, FlowRollup>(
(rollups ?? []).map((row: FlowRollup) => [row.flow, row]),
)
return (
<section className="grid gap-3">
<div className="flex flex-wrap items-baseline gap-x-3">
<h2 className={PANEL_SECTION}>Flows</h2>
<p className="text-muted-foreground text-xs">
activity over the last {range.label}
</p>
</div>
{isPending ? (
<div className={`${CARD} grid gap-3`}>
<Skeleton className="h-5 w-40" />
<Skeleton className="h-5 w-28" />
</div>
) : flows.length === 0 ? (
<div
className={`${CARD} flex flex-col items-center gap-3 py-10 text-center`}
>
<span className="flex size-12 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Workflow className="size-5" />
</span>
<p className="text-muted-foreground text-sm">
Flows you build show up here, with what they are doing.
</p>
<Link to="/flows" className="font-medium text-sm underline">
Go to flows
</Link>
</div>
) : (
<div className={`${CARD} ${HEIGHT} overflow-auto p-0`}>
{/* The name anchors the left, what it is doing sits beside it, and
the numbers read down their own centre with the trend closing the
row on the right. */}
<table className="w-full text-sm">
<thead className="sticky top-0 z-10 bg-card text-muted-foreground text-xs">
<tr>
<th className="px-4 pt-4 pb-2 text-left font-medium">Flow</th>
<th className="px-3 pt-4 pb-2 text-left font-medium">Status</th>
<th className="px-3 pt-4 pb-2 text-center font-medium">Run</th>
<th className="hidden px-3 pt-4 pb-2 text-center font-medium sm:table-cell">
Executions
</th>
<th className="hidden px-3 pt-4 pb-2 text-center font-medium sm:table-cell">
Errors
</th>
<th className="hidden px-3 pt-4 pb-2 text-center font-medium sm:table-cell">
Avg
</th>
<th className="hidden px-3 pt-4 pb-2 text-center font-medium sm:table-cell">
Lag
</th>
{/* A bounded share rather than all the slack, so the numbers
spread across the middle instead of huddling on the left.
Its 128px floor is more than a phone has to spare, and a
curve that narrow says nothing, so it goes below `sm`. */}
<th className="hidden w-1/4 px-4 pt-4 pb-2 text-right font-medium sm:table-cell sm:min-w-32">
Trend
</th>
</tr>
</thead>
<tbody>
{flows.map((flow) => (
<Row
key={flow.name}
flow={flow}
activity={activity.get(flow.name)}
/>
))}
</tbody>
</table>
</div>
)}
</section>
)
}
function Row({
flow,
activity,
}: {
flow: FlowSummary
/** Absent when the flow did not run inside the selected window. */
activity?: FlowRollup
}) {
const queryClient = useQueryClient()
const { showErrorToast } = useCustomToast()
const enabled = flow.enabled ?? true
const toggle = useMutation({
mutationFn: (next: boolean) =>
next
? FlowsService.startFlow({ name: flow.name })
: FlowsService.stopFlow({ name: flow.name }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: flowKeys.all })
queryClient.invalidateQueries({ queryKey: flowKeys.detail(flow.name) })
},
onError: () => showErrorToast("The flow could not be started or stopped."),
})
// Nothing to report is not zero: a flow that never ran in this window and a
// flow that ran and did nothing are different answers.
const idle = <span className="text-muted-foreground"></span>
return (
<tr className="border-border border-t" data-testid="home-flow-row">
{/* `max-w-0` is what lets a cell truncate at all: without it the table
sizes to the longest name and pushes the page sideways. */}
<td className="max-w-0 px-4 py-2 sm:min-w-40">
<Link
to="/flows/$flowName"
params={{ flowName: flow.name }}
className="block min-w-0"
>
<p className="truncate font-medium">{flow.title || flow.name}</p>
<p className="truncate text-muted-foreground text-xs">
{flow.node_count === 1 ? "1 node" : `${flow.node_count} nodes`}
{flow.has_draft ? " · unpublished changes" : ""}
</p>
</Link>
</td>
<td className="whitespace-nowrap px-3 py-2">
<span className="flex items-center gap-1.5">
{(flow.error_count ?? 0) > 0 ? (
<Badge variant="destructive" className="gap-1">
<AlertCircle />
{flow.error_count}
</Badge>
) : null}
<Badge
variant="outline"
className={
enabled
? flow.paused
? "border-transparent bg-primary/15 text-primary"
: "border-transparent bg-status-success/15 text-status-success"
: "text-muted-foreground"
}
>
{enabled ? (flow.paused ? "Paused" : "Running") : "Stopped"}
</Badge>
</span>
</td>
<td className="px-3 py-2 text-center">
<Switch
checked={enabled}
disabled={toggle.isPending}
onCheckedChange={(next) => toggle.mutate(next)}
aria-label={`Run ${flow.title || flow.name}`}
data-testid="flow-enabled-switch"
/>
</td>
<td className="hidden px-3 py-2 text-center sm:table-cell">
{activity ? si(activity.executions) : idle}
</td>
<td className="hidden px-3 py-2 text-center sm:table-cell">
{!activity ? (
idle
) : activity.errors ? (
<Badge variant="destructive">{si(activity.errors)} failed</Badge>
) : (
<span className="text-muted-foreground">none</span>
)}
</td>
<td className="hidden whitespace-nowrap px-3 py-2 text-center sm:table-cell">
{activity ? dur(activity.avg_ms) : idle}
</td>
<td className="hidden whitespace-nowrap px-3 py-2 text-center sm:table-cell">
{activity ? dur(activity.avg_lag_ms) : idle}
</td>
{/* The dot straddles the curve's right edge, so the cell keeps a little
room for the half that hangs out. */}
<td className="hidden py-2 pr-3 pl-3 text-right sm:table-cell">
{activity ? <Spark counts={activity.spark} /> : idle}
</td>
</tr>
)
}
+78 -194
View File
@@ -1,9 +1,6 @@
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 { runOverviewQueryOptions } from "@/components/Runs/queries"
import { Badge } from "@/components/ui/badge"
@@ -37,40 +34,11 @@ function Tile({
}
/**
* A flow's execution trend, drawn from the 60 slices the rollup carries.
* How the engine is doing: the standing state, as tiles.
*
* 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 server
* holds the slice that is still filling back, so the curve ends on one that is
* all there.
*/
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.
* 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,
@@ -96,169 +64,85 @@ export function HealthOverview({
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>
<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>
{summary?.problems.length ? (
<p className="text-sm text-muted-foreground">
{summary.problems.join(" · ")}
</p>
) : null}
</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"}`}
>
<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"
}
/>
<Tile
label="Flows running"
value={`${summary?.flows.running ?? 0}/${summary?.flows.total ?? 0}`}
// 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="Nodes"
value={String(summary?.nodes.total ?? 0)}
note={
summary?.nodes.error
? `${summary.nodes.error} failed to load`
: "all loaded"
}
label="Runs running"
value={String(running)}
note={queued ? `${queued} queued` : "none queued"}
/>
<Tile
label="Flows running"
value={`${summary?.flows.running ?? 0}/${summary?.flows.total ?? 0}`}
// 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"}
/>
) : 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"
}
/>
{/* Backlog leads: what is waiting is what says the engine is
) : 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"
}
/>
{/* 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>
<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.
Its 128px floor is more than a phone has to spare, and a
curve that narrow says nothing, so it goes below `sm`. */}
<th className="hidden w-1/3 pb-2 pl-3 text-right font-medium sm:table-cell sm:min-w-32">
Trend
</th>
</tr>
</thead>
<tbody>
{(flows ?? []).map((row: FlowRollup) => (
<tr key={row.flow} className="border-t border-border">
<td className="max-w-0 py-2 pr-3 sm:min-w-32">
{/* `max-w-0` is what lets a cell truncate at all: without
it the table sizes to the longest name and pushes the
page sideways. The floor beside it keeps an ordinary
name readable where there is room for one; a phone has
none to spare, so it starts at `sm` like the trend. */}
<Link
to="/flows/$flowName"
params={{ flowName: row.flow }}
className="block truncate 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">
{dur(row.avg_ms)}
</td>
<td className="whitespace-nowrap px-3 py-2 text-center">
{dur(row.avg_lag_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="hidden py-2 pr-2 pl-3 text-right sm:table-cell">
<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>
</>
<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>
)
}
+30
View File
@@ -0,0 +1,30 @@
import type { HistoryPoint } from "@/client"
import { Sparkline } from "@/components/Common/Sparkline"
/**
* 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 server
* holds the slice that is still filling back, so the curve ends on one that is
* all there.
*/
export 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}
/>
)
}