Files
app/frontend/src/components/Health/FlowTable.tsx
T
stroblmeandClaude Opus 5 7e506b26c0
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
Act on a run, and read Home top-down
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
2026-08-29 08:37:11 +02:00

225 lines
8.5 KiB
TypeScript

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>
)
}