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
+90 -1
View File
@@ -1,6 +1,8 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useMemo } from "react"
import { OpenAPI, RunsService } from "@/client"
import { flowQueryOptions } from "@/components/Flow/queries"
import { apiToken } from "@/lib/portal"
/**
@@ -147,6 +149,93 @@ export async function downloadArtifact(digest: string, name: string) {
URL.revokeObjectURL(url)
}
/**
* What a flow declares it can be given, by input name.
*
* A batch flow's inputs *are* its parameters — a run supplies values for the
* ones it names and takes the flow's own for the rest — so this is what turns
* `run.params` from "what was passed" into "what the run actually ran with".
*
* The declarations are the flow's *current* ones, while a run carries the
* `flow_version` it was submitted against. An input added since is shown on an
* older run as a default it never actually received.
*/
export function useFlowInputs(flow: string | undefined) {
const { data } = useQuery({
...flowQueryOptions(flow ?? ""),
enabled: Boolean(flow),
})
const inputs = data?.definition.inputs
return useMemo(
() =>
new Map<string, unknown>(
(inputs ?? [])
.filter((one) => Boolean(one.spec.name))
.map((one) => [one.spec.name ?? "", one.initial ?? null]),
),
[inputs],
)
}
/**
* Save the current selection as a file.
*
* The same trip `downloadArtifact` makes and for the same reason — the export
* routes take a bearer token, which an anchor cannot carry. Not the generated
* SDK either: it parses every body as JSON, and these stream csv.
*/
async function exportAs(what: "runs" | "metrics", query: URLSearchParams) {
const token = apiToken()
const answer = await fetch(
`${OpenAPI.BASE}/api/v1/runs/export/${what}?${query}`,
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
)
if (!answer.ok) throw new Error(`Could not export the ${what}`)
const url = URL.createObjectURL(await answer.blob())
const link = document.createElement("a")
link.href = url
link.download = `${what}.${query.get("format") ?? "csv"}`
link.click()
URL.revokeObjectURL(url)
}
/** The runs themselves: one row each, with the parameters that varied. */
export const exportRuns = (query: URLSearchParams) => exportAs("runs", query)
/** Every recorded number of the selection, one row per point. */
export const exportMetrics = (query: URLSearchParams) =>
exportAs("metrics", query)
/** How long a cancelled run is given to actually stop before delete gives up. */
const SETTLE_TRIES = 30
const SETTLE_WAIT_MS = 500
/**
* Delete a run, cancelling it first if it is still going.
*
* The route refuses a live run rather than racing its driver, so the two steps
* are the caller's to sequence. The wait is bounded and throws when it runs
* out, which is what puts a stuck run in the partial-success toast by name
* instead of hanging the button.
*
* ponytail: polling, because nothing pushes a run's status to a caller that is
* not rendering it. The socket already carries run_finished if this ever needs
* to be immediate.
*/
export async function cancelThenDelete(runId: string) {
const run = await RunsService.readRun({ runId })
if (isLive(run.status)) {
await RunsService.cancelRun({ runId })
let settled = false
for (let tries = 0; tries < SETTLE_TRIES && !settled; tries++) {
await new Promise((wake) => setTimeout(wake, SETTLE_WAIT_MS))
settled = !isLive((await RunsService.readRun({ runId })).status)
}
if (!settled) throw new Error(`${shortId(runId)} did not stop`)
}
await RunsService.deleteRun({ runId })
}
/**
* Why a finished run can have nothing to draw.
*