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
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user