Add a runs screen: a table, a run in full, and curves side by side

This commit is contained in:
2026-08-25 11:44:13 +02:00
parent 7e422c0047
commit d2951a325e
17 changed files with 1564 additions and 5 deletions
+37 -1
View File
@@ -14,9 +14,11 @@ from fluksio.flow.dashboards import (
DashboardNotFound,
DashboardsPublic,
default_dashboard,
results_dashboard,
results_name,
)
from fluksio.flow.events import event_bus
from fluksio.flow.store import StaleVersion
from fluksio.flow.store import FlowNotFound, StaleVersion
from fluksio.models import Message
router = APIRouter(
@@ -73,6 +75,40 @@ async def create_dashboard(name: str, store: DashboardStoreDep) -> Any:
return await run_in_threadpool(store.write_draft, defn, 0)
@router.post("/from-flow/{flow}", response_model=DashboardDef)
async def generate_results_dashboard(
flow: str,
store: DashboardStoreDep,
controller: FlowControllerDep,
) -> Any:
"""Draw a batch flow's results as a dashboard, from the ports it declares.
Published straight away rather than left as a draft: there is nothing to
review that the flow did not already say, and what makes it useful is
being able to open it against a run immediately. It is an ordinary
dashboard afterwards — editing it is how it stops being generic.
"""
try:
definition = await run_in_threadpool(controller.store.read_flow, flow)
except FlowNotFound:
raise HTTPException(status_code=404, detail=f"No flow named '{flow}'")
if definition.mode != "batch":
raise HTTPException(
status_code=422,
detail=f"'{flow}' is a live flow; a results dashboard is a run's view",
)
name = results_name(flow)
if await run_in_threadpool(store.exists, name):
raise HTTPException(status_code=409, detail=f"'{name}' already exists")
written = await run_in_threadpool(store.write, results_dashboard(definition))
await run_in_threadpool(_apply_history_limits, store, controller)
event_bus.publish(
{"type": "dashboard_changed", "dashboard": name, "ts": time.time()}
)
return written
@router.put("/{name}", response_model=DashboardDef)
async def save_dashboard(
name: str,
+101 -1
View File
@@ -23,7 +23,8 @@ from typing import Any, Literal
from pydantic import BaseModel, Field, field_validator, model_validator
from fluksio.flow.schemas import _validate_name
from fluksio.flow.messages import DType, qualify
from fluksio.flow.schemas import FlowDef, _validate_name
from fluksio.flow.store import FlowStore, StaleVersion
#: Sibling of the shared-node library, and likewise not a flow.
@@ -716,6 +717,105 @@ def default_dashboard(name: str) -> DashboardDef:
)
#: What the generated dashboard is called, for a flow of this name.
def results_name(flow: str) -> str:
return f"{flow}_results"
#: Numbers a stat or a chart can draw.
_NUMERIC = {DType.FLOAT, DType.INT}
def results_dashboard(flow: FlowDef) -> DashboardDef:
"""A results dashboard for a batch flow, from the ports it declares.
A streaming output is a curve and gets a chart; a scalar output is a
number and gets a stat. Nothing here is specific to runs: the widgets bind
to the flow's own message names, which is what makes the same page draw a
run live, draw a finished one when opened in a run's context, and stay an
ordinary dashboard anyone can edit afterwards.
A starting point rather than a finished page — which is the only reason
generating one is worth doing at all.
"""
charts = [
spec
for node in flow.nodes
for spec in node.provides
if spec.stream and spec.dtype in _NUMERIC and spec.name
]
# What a run reports. Declared outputs are unqualified names; an empty list
# means "everything the flow ends up holding", which is not a set this can
# enumerate, so it draws no stats rather than guessing at them.
produced = {
spec.name.rsplit(".", 1)[-1]: spec
for node in flow.nodes
for spec in node.provides
if spec.name
}
stats = [
produced[name]
for name in flow.outputs
if name in produced and not produced[name].stream
]
widgets: list[WidgetDef] = []
for index, spec in enumerate(charts):
widgets.append(
WidgetDef(
id=f"chart_{spec.port or index}",
type="chart",
title=spec.port or spec.name,
layout={"lg": Placement(x=0, y=index * 4, w=8, h=4)},
config={
"series": [
{
"message": qualify(flow.name, spec.name),
"dtype": spec.dtype.value,
"label": spec.port or spec.name,
}
],
"history": {"points": 600},
},
)
)
row = 0
for spec in stats:
if spec.dtype is DType.RECORD:
kind: WidgetType = "notification"
elif spec.dtype in _NUMERIC or spec.dtype is DType.STR:
kind = "stat"
else:
# A list, a series or an artifact has no single reading to show.
continue
widgets.append(
WidgetDef(
id=f"out_{spec.port}",
type=kind,
title=spec.port,
layout={"lg": Placement(x=8, y=row * 2, w=4, h=2)},
config={
"message": qualify(flow.name, spec.name),
"dtype": spec.dtype.value,
},
)
)
row += 1
return DashboardDef(
name=results_name(flow.name),
title=f"{flow.title or flow.name} results",
pages=[
PageDef(
id="main",
title="Results",
sections=[SectionDef(id="main", widgets=widgets)],
)
],
)
__all__ = [
"BAR_ROWS",
"COLOR_DTYPES",
+49
View File
@@ -11,10 +11,12 @@ from fluksio.flow.dashboards import (
SettingDef,
WidgetDef,
default_dashboard,
results_dashboard,
)
from fluksio.flow.messages import DType, MessageSpec
from fluksio.flow.nodes import Node
from fluksio.flow.pipeline import Pipeline
from fluksio.flow.schemas import FlowDef, NodeDef
from fluksio.flow.state import MemoryState
from fluksio.flow.store import FlowStore, StaleVersion
@@ -444,3 +446,50 @@ def test_a_bound_setting_is_drawn_on_the_canvas(store: DashboardStore):
assert binding["requires"] == ["home.theme"]
assert not binding["provides"]
assert store.bindings_for("other") == []
def training_flow() -> FlowDef:
"""A batch flow shaped like an experiment: a curve and two results."""
return FlowDef(
name="study",
mode="batch",
outputs=["accuracy", "report"],
nodes=[
NodeDef(
id="fit",
provides=[
MessageSpec(name="loss", dtype=DType.FLOAT, stream=True),
MessageSpec(name="accuracy", dtype=DType.FLOAT),
MessageSpec(name="report", dtype=DType.RECORD),
MessageSpec(name="weights", dtype=DType.ARTIFACT),
],
)
],
)
def test_a_generated_dashboard_charts_the_curves_and_states_the_results():
"""The ports are the whole specification; nothing else is guessed."""
defn = results_dashboard(training_flow())
kinds = [(w.type, w.config.get("message")) for w in defn.widgets]
assert [w.type for w in defn.widgets] == ["chart", "stat", "notification"]
assert defn.widgets[0].config["series"][0]["message"] == "study.loss"
assert ("stat", "study.accuracy") in kinds
assert ("notification", "study.report") in kinds
def test_an_artifact_output_gets_no_widget():
"""A checkpoint has no single reading to draw."""
defn = results_dashboard(
training_flow().model_copy(update={"outputs": ["weights"]})
)
assert [w.type for w in defn.widgets] == ["chart"]
def test_a_flow_declaring_no_outputs_draws_no_stats():
"""Empty outputs means "everything", which is not a set to enumerate."""
defn = results_dashboard(training_flow().model_copy(update={"outputs": []}))
assert [w.type for w in defn.widgets] == ["chart"]
+27 -1
View File
@@ -3,7 +3,7 @@
import type { CancelablePromise } from './core/CancelablePromise';
import { OpenAPI } from './core/OpenAPI';
import { request as __request } from './core/request';
import type { AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsTestChannelData, AlertsTestChannelResponse, ArtifactsPutArtifactData, ArtifactsPutArtifactResponse, ArtifactsGetArtifactData, ArtifactsGetArtifactResponse, CloudReadStatusResponse, CloudEnrollData, CloudEnrollResponse, CloudAddRemoteUserData, CloudAddRemoteUserResponse, CloudDisconnectResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsPublishDashboardData, DashboardsPublishDashboardResponse, DashboardsDiscardDashboardDraftData, DashboardsDiscardDashboardDraftResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadGraphResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsStepFlowData, FlowsStepFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsCancelNodeData, FlowsCancelNodeResponse, FlowsAcknowledgeNodeErrorData, FlowsAcknowledgeNodeErrorResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, ModulesRefreshModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, ObservabilityReadSummaryResponse, ObservabilityReadTimeseriesData, ObservabilityReadTimeseriesResponse, ObservabilityReadFlowRollupsData, ObservabilityReadFlowRollupsResponse, ObservabilityReadRunsData, ObservabilityReadRunsResponse, ObservabilityReadEventsData, ObservabilityReadEventsResponse, ObservabilityReadDeadLettersData, ObservabilityReadDeadLettersResponse, PanelsReadPanelsResponse, PanelsSavePanelsData, PanelsSavePanelsResponse, PanelsStartPairingResponse, PanelsPollPairingData, PanelsPollPairingResponse, PanelsPendingDeviceData, PanelsPendingDeviceResponse, PanelsApprovePairingData, PanelsApprovePairingResponse, PanelsUnpairPanelData, PanelsUnpairPanelResponse, PanelsReadPanelData, PanelsReadPanelResponse, PrivateCreateUserData, PrivateCreateUserResponse, RunsCreateRunData, RunsCreateRunResponse, RunsCreateSweepData, RunsCreateSweepResponse, RunsReadRunsData, RunsReadRunsResponse, RunsReadOverviewResponse, RunsReadRunData, RunsReadRunResponse, RunsCancelRunData, RunsCancelRunResponse, RunsReadMetricsData, RunsReadMetricsResponse, RunsCompareMetricData, RunsCompareMetricResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsHealthResponse, WorkersReadWorkersResponse, WorkersIssueTokenData, WorkersIssueTokenResponse, WorkersReadRuntimeResponse } from './types.gen';
import type { AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsTestChannelData, AlertsTestChannelResponse, ArtifactsPutArtifactData, ArtifactsPutArtifactResponse, ArtifactsGetArtifactData, ArtifactsGetArtifactResponse, CloudReadStatusResponse, CloudEnrollData, CloudEnrollResponse, CloudAddRemoteUserData, CloudAddRemoteUserResponse, CloudDisconnectResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsGenerateResultsDashboardData, DashboardsGenerateResultsDashboardResponse, DashboardsPublishDashboardData, DashboardsPublishDashboardResponse, DashboardsDiscardDashboardDraftData, DashboardsDiscardDashboardDraftResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadGraphResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsStepFlowData, FlowsStepFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsCancelNodeData, FlowsCancelNodeResponse, FlowsAcknowledgeNodeErrorData, FlowsAcknowledgeNodeErrorResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, ModulesRefreshModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, ObservabilityReadSummaryResponse, ObservabilityReadTimeseriesData, ObservabilityReadTimeseriesResponse, ObservabilityReadFlowRollupsData, ObservabilityReadFlowRollupsResponse, ObservabilityReadRunsData, ObservabilityReadRunsResponse, ObservabilityReadEventsData, ObservabilityReadEventsResponse, ObservabilityReadDeadLettersData, ObservabilityReadDeadLettersResponse, PanelsReadPanelsResponse, PanelsSavePanelsData, PanelsSavePanelsResponse, PanelsStartPairingResponse, PanelsPollPairingData, PanelsPollPairingResponse, PanelsPendingDeviceData, PanelsPendingDeviceResponse, PanelsApprovePairingData, PanelsApprovePairingResponse, PanelsUnpairPanelData, PanelsUnpairPanelResponse, PanelsReadPanelData, PanelsReadPanelResponse, PrivateCreateUserData, PrivateCreateUserResponse, RunsCreateRunData, RunsCreateRunResponse, RunsCreateSweepData, RunsCreateSweepResponse, RunsReadRunsData, RunsReadRunsResponse, RunsReadOverviewResponse, RunsReadRunData, RunsReadRunResponse, RunsCancelRunData, RunsCancelRunResponse, RunsReadMetricsData, RunsReadMetricsResponse, RunsCompareMetricData, RunsCompareMetricResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsHealthResponse, WorkersReadWorkersResponse, WorkersIssueTokenData, WorkersIssueTokenResponse, WorkersReadRuntimeResponse } from './types.gen';
export class AlertsService {
/**
@@ -307,6 +307,32 @@ export class DashboardsService {
});
}
/**
* Generate Results Dashboard
* Draw a batch flow's results as a dashboard, from the ports it declares.
*
* Published straight away rather than left as a draft: there is nothing to
* review that the flow did not already say, and what makes it useful is
* being able to open it against a run immediately. It is an ordinary
* dashboard afterwards — editing it is how it stops being generic.
* @param data The data for the request.
* @param data.flow
* @returns DashboardDef_Output Successful Response
* @throws ApiError
*/
public static generateResultsDashboard(data: DashboardsGenerateResultsDashboardData): CancelablePromise<DashboardsGenerateResultsDashboardResponse> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/dashboards/from-flow/{flow}',
path: {
flow: data.flow
},
errors: {
422: 'Validation Error'
}
});
}
/**
* Publish Dashboard
* Put the unpublished changes on the panels.
+6
View File
@@ -1236,6 +1236,12 @@ export type DashboardsDeleteDashboardData = {
export type DashboardsDeleteDashboardResponse = (Message);
export type DashboardsGenerateResultsDashboardData = {
flow: string;
};
export type DashboardsGenerateResultsDashboardResponse = (DashboardDef_Output);
export type DashboardsPublishDashboardData = {
name: string;
requestBody: fluksio__api__routes__dashboards__PublishRequest;
@@ -236,6 +236,7 @@ export function UplotChart({
yLabel,
palette,
smooth = false,
xTime = true,
onCursor,
onSelect,
}: {
@@ -262,6 +263,10 @@ export function UplotChart({
* Monotone rather than plain cubic on purpose: a spline that overshoots
* invents readings between two the sensor actually took. */
smooth?: boolean
/** The x axis reads as time. False when x is a count rather than a moment —
* a run's metric is indexed by step, and drawn as time it would date every
* point to 1970. */
xTime?: boolean
/** The x value under the pointer, and null once it leaves the plot. */
onCursor?: (ts: number | null) => void
/** The x value clicked, or null for a click that landed on no point. */
@@ -284,7 +289,7 @@ export function UplotChart({
// while a new reading only sets its data. The unit, the fixed range and the
// axis title are part of it — all are baked into the axes at build time —
// and so are the line shape and the palette, which the series close over.
const key = `${labels.join(" ")}|${unit ?? ""}|${yRange?.join(",") ?? ""}|${yLabel ?? ""}|${smooth}|${palette?.join("") ?? ""}`
const key = `${labels.join(" ")}|${unit ?? ""}|${yRange?.join(",") ?? ""}|${yLabel ?? ""}|${smooth}|${palette?.join("") ?? ""}|${xTime}`
// uPlot leaves its axes half-initialised while the scales have no range, and
// a resize in that window (a card still settling, say) draws them anyway and
// throws. Waiting for the first reading avoids the state altogether.
@@ -344,7 +349,7 @@ export function UplotChart({
],
},
scales: {
x: { time: true },
x: { time: xTime },
...(yRange ? { y: { range: yRange } } : {}),
},
axes: [
@@ -4,6 +4,7 @@ import { useEffect } from "react"
import { OpenAPI } from "@/client"
import { dashboardKeys, panelKeys } from "@/components/Dashboard/queries"
import { runKeys } from "@/components/Runs/queries"
import { connectionStore } from "@/lib/connectionStore"
import { apiToken } from "@/lib/portal"
import { type LogLine, liveStore, type ValueSource } from "./liveStore"
@@ -89,6 +90,14 @@ type FlowEvent =
paused?: string[]
}
| { type: "dashboard_changed"; dashboard?: string; ts?: number }
| {
type: "run_started" | "run_finished"
flow: string
run: string
status?: string
group?: string
ts?: number
}
function socketUrl(): string {
const base = String(OpenAPI.BASE || window.location.origin)
@@ -251,6 +260,13 @@ function connect() {
: panelKeys.all,
})
break
case "run_started":
case "run_finished":
// One invalidation covers the lot: the list, the flow counts, the run
// being watched, and any chart drawing a run's curve. A run is not a
// live value, so nothing here goes through the live store.
client?.invalidateQueries({ queryKey: runKeys.all })
break
}
}
@@ -0,0 +1,108 @@
import { useQuery } from "@tanstack/react-query"
import type { HistoryPoint } from "@/client"
import { MAX_SERIES, UplotChart } from "@/components/Common/UplotChart"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { compareQueryOptions, runMetricsQueryOptions, shortId } from "./queries"
/**
* Why a finished run can have nothing to draw.
*
* A cache hit restores what a node returned, not the values it emitted along
* the way, so a run whose training node was reused has a result and no curve.
* Said here rather than left as an empty chart, which reads as a fault.
*/
export const NO_CURVE =
"No curve was recorded. A node restored from the cache replays no emissions, so a run that reused an earlier one draws nothing here — its outputs are still on the result."
/** The metrics one run recorded, in the order they are worth offering. */
export function useMetricNames(runId: string | undefined) {
const { data } = useQuery({
...runMetricsQueryOptions(runId ?? "", ""),
enabled: Boolean(runId),
})
const names = new Set<string>()
for (const point of data ?? []) if (point.name) names.add(point.name)
return [...names]
}
/**
* One metric across one or more runs, drawn on a step axis.
*
* The endpoint answers in the chart widget's own series shape, so comparing
* three curves and showing one are the same call with a different id list.
*/
export function RunMetricChart({
ids,
metric,
refreshMs,
}: {
ids: string[]
metric: string
refreshMs?: number
}) {
const { data, isPending } = useQuery(
compareQueryOptions(ids, metric, refreshMs),
)
const lines = (data?.lines ?? []).slice(0, MAX_SERIES)
const labels = lines.map((line) =>
// The endpoint labels a line with the whole run id, which is too long to
// read in a legend beside four others.
line.label.replace(/^\S+/, (id) => shortId(id)),
)
const plots: HistoryPoint[][] = lines.map((line) =>
(line.points ?? []).map(([step, value]) => ({ ts: step, value })),
)
const drawn = plots.reduce((total, plot) => total + plot.length, 0)
return (
<div className="flex flex-col gap-2">
<UplotChart
labels={labels}
plots={plots}
xTime={false}
yLabel={metric}
pending={isPending}
empty={NO_CURVE}
/>
{drawn > 0 && (data?.lines?.length ?? 0) > MAX_SERIES && (
<p className="text-muted-foreground text-xs">
Showing {MAX_SERIES} of {data?.lines?.length} runs.
</p>
)}
</div>
)
}
/** The metric picker both the detail and the comparison sit under. */
export function MetricPicker({
names,
value,
onChange,
}: {
names: string[]
value: string
onChange: (name: string) => void
}) {
if (names.length === 0) return null
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger className="h-8 w-56">
<SelectValue placeholder="Metric" />
</SelectTrigger>
<SelectContent>
{names.map((name) => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
)
}
@@ -0,0 +1,117 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useNavigate } from "@tanstack/react-router"
import { LayoutDashboard } from "lucide-react"
import { useState } from "react"
import { DashboardsService } from "@/client"
import {
dashboardKeys,
dashboardsQueryOptions,
} from "@/components/Dashboard/queries"
import { Button } from "@/components/ui/button"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import useCustomToast from "@/hooks/useCustomToast"
/** What the generator calls a flow's results dashboard. Mirrors the backend. */
const resultsName = (flow: string) => `${flow}_results`
/**
* Send these runs to a dashboard.
*
* The dashboard does the drawing; this only says which one and against what.
* A flow with no results dashboard yet is offered one built from its own
* declared ports, which is the shortest path from "I ran something" to "I can
* see it" — and an ordinary dashboard afterwards.
*/
export function OpenInDashboard({
flow,
ids,
}: {
flow?: string
ids: string[]
}) {
const navigate = useNavigate()
const client = useQueryClient()
const { showErrorToast } = useCustomToast()
const [open, setOpen] = useState(false)
const { data } = useQuery(dashboardsQueryOptions())
const show = (name: string) => {
setOpen(false)
navigate({
to: "/view/$name",
params: { name },
search: { runs: ids.join(",") },
})
}
const generate = useMutation({
mutationFn: (name: string) =>
DashboardsService.generateResultsDashboard({ flow: name }),
onSuccess: (made) => {
client.invalidateQueries({ queryKey: dashboardKeys.all })
show(made.name)
},
onError: (error: { status?: number }) => {
// Someone else made it between the listing and the click; it is the one
// that was wanted either way.
if (error.status === 409 && flow) return show(resultsName(flow))
showErrorToast("Could not build a results dashboard for this flow")
},
})
const dashboards = data?.data ?? []
const results = flow ? resultsName(flow) : ""
const hasResults = dashboards.some((one) => one.name === results)
if (ids.length === 0) return null
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-8">
<LayoutDashboard className="mr-1 size-3.5" />
Open in dashboard
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-64 p-1">
{flow && !hasResults && (
<button
type="button"
onClick={() => generate.mutate(flow)}
disabled={generate.isPending}
className="w-full rounded-md px-2 py-1.5 text-left text-sm hover:bg-accent disabled:opacity-50"
>
{generate.isPending
? "Building…"
: `Build a results dashboard for ${flow}`}
</button>
)}
{dashboards.map((one) => (
<button
key={one.name}
type="button"
onClick={() => show(one.name)}
className="w-full rounded-md px-2 py-1.5 text-left text-sm hover:bg-accent"
>
{one.title || one.name}
{one.name === results && (
<span className="ml-2 text-muted-foreground text-xs">
results
</span>
)}
</button>
))}
{dashboards.length === 0 && !flow && (
<p className="px-2 py-1.5 text-muted-foreground text-sm">
No dashboards yet.
</p>
)}
</PopoverContent>
</Popover>
)
}
+325
View File
@@ -0,0 +1,325 @@
import { useQuery } from "@tanstack/react-query"
import { Link } from "@tanstack/react-router"
import { ChevronDown, ChevronRight, Download } from "lucide-react"
import { useState } from "react"
import type { ArtifactRow, RunNodeRow } from "@/client"
import { ago } from "@/components/Health/queries"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import useCustomToast from "@/hooks/useCustomToast"
import { cn, dur, si } from "@/lib/utils"
import {
MetricPicker,
NO_CURVE,
RunMetricChart,
useMetricNames,
} from "./MetricChart"
import { OpenInDashboard } from "./OpenInDashboard"
import {
CARD,
downloadArtifact,
isLive,
paramText,
runQueryOptions,
shortCommit,
shortId,
useCancelRun,
} from "./queries"
import { NodeStatusBadge, RunStatusBadge, statusReason } from "./RunStatus"
const LABEL = "text-muted-foreground text-xs"
export function RunDetail({ id }: { id: string }) {
const { data: run, isPending } = useQuery(runQueryOptions(id))
const cancel = useCancelRun()
const names = useMetricNames(id)
const [metric, setMetric] = useState("")
if (isPending || !run) return <Skeleton className="h-96 w-full rounded-lg" />
const shown = metric && names.includes(metric) ? metric : (names[0] ?? "")
const nodes = run.nodes ?? []
const artifacts = run.artifacts ?? []
const reason = statusReason(run)
// A run whose nodes were all restored emits nothing, so an empty chart is
// the expected outcome rather than a fault. Said once, where it applies.
const cached = nodes.some((node) => node.status === "cached")
return (
<div className="flex flex-col gap-4">
<header className="flex flex-wrap items-center gap-3">
<Link
to="/runs"
search={{ flow: run.flow }}
className="text-muted-foreground text-sm hover:underline"
>
{run.flow}
</Link>
<h1 className="font-mono font-semibold text-xl">{shortId(run.id)}</h1>
<RunStatusBadge run={run} />
{run.group_id && (
<Link
to="/runs"
search={{ flow: run.flow, group: run.group_id }}
className="rounded-full border border-border px-2 py-0.5 text-muted-foreground text-xs hover:bg-accent"
>
in a sweep
</Link>
)}
<div className="ml-auto flex items-center gap-2">
<OpenInDashboard flow={run.flow} ids={[run.id]} />
{isLive(run.status) && (
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => cancel.mutate(run.id)}
disabled={cancel.isPending}
>
Cancel
</Button>
)}
</div>
</header>
{reason && <p className="text-muted-foreground text-sm">{reason}</p>}
<section className={cn(CARD, "grid gap-4 sm:grid-cols-2 lg:grid-cols-4")}>
<Fact label="Submitted">{ago(String(run.created_at ?? ""))}</Fact>
<Fact label="Took">{run.duration_ms ? dur(run.duration_ms) : "—"}</Fact>
<Fact label="By">{run.actor || "—"}</Fact>
<Fact label="Cause">{run.cause}</Fact>
<Fact label="Seed">{run.seed ?? "—"}</Fact>
<Fact label="Code">
{/* The user's own repository for a code-declared flow; the store's
commit is a generated shim and says less. */}
<span className="font-mono">
{shortCommit(run.origin_commit || run.commit || "") || "—"}
</span>
</Fact>
<Fact label="Labels">{run.labels.join(", ") || "—"}</Fact>
<Fact label="Parameters">
<span className="font-mono text-xs">
{shortId(run.params_digest || "")}
</span>
</Fact>
</section>
<section className={cn(CARD, "flex flex-col gap-2")}>
<h2 className="font-medium text-sm">Parameters</h2>
{Object.keys(run.params).length === 0 ? (
<p className="text-muted-foreground text-sm">
This run took its flow's own defaults.
</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]) => (
<div
key={key}
className="flex justify-between gap-4 border-border border-b py-1"
>
<dt className={LABEL}>{key}</dt>
<dd className="truncate font-mono text-sm">
{paramText(value)}
</dd>
</div>
))}
</dl>
)}
</section>
{names.length > 0 && (
<section className={cn(CARD, "flex flex-col gap-3")}>
<header className="flex items-center gap-3">
<h2 className="mr-auto font-medium text-sm">Metrics</h2>
<MetricPicker names={names} value={shown} onChange={setMetric} />
</header>
<RunMetricChart ids={[run.id]} metric={shown} />
</section>
)}
{names.length === 0 && cached && (
<p className={cn(CARD, "text-muted-foreground text-sm")}>{NO_CURVE}</p>
)}
{Object.keys(run.result ?? {}).length > 0 && (
<section className={cn(CARD, "flex flex-col gap-2")}>
<h2 className="font-medium text-sm">Result</h2>
<dl className="grid gap-x-6 gap-y-1 sm:grid-cols-2 lg:grid-cols-3">
{Object.entries(run.result ?? {}).map(([key, value]) => (
<div
key={key}
className="flex justify-between gap-4 border-border border-b py-1"
>
<dt className={LABEL}>{key}</dt>
<dd className="truncate font-mono text-sm">
{paramText(value)}
</dd>
</div>
))}
</dl>
</section>
)}
<NodesTable nodes={nodes} />
{artifacts.length > 0 && <Artifacts rows={artifacts} />}
</div>
)
}
function Fact({
label,
children,
}: {
label: string
children: React.ReactNode
}) {
return (
<div className="flex flex-col gap-0.5">
<span className={LABEL}>{label}</span>
<span className="truncate text-sm">{children}</span>
</div>
)
}
/** What each node did, with its logs and its traceback behind a disclosure. */
function NodesTable({ nodes }: { nodes: RunNodeRow[] }) {
const [open, setOpen] = useState<string | null>(null)
if (nodes.length === 0) return null
return (
<section className={cn(CARD, "overflow-x-auto p-0")}>
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-8" />
<TableHead>Node</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Took</TableHead>
<TableHead>Worker</TableHead>
<TableHead className="text-right">Attempt</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{nodes.map((node) => {
const detail = node.error || node.logs
const isOpen = open === node.node
return [
<TableRow key={node.node}>
<TableCell>
{detail && (
<button
type="button"
onClick={() => setOpen(isOpen ? null : node.node)}
aria-label={`Logs for ${node.node}`}
className="text-muted-foreground"
>
{isOpen ? (
<ChevronDown className="size-4" />
) : (
<ChevronRight className="size-4" />
)}
</button>
)}
</TableCell>
<TableCell className="font-mono text-sm">{node.node}</TableCell>
<TableCell>
<NodeStatusBadge status={node.status} />
</TableCell>
<TableCell className="text-right text-muted-foreground text-sm tabular-nums">
{node.duration_ms ? dur(node.duration_ms) : "—"}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{node.worker || "—"}
</TableCell>
<TableCell className="text-right text-muted-foreground text-sm tabular-nums">
{node.attempt}
</TableCell>
</TableRow>,
isOpen && detail ? (
<TableRow
key={`${node.node}-detail`}
className="hover:bg-transparent"
>
<TableCell colSpan={6} className="bg-muted/30">
{node.error && (
<pre className="mb-2 overflow-x-auto whitespace-pre-wrap text-destructive text-xs">
{node.error}
</pre>
)}
{node.logs && (
<pre className="max-h-64 overflow-auto whitespace-pre-wrap text-muted-foreground text-xs">
{node.logs}
</pre>
)}
</TableCell>
</TableRow>
) : null,
]
})}
</TableBody>
</Table>
</section>
)
}
function Artifacts({ rows }: { rows: ArtifactRow[] }) {
const { showErrorToast } = useCustomToast()
return (
<section className={cn(CARD, "overflow-x-auto p-0")}>
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Artifact</TableHead>
<TableHead>From</TableHead>
<TableHead>Type</TableHead>
<TableHead className="text-right">Size</TableHead>
<TableHead className="w-10" />
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.name}>
<TableCell className="font-mono text-sm">{row.name}</TableCell>
<TableCell className="text-muted-foreground text-sm">
{row.node}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{row.media_type || "—"}
</TableCell>
<TableCell className="text-right text-muted-foreground text-sm tabular-nums">
{si(row.size)}B
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
className="size-8"
aria-label={`Download ${row.name}`}
onClick={() =>
downloadArtifact(row.digest, row.name).catch(() =>
showErrorToast(`Could not download ${row.name}`),
)
}
>
<Download className="size-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</section>
)
}
@@ -0,0 +1,90 @@
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
/**
* How each status is drawn.
*
* Colour never carries the status on its own — the word is always written
* beside it (DESIGN-GUIDELINES.md → status is named in text).
*/
const LOOKS: Record<string, string> = {
ok: "border-transparent bg-status-success/15 text-status-success",
running: "border-transparent bg-primary/15 text-primary",
queued: "border-border bg-muted text-muted-foreground",
error: "border-transparent bg-destructive/15 text-destructive",
cancelled: "border-border bg-muted text-muted-foreground",
abandoned: "border-transparent bg-destructive/10 text-destructive",
}
/**
* Why a run is where it is, when the status alone does not say.
*
* A run that sits queued forever is the one genuinely puzzling state, and its
* reason is the answer: no worker carrying the labels it asked for is attached.
*/
export function statusReason(run: {
status: string
status_reason: string
labels: string[]
started_at?: unknown
}): string {
if (run.status_reason) return run.status_reason
if (run.status === "queued" && !run.started_at && run.labels.length)
return `Waiting for a worker labelled ${run.labels.join(", ")}`
return ""
}
export function RunStatusBadge({
run,
className,
}: {
run: {
status: string
status_reason: string
labels: string[]
started_at?: unknown
}
className?: string
}) {
const reason = statusReason(run)
const badge = (
<span
className={cn(
"inline-flex w-fit shrink-0 items-center rounded-full border px-2 py-0.5 text-xs font-medium",
LOOKS[run.status] ?? "border-border text-muted-foreground",
className,
)}
>
{run.status}
</span>
)
if (!reason) return badge
return (
<Tooltip>
<TooltipTrigger asChild>{badge}</TooltipTrigger>
<TooltipContent className="max-w-xs">{reason}</TooltipContent>
</Tooltip>
)
}
/** A node's outcome inside a run, where "cached" is its own thing. */
export function NodeStatusBadge({ status }: { status: string }) {
const look =
status === "cached"
? "border-border bg-muted text-muted-foreground"
: (LOOKS[status] ?? "border-border text-muted-foreground")
return (
<span
className={cn(
"inline-flex w-fit shrink-0 items-center rounded-full border px-2 py-0.5 text-xs font-medium",
look,
)}
>
{status}
</span>
)
}
+422
View File
@@ -0,0 +1,422 @@
import { useInfiniteQuery, useQuery } from "@tanstack/react-query"
import { Link } from "@tanstack/react-router"
import { FlaskConical, X } from "lucide-react"
import type { fluksio__api__routes__runs__RunRow as RunRow } from "@/client"
import { MAX_SERIES } from "@/components/Common/UplotChart"
import { ago } from "@/components/Health/queries"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { cn, dur } from "@/lib/utils"
import { MetricPicker, RunMetricChart, useMetricNames } from "./MetricChart"
import { OpenInDashboard } from "./OpenInDashboard"
import {
CARD,
LIST_CAP,
paramsSummary,
paramText,
runOverviewQueryOptions,
runsInfiniteQueryOptions,
STATUSES,
shortCommit,
shortId,
varyingKeys,
} from "./queries"
import { RunStatusBadge } from "./RunStatus"
export type RunsSearch = {
flow?: string
status?: string
group?: string
/** The runs being compared, comma-joined — a comparison is a link. */
compare?: string
metric?: string
}
export function RunsScreen({
search,
update,
}: {
search: RunsSearch
update: (next: Partial<RunsSearch>) => void
}) {
const filters = {
...(search.flow ? { flow: search.flow } : {}),
...(search.status ? { status: search.status } : {}),
...(search.group ? { group: search.group } : {}),
}
const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery(runsInfiniteQueryOptions(filters))
const { data: overview } = useQuery(runOverviewQueryOptions())
const runs: RunRow[] = data?.pages.flat() ?? []
const selected = search.compare ? search.compare.split(",") : []
const toggle = (id: string) => {
const next = selected.includes(id)
? selected.filter((one) => one !== id)
: [...selected, id]
update({ compare: next.length ? next.join(",") : undefined })
}
return (
<div className="flex flex-col gap-6 lg:flex-row">
<FlowRail
rows={overview ?? []}
active={search.flow}
onPick={(flow) =>
update({ flow, group: undefined, compare: undefined })
}
/>
<section className="flex min-w-0 flex-1 flex-col gap-4">
<header className="flex flex-wrap items-center gap-3">
<h1 className="mr-auto font-semibold text-2xl">
{search.flow ?? "Runs"}
</h1>
<Select
value={search.status ?? "all"}
onValueChange={(value) =>
update({ status: value === "all" ? undefined : value })
}
>
<SelectTrigger className="h-8 w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Any status</SelectItem>
{STATUSES.map((status) => (
<SelectItem key={status} value={status}>
{status}
</SelectItem>
))}
</SelectContent>
</Select>
{search.group && (
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => update({ group: undefined })}
>
sweep {shortId(search.group)}
<X className="ml-1 size-3" />
</Button>
)}
</header>
{isPending ? (
<Skeleton className="h-64 w-full rounded-lg" />
) : (
<RunsTable
runs={runs}
search={search}
selected={selected}
onToggle={toggle}
onGroup={(group) => update({ group, compare: undefined })}
/>
)}
<div className="flex items-center gap-3">
{hasNextPage && (
<Button
variant="outline"
size="sm"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? "Loading…" : "Load more"}
</Button>
)}
<p className="text-muted-foreground text-xs">
{runs.length} run{runs.length === 1 ? "" : "s"}
{!hasNextPage && runs.length >= LIST_CAP
? ` — the newest ${LIST_CAP}, which is as deep as this list reads`
: ""}
</p>
</div>
{selected.length > 0 && (
<Compare
ids={selected}
search={search}
update={update}
flow={runs.find((run) => run.id === selected[0])?.flow}
/>
)}
</section>
</div>
)
}
/** The flows that have runs, which is what an experiment log is indexed by. */
function FlowRail({
rows,
active,
onPick,
}: {
rows: { flow: string; runs: number; running: number; queued: number }[]
active?: string
onPick: (flow: string | undefined) => void
}) {
const entry = (
key: string,
label: string,
count: number,
busy: number,
isActive: boolean,
flow: string | undefined,
) => (
<button
key={key}
type="button"
onClick={() => onPick(flow)}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm",
isActive
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50",
)}
>
<span className="min-w-0 flex-1 truncate">{label}</span>
{busy > 0 && (
<span className="rounded-full bg-primary/15 px-1.5 text-primary text-xs">
{busy}
</span>
)}
<span className="text-xs tabular-nums">{count}</span>
</button>
)
return (
<aside className="flex w-full shrink-0 flex-col gap-1 lg:w-56">
{entry(
"all",
"All runs",
rows.reduce((total, row) => total + row.runs, 0),
rows.reduce((total, row) => total + row.running + row.queued, 0),
!active,
undefined,
)}
{rows.map((row) =>
entry(
row.flow,
row.flow,
row.runs,
row.running + row.queued,
active === row.flow,
row.flow,
),
)}
{rows.length === 0 && (
<p className="px-2 py-4 text-muted-foreground text-sm">
<FlaskConical className="mb-1 size-4" />
<br />
Nothing has been run yet. Submit a batch flow from its editor, the CLI
or the API and it lands here.
</p>
)}
</aside>
)
}
function RunsTable({
runs,
search,
selected,
onToggle,
onGroup,
}: {
runs: RunRow[]
search: RunsSearch
selected: string[]
onToggle: (id: string) => void
onGroup: (group: string) => void
}) {
// Under a sweep filter the shared parameters say nothing; the two or three
// that were swept are the whole point, so they get columns of their own.
const varying = search.group ? varyingKeys(runs).slice(0, 4) : []
const showFlow = !search.flow
return (
<div className={cn(CARD, "overflow-x-auto p-0")}>
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-8" />
<TableHead>Run</TableHead>
{showFlow && <TableHead>Flow</TableHead>}
<TableHead>Status</TableHead>
{varying.length > 0 ? (
varying.map((key) => <TableHead key={key}>{key}</TableHead>)
) : (
<TableHead>Parameters</TableHead>
)}
<TableHead>Seed</TableHead>
<TableHead>Code</TableHead>
<TableHead className="text-right">Took</TableHead>
<TableHead>Started</TableHead>
<TableHead>By</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{runs.length === 0 && (
<TableRow className="hover:bg-transparent">
<TableCell
colSpan={10}
className="h-24 text-center text-muted-foreground"
>
No runs match this filter.
</TableCell>
</TableRow>
)}
{runs.map((run) => (
<TableRow key={run.id}>
<TableCell>
<Checkbox
checked={selected.includes(run.id)}
onCheckedChange={() => onToggle(run.id)}
aria-label={`Compare ${run.id}`}
/>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Link
to="/runs/$id"
params={{ id: run.id }}
className="font-mono text-sm hover:underline"
>
{shortId(run.id)}
</Link>
{run.group_id && !search.group && (
<button
type="button"
onClick={() => onGroup(run.group_id as string)}
className="rounded-full border border-border px-1.5 text-muted-foreground text-xs hover:bg-accent"
>
sweep
</button>
)}
</div>
</TableCell>
{showFlow && (
<TableCell className="text-muted-foreground text-sm">
{run.flow}
</TableCell>
)}
<TableCell>
<RunStatusBadge run={run} />
</TableCell>
{varying.length > 0 ? (
varying.map((key) => (
<TableCell key={key} className="font-mono text-sm">
{paramText(run.params[key])}
</TableCell>
))
) : (
<TableCell className="max-w-64 truncate font-mono text-muted-foreground text-xs">
{paramsSummary(run.params) || "—"}
</TableCell>
)}
<TableCell className="text-muted-foreground text-sm tabular-nums">
{run.seed ?? "—"}
</TableCell>
<TableCell className="font-mono text-muted-foreground text-xs">
{/* The user's own repository when there is one: for a flow
declared in code, the store's commit names a generated
shim rather than anything anyone wrote. */}
{shortCommit(run.origin_commit ?? "") || "—"}
</TableCell>
<TableCell className="text-right text-muted-foreground text-sm tabular-nums">
{run.duration_ms ? dur(run.duration_ms) : "—"}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{ago(String(run.created_at ?? ""))}
</TableCell>
<TableCell className="max-w-40 truncate text-muted-foreground text-sm">
{run.actor || "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}
/** The picked runs, one metric at a time. */
function Compare({
ids,
search,
update,
flow,
}: {
ids: string[]
search: RunsSearch
update: (next: Partial<RunsSearch>) => void
flow?: string
}) {
const names = useMetricNames(ids[0])
const metric =
search.metric && names.includes(search.metric)
? search.metric
: (names[0] ?? "")
const tooMany = ids.length > MAX_SERIES
return (
<section className={cn(CARD, "flex flex-col gap-3")}>
<header className="flex flex-wrap items-center gap-3">
<h2 className="mr-auto font-medium text-sm">
Comparing {ids.length} run{ids.length === 1 ? "" : "s"}
</h2>
<MetricPicker
names={names}
value={metric}
onChange={(name) => update({ metric: name })}
/>
<OpenInDashboard flow={flow} ids={ids} />
<Button
variant="ghost"
size="sm"
className="h-8"
onClick={() => update({ compare: undefined })}
>
Clear
</Button>
</header>
{tooMany && (
<p className="text-muted-foreground text-xs">
A chart carries {MAX_SERIES} lines; the first {MAX_SERIES} of these
are drawn.
</p>
)}
{metric ? (
<RunMetricChart ids={ids.slice(0, MAX_SERIES)} metric={metric} />
) : (
<p className="text-muted-foreground text-sm">
These runs recorded no metric series to compare.
</p>
)}
</section>
)
}
+164
View File
@@ -0,0 +1,164 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { OpenAPI, RunsService } from "@/client"
import { apiToken } from "@/lib/portal"
/**
* How deep one page of the run list is read.
*
* The endpoint caps a page at 500; asking for a hundred at a time keeps the
* first paint quick and leaves "Load more" something to do.
*/
export const PAGE = 100
/** How far the list will page before it stops offering to go deeper. */
export const LIST_CAP = 500
export type RunFilters = {
flow?: string
status?: string
group?: string
}
export const runKeys = {
all: ["runs"] as const,
overview: ["runs", "overview"] as const,
list: (filters: RunFilters) => ["runs", "list", filters] as const,
detail: (id: string) => ["runs", "detail", id] as const,
metrics: (id: string, name: string) =>
["runs", "detail", id, "metrics", name] as const,
compare: (ids: string[], metric: string) =>
["runs", "compare", ids.join(","), metric] as const,
}
/** The resting surface these screens are built from, as Health names it. */
export const CARD = "rounded-lg border border-border bg-card p-4 shadow-e1"
/** The statuses a run passes through, as the filter offers them. */
export const STATUSES = [
"queued",
"running",
"ok",
"error",
"cancelled",
"abandoned",
] as const
/** A run that has not settled is still worth re-reading. */
export const isLive = (status: string) =>
status === "queued" || status === "running"
export const runsInfiniteQueryOptions = (filters: RunFilters) => ({
queryKey: runKeys.list(filters),
queryFn: ({ pageParam }: { pageParam: number }) =>
RunsService.readRuns({ ...filters, limit: PAGE, offset: pageParam }),
initialPageParam: 0,
getNextPageParam: (last: unknown[], all: unknown[][]) => {
const read = all.reduce((total, page) => total + page.length, 0)
// A short page is the end of the history; the cap is the end of what this
// list will show of it.
return last.length < PAGE || read >= LIST_CAP ? undefined : read
},
})
export const runOverviewQueryOptions = () => ({
queryKey: runKeys.overview,
queryFn: () => RunsService.readOverview(),
refetchInterval: 30_000,
})
export const runQueryOptions = (id: string) => ({
queryKey: runKeys.detail(id),
queryFn: () => RunsService.readRun({ runId: id }),
// A finished run never changes again, so only a live one is polled. The
// socket's run_finished lands the last transition either way; this covers
// the metrics and node rows filling in while it runs.
refetchInterval: (query: { state: { data?: { status: string } } }) =>
query.state.data && isLive(query.state.data.status)
? 5_000
: (false as const),
})
/** One run's series, or every one of them when `name` is empty. */
export const runMetricsQueryOptions = (id: string, name = "") => ({
queryKey: runKeys.metrics(id, name),
queryFn: () => RunsService.readMetrics({ runId: id, name }),
})
export const compareQueryOptions = (
ids: string[],
metric: string,
refetchInterval?: number,
) => ({
queryKey: runKeys.compare(ids, metric),
queryFn: () => RunsService.compareMetric({ ids: ids.join(","), metric }),
enabled: ids.length > 0 && Boolean(metric),
...(refetchInterval ? { refetchInterval } : {}),
})
export function useCancelRun() {
const client = useQueryClient()
return useMutation({
mutationFn: (runId: string) => RunsService.cancelRun({ runId }),
onSuccess: () => client.invalidateQueries({ queryKey: runKeys.all }),
})
}
/**
* Save an artifact to disk.
*
* Not a plain link: `/artifacts/{digest}` takes a bearer token, which an
* anchor cannot carry. The bytes come through fetch and leave as an object
* URL — the same trip the browser would have made, with the header on it.
*/
export async function downloadArtifact(digest: string, name: string) {
const token = apiToken()
const answer = await fetch(`${OpenAPI.BASE}/api/v1/artifacts/${digest}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
if (!answer.ok) throw new Error(`Could not read ${name}`)
const url = URL.createObjectURL(await answer.blob())
const link = document.createElement("a")
link.href = url
link.download = name
link.click()
URL.revokeObjectURL(url)
}
/** A run id, short enough for a table cell. The tail is the random half. */
export const shortId = (id: string) => id.slice(-8)
/** A commit, at the length everyone reads one at. */
export const shortCommit = (commit: string) => commit.slice(0, 7)
/**
* The parameter keys that differ across these runs.
*
* What makes a sweep readable: fifty runs of one flow share everything but the
* two knobs that were swept, and those two are the only columns worth drawing.
*/
export function varyingKeys(runs: { params: Record<string, unknown> }[]) {
if (runs.length < 2) return []
const keys = new Set<string>()
for (const run of runs)
for (const key of Object.keys(run.params)) keys.add(key)
return [...keys].filter((key) => {
const first = JSON.stringify(runs[0].params[key])
return runs.some((run) => JSON.stringify(run.params[key]) !== first)
})
}
/** A parameter value, as narrow as it can be written. */
export function paramText(value: unknown): string {
if (value === null || value === undefined) return "—"
if (typeof value === "number" || typeof value === "boolean")
return String(value)
if (typeof value === "string") return value
return JSON.stringify(value)
}
/** A run's parameters on one line, for a table cell. */
export const paramsSummary = (params: Record<string, unknown>) =>
Object.entries(params)
.map(([key, value]) => `${key}=${paramText(value)}`)
.join(" ")
@@ -1,6 +1,7 @@
import {
ArrowLeft,
Bell,
FlaskConical,
Home,
KeyRound,
LayoutDashboard,
@@ -29,6 +30,9 @@ const baseItems: Item[] = [
{ icon: Home, title: "Home", path: "/" },
{ icon: Workflow, title: "Flows", path: "/flows" },
{ icon: LayoutDashboard, title: "Dashboards", path: "/dashboards" },
// What a batch flow leaves behind. Beside the flows rather than under Home:
// an experiment log is browsed, not glanced at.
{ icon: FlaskConical, title: "Runs", path: "/runs" },
// Both are engine-wide operator settings rather than personal ones, so they
// sit here and not among the per-user tabs under Settings.
{ icon: KeyRound, title: "Secrets", path: "/secrets" },
+42
View File
@@ -25,8 +25,10 @@ import { Route as LayoutSecretsRouteImport } from './routes/_layout/secrets'
import { Route as LayoutModulesRouteImport } from './routes/_layout/modules'
import { Route as LayoutAlertsRouteImport } from './routes/_layout/alerts'
import { Route as LayoutAdminRouteImport } from './routes/_layout/admin'
import { Route as LayoutRunsIndexRouteImport } from './routes/_layout/runs/index'
import { Route as LayoutFlowsIndexRouteImport } from './routes/_layout/flows/index'
import { Route as LayoutDashboardsIndexRouteImport } from './routes/_layout/dashboards/index'
import { Route as LayoutRunsIdRouteImport } from './routes/_layout/runs/$id'
import { Route as CanvasFlowsFlowNameRouteImport } from './routes/_canvas/flows/$flowName'
import { Route as CanvasDashboardsNameRouteImport } from './routes/_canvas/dashboards/$name'
@@ -108,6 +110,11 @@ const LayoutAdminRoute = LayoutAdminRouteImport.update({
path: '/admin',
getParentRoute: () => LayoutRoute,
} as any)
const LayoutRunsIndexRoute = LayoutRunsIndexRouteImport.update({
id: '/runs/',
path: '/runs/',
getParentRoute: () => LayoutRoute,
} as any)
const LayoutFlowsIndexRoute = LayoutFlowsIndexRouteImport.update({
id: '/flows/',
path: '/flows/',
@@ -118,6 +125,11 @@ const LayoutDashboardsIndexRoute = LayoutDashboardsIndexRouteImport.update({
path: '/dashboards/',
getParentRoute: () => LayoutRoute,
} as any)
const LayoutRunsIdRoute = LayoutRunsIdRouteImport.update({
id: '/runs/$id',
path: '/runs/$id',
getParentRoute: () => LayoutRoute,
} as any)
const CanvasFlowsFlowNameRoute = CanvasFlowsFlowNameRouteImport.update({
id: '/flows/$flowName',
path: '/flows/$flowName',
@@ -146,8 +158,10 @@ export interface FileRoutesByFullPath {
'/panel/': typeof PanelIndexRoute
'/dashboards/$name': typeof CanvasDashboardsNameRoute
'/flows/$flowName': typeof CanvasFlowsFlowNameRoute
'/runs/$id': typeof LayoutRunsIdRoute
'/dashboards/': typeof LayoutDashboardsIndexRoute
'/flows/': typeof LayoutFlowsIndexRoute
'/runs/': typeof LayoutRunsIndexRoute
}
export interface FileRoutesByTo {
'/': typeof LayoutIndexRoute
@@ -166,8 +180,10 @@ export interface FileRoutesByTo {
'/panel': typeof PanelIndexRoute
'/dashboards/$name': typeof CanvasDashboardsNameRoute
'/flows/$flowName': typeof CanvasFlowsFlowNameRoute
'/runs/$id': typeof LayoutRunsIdRoute
'/dashboards': typeof LayoutDashboardsIndexRoute
'/flows': typeof LayoutFlowsIndexRoute
'/runs': typeof LayoutRunsIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@@ -189,8 +205,10 @@ export interface FileRoutesById {
'/panel/': typeof PanelIndexRoute
'/_canvas/dashboards/$name': typeof CanvasDashboardsNameRoute
'/_canvas/flows/$flowName': typeof CanvasFlowsFlowNameRoute
'/_layout/runs/$id': typeof LayoutRunsIdRoute
'/_layout/dashboards/': typeof LayoutDashboardsIndexRoute
'/_layout/flows/': typeof LayoutFlowsIndexRoute
'/_layout/runs/': typeof LayoutRunsIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -211,8 +229,10 @@ export interface FileRouteTypes {
| '/panel/'
| '/dashboards/$name'
| '/flows/$flowName'
| '/runs/$id'
| '/dashboards/'
| '/flows/'
| '/runs/'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
@@ -231,8 +251,10 @@ export interface FileRouteTypes {
| '/panel'
| '/dashboards/$name'
| '/flows/$flowName'
| '/runs/$id'
| '/dashboards'
| '/flows'
| '/runs'
id:
| '__root__'
| '/_canvas'
@@ -253,8 +275,10 @@ export interface FileRouteTypes {
| '/panel/'
| '/_canvas/dashboards/$name'
| '/_canvas/flows/$flowName'
| '/_layout/runs/$id'
| '/_layout/dashboards/'
| '/_layout/flows/'
| '/_layout/runs/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -384,6 +408,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LayoutAdminRouteImport
parentRoute: typeof LayoutRoute
}
'/_layout/runs/': {
id: '/_layout/runs/'
path: '/runs'
fullPath: '/runs/'
preLoaderRoute: typeof LayoutRunsIndexRouteImport
parentRoute: typeof LayoutRoute
}
'/_layout/flows/': {
id: '/_layout/flows/'
path: '/flows'
@@ -398,6 +429,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LayoutDashboardsIndexRouteImport
parentRoute: typeof LayoutRoute
}
'/_layout/runs/$id': {
id: '/_layout/runs/$id'
path: '/runs/$id'
fullPath: '/runs/$id'
preLoaderRoute: typeof LayoutRunsIdRouteImport
parentRoute: typeof LayoutRoute
}
'/_canvas/flows/$flowName': {
id: '/_canvas/flows/$flowName'
path: '/flows/$flowName'
@@ -435,8 +473,10 @@ interface LayoutRouteChildren {
LayoutSecretsRoute: typeof LayoutSecretsRoute
LayoutSettingsRoute: typeof LayoutSettingsRoute
LayoutIndexRoute: typeof LayoutIndexRoute
LayoutRunsIdRoute: typeof LayoutRunsIdRoute
LayoutDashboardsIndexRoute: typeof LayoutDashboardsIndexRoute
LayoutFlowsIndexRoute: typeof LayoutFlowsIndexRoute
LayoutRunsIndexRoute: typeof LayoutRunsIndexRoute
}
const LayoutRouteChildren: LayoutRouteChildren = {
@@ -446,8 +486,10 @@ const LayoutRouteChildren: LayoutRouteChildren = {
LayoutSecretsRoute: LayoutSecretsRoute,
LayoutSettingsRoute: LayoutSettingsRoute,
LayoutIndexRoute: LayoutIndexRoute,
LayoutRunsIdRoute: LayoutRunsIdRoute,
LayoutDashboardsIndexRoute: LayoutDashboardsIndexRoute,
LayoutFlowsIndexRoute: LayoutFlowsIndexRoute,
LayoutRunsIndexRoute: LayoutRunsIndexRoute,
}
const LayoutRouteWithChildren =
+13
View File
@@ -0,0 +1,13 @@
import { createFileRoute } from "@tanstack/react-router"
import { RunDetail } from "@/components/Runs/RunDetail"
export const Route = createFileRoute("/_layout/runs/$id")({
component: Run,
head: ({ params }) => ({ meta: [{ title: `Run ${params.id} - Fluksio` }] }),
})
function Run() {
const { id } = Route.useParams()
return <RunDetail id={id} />
}
@@ -0,0 +1,36 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router"
import { RunsScreen, type RunsSearch } from "@/components/Runs/RunsScreen"
/** A string search param, or nothing when it is absent or empty. */
const text = (value: unknown) =>
typeof value === "string" && value ? value : undefined
export const Route = createFileRoute("/_layout/runs/")({
component: Runs,
// The whole state of this screen — which flow, which sweep, which runs are
// being compared — is the address, so a comparison is something to send
// rather than something to describe.
validateSearch: (search: Record<string, unknown>): RunsSearch => ({
flow: text(search.flow),
status: text(search.status),
group: text(search.group),
compare: text(search.compare),
metric: text(search.metric),
}),
head: () => ({ meta: [{ title: "Runs - Fluksio" }] }),
})
function Runs() {
const search = Route.useSearch()
const navigate = useNavigate()
return (
<RunsScreen
search={search}
update={(next) =>
navigate({ to: "/runs", search: { ...search, ...next } })
}
/>
)
}