diff --git a/backend/fluksio/api/routes/dashboards.py b/backend/fluksio/api/routes/dashboards.py index 396d8dc..142e912 100644 --- a/backend/fluksio/api/routes/dashboards.py +++ b/backend/fluksio/api/routes/dashboards.py @@ -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, diff --git a/backend/fluksio/flow/dashboards.py b/backend/fluksio/flow/dashboards.py index be5a25e..aa76be3 100644 --- a/backend/fluksio/flow/dashboards.py +++ b/backend/fluksio/flow/dashboards.py @@ -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", diff --git a/backend/tests/flow/test_dashboards.py b/backend/tests/flow/test_dashboards.py index a1db9ee..d13b717 100644 --- a/backend/tests/flow/test_dashboards.py +++ b/backend/tests/flow/test_dashboards.py @@ -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"] diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index cc46ec2..b667951 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -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 { + 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. diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 5957f69..5a630b3 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -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; diff --git a/frontend/src/components/Common/UplotChart.tsx b/frontend/src/components/Common/UplotChart.tsx index fa9867f..ffb3a63 100644 --- a/frontend/src/components/Common/UplotChart.tsx +++ b/frontend/src/components/Common/UplotChart.tsx @@ -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: [ diff --git a/frontend/src/components/Flow/useFlowSocket.ts b/frontend/src/components/Flow/useFlowSocket.ts index 05681b7..6617a7c 100644 --- a/frontend/src/components/Flow/useFlowSocket.ts +++ b/frontend/src/components/Flow/useFlowSocket.ts @@ -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 } } diff --git a/frontend/src/components/Runs/MetricChart.tsx b/frontend/src/components/Runs/MetricChart.tsx new file mode 100644 index 0000000..f2fee4b --- /dev/null +++ b/frontend/src/components/Runs/MetricChart.tsx @@ -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() + 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 ( +
+ + {drawn > 0 && (data?.lines?.length ?? 0) > MAX_SERIES && ( +

+ Showing {MAX_SERIES} of {data?.lines?.length} runs. +

+ )} +
+ ) +} + +/** 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 ( + + ) +} diff --git a/frontend/src/components/Runs/OpenInDashboard.tsx b/frontend/src/components/Runs/OpenInDashboard.tsx new file mode 100644 index 0000000..9ab9ebf --- /dev/null +++ b/frontend/src/components/Runs/OpenInDashboard.tsx @@ -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 ( + + + + + + {flow && !hasResults && ( + + )} + {dashboards.map((one) => ( + + ))} + {dashboards.length === 0 && !flow && ( +

+ No dashboards yet. +

+ )} +
+
+ ) +} diff --git a/frontend/src/components/Runs/RunDetail.tsx b/frontend/src/components/Runs/RunDetail.tsx new file mode 100644 index 0000000..4f2586b --- /dev/null +++ b/frontend/src/components/Runs/RunDetail.tsx @@ -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 + + 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 ( +
+
+ + {run.flow} + +

{shortId(run.id)}

+ + {run.group_id && ( + + in a sweep + + )} + +
+ + {isLive(run.status) && ( + + )} +
+
+ + {reason &&

{reason}

} + +
+ {ago(String(run.created_at ?? ""))} + {run.duration_ms ? dur(run.duration_ms) : "—"} + {run.actor || "—"} + {run.cause} + {run.seed ?? "—"} + + {/* The user's own repository for a code-declared flow; the store's + commit is a generated shim and says less. */} + + {shortCommit(run.origin_commit || run.commit || "") || "—"} + + + {run.labels.join(", ") || "—"} + + + {shortId(run.params_digest || "")} + + +
+ +
+

Parameters

+ {Object.keys(run.params).length === 0 ? ( +

+ This run took its flow's own defaults. +

+ ) : ( +
+ {Object.entries(run.params).map(([key, value]) => ( +
+
{key}
+
+ {paramText(value)} +
+
+ ))} +
+ )} +
+ + {names.length > 0 && ( +
+
+

Metrics

+ +
+ +
+ )} + + {names.length === 0 && cached && ( +

{NO_CURVE}

+ )} + + {Object.keys(run.result ?? {}).length > 0 && ( +
+

Result

+
+ {Object.entries(run.result ?? {}).map(([key, value]) => ( +
+
{key}
+
+ {paramText(value)} +
+
+ ))} +
+
+ )} + + + + {artifacts.length > 0 && } +
+ ) +} + +function Fact({ + label, + children, +}: { + label: string + children: React.ReactNode +}) { + return ( +
+ {label} + {children} +
+ ) +} + +/** What each node did, with its logs and its traceback behind a disclosure. */ +function NodesTable({ nodes }: { nodes: RunNodeRow[] }) { + const [open, setOpen] = useState(null) + if (nodes.length === 0) return null + + return ( +
+ + + + + Node + Status + Took + Worker + Attempt + + + + {nodes.map((node) => { + const detail = node.error || node.logs + const isOpen = open === node.node + return [ + + + {detail && ( + + )} + + {node.node} + + + + + {node.duration_ms ? dur(node.duration_ms) : "—"} + + + {node.worker || "—"} + + + {node.attempt} + + , + isOpen && detail ? ( + + + {node.error && ( +
+                        {node.error}
+                      
+ )} + {node.logs && ( +
+                        {node.logs}
+                      
+ )} +
+
+ ) : null, + ] + })} +
+
+
+ ) +} + +function Artifacts({ rows }: { rows: ArtifactRow[] }) { + const { showErrorToast } = useCustomToast() + return ( +
+ + + + Artifact + From + Type + Size + + + + + {rows.map((row) => ( + + {row.name} + + {row.node} + + + {row.media_type || "—"} + + + {si(row.size)}B + + + + + + ))} + +
+
+ ) +} diff --git a/frontend/src/components/Runs/RunStatus.tsx b/frontend/src/components/Runs/RunStatus.tsx new file mode 100644 index 0000000..e0630b6 --- /dev/null +++ b/frontend/src/components/Runs/RunStatus.tsx @@ -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 = { + 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 = ( + + {run.status} + + ) + if (!reason) return badge + return ( + + {badge} + {reason} + + ) +} + +/** 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 ( + + {status} + + ) +} diff --git a/frontend/src/components/Runs/RunsScreen.tsx b/frontend/src/components/Runs/RunsScreen.tsx new file mode 100644 index 0000000..6d20eb0 --- /dev/null +++ b/frontend/src/components/Runs/RunsScreen.tsx @@ -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) => 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 ( +
+ + update({ flow, group: undefined, compare: undefined }) + } + /> + +
+
+

+ {search.flow ?? "Runs"} +

+ + + + {search.group && ( + + )} +
+ + {isPending ? ( + + ) : ( + update({ group, compare: undefined })} + /> + )} + +
+ {hasNextPage && ( + + )} +

+ {runs.length} run{runs.length === 1 ? "" : "s"} + {!hasNextPage && runs.length >= LIST_CAP + ? ` — the newest ${LIST_CAP}, which is as deep as this list reads` + : ""} +

+
+ + {selected.length > 0 && ( + run.id === selected[0])?.flow} + /> + )} +
+
+ ) +} + +/** 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, + ) => ( + + ) + + return ( + + ) +} + +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 ( +
+ + + + + Run + {showFlow && Flow} + Status + {varying.length > 0 ? ( + varying.map((key) => {key}) + ) : ( + Parameters + )} + Seed + Code + Took + Started + By + + + + {runs.length === 0 && ( + + + No runs match this filter. + + + )} + {runs.map((run) => ( + + + onToggle(run.id)} + aria-label={`Compare ${run.id}`} + /> + + +
+ + {shortId(run.id)} + + {run.group_id && !search.group && ( + + )} +
+
+ {showFlow && ( + + {run.flow} + + )} + + + + {varying.length > 0 ? ( + varying.map((key) => ( + + {paramText(run.params[key])} + + )) + ) : ( + + {paramsSummary(run.params) || "—"} + + )} + + {run.seed ?? "—"} + + + {/* 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 ?? "") || "—"} + + + {run.duration_ms ? dur(run.duration_ms) : "—"} + + + {ago(String(run.created_at ?? ""))} + + + {run.actor || "—"} + +
+ ))} +
+
+
+ ) +} + +/** The picked runs, one metric at a time. */ +function Compare({ + ids, + search, + update, + flow, +}: { + ids: string[] + search: RunsSearch + update: (next: Partial) => 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 ( +
+
+

+ Comparing {ids.length} run{ids.length === 1 ? "" : "s"} +

+ update({ metric: name })} + /> + + +
+ + {tooMany && ( +

+ A chart carries {MAX_SERIES} lines; the first {MAX_SERIES} of these + are drawn. +

+ )} + + {metric ? ( + + ) : ( +

+ These runs recorded no metric series to compare. +

+ )} +
+ ) +} diff --git a/frontend/src/components/Runs/queries.ts b/frontend/src/components/Runs/queries.ts new file mode 100644 index 0000000..73f855a --- /dev/null +++ b/frontend/src/components/Runs/queries.ts @@ -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 }[]) { + if (runs.length < 2) return [] + const keys = new Set() + 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) => + Object.entries(params) + .map(([key, value]) => `${key}=${paramText(value)}`) + .join(" ") diff --git a/frontend/src/components/Sidebar/AppSidebar.tsx b/frontend/src/components/Sidebar/AppSidebar.tsx index b7b659f..4c733f8 100644 --- a/frontend/src/components/Sidebar/AppSidebar.tsx +++ b/frontend/src/components/Sidebar/AppSidebar.tsx @@ -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" }, diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 5f3b0e3..e710eaa 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -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 = diff --git a/frontend/src/routes/_layout/runs/$id.tsx b/frontend/src/routes/_layout/runs/$id.tsx new file mode 100644 index 0000000..e5f0afb --- /dev/null +++ b/frontend/src/routes/_layout/runs/$id.tsx @@ -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 +} diff --git a/frontend/src/routes/_layout/runs/index.tsx b/frontend/src/routes/_layout/runs/index.tsx new file mode 100644 index 0000000..c88554d --- /dev/null +++ b/frontend/src/routes/_layout/runs/index.tsx @@ -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): 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 ( + + navigate({ to: "/runs", search: { ...search, ...next } }) + } + /> + ) +}