From 7e506b26c045d3d9e48437c6b90f86af83358834 Mon Sep 17 00:00:00 2001 From: stroblme Date: Sat, 29 Aug 2026 08:37:11 +0200 Subject: [PATCH] Act on a run, and read Home top-down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runs: a run can now be deleted (DELETE /runs/{id}, cancelling a live one first), exported as csv from the screen's own filters, and its flow label opens the flow. Its "Parameters" panel became "Inputs" and lists every input the flow declares, marking the ones that took the flow's own value rather than the run's — the comparison table resolves the same defaults instead of printing "unset". Home reads brain, dashboards, health, flows: the mosaic is one full-width scrolling strip, and the flows list and the flow-activity rollups merged into a single left-joined table so a flow's state and its numbers sit on one row. Charts take a drag to narrow the x window and a double click or tap to come back out. UplotChart holds the scale and passes resetScales:false while a window is held, which is what the old comment said made this impossible. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019LrWVRguqbk33YzfEeUx5W --- backend/fluksio/api/routes/runs.py | 56 +++- backend/tests/api/routes/test_runs.py | 93 ++++++ frontend/scripts/capture-screenshots.mjs | 2 +- frontend/src/client/core/OpenAPI.ts | 2 +- frontend/src/client/sdk.gen.ts | 40 ++- frontend/src/client/types.gen.ts | 6 + .../src/components/Common/DashboardMosaic.tsx | 35 ++- .../src/components/Common/OverviewToolbar.tsx | 14 +- frontend/src/components/Common/UplotChart.tsx | 93 +++++- frontend/src/components/Health/FlowTable.tsx | 224 +++++++++++++++ .../src/components/Health/HealthOverview.tsx | 272 +++++------------- frontend/src/components/Health/Spark.tsx | 30 ++ frontend/src/components/Runs/RunDetail.tsx | 56 +++- frontend/src/components/Runs/RunsScreen.tsx | 117 +++++++- frontend/src/components/Runs/queries.ts | 91 +++++- frontend/src/routes/_layout/index.tsx | 163 ++--------- frontend/tests/mobile.spec.ts | 2 +- frontend/tests/runs.spec.ts | 151 ++++++++++ 18 files changed, 1082 insertions(+), 365 deletions(-) create mode 100644 frontend/src/components/Health/FlowTable.tsx create mode 100644 frontend/src/components/Health/Spark.tsx create mode 100644 frontend/tests/runs.spec.ts diff --git a/backend/fluksio/api/routes/runs.py b/backend/fluksio/api/routes/runs.py index 3cd9b46..bb7b820 100644 --- a/backend/fluksio/api/routes/runs.py +++ b/backend/fluksio/api/routes/runs.py @@ -8,20 +8,22 @@ or to listen on the flow socket, which carries its start and finish. import csv import io import json +import time from collections.abc import Iterator from datetime import UTC, datetime from itertools import groupby from typing import Any, Literal -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Request, Response from fastapi.concurrency import run_in_threadpool from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field, model_validator -from sqlalchemy import func +from sqlalchemy import delete, func from sqlalchemy import select as sa_select from sqlmodel import Session, col, select from fluksio.api.deps import CurrentUser, SessionDep, get_current_user +from fluksio.flow.events import event_bus from fluksio.flow.messages import requalify from fluksio.flow.runs import RunRejected, RunService, new_run_id from fluksio.flow.store import FlowNotFound @@ -613,6 +615,56 @@ async def cancel_run(run_id: str, request: Request, session: SessionDep) -> Any: return run +@router.delete("/{run_id}", status_code=204) +def delete_run(run_id: str, session: SessionDep, user: CurrentUser) -> Response: + """Forget a run and everything hanging off it. + + The same four statements ``_forget_runs`` uses when a flow goes: the run + tables carry a plain string ``run_id`` and no foreign key, so nothing + cascades on its own. ``flow_run``, ``metric_minute`` and ``engine_event`` + stay — they are the observability record and are pruned on their own window. + + A live run is refused rather than raced: the driver writes its nodes back + when it finishes, and those rows would arrive for a run that no longer + exists. Cancel it first. + + Two things this costs, both deliberate. ``RunNode.outputs`` *is* the stage + cache, so a later run loses hits this one would have served. And a node + restored from this run points here through ``cached_from`` — ``_series`` + already reads a missing source as an empty curve, which is what ``NO_CURVE`` + explains on the screen. The artifact bytes need no help: ``sweep_artifacts`` + keeps whatever a ``run_artifact`` row or a live message still names, so + dropping the rows is enough and the hourly sweep reclaims the blobs. + """ + run = session.get(Run, run_id) + if run is None: + raise HTTPException(status_code=404, detail="No such run") + if run.status in ("running", "queued"): + raise HTTPException( + status_code=409, + detail=( + f"Run {run_id} is {run.status}. Cancel it, or wait for it to " + "finish, before deleting it." + ), + ) + flow = run.flow + session.execute(delete(RunNode).where(col(RunNode.run_id) == run_id)) + session.execute(delete(RunMetric).where(col(RunMetric.run_id) == run_id)) + session.execute(delete(RunArtifact).where(col(RunArtifact.run_id) == run_id)) + session.execute(delete(Run).where(col(Run.id) == run_id)) + session.commit() + event_bus.publish( + { + "type": "audit", + "action": f"deleted run {run_id}", + "flow": flow, + "user": user.email, + "ts": time.time(), + } + ) + return Response(status_code=204) + + def _series(session: Session, run_id: str, name: str = "") -> list[RunMetric]: """A run's numbers, including the ones a cached node points at. diff --git a/backend/tests/api/routes/test_runs.py b/backend/tests/api/routes/test_runs.py index 9fe41ba..9eb7a61 100644 --- a/backend/tests/api/routes/test_runs.py +++ b/backend/tests/api/routes/test_runs.py @@ -432,6 +432,99 @@ def test_overview_is_not_read_as_a_run_id(client, superuser_token_headers): assert isinstance(answer.json(), list) +# ----------------------------------------------------------------------------- +# Deleting a run +# +# The route owns the four statements; what these guard is that it takes the +# children with it and refuses a run the driver is still writing to. +# ----------------------------------------------------------------------------- + + +@pytest.fixture +def deletable_run(): + """One finished run with a node, a number and an artifact row hanging off it.""" + run_id = "del-1" + with Session(db_engine) as session: + session.add( + Run(id=run_id, flow="deleted", status="ok", created_at=datetime.now(UTC)) + ) + session.add(RunNode(run_id=run_id, node="deleted.a", status="ok")) + session.add(RunMetric(run_id=run_id, name="deleted.loss", step=0, value=1.0)) + session.add( + RunArtifact( + run_id=run_id, + name="deleted.out", + filename="out.bin", + node="a", + digest="d" * 64, + size=7, + ) + ) + session.commit() + yield run_id + with Session(db_engine) as session: + run = session.get(Run, run_id) + if run is not None: + session.delete(run) + session.commit() + + +def test_deleting_a_run_takes_its_children_with_it( + client, superuser_token_headers, deletable_run +): + """No foreign key cascades here, so the route has to do it itself.""" + answer = client.delete( + f"{settings.API_V1_STR}/runs/{deletable_run}", headers=superuser_token_headers + ) + + assert answer.status_code == 204 + with Session(db_engine) as session: + assert session.get(Run, deletable_run) is None + for table in (RunNode, RunMetric, RunArtifact): + left = session.exec( + select(table).where(col(table.run_id) == deletable_run) + ).all() + assert left == [], f"{table.__name__} rows outlived the run" + + +def test_deleting_a_run_that_is_not_there_is_a_404(client, superuser_token_headers): + answer = client.delete( + f"{settings.API_V1_STR}/runs/nope-1", headers=superuser_token_headers + ) + + assert answer.status_code == 404 + + +def test_a_running_run_is_refused_rather_than_raced(client, superuser_token_headers): + """The driver writes its nodes back at the end; they would have no run.""" + run_id = "del-live" + with Session(db_engine) as session: + session.add( + Run( + id=run_id, + flow="deleted", + status="running", + created_at=datetime.now(UTC), + ) + ) + session.commit() + try: + answer = client.delete( + f"{settings.API_V1_STR}/runs/{run_id}", headers=superuser_token_headers + ) + + assert answer.status_code == 409 + assert "Cancel it" in answer.json()["detail"] + with Session(db_engine) as session: + assert session.get(Run, run_id) is not None + finally: + with Session(db_engine) as session: + run = session.get(Run, run_id) + if run is not None: + session.delete(run) + session.commit() + + # ----------------------------------------------------------------------------- # A cached node's curve # diff --git a/frontend/scripts/capture-screenshots.mjs b/frontend/scripts/capture-screenshots.mjs index 2e0a4d5..d8d55de 100644 --- a/frontend/scripts/capture-screenshots.mjs +++ b/frontend/scripts/capture-screenshots.mjs @@ -81,7 +81,7 @@ for (const theme of ["light", "dark"]) { // Home's sections fetch independently, so networkidle can fall between them // and photograph the skeletons. The flow table is the last of them to land. await page - .getByText(/Flow activity/i) + .getByText(/activity over the last/i) .first() .waitFor({ timeout: 15000 }) await page.waitForTimeout(1500) diff --git a/frontend/src/client/core/OpenAPI.ts b/frontend/src/client/core/OpenAPI.ts index 106f4d8..327a9ad 100644 --- a/frontend/src/client/core/OpenAPI.ts +++ b/frontend/src/client/core/OpenAPI.ts @@ -48,7 +48,7 @@ export const OpenAPI: OpenAPIConfig = { PASSWORD: undefined, TOKEN: undefined, USERNAME: undefined, - VERSION: '0.1.4', + VERSION: '0.1.4+dev', WITH_CREDENTIALS: false, interceptors: { request: new Interceptors(), diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index 9d49031..c461744 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, DashboardsGenerateResultsDashboardData, DashboardsGenerateResultsDashboardResponse, DashboardsPublishDashboardData, DashboardsPublishDashboardResponse, DashboardsDiscardDashboardDraftData, DashboardsDiscardDashboardDraftResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, FlavorsReadFlavorsResponse, FlavorsCreateFlavorData, FlavorsCreateFlavorResponse, FlavorsUpdateFlavorData, FlavorsUpdateFlavorResponse, FlavorsDeleteFlavorData, FlavorsDeleteFlavorResponse, 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, RunsExportMetricsData, RunsExportMetricsResponse, RunsExportRunsData, RunsExportRunsResponse, RunsReadRunData, RunsReadRunResponse, RunsCancelRunData, RunsCancelRunResponse, RunsReadMetricsData, RunsReadMetricsResponse, RunsCompareMetricData, RunsCompareMetricResponse, SearchReadSearchIndexResponse, 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, WorkersReadResourcesResponse, 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, FlavorsReadFlavorsResponse, FlavorsCreateFlavorData, FlavorsCreateFlavorResponse, FlavorsUpdateFlavorData, FlavorsUpdateFlavorResponse, FlavorsDeleteFlavorData, FlavorsDeleteFlavorResponse, 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, RunsExportMetricsData, RunsExportMetricsResponse, RunsExportRunsData, RunsExportRunsResponse, RunsReadRunData, RunsReadRunResponse, RunsDeleteRunData, RunsDeleteRunResponse, RunsCancelRunData, RunsCancelRunResponse, RunsReadMetricsData, RunsReadMetricsResponse, RunsCompareMetricData, RunsCompareMetricResponse, SearchReadSearchIndexResponse, 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, WorkersReadResourcesResponse, WorkersIssueTokenData, WorkersIssueTokenResponse, WorkersReadRuntimeResponse } from './types.gen'; export class AlertsService { /** @@ -2036,6 +2036,44 @@ export class RunsService { }); } + /** + * Delete Run + * Forget a run and everything hanging off it. + * + * The same four statements ``_forget_runs`` uses when a flow goes: the run + * tables carry a plain string ``run_id`` and no foreign key, so nothing + * cascades on its own. ``flow_run``, ``metric_minute`` and ``engine_event`` + * stay — they are the observability record and are pruned on their own window. + * + * A live run is refused rather than raced: the driver writes its nodes back + * when it finishes, and those rows would arrive for a run that no longer + * exists. Cancel it first. + * + * Two things this costs, both deliberate. ``RunNode.outputs`` *is* the stage + * cache, so a later run loses hits this one would have served. And a node + * restored from this run points here through ``cached_from`` — ``_series`` + * already reads a missing source as an empty curve, which is what ``NO_CURVE`` + * explains on the screen. The artifact bytes need no help: ``sweep_artifacts`` + * keeps whatever a ``run_artifact`` row or a live message still names, so + * dropping the rows is enough and the hourly sweep reclaims the blobs. + * @param data The data for the request. + * @param data.runId + * @returns void Successful Response + * @throws ApiError + */ + public static deleteRun(data: RunsDeleteRunData): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/api/v1/runs/{run_id}', + path: { + run_id: data.runId + }, + errors: { + 422: 'Validation Error' + } + }); + } + /** * Cancel Run * Stop a run. One already past its last node is left as it finished. diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index a416a53..f09cedb 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -1825,6 +1825,12 @@ export type RunsReadRunData = { export type RunsReadRunResponse = (RunDetail); +export type RunsDeleteRunData = { + runId: string; +}; + +export type RunsDeleteRunResponse = (void); + export type RunsCancelRunData = { runId: string; }; diff --git a/frontend/src/components/Common/DashboardMosaic.tsx b/frontend/src/components/Common/DashboardMosaic.tsx index 035ae27..0c0e67c 100644 --- a/frontend/src/components/Common/DashboardMosaic.tsx +++ b/frontend/src/components/Common/DashboardMosaic.tsx @@ -153,10 +153,13 @@ function Footprint({ dashboard }: { dashboard: DashboardDef_Output }) { function Tile({ dashboard, preview, + className, }: { dashboard: DashboardSummary /** Read the document for a footprint, or settle for the name alone. */ preview: boolean + /** What the layout needs of it — a width, in the scrolling strip. */ + className?: string }) { // The working copy, which is what the list itself is a summary of, so the // preview shows what an editor would open rather than the last publish. @@ -170,7 +173,10 @@ function Tile({ to="/dashboards/$name" params={{ name: dashboard.name }} data-testid="home-dashboard-tile" - className="grid content-start gap-2 rounded-lg border border-border p-2 transition-colors hover:bg-accent/50" + className={cn( + "grid content-start gap-2 rounded-lg border border-border p-2 transition-colors hover:bg-accent/50", + className, + )} > {preview && isPending ? ( @@ -213,19 +219,37 @@ export function byRecency< ) } -/** The dashboards, as the shapes they are, beside the flows on the home view. */ +/** + * The dashboards, as the shapes they are. + * + * Two layouts, because it is read two ways: a column of pairs where it sits + * beside something else, and one wide strip where it has the page to itself. + * A tile is `content-start` around an `aspect-video` footprint and so has no + * width of its own — the strip has to give it one. + */ export function DashboardMosaic({ dashboards, isPending, + row = false, }: { dashboards: DashboardSummary[] isPending: boolean + /** One scrolling strip instead of a two-column grid. */ + row?: boolean }) { + const container = row + ? "flex snap-x snap-mandatory gap-3 overflow-x-auto p-3" + : "grid gap-3 p-3 sm:grid-cols-2" + const tile = row ? "w-56 shrink-0 snap-start" : "" + if (isPending) { return ( -
+
{Array.from({ length: 2 }).map((_, index) => ( - + ))}
) @@ -248,12 +272,13 @@ export function DashboardMosaic({ } return ( -
+
{dashboards.map((dashboard, index) => ( ))}
diff --git a/frontend/src/components/Common/OverviewToolbar.tsx b/frontend/src/components/Common/OverviewToolbar.tsx index 6174650..4554bd8 100644 --- a/frontend/src/components/Common/OverviewToolbar.tsx +++ b/frontend/src/components/Common/OverviewToolbar.tsx @@ -279,6 +279,7 @@ export function ConfirmDelete({ names, noun, pending, + description, onConfirm, }: { open: boolean @@ -286,6 +287,8 @@ export function ConfirmDelete({ names: string[] noun: string pending: boolean + /** What is actually lost, when the git-backed answer below is not it. */ + description?: string onConfirm: () => void }) { return ( @@ -298,9 +301,14 @@ export function ConfirmDelete({ : `Delete these ${names.length} ${noun}s?`} - {names.length === 1 ? "It goes" : "They go"} from the installation - at once. The store's git history keeps what was there, but nothing - in the app brings {names.length === 1 ? "it" : "them"} back. + {description ?? ( + <> + {names.length === 1 ? "It goes" : "They go"} from the + installation at once. The store's git history keeps what was + there, but nothing in the app brings{" "} + {names.length === 1 ? "it" : "them"} back. + + )} diff --git a/frontend/src/components/Common/UplotChart.tsx b/frontend/src/components/Common/UplotChart.tsx index 5943ffe..85528de 100644 --- a/frontend/src/components/Common/UplotChart.tsx +++ b/frontend/src/components/Common/UplotChart.tsx @@ -1,4 +1,4 @@ -import { useEffect, useLayoutEffect, useRef } from "react" +import { useEffect, useLayoutEffect, useRef, useState } from "react" import uPlot from "uplot" import "uplot/dist/uPlot.min.css" @@ -146,15 +146,23 @@ export const CURSOR: uPlot.Cursor = { mousemove: binder(false), } as unknown as uPlot.Cursor.Bind, drag: { - // No drag-to-zoom. `setData` re-ranges the scales from the data and runs - // on every render, so a dragged range was erased by the next reading — all - // it ever did here was flash a selection box over a live chart. - x: false, + // Drag across the plot to read a stretch of it closer. `setScale` stays + // off because the chart owns its x range itself: `setData` runs on every + // render and would re-range the scales from the data, so a held window is + // what tells it to leave them alone. Without that this only ever flashed a + // selection box over a live chart, which is why it used to be off. + x: true, y: false, setScale: false, }, } +/** Below this a drag is a click that moved, not a window. In pixels. */ +const DRAG_FLOOR = 4 + +/** How close two taps have to be to count as one gesture. */ +const DOUBLE_TAP_MS = 300 + /** Room for the axis ticks; uPlot measures the rest of the box itself. */ const PADDING: uPlot.Padding = [10, 12, 0, 0] @@ -278,6 +286,15 @@ export function UplotChart({ const host = useRef(null) const legend = useRef(null) const chart = useRef(null) + // A dragged x window, held so the next reading does not wash it away. The + // ref is what the data effect reads; the state is only what draws the way + // back out, and the two are set together. + const zoomed = useRef(false) + const [showReset, setShowReset] = useState(false) + const clearZoom = () => { + zoomed.current = false + setShowReset(false) + } // The chart outlives a render, so its handlers are read through a ref // rather than baked into the config it was built with. const report = useRef({ onCursor, onSelect }) @@ -302,6 +319,9 @@ export function UplotChart({ useLayoutEffect(() => { const element = host.current if (!element || labels.length === 0 || !ready) return + // A different set of series is a different picture; the window that was + // held over the old one means nothing on it. + clearZoom() const axis = { stroke: () => token("--muted-foreground", element), @@ -312,6 +332,8 @@ export function UplotChart({ /** The x value the page was last told about, so a move within one bucket * does not re-render it. */ let told: number | null = null + /** Whether the click about to arrive is the end of a drag. */ + let dragging = false // Resolved once for the whole chart: how many lines there are is part of // which slots they take, when nothing named them. const slots = slotsFor(labels.length, palette) @@ -343,11 +365,47 @@ export function UplotChart({ report.current.onCursor?.(ts) }, ], + setSelect: [ + (self) => { + // uPlot fires this for a plain click too. A few pixels is a + // slip of the hand, not a window anybody meant to ask for. + if (self.select.width <= DRAG_FLOOR) return + const from = self.posToVal(self.select.left, "x") + const to = self.posToVal( + self.select.left + self.select.width, + "x", + ) + // The box has done its job; the scale is what holds the window + // from here. `false` so this hook does not fire on itself. + self.setSelect({ left: 0, width: 0, top: 0, height: 0 }, false) + self.setScale("x", { min: from, max: to }) + dragging = true + zoomed.current = true + setShowReset(true) + }, + ], ready: [ (self) => { - self.over.addEventListener("click", () => - report.current.onSelect?.(under(self)), - ) + self.over.addEventListener("click", () => { + // The mouseup that ended a drag arrives here as a click as + // well; pinning a moment is not what it was asking for. + if (dragging) { + dragging = false + return + } + report.current.onSelect?.(under(self)) + }) + self.over.addEventListener("dblclick", clearZoom) + // ponytail: a touch screen gets no dblclick from every browser, + // and uPlot has no dbltap of its own. Two taps in a moment is + // the whole of the gesture. + let lastTap = 0 + self.over.addEventListener("pointerup", (event) => { + if (event.pointerType !== "touch") return + const now = event.timeStamp + if (now - lastTap < DOUBLE_TAP_MS) clearZoom() + lastTap = now + }) }, ], }, @@ -426,10 +484,14 @@ export function UplotChart({ // the blind spot a point count has: once a rolling window is full, a refetch // carrying different readings leaves the count where it was and never fires. // Safe to run this often because `setData` is idempotent and re-ranges the - // scales *from the data* — the opposite of the `redraw(false)` below. + // scales *from the data* — the opposite of the `redraw(false)` below. That + // re-ranging is exactly what a dragged window has to be spared, so while one + // is held the data goes in and the scales stay where they were put. Clearing + // the window renders, which brings the next pass through here with the reset + // back on: that is what puts the whole range back. useEffect(() => { if (!chart.current || plots.length === 0) return - chart.current.setData(table(plots)) + chart.current.setData(table(plots), !zoomed.current) }) // The canvas cannot follow a CSS variable, so a theme swap is a redraw. The @@ -448,6 +510,17 @@ export function UplotChart({
+ {/* Double-clicking does the same thing, but nothing says so. */} + {showReset ? ( + + ) : null} {points === 0 ? ( pending ? ( diff --git a/frontend/src/components/Health/FlowTable.tsx b/frontend/src/components/Health/FlowTable.tsx new file mode 100644 index 0000000..adfe979 --- /dev/null +++ b/frontend/src/components/Health/FlowTable.tsx @@ -0,0 +1,224 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { Link } from "@tanstack/react-router" +import { AlertCircle, Workflow } from "lucide-react" + +import { type FlowRollup, type FlowSummary, FlowsService } from "@/client" +import { byRecency } from "@/components/Common/DashboardMosaic" +import type { Range } from "@/components/Common/RangePicker" +import { flowKeys, flowsQueryOptions } from "@/components/Flow/queries" +import { PANEL_SECTION } from "@/components/Flow/SidePanel" +import { Badge } from "@/components/ui/badge" +import { Skeleton } from "@/components/ui/skeleton" +import { Switch } from "@/components/ui/switch" +import useCustomToast from "@/hooks/useCustomToast" +import { dur, si } from "@/lib/utils" +import { CARD, flowRollupsQueryOptions } from "./queries" +import { Spark } from "./Spark" + +/** Another tab can stop a flow, and the engine can fail one on its own. */ +const REFRESH_INTERVAL = 10_000 + +/** About six rows. Past that the table scrolls rather than the page. */ +const HEIGHT = "max-h-96" + +/** + * Every flow, with what it has been doing. + * + * One table rather than a list beside a rollup table: they are two halves of + * the same question and were previously read by matching rows up by eye. The + * join is a left one — the rollups only carry flows that ran inside the + * window, and a flow that has never run is still a flow. + */ +export function FlowTable({ range }: { range: Range }) { + const { data, isPending } = useQuery({ + ...flowsQueryOptions(), + refetchInterval: REFRESH_INTERVAL, + }) + const { data: rollups } = useQuery(flowRollupsQueryOptions(range)) + + const flows = [...(data?.data ?? [])].sort(byRecency) + const activity = new Map( + (rollups ?? []).map((row: FlowRollup) => [row.flow, row]), + ) + + return ( +
+
+

Flows

+

+ activity over the last {range.label} +

+
+ + {isPending ? ( +
+ + +
+ ) : flows.length === 0 ? ( +
+ + + +

+ Flows you build show up here, with what they are doing. +

+ + Go to flows + +
+ ) : ( +
+ {/* The name anchors the left, what it is doing sits beside it, and + the numbers read down their own centre with the trend closing the + row on the right. */} + + + + + + + + + + + {/* A bounded share rather than all the slack, so the numbers + spread across the middle instead of huddling on the left. + Its 128px floor is more than a phone has to spare, and a + curve that narrow says nothing, so it goes below `sm`. */} + + + + + {flows.map((flow) => ( + + ))} + +
FlowStatusRun + Executions + + Errors + + Avg + + Lag + + Trend +
+
+ )} +
+ ) +} + +function Row({ + flow, + activity, +}: { + flow: FlowSummary + /** Absent when the flow did not run inside the selected window. */ + activity?: FlowRollup +}) { + const queryClient = useQueryClient() + const { showErrorToast } = useCustomToast() + const enabled = flow.enabled ?? true + + const toggle = useMutation({ + mutationFn: (next: boolean) => + next + ? FlowsService.startFlow({ name: flow.name }) + : FlowsService.stopFlow({ name: flow.name }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: flowKeys.all }) + queryClient.invalidateQueries({ queryKey: flowKeys.detail(flow.name) }) + }, + onError: () => showErrorToast("The flow could not be started or stopped."), + }) + + // Nothing to report is not zero: a flow that never ran in this window and a + // flow that ran and did nothing are different answers. + const idle = + + return ( + + {/* `max-w-0` is what lets a cell truncate at all: without it the table + sizes to the longest name and pushes the page sideways. */} + + +

{flow.title || flow.name}

+

+ {flow.node_count === 1 ? "1 node" : `${flow.node_count} nodes`} + {flow.has_draft ? " · unpublished changes" : ""} +

+ + + + + + {(flow.error_count ?? 0) > 0 ? ( + + + {flow.error_count} + + ) : null} + + {enabled ? (flow.paused ? "Paused" : "Running") : "Stopped"} + + + + + + toggle.mutate(next)} + aria-label={`Run ${flow.title || flow.name}`} + data-testid="flow-enabled-switch" + /> + + + + {activity ? si(activity.executions) : idle} + + + {!activity ? ( + idle + ) : activity.errors ? ( + {si(activity.errors)} failed + ) : ( + none + )} + + + {activity ? dur(activity.avg_ms) : idle} + + + {activity ? dur(activity.avg_lag_ms) : idle} + + {/* The dot straddles the curve's right edge, so the cell keeps a little + room for the half that hangs out. */} + + {activity ? : idle} + + + ) +} diff --git a/frontend/src/components/Health/HealthOverview.tsx b/frontend/src/components/Health/HealthOverview.tsx index 6a4826d..7426945 100644 --- a/frontend/src/components/Health/HealthOverview.tsx +++ b/frontend/src/components/Health/HealthOverview.tsx @@ -1,9 +1,6 @@ import { useQuery } from "@tanstack/react-query" -import { Link } from "@tanstack/react-router" -import type { FlowRollup, HistoryPoint } from "@/client" import { type Range, RangePicker } from "@/components/Common/RangePicker" -import { Sparkline } from "@/components/Common/Sparkline" import { PANEL_SECTION } from "@/components/Flow/SidePanel" import { runOverviewQueryOptions } from "@/components/Runs/queries" import { Badge } from "@/components/ui/badge" @@ -37,40 +34,11 @@ function Tile({ } /** - * A flow's execution trend, drawn from the 60 slices the rollup carries. + * How the engine is doing: the standing state, as tiles. * - * Sixty slices of whatever window is selected, so the curve stays the same - * width and only its resolution moves. The same curve the node panel and the - * edge popover draw, dot included, in the chart ramp this page's other graphs - * use. The dot marks the newest slice rather than this instant: the server - * holds the slice that is still filling back, so the curve ends on one that is - * all there. - */ -function Spark({ counts }: { counts: number[] }) { - const points: HistoryPoint[] = counts.map((value, index) => ({ - ts: index, - value, - })) - if (points.every((point) => point.value === 0)) { - return nothing yet - } - return ( - - ) -} - -/** - * How the engine is doing, and how each flow has been doing over the window. - * - * The tiles are the standing state; the table below is the same window the - * charts cover, one row per flow. The range control sits on this heading - * because it governs the whole health block, the activity below included — - * one window, not one per card. + * The range control sits on this heading because it governs everything under + * it — the flow table, the charts and the lists — rather than this block + * alone. One window, not one per card. */ export function HealthOverview({ range, @@ -96,169 +64,85 @@ export function HealthOverview({ const queued = (runs ?? []).reduce((total, row) => total + row.queued, 0) return ( - <> -
-
-

Health

- - {degraded ? "Degraded" : "Running normally"} - -
- -
+
+
+

Health

+ + {degraded ? "Degraded" : "Running normally"} + +
+
- {summary?.problems.length ? ( -

- {summary.problems.join(" · ")} -

- ) : null} +
+ {summary?.problems.length ? ( +

+ {summary.problems.join(" · ")} +

+ ) : null} -
+
+ + + {runs?.length ? ( - - {runs?.length ? ( - - ) : null} - - {/* Backlog leads: what is waiting is what says the engine is + ) : null} + + {/* Backlog leads: what is waiting is what says the engine is behind. `pending` is work already running, which reads as idle on an engine hours behind. */} - - -
-
- -
-

Flow activity ({range.label})

-
- {/* The name anchors the left, the numbers read down their own - centre, and the trend closes the row on the right. */} - - - - - - - - - {/* A bounded share rather than all the slack: the columns - beside it grow with their own content, so the numbers - spread across the middle instead of huddling on the left. - Its 128px floor is more than a phone has to spare, and a - curve that narrow says nothing, so it goes below `sm`. */} - - - - - {(flows ?? []).map((row: FlowRollup) => ( - - - - - - - {/* The dot straddles the curve's right edge, so the cell - keeps a little room for the half that hangs out. */} - - - ))} - {flows?.length === 0 ? ( - - - - ) : null} - -
Flow - Executions - ErrorsAvgLag - Trend -
- {/* `max-w-0` is what lets a cell truncate at all: without - it the table sizes to the longest name and pushes the - page sideways. The floor beside it keeps an ordinary - name readable where there is room for one; a phone has - none to spare, so it starts at `sm` like the trend. */} - - {row.flow || "—"} - - - {si(row.executions)} - - {row.errors ? ( - - {si(row.errors)} failed - - ) : ( - none - )} - - {dur(row.avg_ms)} - - {dur(row.avg_lag_ms)} - - -
- No flow has run in the last {range.label}. -
-
-
- + + +
+
) } diff --git a/frontend/src/components/Health/Spark.tsx b/frontend/src/components/Health/Spark.tsx new file mode 100644 index 0000000..2439ccb --- /dev/null +++ b/frontend/src/components/Health/Spark.tsx @@ -0,0 +1,30 @@ +import type { HistoryPoint } from "@/client" +import { Sparkline } from "@/components/Common/Sparkline" + +/** + * A flow's execution trend, drawn from the 60 slices the rollup carries. + * + * Sixty slices of whatever window is selected, so the curve stays the same + * width and only its resolution moves. The same curve the node panel and the + * edge popover draw, dot included, in the chart ramp this page's other graphs + * use. The dot marks the newest slice rather than this instant: the server + * holds the slice that is still filling back, so the curve ends on one that is + * all there. + */ +export function Spark({ counts }: { counts: number[] }) { + const points: HistoryPoint[] = counts.map((value, index) => ({ + ts: index, + value, + })) + if (points.every((point) => point.value === 0)) { + return nothing yet + } + return ( + + ) +} diff --git a/frontend/src/components/Runs/RunDetail.tsx b/frontend/src/components/Runs/RunDetail.tsx index d4cd5c8..c70c086 100644 --- a/frontend/src/components/Runs/RunDetail.tsx +++ b/frontend/src/components/Runs/RunDetail.tsx @@ -30,6 +30,7 @@ import { shortCommit, shortId, useCancelRun, + useFlowInputs, } from "./queries" import { NodeStatusBadge, RunStatusBadge, statusReason } from "./RunStatus" @@ -39,6 +40,7 @@ export function RunDetail({ id }: { id: string }) { const { data: run, isPending } = useQuery(runQueryOptions(id)) const cancel = useCancelRun() const names = useMetricNames(id) + const declared = useFlowInputs(run?.flow) const [metric, setMetric] = useState("") if (isPending || !run) return @@ -52,6 +54,20 @@ export function RunDetail({ id }: { id: string }) { // Said once, where it applies. const cached = nodes.some((node) => node.status === "cached") + // Declared order first, so the panel reads the way the flow does; anything + // the run carries that the flow no longer declares still shows, since it is + // what the run was actually given. + const fed = [ + ...[...declared.keys()].map((name) => ({ + name, + value: name in run.params ? run.params[name] : declared.get(name), + fromFlow: !(name in run.params), + })), + ...Object.keys(run.params) + .filter((name) => !declared.has(name)) + .map((name) => ({ name, value: run.params[name], fromFlow: false })), + ] + return (
@@ -113,16 +129,26 @@ export function RunDetail({ id }: { id: string }) { + {/* What the run actually ran with, which is not the same list as what it + was passed: a flow's inputs *are* its parameters, and one left alone + took the flow's own value. Both are drawn, and which is which is + marked — reading a number off a chart is worth nothing if the other + half of the setting is invisible. */}
-

Parameters

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

Inputs

+ {fed.length === 0 ? (

- This run took its flow's own defaults. + This flow declares no inputs, so there was nothing to choose.

) : (
- {Object.entries(run.params).map(([key, value]) => ( - + {fed.map(({ name, value, fromFlow }) => ( + ))}
)} @@ -167,7 +193,16 @@ export function RunDetail({ id }: { id: string }) { * record like any other, and serialising it onto one truncated line answers * nothing. */ -function Entry({ name, value }: { name: string; value: unknown }) { +function Entry({ + name, + value, + note, +}: { + name: string + value: unknown + /** Where the value came from, when it was not this run. */ + note?: string +}) { const structured = value !== null && typeof value === "object" return (
-
{name}
+
+ {name} + {note && ( + + {note} + + )} +
+ new URLSearchParams( + selected.length + ? { ids: selected.join(","), format } + : { ...filters, format }, + ) + + const client = useQueryClient() + const [confirming, setConfirming] = useState(false) + const remove = useDeleteSelected(cancelThenDelete, "run", () => { + setConfirming(false) + update({ compare: undefined }) + client.invalidateQueries({ queryKey: runKeys.all }) + }) + return (
)} + + + + + + + exportRuns(exportQuery("csv"))}> + Runs table (.csv) + + exportMetrics(exportQuery("csv"))} + > + Metrics (.csv) + + + + + {selected.length > 0 && ( + + )}
+ remove.mutate(selected)} + /> + {isPending ? ( ) : ( @@ -417,8 +501,14 @@ function RunsTable({
{showFlow && ( - - {run.flow} + + + {run.flow} + )} @@ -576,6 +666,12 @@ function Compare({ * grows downwards, where there is somewhere to grow. */ function ParamDiff({ rows }: { rows: RunRow[] }) { + // What the flow declares, so a run that was never given a parameter reads as + // the value it actually ran with rather than as a blank. + // ponytail: only when the picks share one flow — spanning flows would mean a + // declaration lookup per flow, and a sweep comparison never does. + const one = rows.every((run) => run.flow === rows[0].flow) + const declared = useFlowInputs(one ? rows[0].flow : undefined) const varying = varyingKeys(rows) // Not a parameter, but it is part of what produced the number, and in a // sweep it is often the only thing that moved. @@ -640,9 +736,16 @@ function ParamDiff({ rows }: { rows: RunRow[] }) { {key in run.params ? ( + ) : declared.has(key) ? ( + // Not "empty": this run was never given the parameter, so + // what it ran with is what the flow declares. + + + + default + + ) : ( - // Not "empty": this run was never given the parameter, and - // took whatever the flow declares as its default. unset )} diff --git a/frontend/src/components/Runs/queries.ts b/frontend/src/components/Runs/queries.ts index 73b4e5b..12454cf 100644 --- a/frontend/src/components/Runs/queries.ts +++ b/frontend/src/components/Runs/queries.ts @@ -1,6 +1,8 @@ -import { useMutation, useQueryClient } from "@tanstack/react-query" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { useMemo } from "react" import { OpenAPI, RunsService } from "@/client" +import { flowQueryOptions } from "@/components/Flow/queries" import { apiToken } from "@/lib/portal" /** @@ -147,6 +149,93 @@ export async function downloadArtifact(digest: string, name: string) { URL.revokeObjectURL(url) } +/** + * What a flow declares it can be given, by input name. + * + * A batch flow's inputs *are* its parameters — a run supplies values for the + * ones it names and takes the flow's own for the rest — so this is what turns + * `run.params` from "what was passed" into "what the run actually ran with". + * + * The declarations are the flow's *current* ones, while a run carries the + * `flow_version` it was submitted against. An input added since is shown on an + * older run as a default it never actually received. + */ +export function useFlowInputs(flow: string | undefined) { + const { data } = useQuery({ + ...flowQueryOptions(flow ?? ""), + enabled: Boolean(flow), + }) + const inputs = data?.definition.inputs + return useMemo( + () => + new Map( + (inputs ?? []) + .filter((one) => Boolean(one.spec.name)) + .map((one) => [one.spec.name ?? "", one.initial ?? null]), + ), + [inputs], + ) +} + +/** + * Save the current selection as a file. + * + * The same trip `downloadArtifact` makes and for the same reason — the export + * routes take a bearer token, which an anchor cannot carry. Not the generated + * SDK either: it parses every body as JSON, and these stream csv. + */ +async function exportAs(what: "runs" | "metrics", query: URLSearchParams) { + const token = apiToken() + const answer = await fetch( + `${OpenAPI.BASE}/api/v1/runs/export/${what}?${query}`, + { headers: token ? { Authorization: `Bearer ${token}` } : {} }, + ) + if (!answer.ok) throw new Error(`Could not export the ${what}`) + const url = URL.createObjectURL(await answer.blob()) + const link = document.createElement("a") + link.href = url + link.download = `${what}.${query.get("format") ?? "csv"}` + link.click() + URL.revokeObjectURL(url) +} + +/** The runs themselves: one row each, with the parameters that varied. */ +export const exportRuns = (query: URLSearchParams) => exportAs("runs", query) + +/** Every recorded number of the selection, one row per point. */ +export const exportMetrics = (query: URLSearchParams) => + exportAs("metrics", query) + +/** How long a cancelled run is given to actually stop before delete gives up. */ +const SETTLE_TRIES = 30 +const SETTLE_WAIT_MS = 500 + +/** + * Delete a run, cancelling it first if it is still going. + * + * The route refuses a live run rather than racing its driver, so the two steps + * are the caller's to sequence. The wait is bounded and throws when it runs + * out, which is what puts a stuck run in the partial-success toast by name + * instead of hanging the button. + * + * ponytail: polling, because nothing pushes a run's status to a caller that is + * not rendering it. The socket already carries run_finished if this ever needs + * to be immediate. + */ +export async function cancelThenDelete(runId: string) { + const run = await RunsService.readRun({ runId }) + if (isLive(run.status)) { + await RunsService.cancelRun({ runId }) + let settled = false + for (let tries = 0; tries < SETTLE_TRIES && !settled; tries++) { + await new Promise((wake) => setTimeout(wake, SETTLE_WAIT_MS)) + settled = !isLive((await RunsService.readRun({ runId })).status) + } + if (!settled) throw new Error(`${shortId(runId)} did not stop`) + } + await RunsService.deleteRun({ runId }) +} + /** * Why a finished run can have nothing to draw. * diff --git a/frontend/src/routes/_layout/index.tsx b/frontend/src/routes/_layout/index.tsx index 4a9fc4a..589e994 100644 --- a/frontend/src/routes/_layout/index.tsx +++ b/frontend/src/routes/_layout/index.tsx @@ -1,22 +1,17 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" -import { createFileRoute, Link } from "@tanstack/react-router" -import { AlertCircle, Workflow } from "lucide-react" +import { useQuery } from "@tanstack/react-query" +import { createFileRoute } from "@tanstack/react-router" import { useState } from "react" -import { type FlowSummary, FlowsService } from "@/client" import { byRecency, DashboardMosaic } from "@/components/Common/DashboardMosaic" import { DEFAULT_RANGE } from "@/components/Common/RangePicker" import { dashboardsQueryOptions } from "@/components/Dashboard/queries" import { BrainView } from "@/components/Flow/BrainView" -import { flowKeys, flowsQueryOptions } from "@/components/Flow/queries" +import { flowsQueryOptions } from "@/components/Flow/queries" +import { FlowTable } from "@/components/Health/FlowTable" import { HealthActivity } from "@/components/Health/HealthActivity" import { HealthOverview } from "@/components/Health/HealthOverview" import { LiveIndicator } from "@/components/Health/LiveIndicator" -import { Badge } from "@/components/ui/badge" import { Card } from "@/components/ui/card" -import { Skeleton } from "@/components/ui/skeleton" -import { Switch } from "@/components/ui/switch" -import useCustomToast from "@/hooks/useCustomToast" export const Route = createFileRoute("/_layout/")({ component: Home, @@ -29,102 +24,29 @@ export const Route = createFileRoute("/_layout/")({ }), }) -/** Another tab can stop a flow, and the engine can fail one on its own. */ -const REFRESH_INTERVAL = 10_000 - -/** - * How tall the two lists beside each other are allowed to get: about six flow - * rows, and whatever the mosaic fits in the same space. Past it each column - * scrolls on its own rather than pushing the health block off the screen. - */ -const LISTS = "grid max-h-96 grid-rows-[auto_minmax(0,1fr)] gap-2" - /** DESIGN-GUIDELINES.md → Typography, the canonical section header. */ const HEADER = "text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground" -function FlowRow({ flow }: { flow: FlowSummary }) { - const queryClient = useQueryClient() - const { showErrorToast } = useCustomToast() - const enabled = flow.enabled ?? true - - const toggle = useMutation({ - mutationFn: (next: boolean) => - next - ? FlowsService.startFlow({ name: flow.name }) - : FlowsService.stopFlow({ name: flow.name }), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: flowKeys.all }) - queryClient.invalidateQueries({ queryKey: flowKeys.detail(flow.name) }) - }, - onError: () => showErrorToast("The flow could not be started or stopped."), - }) - - return ( -
- -

{flow.title || flow.name}

-

- {flow.node_count === 1 ? "1 node" : `${flow.node_count} nodes`} - {flow.has_draft ? " · unpublished changes" : ""} -

- - - {(flow.error_count ?? 0) > 0 ? ( - - - {flow.error_count} - - ) : null} - - - {enabled ? (flow.paused ? "Paused" : "Running") : "Stopped"} - - - toggle.mutate(next)} - aria-label={`Run ${flow.title || flow.name}`} - data-testid="flow-enabled-switch" - /> -
- ) -} - /** * The one overview: what the engine is wired up as, what is running, and how * it has been doing. The brain and the health screens compose in here rather * than living at routes of their own. + * + * Top to bottom it is a widening lens: the whole installation as a graph, what + * has been built on it, whether it is well, then flow by flow and finally + * moment by moment. */ function Home() { // The health block's window: one choice, read by the tiles, the flow table, // the charts and the lists under them. const [range, setRange] = useState(DEFAULT_RANGE) - const { data, isPending } = useQuery({ - ...flowsQueryOptions(), - refetchInterval: REFRESH_INTERVAL, - }) - // Its own query beside the flows one, so neither list waits for the other. const boards = useQuery(dashboardsQueryOptions()) + // Only to know whether the brain has anything to draw; the table below runs + // its own copy of the same query. + const { data } = useQuery(flowsQueryOptions()) - const flows = [...(data?.data ?? [])].sort(byRecency) + const flows = data?.data ?? [] const dashboards = [...(boards.data?.data ?? [])].sort(byRecency) return ( @@ -138,53 +60,30 @@ function Home() { {/* Nothing wired up yet means nothing to draw, and the band would still - hold a screenful of empty space above the flows card. Node count + hold a screenful of empty space above the flows table. Node count rather than flow count: a flow made a minute ago has none. */} {flows.some((flow) => (flow.node_count ?? 0) > 0) ? : null} - {/* One grid row holding both: a grid item stretches to the row, so the - two columns come out exactly as tall as each other whatever is in - them — and with one flow and one dashboard that is simply the taller - of the two, which is the floor the cap never goes under. */} -
-
-

Flows

- - {isPending ? ( -
- - -
- ) : flows.length === 0 ? ( -
- - - -

- Flows you build show up here, with what they are doing. -

- - Go to flows - -
- ) : ( - flows.map((flow) => ) - )} -
-
- -
-

Dashboards

- - - -
-
+ {/* One row across the full width rather than a column beside the flows: + a dashboard tile is a picture, and a picture wants to be wide. Past + what fits, the strip scrolls sideways on its own — the page must not, + which is what the `min-w-0` above is holding. */} +
+

Dashboards

+ {/* `min-w-0`: the strip inside scrolls, but this card is a grid item + whose automatic minimum is its content — without this the tiles + widen it, and the page with it. */} + + + +
+
) diff --git a/frontend/tests/mobile.spec.ts b/frontend/tests/mobile.spec.ts index 90921a9..6e4ac4a 100644 --- a/frontend/tests/mobile.spec.ts +++ b/frontend/tests/mobile.spec.ts @@ -311,7 +311,7 @@ test.afterAll(async ({ browser }) => { test("home fits the viewport", async ({ page }) => { await page.goto("/") await page - .getByText(/Flow activity/i) + .getByText(/activity over the last/i) .first() .waitFor({ timeout: 15000 }) await expectFits(page, "home") diff --git a/frontend/tests/runs.spec.ts b/frontend/tests/runs.spec.ts new file mode 100644 index 0000000..58a1b8c --- /dev/null +++ b/frontend/tests/runs.spec.ts @@ -0,0 +1,151 @@ +import { expect, test } from "@playwright/test" +import { api, apiPage, deleteAll } from "./utils/api" + +/** + * What the runs screen can do to a run, rather than what it can show about one. + * + * The reading half is covered by the run itself being there; these are the two + * things that change something — a delete, and a download — plus the two links + * a run makes to what produced it. + */ + +const flowName = `test_runs_${Date.now().toString(36)}` + +test.use({ storageState: "playwright/.auth/user.json" }) +test.describe.configure({ mode: "serial" }) + +// Yields a curve so the run has something to draw, which is what the zoom +// below needs; the streaming port is what makes those yields a series. +const TRAIN = `def process(epochs): + for step in range(epochs): + yield {"loss": 1.0 / (step + 1)} + return {"score": epochs * 0.5} +` + +test.afterAll(async ({ browser }) => { + await deleteAll(browser, [`/flows/${flowName}`]) +}) + +test.beforeAll(async ({ browser }) => { + const page = await apiPage(browser) + await api(page, `/flows/${flowName}`, { + method: "PUT", + data: { + name: flowName, + title: "Runs under test", + // `rate` is declared and never passed, which is what puts a default in + // the Inputs panel below. + inputs: [ + { spec: { name: "epochs", dtype: "int" }, initial: 2 }, + { spec: { name: "rate", dtype: "float" }, initial: 0.5 }, + ], + nodes: [ + { + id: "train", + type: "python", + requires: [{ name: "epochs", dtype: "int" }], + provides: [ + { name: "loss", dtype: "float", stream: true }, + { name: "score", dtype: "float" }, + ], + }, + ], + }, + }) + await api(page, `/flows/${flowName}/nodes/train/source`, { + method: "PUT", + data: { code: TRAIN }, + }) + const detail = await (await api(page, `/flows/${flowName}`)).json() + await api(page, `/flows/${flowName}/publish`, { + method: "POST", + data: { version: detail.definition.version }, + }) + + for (const epochs of [4, 6]) { + const answer = await api(page, `/runs/flows/${flowName}`, { + method: "POST", + data: { params: { epochs } }, + }) + expect(answer.ok()).toBeTruthy() + } + await page.close() +}) + +/** The screen filtered to this flow, once both runs have stopped moving. */ +async function openRuns(page: import("@playwright/test").Page) { + await page.goto(`/runs?flow=${flowName}`) + await expect(page.getByTestId("run-row")).toHaveCount(2) + await expect(page.getByText(/queued|running/)).toHaveCount(0, { + timeout: 30_000, + }) +} + +test("a run names the flow it came from, and links to it", async ({ page }) => { + await page.goto("/runs") + const link = page + .getByTestId("run-row") + .filter({ hasText: flowName }) + .first() + .getByRole("link", { name: flowName }) + await link.click() + await page.waitForURL(`**/flows/${flowName}`) +}) + +test("an input the run never passed reads as the flow's own", async ({ + page, +}) => { + await openRuns(page) + await page.getByTestId("run-link").first().click() + + const inputs = page.locator("section", { hasText: "Inputs" }).last() + // Passed, so no marker. + await expect(inputs).toContainText("epochs") + // Declared and left alone: the value shows, and says where it came from. + await expect(inputs).toContainText("rate") + await expect(inputs).toContainText("0.5") + await expect(inputs.getByText("default").first()).toBeVisible() +}) + +test("a chart can be dragged into and double-clicked back out of", async ({ + page, +}) => { + await openRuns(page) + await page.getByTestId("run-link").first().click() + + const plot = page.locator(".u-over").first() + await expect(plot).toBeVisible() + const box = await plot.boundingBox() + if (!box) throw new Error("the chart has no box to drag across") + + const y = box.y + box.height / 2 + await page.mouse.move(box.x + box.width * 0.3, y) + await page.mouse.down() + await page.mouse.move(box.x + box.width * 0.7, y, { steps: 8 }) + await page.mouse.up() + + const reset = page.getByTestId("chart-reset-zoom") + await expect(reset).toBeVisible() + await plot.dblclick() + await expect(reset).toBeHidden() +}) + +test("the export button downloads the selection", async ({ page }) => { + await openRuns(page) + await page.getByTestId("export-runs").click() + + const download = page.waitForEvent("download") + await page.getByRole("menuitem", { name: /runs table/i }).click() + expect((await download).suggestedFilename()).toBe("runs.csv") +}) + +test("picked runs can be deleted", async ({ page }) => { + await openRuns(page) + for (const box of await page.getByTestId("run-select").all()) + await box.click() + + await page.getByTestId("delete-selected").click() + await page.getByTestId("confirm-delete").click() + + await expect(page.getByTestId("run-row")).toHaveCount(0, { timeout: 30_000 }) +})