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, DashboardNotFound,
DashboardsPublic, DashboardsPublic,
default_dashboard, default_dashboard,
results_dashboard,
results_name,
) )
from fluksio.flow.events import event_bus 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 from fluksio.models import Message
router = APIRouter( 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) 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) @router.put("/{name}", response_model=DashboardDef)
async def save_dashboard( async def save_dashboard(
name: str, 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 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 from fluksio.flow.store import FlowStore, StaleVersion
#: Sibling of the shared-node library, and likewise not a flow. #: 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__ = [ __all__ = [
"BAR_ROWS", "BAR_ROWS",
"COLOR_DTYPES", "COLOR_DTYPES",
+49
View File
@@ -11,10 +11,12 @@ from fluksio.flow.dashboards import (
SettingDef, SettingDef,
WidgetDef, WidgetDef,
default_dashboard, default_dashboard,
results_dashboard,
) )
from fluksio.flow.messages import DType, MessageSpec from fluksio.flow.messages import DType, MessageSpec
from fluksio.flow.nodes import Node from fluksio.flow.nodes import Node
from fluksio.flow.pipeline import Pipeline from fluksio.flow.pipeline import Pipeline
from fluksio.flow.schemas import FlowDef, NodeDef
from fluksio.flow.state import MemoryState from fluksio.flow.state import MemoryState
from fluksio.flow.store import FlowStore, StaleVersion 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 binding["requires"] == ["home.theme"]
assert not binding["provides"] assert not binding["provides"]
assert store.bindings_for("other") == [] 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 type { CancelablePromise } from './core/CancelablePromise';
import { OpenAPI } from './core/OpenAPI'; import { OpenAPI } from './core/OpenAPI';
import { request as __request } from './core/request'; 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 { 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 * Publish Dashboard
* Put the unpublished changes on the panels. * Put the unpublished changes on the panels.
+6
View File
@@ -1236,6 +1236,12 @@ export type DashboardsDeleteDashboardData = {
export type DashboardsDeleteDashboardResponse = (Message); export type DashboardsDeleteDashboardResponse = (Message);
export type DashboardsGenerateResultsDashboardData = {
flow: string;
};
export type DashboardsGenerateResultsDashboardResponse = (DashboardDef_Output);
export type DashboardsPublishDashboardData = { export type DashboardsPublishDashboardData = {
name: string; name: string;
requestBody: fluksio__api__routes__dashboards__PublishRequest; requestBody: fluksio__api__routes__dashboards__PublishRequest;
@@ -236,6 +236,7 @@ export function UplotChart({
yLabel, yLabel,
palette, palette,
smooth = false, smooth = false,
xTime = true,
onCursor, onCursor,
onSelect, onSelect,
}: { }: {
@@ -262,6 +263,10 @@ export function UplotChart({
* Monotone rather than plain cubic on purpose: a spline that overshoots * Monotone rather than plain cubic on purpose: a spline that overshoots
* invents readings between two the sensor actually took. */ * invents readings between two the sensor actually took. */
smooth?: boolean 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. */ /** The x value under the pointer, and null once it leaves the plot. */
onCursor?: (ts: number | null) => void onCursor?: (ts: number | null) => void
/** The x value clicked, or null for a click that landed on no point. */ /** 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 // 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 — // 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. // 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 // 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 // a resize in that window (a card still settling, say) draws them anyway and
// throws. Waiting for the first reading avoids the state altogether. // throws. Waiting for the first reading avoids the state altogether.
@@ -344,7 +349,7 @@ export function UplotChart({
], ],
}, },
scales: { scales: {
x: { time: true }, x: { time: xTime },
...(yRange ? { y: { range: yRange } } : {}), ...(yRange ? { y: { range: yRange } } : {}),
}, },
axes: [ axes: [
@@ -4,6 +4,7 @@ import { useEffect } from "react"
import { OpenAPI } from "@/client" import { OpenAPI } from "@/client"
import { dashboardKeys, panelKeys } from "@/components/Dashboard/queries" import { dashboardKeys, panelKeys } from "@/components/Dashboard/queries"
import { runKeys } from "@/components/Runs/queries"
import { connectionStore } from "@/lib/connectionStore" import { connectionStore } from "@/lib/connectionStore"
import { apiToken } from "@/lib/portal" import { apiToken } from "@/lib/portal"
import { type LogLine, liveStore, type ValueSource } from "./liveStore" import { type LogLine, liveStore, type ValueSource } from "./liveStore"
@@ -89,6 +90,14 @@ type FlowEvent =
paused?: string[] paused?: string[]
} }
| { type: "dashboard_changed"; dashboard?: string; ts?: number } | { 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 { function socketUrl(): string {
const base = String(OpenAPI.BASE || window.location.origin) const base = String(OpenAPI.BASE || window.location.origin)
@@ -251,6 +260,13 @@ function connect() {
: panelKeys.all, : panelKeys.all,
}) })
break 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 { import {
ArrowLeft, ArrowLeft,
Bell, Bell,
FlaskConical,
Home, Home,
KeyRound, KeyRound,
LayoutDashboard, LayoutDashboard,
@@ -29,6 +30,9 @@ const baseItems: Item[] = [
{ icon: Home, title: "Home", path: "/" }, { icon: Home, title: "Home", path: "/" },
{ icon: Workflow, title: "Flows", path: "/flows" }, { icon: Workflow, title: "Flows", path: "/flows" },
{ icon: LayoutDashboard, title: "Dashboards", path: "/dashboards" }, { 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 // Both are engine-wide operator settings rather than personal ones, so they
// sit here and not among the per-user tabs under Settings. // sit here and not among the per-user tabs under Settings.
{ icon: KeyRound, title: "Secrets", path: "/secrets" }, { 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 LayoutModulesRouteImport } from './routes/_layout/modules'
import { Route as LayoutAlertsRouteImport } from './routes/_layout/alerts' import { Route as LayoutAlertsRouteImport } from './routes/_layout/alerts'
import { Route as LayoutAdminRouteImport } from './routes/_layout/admin' 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 LayoutFlowsIndexRouteImport } from './routes/_layout/flows/index'
import { Route as LayoutDashboardsIndexRouteImport } from './routes/_layout/dashboards/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 CanvasFlowsFlowNameRouteImport } from './routes/_canvas/flows/$flowName'
import { Route as CanvasDashboardsNameRouteImport } from './routes/_canvas/dashboards/$name' import { Route as CanvasDashboardsNameRouteImport } from './routes/_canvas/dashboards/$name'
@@ -108,6 +110,11 @@ const LayoutAdminRoute = LayoutAdminRouteImport.update({
path: '/admin', path: '/admin',
getParentRoute: () => LayoutRoute, getParentRoute: () => LayoutRoute,
} as any) } as any)
const LayoutRunsIndexRoute = LayoutRunsIndexRouteImport.update({
id: '/runs/',
path: '/runs/',
getParentRoute: () => LayoutRoute,
} as any)
const LayoutFlowsIndexRoute = LayoutFlowsIndexRouteImport.update({ const LayoutFlowsIndexRoute = LayoutFlowsIndexRouteImport.update({
id: '/flows/', id: '/flows/',
path: '/flows/', path: '/flows/',
@@ -118,6 +125,11 @@ const LayoutDashboardsIndexRoute = LayoutDashboardsIndexRouteImport.update({
path: '/dashboards/', path: '/dashboards/',
getParentRoute: () => LayoutRoute, getParentRoute: () => LayoutRoute,
} as any) } as any)
const LayoutRunsIdRoute = LayoutRunsIdRouteImport.update({
id: '/runs/$id',
path: '/runs/$id',
getParentRoute: () => LayoutRoute,
} as any)
const CanvasFlowsFlowNameRoute = CanvasFlowsFlowNameRouteImport.update({ const CanvasFlowsFlowNameRoute = CanvasFlowsFlowNameRouteImport.update({
id: '/flows/$flowName', id: '/flows/$flowName',
path: '/flows/$flowName', path: '/flows/$flowName',
@@ -146,8 +158,10 @@ export interface FileRoutesByFullPath {
'/panel/': typeof PanelIndexRoute '/panel/': typeof PanelIndexRoute
'/dashboards/$name': typeof CanvasDashboardsNameRoute '/dashboards/$name': typeof CanvasDashboardsNameRoute
'/flows/$flowName': typeof CanvasFlowsFlowNameRoute '/flows/$flowName': typeof CanvasFlowsFlowNameRoute
'/runs/$id': typeof LayoutRunsIdRoute
'/dashboards/': typeof LayoutDashboardsIndexRoute '/dashboards/': typeof LayoutDashboardsIndexRoute
'/flows/': typeof LayoutFlowsIndexRoute '/flows/': typeof LayoutFlowsIndexRoute
'/runs/': typeof LayoutRunsIndexRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof LayoutIndexRoute '/': typeof LayoutIndexRoute
@@ -166,8 +180,10 @@ export interface FileRoutesByTo {
'/panel': typeof PanelIndexRoute '/panel': typeof PanelIndexRoute
'/dashboards/$name': typeof CanvasDashboardsNameRoute '/dashboards/$name': typeof CanvasDashboardsNameRoute
'/flows/$flowName': typeof CanvasFlowsFlowNameRoute '/flows/$flowName': typeof CanvasFlowsFlowNameRoute
'/runs/$id': typeof LayoutRunsIdRoute
'/dashboards': typeof LayoutDashboardsIndexRoute '/dashboards': typeof LayoutDashboardsIndexRoute
'/flows': typeof LayoutFlowsIndexRoute '/flows': typeof LayoutFlowsIndexRoute
'/runs': typeof LayoutRunsIndexRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
@@ -189,8 +205,10 @@ export interface FileRoutesById {
'/panel/': typeof PanelIndexRoute '/panel/': typeof PanelIndexRoute
'/_canvas/dashboards/$name': typeof CanvasDashboardsNameRoute '/_canvas/dashboards/$name': typeof CanvasDashboardsNameRoute
'/_canvas/flows/$flowName': typeof CanvasFlowsFlowNameRoute '/_canvas/flows/$flowName': typeof CanvasFlowsFlowNameRoute
'/_layout/runs/$id': typeof LayoutRunsIdRoute
'/_layout/dashboards/': typeof LayoutDashboardsIndexRoute '/_layout/dashboards/': typeof LayoutDashboardsIndexRoute
'/_layout/flows/': typeof LayoutFlowsIndexRoute '/_layout/flows/': typeof LayoutFlowsIndexRoute
'/_layout/runs/': typeof LayoutRunsIndexRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
@@ -211,8 +229,10 @@ export interface FileRouteTypes {
| '/panel/' | '/panel/'
| '/dashboards/$name' | '/dashboards/$name'
| '/flows/$flowName' | '/flows/$flowName'
| '/runs/$id'
| '/dashboards/' | '/dashboards/'
| '/flows/' | '/flows/'
| '/runs/'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
| '/' | '/'
@@ -231,8 +251,10 @@ export interface FileRouteTypes {
| '/panel' | '/panel'
| '/dashboards/$name' | '/dashboards/$name'
| '/flows/$flowName' | '/flows/$flowName'
| '/runs/$id'
| '/dashboards' | '/dashboards'
| '/flows' | '/flows'
| '/runs'
id: id:
| '__root__' | '__root__'
| '/_canvas' | '/_canvas'
@@ -253,8 +275,10 @@ export interface FileRouteTypes {
| '/panel/' | '/panel/'
| '/_canvas/dashboards/$name' | '/_canvas/dashboards/$name'
| '/_canvas/flows/$flowName' | '/_canvas/flows/$flowName'
| '/_layout/runs/$id'
| '/_layout/dashboards/' | '/_layout/dashboards/'
| '/_layout/flows/' | '/_layout/flows/'
| '/_layout/runs/'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
@@ -384,6 +408,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LayoutAdminRouteImport preLoaderRoute: typeof LayoutAdminRouteImport
parentRoute: typeof LayoutRoute parentRoute: typeof LayoutRoute
} }
'/_layout/runs/': {
id: '/_layout/runs/'
path: '/runs'
fullPath: '/runs/'
preLoaderRoute: typeof LayoutRunsIndexRouteImport
parentRoute: typeof LayoutRoute
}
'/_layout/flows/': { '/_layout/flows/': {
id: '/_layout/flows/' id: '/_layout/flows/'
path: '/flows' path: '/flows'
@@ -398,6 +429,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LayoutDashboardsIndexRouteImport preLoaderRoute: typeof LayoutDashboardsIndexRouteImport
parentRoute: typeof LayoutRoute 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': { '/_canvas/flows/$flowName': {
id: '/_canvas/flows/$flowName' id: '/_canvas/flows/$flowName'
path: '/flows/$flowName' path: '/flows/$flowName'
@@ -435,8 +473,10 @@ interface LayoutRouteChildren {
LayoutSecretsRoute: typeof LayoutSecretsRoute LayoutSecretsRoute: typeof LayoutSecretsRoute
LayoutSettingsRoute: typeof LayoutSettingsRoute LayoutSettingsRoute: typeof LayoutSettingsRoute
LayoutIndexRoute: typeof LayoutIndexRoute LayoutIndexRoute: typeof LayoutIndexRoute
LayoutRunsIdRoute: typeof LayoutRunsIdRoute
LayoutDashboardsIndexRoute: typeof LayoutDashboardsIndexRoute LayoutDashboardsIndexRoute: typeof LayoutDashboardsIndexRoute
LayoutFlowsIndexRoute: typeof LayoutFlowsIndexRoute LayoutFlowsIndexRoute: typeof LayoutFlowsIndexRoute
LayoutRunsIndexRoute: typeof LayoutRunsIndexRoute
} }
const LayoutRouteChildren: LayoutRouteChildren = { const LayoutRouteChildren: LayoutRouteChildren = {
@@ -446,8 +486,10 @@ const LayoutRouteChildren: LayoutRouteChildren = {
LayoutSecretsRoute: LayoutSecretsRoute, LayoutSecretsRoute: LayoutSecretsRoute,
LayoutSettingsRoute: LayoutSettingsRoute, LayoutSettingsRoute: LayoutSettingsRoute,
LayoutIndexRoute: LayoutIndexRoute, LayoutIndexRoute: LayoutIndexRoute,
LayoutRunsIdRoute: LayoutRunsIdRoute,
LayoutDashboardsIndexRoute: LayoutDashboardsIndexRoute, LayoutDashboardsIndexRoute: LayoutDashboardsIndexRoute,
LayoutFlowsIndexRoute: LayoutFlowsIndexRoute, LayoutFlowsIndexRoute: LayoutFlowsIndexRoute,
LayoutRunsIndexRoute: LayoutRunsIndexRoute,
} }
const LayoutRouteWithChildren = 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 } })
}
/>
)
}