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 }) +})