Let Postgres fold the rollups, and say when a run list was cut short

/observability/timeseries and /flows read every metric_minute row in the window
and folded them in Python, so the 7d preset pulled a week of rows on each 30 s
poll. date_bin() does the binning now — the row count drops to the slices asked
for, and to flows × 60 for the sparklines. A window of zero hours used to divide
by nothing and answer 500; windows are clamped to an hour at the low end and to
the retention period at the high end, past which there is nothing to find.

/observability/runs returns {data, count} rather than a bare list, so a minute
busier than the 200-row cap says so instead of quietly showing its newest 200.
The count is only queried when the page comes back full, which keeps the poll
from handing back what the fold just saved.

failures_24h leaves the summary — the Home tile counts errors over the selected
window from the rollups, and nothing had read the field since.

Deleting a flow now takes its Run rows and their nodes, metrics and artifacts
with it. This lives in the route rather than in forget_flow because renaming a
flow calls that too, and a rename must keep its history. The observability
rollups stay: they are the record of what ran, and retention already prunes them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uq8mtNb97A7praJLyeEYgs
This commit is contained in:
2026-08-21 10:11:19 +02:00
co-authored by Claude Opus 5
parent 08ccbbac5b
commit d375ea97cc
9 changed files with 291 additions and 86 deletions
@@ -8,7 +8,7 @@ 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, dur } from "@/lib/utils"
import { cn, dur, si } from "@/lib/utils"
import {
ago,
auditQueryOptions,
@@ -138,7 +138,15 @@ function Chart({
}
/** What a list is showing, and the way back to all of it. */
function ListHeader({ title, moment }: { title: string; moment: Moment }) {
function ListHeader({
title,
moment,
note,
}: {
title: string
moment: Moment
note?: string
}) {
return (
<div className="flex min-h-6 flex-wrap items-center gap-2">
<h2 className={PANEL_SECTION}>{title}</h2>
@@ -147,6 +155,9 @@ function ListHeader({ title, moment }: { title: string; moment: Moment }) {
{moment.pinned !== null ? "pinned to" : "showing"} {clock(moment.at)}
</span>
) : null}
{note ? (
<span className="text-xs text-muted-foreground">{note}</span>
) : null}
{moment.pinned !== null ? <ClearPin moment={moment} /> : null}
</div>
)
@@ -249,10 +260,18 @@ export function HealthActivity({ range }: { range: Range }) {
// minute the pointer rests on.
const shownRuns =
runsAt.pinned !== null
? (pinnedRuns ?? [])
? (pinnedRuns?.data ?? [])
: runsAt.at === null
? (runs ?? []).slice(0, RUNS_SHOWN)
: (runs ?? []).filter((run) => minuteOf(run.started_at) === runsAt.at)
? (runs?.data ?? []).slice(0, RUNS_SHOWN)
: (runs?.data ?? []).filter(
(run) => minuteOf(run.started_at) === runsAt.at,
)
// A busy minute writes more runs than one page carries. Without the total
// behind it, a truncated list is indistinguishable from a quiet minute.
const runsNote =
runsAt.pinned !== null && (pinnedRuns?.count ?? 0) > shownRuns.length
? `showing ${shownRuns.length} of ${si(pinnedRuns?.count ?? 0)}`
: undefined
const shownFailures =
failuresAt.pinned !== null
? (pinnedFailures ?? [])
@@ -290,7 +309,7 @@ export function HealthActivity({ range }: { range: Range }) {
<section className="grid content-start gap-4 lg:grid-cols-2">
<div className="grid content-start gap-3">
<ListHeader title="Recent runs" moment={runsAt} />
<ListHeader title="Recent runs" moment={runsAt} note={runsNote} />
<div
className={cn(
CARD,