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
+54 -2
View File
@@ -8,20 +8,22 @@ or to listen on the flow socket, which carries its start and finish.
import csv
import io
import json
import time
from collections.abc import Iterator
from datetime import UTC, datetime
from itertools import groupby
from typing import Any, Literal
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field, model_validator
from sqlalchemy import func
from sqlalchemy import delete, func
from sqlalchemy import select as sa_select
from sqlmodel import Session, col, select
from fluksio.api.deps import CurrentUser, SessionDep, get_current_user
from fluksio.flow.events import event_bus
from fluksio.flow.messages import requalify
from fluksio.flow.runs import RunRejected, RunService, new_run_id
from fluksio.flow.store import FlowNotFound
@@ -613,6 +615,56 @@ async def cancel_run(run_id: str, request: Request, session: SessionDep) -> Any:
return run
@router.delete("/{run_id}", status_code=204)
def delete_run(run_id: str, session: SessionDep, user: CurrentUser) -> Response:
"""Forget a run and everything hanging off it.
The same four statements ``_forget_runs`` uses when a flow goes: the run
tables carry a plain string ``run_id`` and no foreign key, so nothing
cascades on its own. ``flow_run``, ``metric_minute`` and ``engine_event``
stay — they are the observability record and are pruned on their own window.
A live run is refused rather than raced: the driver writes its nodes back
when it finishes, and those rows would arrive for a run that no longer
exists. Cancel it first.
Two things this costs, both deliberate. ``RunNode.outputs`` *is* the stage
cache, so a later run loses hits this one would have served. And a node
restored from this run points here through ``cached_from`` — ``_series``
already reads a missing source as an empty curve, which is what ``NO_CURVE``
explains on the screen. The artifact bytes need no help: ``sweep_artifacts``
keeps whatever a ``run_artifact`` row or a live message still names, so
dropping the rows is enough and the hourly sweep reclaims the blobs.
"""
run = session.get(Run, run_id)
if run is None:
raise HTTPException(status_code=404, detail="No such run")
if run.status in ("running", "queued"):
raise HTTPException(
status_code=409,
detail=(
f"Run {run_id} is {run.status}. Cancel it, or wait for it to "
"finish, before deleting it."
),
)
flow = run.flow
session.execute(delete(RunNode).where(col(RunNode.run_id) == run_id))
session.execute(delete(RunMetric).where(col(RunMetric.run_id) == run_id))
session.execute(delete(RunArtifact).where(col(RunArtifact.run_id) == run_id))
session.execute(delete(Run).where(col(Run.id) == run_id))
session.commit()
event_bus.publish(
{
"type": "audit",
"action": f"deleted run {run_id}",
"flow": flow,
"user": user.email,
"ts": time.time(),
}
)
return Response(status_code=204)
def _series(session: Session, run_id: str, name: str = "") -> list[RunMetric]:
"""A run's numbers, including the ones a cached node points at.
+93
View File
@@ -432,6 +432,99 @@ def test_overview_is_not_read_as_a_run_id(client, superuser_token_headers):
assert isinstance(answer.json(), list)
# -----------------------------------------------------------------------------
# Deleting a run
#
# The route owns the four statements; what these guard is that it takes the
# children with it and refuses a run the driver is still writing to.
# -----------------------------------------------------------------------------
@pytest.fixture
def deletable_run():
"""One finished run with a node, a number and an artifact row hanging off it."""
run_id = "del-1"
with Session(db_engine) as session:
session.add(
Run(id=run_id, flow="deleted", status="ok", created_at=datetime.now(UTC))
)
session.add(RunNode(run_id=run_id, node="deleted.a", status="ok"))
session.add(RunMetric(run_id=run_id, name="deleted.loss", step=0, value=1.0))
session.add(
RunArtifact(
run_id=run_id,
name="deleted.out",
filename="out.bin",
node="a",
digest="d" * 64,
size=7,
)
)
session.commit()
yield run_id
with Session(db_engine) as session:
run = session.get(Run, run_id)
if run is not None:
session.delete(run)
session.commit()
def test_deleting_a_run_takes_its_children_with_it(
client, superuser_token_headers, deletable_run
):
"""No foreign key cascades here, so the route has to do it itself."""
answer = client.delete(
f"{settings.API_V1_STR}/runs/{deletable_run}", headers=superuser_token_headers
)
assert answer.status_code == 204
with Session(db_engine) as session:
assert session.get(Run, deletable_run) is None
for table in (RunNode, RunMetric, RunArtifact):
left = session.exec(
select(table).where(col(table.run_id) == deletable_run)
).all()
assert left == [], f"{table.__name__} rows outlived the run"
def test_deleting_a_run_that_is_not_there_is_a_404(client, superuser_token_headers):
answer = client.delete(
f"{settings.API_V1_STR}/runs/nope-1", headers=superuser_token_headers
)
assert answer.status_code == 404
def test_a_running_run_is_refused_rather_than_raced(client, superuser_token_headers):
"""The driver writes its nodes back at the end; they would have no run."""
run_id = "del-live"
with Session(db_engine) as session:
session.add(
Run(
id=run_id,
flow="deleted",
status="running",
created_at=datetime.now(UTC),
)
)
session.commit()
try:
answer = client.delete(
f"{settings.API_V1_STR}/runs/{run_id}", headers=superuser_token_headers
)
assert answer.status_code == 409
assert "Cancel it" in answer.json()["detail"]
with Session(db_engine) as session:
assert session.get(Run, run_id) is not None
finally:
with Session(db_engine) as session:
run = session.get(Run, run_id)
if run is not None:
session.delete(run)
session.commit()
# -----------------------------------------------------------------------------
# A cached node's curve
#
+1 -1
View File
@@ -81,7 +81,7 @@ for (const theme of ["light", "dark"]) {
// Home's sections fetch independently, so networkidle can fall between them
// and photograph the skeletons. The flow table is the last of them to land.
await page
.getByText(/Flow activity/i)
.getByText(/activity over the last/i)
.first()
.waitFor({ timeout: 15000 })
await page.waitForTimeout(1500)
+1 -1
View File
@@ -48,7 +48,7 @@ export const OpenAPI: OpenAPIConfig = {
PASSWORD: undefined,
TOKEN: undefined,
USERNAME: undefined,
VERSION: '0.1.4',
VERSION: '0.1.4+dev',
WITH_CREDENTIALS: false,
interceptors: {
request: new Interceptors(),
File diff suppressed because one or more lines are too long
+6
View File
@@ -1825,6 +1825,12 @@ export type RunsReadRunData = {
export type RunsReadRunResponse = (RunDetail);
export type RunsDeleteRunData = {
runId: string;
};
export type RunsDeleteRunResponse = (void);
export type RunsCancelRunData = {
runId: string;
};
@@ -153,10 +153,13 @@ function Footprint({ dashboard }: { dashboard: DashboardDef_Output }) {
function Tile({
dashboard,
preview,
className,
}: {
dashboard: DashboardSummary
/** Read the document for a footprint, or settle for the name alone. */
preview: boolean
/** What the layout needs of it — a width, in the scrolling strip. */
className?: string
}) {
// The working copy, which is what the list itself is a summary of, so the
// preview shows what an editor would open rather than the last publish.
@@ -170,7 +173,10 @@ function Tile({
to="/dashboards/$name"
params={{ name: dashboard.name }}
data-testid="home-dashboard-tile"
className="grid content-start gap-2 rounded-lg border border-border p-2 transition-colors hover:bg-accent/50"
className={cn(
"grid content-start gap-2 rounded-lg border border-border p-2 transition-colors hover:bg-accent/50",
className,
)}
>
{preview && isPending ? (
<Skeleton className="aspect-video rounded-sm" />
@@ -213,19 +219,37 @@ export function byRecency<
)
}
/** The dashboards, as the shapes they are, beside the flows on the home view. */
/**
* The dashboards, as the shapes they are.
*
* Two layouts, because it is read two ways: a column of pairs where it sits
* beside something else, and one wide strip where it has the page to itself.
* A tile is `content-start` around an `aspect-video` footprint and so has no
* width of its own — the strip has to give it one.
*/
export function DashboardMosaic({
dashboards,
isPending,
row = false,
}: {
dashboards: DashboardSummary[]
isPending: boolean
/** One scrolling strip instead of a two-column grid. */
row?: boolean
}) {
const container = row
? "flex snap-x snap-mandatory gap-3 overflow-x-auto p-3"
: "grid gap-3 p-3 sm:grid-cols-2"
const tile = row ? "w-56 shrink-0 snap-start" : ""
if (isPending) {
return (
<div className="grid gap-3 p-3 sm:grid-cols-2">
<div className={container}>
{Array.from({ length: 2 }).map((_, index) => (
<Skeleton key={index} className="aspect-[4/3] rounded-lg" />
<Skeleton
key={index}
className={cn("aspect-[4/3] rounded-lg", tile)}
/>
))}
</div>
)
@@ -248,12 +272,13 @@ export function DashboardMosaic({
}
return (
<div className="grid gap-3 p-3 sm:grid-cols-2">
<div className={container}>
{dashboards.map((dashboard, index) => (
<Tile
key={dashboard.name}
dashboard={dashboard}
preview={index < PREVIEWS}
className={tile}
/>
))}
</div>
@@ -279,6 +279,7 @@ export function ConfirmDelete({
names,
noun,
pending,
description,
onConfirm,
}: {
open: boolean
@@ -286,6 +287,8 @@ export function ConfirmDelete({
names: string[]
noun: string
pending: boolean
/** What is actually lost, when the git-backed answer below is not it. */
description?: string
onConfirm: () => void
}) {
return (
@@ -298,9 +301,14 @@ export function ConfirmDelete({
: `Delete these ${names.length} ${noun}s?`}
</DialogTitle>
<DialogDescription>
{names.length === 1 ? "It goes" : "They go"} from the installation
at once. The store's git history keeps what was there, but nothing
in the app brings {names.length === 1 ? "it" : "them"} back.
{description ?? (
<>
{names.length === 1 ? "It goes" : "They go"} from the
installation at once. The store's git history keeps what was
there, but nothing in the app brings{" "}
{names.length === 1 ? "it" : "them"} back.
</>
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
+83 -10
View File
@@ -1,4 +1,4 @@
import { useEffect, useLayoutEffect, useRef } from "react"
import { useEffect, useLayoutEffect, useRef, useState } from "react"
import uPlot from "uplot"
import "uplot/dist/uPlot.min.css"
@@ -146,15 +146,23 @@ export const CURSOR: uPlot.Cursor = {
mousemove: binder(false),
} as unknown as uPlot.Cursor.Bind,
drag: {
// No drag-to-zoom. `setData` re-ranges the scales from the data and runs
// on every render, so a dragged range was erased by the next reading — all
// it ever did here was flash a selection box over a live chart.
x: false,
// Drag across the plot to read a stretch of it closer. `setScale` stays
// off because the chart owns its x range itself: `setData` runs on every
// render and would re-range the scales from the data, so a held window is
// what tells it to leave them alone. Without that this only ever flashed a
// selection box over a live chart, which is why it used to be off.
x: true,
y: false,
setScale: false,
},
}
/** Below this a drag is a click that moved, not a window. In pixels. */
const DRAG_FLOOR = 4
/** How close two taps have to be to count as one gesture. */
const DOUBLE_TAP_MS = 300
/** Room for the axis ticks; uPlot measures the rest of the box itself. */
const PADDING: uPlot.Padding = [10, 12, 0, 0]
@@ -278,6 +286,15 @@ export function UplotChart({
const host = useRef<HTMLDivElement>(null)
const legend = useRef<HTMLDivElement>(null)
const chart = useRef<uPlot | null>(null)
// A dragged x window, held so the next reading does not wash it away. The
// ref is what the data effect reads; the state is only what draws the way
// back out, and the two are set together.
const zoomed = useRef(false)
const [showReset, setShowReset] = useState(false)
const clearZoom = () => {
zoomed.current = false
setShowReset(false)
}
// The chart outlives a render, so its handlers are read through a ref
// rather than baked into the config it was built with.
const report = useRef({ onCursor, onSelect })
@@ -302,6 +319,9 @@ export function UplotChart({
useLayoutEffect(() => {
const element = host.current
if (!element || labels.length === 0 || !ready) return
// A different set of series is a different picture; the window that was
// held over the old one means nothing on it.
clearZoom()
const axis = {
stroke: () => token("--muted-foreground", element),
@@ -312,6 +332,8 @@ export function UplotChart({
/** The x value the page was last told about, so a move within one bucket
* does not re-render it. */
let told: number | null = null
/** Whether the click about to arrive is the end of a drag. */
let dragging = false
// Resolved once for the whole chart: how many lines there are is part of
// which slots they take, when nothing named them.
const slots = slotsFor(labels.length, palette)
@@ -343,11 +365,47 @@ export function UplotChart({
report.current.onCursor?.(ts)
},
],
setSelect: [
(self) => {
// uPlot fires this for a plain click too. A few pixels is a
// slip of the hand, not a window anybody meant to ask for.
if (self.select.width <= DRAG_FLOOR) return
const from = self.posToVal(self.select.left, "x")
const to = self.posToVal(
self.select.left + self.select.width,
"x",
)
// The box has done its job; the scale is what holds the window
// from here. `false` so this hook does not fire on itself.
self.setSelect({ left: 0, width: 0, top: 0, height: 0 }, false)
self.setScale("x", { min: from, max: to })
dragging = true
zoomed.current = true
setShowReset(true)
},
],
ready: [
(self) => {
self.over.addEventListener("click", () =>
report.current.onSelect?.(under(self)),
)
self.over.addEventListener("click", () => {
// The mouseup that ended a drag arrives here as a click as
// well; pinning a moment is not what it was asking for.
if (dragging) {
dragging = false
return
}
report.current.onSelect?.(under(self))
})
self.over.addEventListener("dblclick", clearZoom)
// ponytail: a touch screen gets no dblclick from every browser,
// and uPlot has no dbltap of its own. Two taps in a moment is
// the whole of the gesture.
let lastTap = 0
self.over.addEventListener("pointerup", (event) => {
if (event.pointerType !== "touch") return
const now = event.timeStamp
if (now - lastTap < DOUBLE_TAP_MS) clearZoom()
lastTap = now
})
},
],
},
@@ -426,10 +484,14 @@ export function UplotChart({
// the blind spot a point count has: once a rolling window is full, a refetch
// carrying different readings leaves the count where it was and never fires.
// Safe to run this often because `setData` is idempotent and re-ranges the
// scales *from the data* — the opposite of the `redraw(false)` below.
// scales *from the data* — the opposite of the `redraw(false)` below. That
// re-ranging is exactly what a dragged window has to be spared, so while one
// is held the data goes in and the scales stay where they were put. Clearing
// the window renders, which brings the next pass through here with the reset
// back on: that is what puts the whole range back.
useEffect(() => {
if (!chart.current || plots.length === 0) return
chart.current.setData(table(plots))
chart.current.setData(table(plots), !zoomed.current)
})
// The canvas cannot follow a CSS variable, so a theme swap is a redraw. The
@@ -448,6 +510,17 @@ export function UplotChart({
<div className="flex min-h-0 flex-1 flex-col">
<div className="relative min-h-0 flex-1">
<div ref={host} className="absolute inset-0" />
{/* Double-clicking does the same thing, but nothing says so. */}
{showReset ? (
<button
type="button"
onClick={clearZoom}
data-testid="chart-reset-zoom"
className="absolute top-1 right-1 z-10 rounded-md border border-border bg-card/90 px-1.5 py-0.5 text-muted-foreground text-xs hover:text-foreground"
>
Reset zoom
</button>
) : null}
{points === 0 ? (
pending ? (
<Skeleton className="absolute inset-0" />
@@ -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,7 +64,6 @@ 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>
@@ -177,88 +144,5 @@ export function HealthOverview({
/>
</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>
</>
)
}
+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}
/>
)
}
+49 -7
View File
@@ -30,6 +30,7 @@ import {
shortCommit,
shortId,
useCancelRun,
useFlowInputs,
} from "./queries"
import { NodeStatusBadge, RunStatusBadge, statusReason } from "./RunStatus"
@@ -39,6 +40,7 @@ export function RunDetail({ id }: { id: string }) {
const { data: run, isPending } = useQuery(runQueryOptions(id))
const cancel = useCancelRun()
const names = useMetricNames(id)
const declared = useFlowInputs(run?.flow)
const [metric, setMetric] = useState("")
if (isPending || !run) return <Skeleton className="h-96 w-full rounded-lg" />
@@ -52,6 +54,20 @@ export function RunDetail({ id }: { id: string }) {
// Said once, where it applies.
const cached = nodes.some((node) => node.status === "cached")
// Declared order first, so the panel reads the way the flow does; anything
// the run carries that the flow no longer declares still shows, since it is
// what the run was actually given.
const fed = [
...[...declared.keys()].map((name) => ({
name,
value: name in run.params ? run.params[name] : declared.get(name),
fromFlow: !(name in run.params),
})),
...Object.keys(run.params)
.filter((name) => !declared.has(name))
.map((name) => ({ name, value: run.params[name], fromFlow: false })),
]
return (
<div className="flex flex-col gap-4">
<header className="flex flex-wrap items-center gap-3">
@@ -113,16 +129,26 @@ export function RunDetail({ id }: { id: string }) {
</Fact>
</section>
{/* What the run actually ran with, which is not the same list as what it
was passed: a flow's inputs *are* its parameters, and one left alone
took the flow's own value. Both are drawn, and which is which is
marked — reading a number off a chart is worth nothing if the other
half of the setting is invisible. */}
<section className={cn(CARD, "flex flex-col gap-2")}>
<h2 className="font-medium text-sm">Parameters</h2>
{Object.keys(run.params).length === 0 ? (
<h2 className="font-medium text-sm">Inputs</h2>
{fed.length === 0 ? (
<p className="text-muted-foreground text-sm">
This run took its flow's own defaults.
This flow declares no inputs, so there was nothing to choose.
</p>
) : (
<dl className="grid gap-x-6 gap-y-1 sm:grid-cols-2 lg:grid-cols-3">
{Object.entries(run.params).map(([key, value]) => (
<Entry key={key} name={key} value={value} />
{fed.map(({ name, value, fromFlow }) => (
<Entry
key={name}
name={name}
value={value}
note={fromFlow ? "default" : undefined}
/>
))}
</dl>
)}
@@ -167,7 +193,16 @@ export function RunDetail({ id }: { id: string }) {
* record like any other, and serialising it onto one truncated line answers
* nothing.
*/
function Entry({ name, value }: { name: string; value: unknown }) {
function Entry({
name,
value,
note,
}: {
name: string
value: unknown
/** Where the value came from, when it was not this run. */
note?: string
}) {
const structured = value !== null && typeof value === "object"
return (
<div
@@ -176,7 +211,14 @@ function Entry({ name, value }: { name: string; value: unknown }) {
structured ? "flex flex-col" : "flex justify-between",
)}
>
<dt className={LABEL}>{name}</dt>
<dt className={cn(LABEL, "flex items-baseline gap-1.5")}>
{name}
{note && (
<span className="rounded-full border border-border px-1.5 text-[10px]">
{note}
</span>
)}
</dt>
<dd
className={cn(
"min-w-0",
+109 -6
View File
@@ -1,13 +1,27 @@
import { useInfiniteQuery, useQuery } from "@tanstack/react-query"
import {
useInfiniteQuery,
useQuery,
useQueryClient,
} from "@tanstack/react-query"
import { Link } from "@tanstack/react-router"
import { FlaskConical, X } from "lucide-react"
import { useRef } from "react"
import { Download, FlaskConical, Trash2, X } from "lucide-react"
import { useRef, useState } from "react"
import type { fluksio__api__routes__runs__RunRow as RunRow } from "@/client"
import {
ConfirmDelete,
useDeleteSelected,
} from "@/components/Common/OverviewToolbar"
import { MAX_SERIES } from "@/components/Common/UplotChart"
import { ValuePreview } from "@/components/Flow/ValuePreview"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Select,
SelectContent,
@@ -34,15 +48,20 @@ import {
import { OpenInDashboard } from "./OpenInDashboard"
import {
CARD,
cancelThenDelete,
exportMetrics,
exportRuns,
LIST_CAP,
MAX_SELECTION,
paramText,
runKeys,
runOverviewQueryOptions,
runsInfiniteQueryOptions,
STATUSES,
STEP_AXIS,
shortCommit,
shortId,
useFlowInputs,
varyingKeys,
} from "./queries"
import { RunStatusBadge } from "./RunStatus"
@@ -111,6 +130,24 @@ export function RunsScreen({
put(already ? [] : shown)
}
// What a download asks for: the picks when there are any, and otherwise
// whatever the screen is currently filtered to. Either way it is what the
// person looking at it would have called "these runs".
const exportQuery = (format: string) =>
new URLSearchParams(
selected.length
? { ids: selected.join(","), format }
: { ...filters, format },
)
const client = useQueryClient()
const [confirming, setConfirming] = useState(false)
const remove = useDeleteSelected(cancelThenDelete, "run", () => {
setConfirming(false)
update({ compare: undefined })
client.invalidateQueries({ queryKey: runKeys.all })
})
return (
<div className="flex flex-col gap-6 lg:flex-row">
<FlowRail
@@ -157,8 +194,55 @@ export function RunsScreen({
<X className="ml-1 size-3" />
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8"
data-testid="export-runs"
>
<Download className="size-3.5" />
Export
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => exportRuns(exportQuery("csv"))}>
Runs table (.csv)
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => exportMetrics(exportQuery("csv"))}
>
Metrics (.csv)
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{selected.length > 0 && (
<Button
variant="destructive"
size="sm"
className="h-8"
onClick={() => setConfirming(true)}
aria-label={`Delete ${selected.length} runs`}
data-testid="delete-selected"
>
<Trash2 className="size-3.5" />
</Button>
)}
</header>
<ConfirmDelete
open={confirming}
onOpenChange={setConfirming}
names={selected.map(shortId)}
noun="run"
pending={remove.isPending}
description="A run still going is cancelled first. What it recorded — its numbers, its nodes and the outputs other runs reuse from the cache — goes with it, and nothing brings it back."
onConfirm={() => remove.mutate(selected)}
/>
{isPending ? (
<Skeleton className="h-64 w-full rounded-lg" />
) : (
@@ -417,8 +501,14 @@ function RunsTable({
</div>
</TableCell>
{showFlow && (
<TableCell className="text-muted-foreground text-sm">
<TableCell className="text-sm">
<Link
to="/flows/$flowName"
params={{ flowName: run.flow }}
className="text-muted-foreground hover:underline"
>
{run.flow}
</Link>
</TableCell>
)}
<TableCell>
@@ -576,6 +666,12 @@ function Compare({
* grows downwards, where there is somewhere to grow.
*/
function ParamDiff({ rows }: { rows: RunRow[] }) {
// What the flow declares, so a run that was never given a parameter reads as
// the value it actually ran with rather than as a blank.
// ponytail: only when the picks share one flow — spanning flows would mean a
// declaration lookup per flow, and a sweep comparison never does.
const one = rows.every((run) => run.flow === rows[0].flow)
const declared = useFlowInputs(one ? rows[0].flow : undefined)
const varying = varyingKeys(rows)
// Not a parameter, but it is part of what produced the number, and in a
// sweep it is often the only thing that moved.
@@ -640,9 +736,16 @@ function ParamDiff({ rows }: { rows: RunRow[] }) {
<TableCell key={key}>
{key in run.params ? (
<ParamValue value={run.params[key]} />
) : declared.has(key) ? (
// Not "empty": this run was never given the parameter, so
// what it ran with is what the flow declares.
<span className="flex items-baseline gap-1.5">
<ParamValue value={declared.get(key)} />
<span className="rounded-full border border-border px-1.5 text-[10px] text-muted-foreground">
default
</span>
</span>
) : (
// Not "empty": this run was never given the parameter, and
// took whatever the flow declares as its default.
<span className="text-muted-foreground text-xs">unset</span>
)}
</TableCell>
+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.
*
+24 -125
View File
@@ -1,22 +1,17 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { createFileRoute, Link } from "@tanstack/react-router"
import { AlertCircle, Workflow } from "lucide-react"
import { useQuery } from "@tanstack/react-query"
import { createFileRoute } from "@tanstack/react-router"
import { useState } from "react"
import { type FlowSummary, FlowsService } from "@/client"
import { byRecency, DashboardMosaic } from "@/components/Common/DashboardMosaic"
import { DEFAULT_RANGE } from "@/components/Common/RangePicker"
import { dashboardsQueryOptions } from "@/components/Dashboard/queries"
import { BrainView } from "@/components/Flow/BrainView"
import { flowKeys, flowsQueryOptions } from "@/components/Flow/queries"
import { flowsQueryOptions } from "@/components/Flow/queries"
import { FlowTable } from "@/components/Health/FlowTable"
import { HealthActivity } from "@/components/Health/HealthActivity"
import { HealthOverview } from "@/components/Health/HealthOverview"
import { LiveIndicator } from "@/components/Health/LiveIndicator"
import { Badge } from "@/components/ui/badge"
import { Card } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
import { Switch } from "@/components/ui/switch"
import useCustomToast from "@/hooks/useCustomToast"
export const Route = createFileRoute("/_layout/")({
component: Home,
@@ -29,102 +24,29 @@ export const Route = createFileRoute("/_layout/")({
}),
})
/** Another tab can stop a flow, and the engine can fail one on its own. */
const REFRESH_INTERVAL = 10_000
/**
* How tall the two lists beside each other are allowed to get: about six flow
* rows, and whatever the mosaic fits in the same space. Past it each column
* scrolls on its own rather than pushing the health block off the screen.
*/
const LISTS = "grid max-h-96 grid-rows-[auto_minmax(0,1fr)] gap-2"
/** DESIGN-GUIDELINES.md → Typography, the canonical section header. */
const HEADER =
"text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground"
function FlowRow({ flow }: { flow: FlowSummary }) {
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."),
})
return (
<div
className="flex items-center gap-3 border-b border-border px-5 py-3 last:border-b-0"
data-testid="home-flow-row"
>
<Link
to="/flows/$flowName"
params={{ flowName: flow.name }}
className="min-w-0 flex-1"
>
<p className="truncate font-medium">{flow.title || flow.name}</p>
<p className="truncate text-sm text-muted-foreground">
{flow.node_count === 1 ? "1 node" : `${flow.node_count} nodes`}
{flow.has_draft ? " · unpublished changes" : ""}
</p>
</Link>
{(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>
<Switch
checked={enabled}
disabled={toggle.isPending}
onCheckedChange={(next) => toggle.mutate(next)}
aria-label={`Run ${flow.title || flow.name}`}
data-testid="flow-enabled-switch"
/>
</div>
)
}
/**
* The one overview: what the engine is wired up as, what is running, and how
* it has been doing. The brain and the health screens compose in here rather
* than living at routes of their own.
*
* Top to bottom it is a widening lens: the whole installation as a graph, what
* has been built on it, whether it is well, then flow by flow and finally
* moment by moment.
*/
function Home() {
// The health block's window: one choice, read by the tiles, the flow table,
// the charts and the lists under them.
const [range, setRange] = useState(DEFAULT_RANGE)
const { data, isPending } = useQuery({
...flowsQueryOptions(),
refetchInterval: REFRESH_INTERVAL,
})
// Its own query beside the flows one, so neither list waits for the other.
const boards = useQuery(dashboardsQueryOptions())
// Only to know whether the brain has anything to draw; the table below runs
// its own copy of the same query.
const { data } = useQuery(flowsQueryOptions())
const flows = [...(data?.data ?? [])].sort(byRecency)
const flows = data?.data ?? []
const dashboards = [...(boards.data?.data ?? [])].sort(byRecency)
return (
@@ -138,53 +60,30 @@ function Home() {
<LiveIndicator />
{/* Nothing wired up yet means nothing to draw, and the band would still
hold a screenful of empty space above the flows card. Node count
hold a screenful of empty space above the flows table. Node count
rather than flow count: a flow made a minute ago has none. */}
{flows.some((flow) => (flow.node_count ?? 0) > 0) ? <BrainView /> : null}
{/* One grid row holding both: a grid item stretches to the row, so the
two columns come out exactly as tall as each other whatever is in
them — and with one flow and one dashboard that is simply the taller
of the two, which is the floor the cap never goes under. */}
<div className="grid gap-6 [&>*]:min-w-0 lg:grid-cols-2">
<section className={LISTS}>
<h2 className={HEADER}>Flows</h2>
<Card className="gap-0 overflow-y-auto py-0">
{isPending ? (
<div className="grid gap-3 p-5">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-5 w-28" />
</div>
) : flows.length === 0 ? (
<div className="flex flex-col items-center gap-3 px-5 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-sm text-muted-foreground">
Flows you build show up here, with what they are doing.
</p>
<Link to="/flows" className="text-sm font-medium underline">
Go to flows
</Link>
</div>
) : (
flows.map((flow) => <FlowRow key={flow.name} flow={flow} />)
)}
</Card>
</section>
<section className={LISTS}>
{/* One row across the full width rather than a column beside the flows:
a dashboard tile is a picture, and a picture wants to be wide. Past
what fits, the strip scrolls sideways on its own — the page must not,
which is what the `min-w-0` above is holding. */}
<section className="grid gap-2">
<h2 className={HEADER}>Dashboards</h2>
<Card className="gap-0 overflow-y-auto py-0">
{/* `min-w-0`: the strip inside scrolls, but this card is a grid item
whose automatic minimum is its content — without this the tiles
widen it, and the page with it. */}
<Card className="min-w-0 gap-0 py-0">
<DashboardMosaic
dashboards={dashboards}
isPending={boards.isPending}
row
/>
</Card>
</section>
</div>
<HealthOverview range={range} onRangeChange={setRange} />
<FlowTable range={range} />
<HealthActivity range={range} />
</div>
)
+1 -1
View File
@@ -311,7 +311,7 @@ test.afterAll(async ({ browser }) => {
test("home fits the viewport", async ({ page }) => {
await page.goto("/")
await page
.getByText(/Flow activity/i)
.getByText(/activity over the last/i)
.first()
.waitFor({ timeout: 15000 })
await expectFits(page, "home")
+151
View File
@@ -0,0 +1,151 @@
import { expect, test } from "@playwright/test"
import { api, apiPage, deleteAll } from "./utils/api"
/**
* What the runs screen can do to a run, rather than what it can show about one.
*
* The reading half is covered by the run itself being there; these are the two
* things that change something — a delete, and a download — plus the two links
* a run makes to what produced it.
*/
const flowName = `test_runs_${Date.now().toString(36)}`
test.use({ storageState: "playwright/.auth/user.json" })
test.describe.configure({ mode: "serial" })
// Yields a curve so the run has something to draw, which is what the zoom
// below needs; the streaming port is what makes those yields a series.
const TRAIN = `def process(epochs):
for step in range(epochs):
yield {"loss": 1.0 / (step + 1)}
return {"score": epochs * 0.5}
`
test.afterAll(async ({ browser }) => {
await deleteAll(browser, [`/flows/${flowName}`])
})
test.beforeAll(async ({ browser }) => {
const page = await apiPage(browser)
await api(page, `/flows/${flowName}`, {
method: "PUT",
data: {
name: flowName,
title: "Runs under test",
// `rate` is declared and never passed, which is what puts a default in
// the Inputs panel below.
inputs: [
{ spec: { name: "epochs", dtype: "int" }, initial: 2 },
{ spec: { name: "rate", dtype: "float" }, initial: 0.5 },
],
nodes: [
{
id: "train",
type: "python",
requires: [{ name: "epochs", dtype: "int" }],
provides: [
{ name: "loss", dtype: "float", stream: true },
{ name: "score", dtype: "float" },
],
},
],
},
})
await api(page, `/flows/${flowName}/nodes/train/source`, {
method: "PUT",
data: { code: TRAIN },
})
const detail = await (await api(page, `/flows/${flowName}`)).json()
await api(page, `/flows/${flowName}/publish`, {
method: "POST",
data: { version: detail.definition.version },
})
for (const epochs of [4, 6]) {
const answer = await api(page, `/runs/flows/${flowName}`, {
method: "POST",
data: { params: { epochs } },
})
expect(answer.ok()).toBeTruthy()
}
await page.close()
})
/** The screen filtered to this flow, once both runs have stopped moving. */
async function openRuns(page: import("@playwright/test").Page) {
await page.goto(`/runs?flow=${flowName}`)
await expect(page.getByTestId("run-row")).toHaveCount(2)
await expect(page.getByText(/queued|running/)).toHaveCount(0, {
timeout: 30_000,
})
}
test("a run names the flow it came from, and links to it", async ({ page }) => {
await page.goto("/runs")
const link = page
.getByTestId("run-row")
.filter({ hasText: flowName })
.first()
.getByRole("link", { name: flowName })
await link.click()
await page.waitForURL(`**/flows/${flowName}`)
})
test("an input the run never passed reads as the flow's own", async ({
page,
}) => {
await openRuns(page)
await page.getByTestId("run-link").first().click()
const inputs = page.locator("section", { hasText: "Inputs" }).last()
// Passed, so no marker.
await expect(inputs).toContainText("epochs")
// Declared and left alone: the value shows, and says where it came from.
await expect(inputs).toContainText("rate")
await expect(inputs).toContainText("0.5")
await expect(inputs.getByText("default").first()).toBeVisible()
})
test("a chart can be dragged into and double-clicked back out of", async ({
page,
}) => {
await openRuns(page)
await page.getByTestId("run-link").first().click()
const plot = page.locator(".u-over").first()
await expect(plot).toBeVisible()
const box = await plot.boundingBox()
if (!box) throw new Error("the chart has no box to drag across")
const y = box.y + box.height / 2
await page.mouse.move(box.x + box.width * 0.3, y)
await page.mouse.down()
await page.mouse.move(box.x + box.width * 0.7, y, { steps: 8 })
await page.mouse.up()
const reset = page.getByTestId("chart-reset-zoom")
await expect(reset).toBeVisible()
await plot.dblclick()
await expect(reset).toBeHidden()
})
test("the export button downloads the selection", async ({ page }) => {
await openRuns(page)
await page.getByTestId("export-runs").click()
const download = page.waitForEvent("download")
await page.getByRole("menuitem", { name: /runs table/i }).click()
expect((await download).suggestedFilename()).toBe("runs.csv")
})
test("picked runs can be deleted", async ({ page }) => {
await openRuns(page)
for (const box of await page.getByTestId("run-select").all())
await box.click()
await page.getByTestId("delete-selected").click()
await page.getByTestId("confirm-delete").click()
await expect(page.getByTestId("run-row")).toHaveCount(0, { timeout: 30_000 })
})