From 7e422c00476929505fffe1f77711200fec5a4e7e Mon Sep 17 00:00:00 2001 From: stroblme Date: Tue, 25 Aug 2026 11:34:07 +0200 Subject: [PATCH] Count runs per flow, and page the run list by offset --- backend/fluksio/api/routes/runs.py | 43 ++++++++++++++++++++++++++- backend/tests/api/routes/test_runs.py | 35 ++++++++++++++++++++++ frontend/src/client/schemas.gen.ts | 28 +++++++++++++++++ frontend/src/client/sdk.gen.ts | 22 ++++++++++++-- frontend/src/client/types.gen.ts | 14 +++++++++ 5 files changed, 139 insertions(+), 3 deletions(-) diff --git a/backend/fluksio/api/routes/runs.py b/backend/fluksio/api/routes/runs.py index d8bf099..54bb5e4 100644 --- a/backend/fluksio/api/routes/runs.py +++ b/backend/fluksio/api/routes/runs.py @@ -10,6 +10,8 @@ from typing import Any from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.concurrency import run_in_threadpool from pydantic import BaseModel, Field +from sqlalchemy import func +from sqlalchemy import select as sa_select from sqlmodel import col, select from fluksio.api.deps import CurrentUser, SessionDep, get_current_user @@ -98,6 +100,16 @@ class RunDetail(RunRow): artifacts: list[ArtifactRow] = Field(default_factory=list) +class FlowRunsRow(BaseModel): + """How much a flow has been run, for the screen's list of flows.""" + + flow: str + runs: int + running: int + queued: int + last_created_at: Any = None + + class MetricPoint(BaseModel): step: int ts: float @@ -197,6 +209,7 @@ def read_runs( group: str | None = None, digest: str | None = None, limit: int = 50, + offset: int = 0, ) -> Any: """Runs, newest first. The queryable table an experiment log needs.""" statement = select(Run).order_by(col(Run.created_at).desc()) @@ -208,7 +221,35 @@ def read_runs( statement = statement.where(col(Run.group_id) == group) if digest: statement = statement.where(col(Run.params_digest) == digest) - return list(session.exec(statement.limit(min(limit, 500)))) + statement = statement.offset(max(0, offset)).limit(min(limit, 500)) + return list(session.exec(statement)) + + +@router.get("/overview", response_model=list[FlowRunsRow]) +def read_overview(session: SessionDep) -> Any: + """One row per flow that has ever run, busiest-recent first. + + The list caps at 500 newest runs, so counting flows on the client goes + wrong the moment a history outgrows one page. The database counts instead. + """ + statement = sa_select( + col(Run.flow), + col(Run.status), + func.count(col(Run.id)), + func.max(col(Run.created_at)), + ).group_by(col(Run.flow), col(Run.status)) + + rows: dict[str, FlowRunsRow] = {} + for flow, status, count, latest in session.execute(statement): + row = rows.setdefault(flow, FlowRunsRow(flow=flow, runs=0, running=0, queued=0)) + row.runs += count + if status == "running": + row.running += count + elif status == "queued": + row.queued += count + if row.last_created_at is None or latest > row.last_created_at: + row.last_created_at = latest + return sorted(rows.values(), key=lambda row: row.last_created_at, reverse=True) @router.get("/{run_id}", response_model=RunDetail) diff --git a/backend/tests/api/routes/test_runs.py b/backend/tests/api/routes/test_runs.py index cbb9902..1a7a1ca 100644 --- a/backend/tests/api/routes/test_runs.py +++ b/backend/tests/api/routes/test_runs.py @@ -10,6 +10,7 @@ from datetime import UTC, datetime import pytest from sqlmodel import Session +from fluksio.core.config import settings from fluksio.core.db import engine as db_engine from fluksio.flow.artifacts import ArtifactStore from fluksio.flow.messages import DType, MessageSpec @@ -196,3 +197,37 @@ def test_a_reference_passed_whole_is_left_alone(made_artifact): assert resolve_references(artifact_flow(), {"dataset": reference}) == { "dataset": reference } + + +def test_the_overview_counts_a_flow_the_list_page_would_not_reach( + client, superuser_token_headers +): + """The list caps at 500 newest; the flow rail needs whole counts.""" + with Session(db_engine) as session: + for index in range(3): + session.add( + Run( + id=f"ov-{index}", + flow="overviewed", + status="ok" if index else "running", + created_at=datetime(2026, 1, 1 + index, tzinfo=UTC), + ) + ) + session.commit() + + rows = client.get( + f"{settings.API_V1_STR}/runs/overview", headers=superuser_token_headers + ).json() + row = next(r for r in rows if r["flow"] == "overviewed") + + assert (row["runs"], row["running"], row["queued"]) == (3, 1, 0) + + +def test_overview_is_not_read_as_a_run_id(client, superuser_token_headers): + """`/overview` is declared before `/{run_id}`, which would swallow it.""" + answer = client.get( + f"{settings.API_V1_STR}/runs/overview", headers=superuser_token_headers + ) + + assert answer.status_code == 200 + assert isinstance(answer.json(), list) diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index f64727c..8c1f6c9 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -1033,6 +1033,34 @@ export const FlowRollupSchema = { title: 'FlowRollup' } as const; +export const FlowRunsRowSchema = { + properties: { + flow: { + type: 'string', + title: 'Flow' + }, + runs: { + type: 'integer', + title: 'Runs' + }, + running: { + type: 'integer', + title: 'Running' + }, + queued: { + type: 'integer', + title: 'Queued' + }, + last_created_at: { + title: 'Last Created At' + } + }, + type: 'object', + required: ['flow', 'runs', 'running', 'queued'], + title: 'FlowRunsRow', + description: "How much a flow has been run, for the screen's list of flows." +} as const; + export const FlowStatePublicSchema = { properties: { values: { diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index 2d50f13..cc46ec2 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -3,7 +3,7 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; import { request as __request } from './core/request'; -import type { AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsTestChannelData, AlertsTestChannelResponse, ArtifactsPutArtifactData, ArtifactsPutArtifactResponse, ArtifactsGetArtifactData, ArtifactsGetArtifactResponse, CloudReadStatusResponse, CloudEnrollData, CloudEnrollResponse, CloudAddRemoteUserData, CloudAddRemoteUserResponse, CloudDisconnectResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsPublishDashboardData, DashboardsPublishDashboardResponse, DashboardsDiscardDashboardDraftData, DashboardsDiscardDashboardDraftResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadGraphResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsStepFlowData, FlowsStepFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsCancelNodeData, FlowsCancelNodeResponse, FlowsAcknowledgeNodeErrorData, FlowsAcknowledgeNodeErrorResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, ModulesRefreshModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, ObservabilityReadSummaryResponse, ObservabilityReadTimeseriesData, ObservabilityReadTimeseriesResponse, ObservabilityReadFlowRollupsData, ObservabilityReadFlowRollupsResponse, ObservabilityReadRunsData, ObservabilityReadRunsResponse, ObservabilityReadEventsData, ObservabilityReadEventsResponse, ObservabilityReadDeadLettersData, ObservabilityReadDeadLettersResponse, PanelsReadPanelsResponse, PanelsSavePanelsData, PanelsSavePanelsResponse, PanelsStartPairingResponse, PanelsPollPairingData, PanelsPollPairingResponse, PanelsPendingDeviceData, PanelsPendingDeviceResponse, PanelsApprovePairingData, PanelsApprovePairingResponse, PanelsUnpairPanelData, PanelsUnpairPanelResponse, PanelsReadPanelData, PanelsReadPanelResponse, PrivateCreateUserData, PrivateCreateUserResponse, RunsCreateRunData, RunsCreateRunResponse, RunsCreateSweepData, RunsCreateSweepResponse, RunsReadRunsData, RunsReadRunsResponse, RunsReadRunData, RunsReadRunResponse, RunsCancelRunData, RunsCancelRunResponse, RunsReadMetricsData, RunsReadMetricsResponse, RunsCompareMetricData, RunsCompareMetricResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsHealthResponse, WorkersReadWorkersResponse, WorkersIssueTokenData, WorkersIssueTokenResponse, WorkersReadRuntimeResponse } from './types.gen'; +import type { AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsTestChannelData, AlertsTestChannelResponse, ArtifactsPutArtifactData, ArtifactsPutArtifactResponse, ArtifactsGetArtifactData, ArtifactsGetArtifactResponse, CloudReadStatusResponse, CloudEnrollData, CloudEnrollResponse, CloudAddRemoteUserData, CloudAddRemoteUserResponse, CloudDisconnectResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsPublishDashboardData, DashboardsPublishDashboardResponse, DashboardsDiscardDashboardDraftData, DashboardsDiscardDashboardDraftResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadGraphResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsStepFlowData, FlowsStepFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsCancelNodeData, FlowsCancelNodeResponse, FlowsAcknowledgeNodeErrorData, FlowsAcknowledgeNodeErrorResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, ModulesRefreshModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, ObservabilityReadSummaryResponse, ObservabilityReadTimeseriesData, ObservabilityReadTimeseriesResponse, ObservabilityReadFlowRollupsData, ObservabilityReadFlowRollupsResponse, ObservabilityReadRunsData, ObservabilityReadRunsResponse, ObservabilityReadEventsData, ObservabilityReadEventsResponse, ObservabilityReadDeadLettersData, ObservabilityReadDeadLettersResponse, PanelsReadPanelsResponse, PanelsSavePanelsData, PanelsSavePanelsResponse, PanelsStartPairingResponse, PanelsPollPairingData, PanelsPollPairingResponse, PanelsPendingDeviceData, PanelsPendingDeviceResponse, PanelsApprovePairingData, PanelsApprovePairingResponse, PanelsUnpairPanelData, PanelsUnpairPanelResponse, PanelsReadPanelData, PanelsReadPanelResponse, PrivateCreateUserData, PrivateCreateUserResponse, RunsCreateRunData, RunsCreateRunResponse, RunsCreateSweepData, RunsCreateSweepResponse, RunsReadRunsData, RunsReadRunsResponse, RunsReadOverviewResponse, RunsReadRunData, RunsReadRunResponse, RunsCancelRunData, RunsCancelRunResponse, RunsReadMetricsData, RunsReadMetricsResponse, RunsCompareMetricData, RunsCompareMetricResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsHealthResponse, WorkersReadWorkersResponse, WorkersIssueTokenData, WorkersIssueTokenResponse, WorkersReadRuntimeResponse } from './types.gen'; export class AlertsService { /** @@ -1756,6 +1756,7 @@ export class RunsService { * @param data.group * @param data.digest * @param data.limit + * @param data.offset * @returns fluksio__api__routes__runs__RunRow Successful Response * @throws ApiError */ @@ -1768,7 +1769,8 @@ export class RunsService { status: data.status, group: data.group, digest: data.digest, - limit: data.limit + limit: data.limit, + offset: data.offset }, errors: { 422: 'Validation Error' @@ -1776,6 +1778,22 @@ export class RunsService { }); } + /** + * Read Overview + * One row per flow that has ever run, busiest-recent first. + * + * The list caps at 500 newest runs, so counting flows on the client goes + * wrong the moment a history outgrows one page. The database counts instead. + * @returns FlowRunsRow Successful Response + * @throws ApiError + */ + public static readOverview(): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/runs/overview' + }); + } + /** * Read Run * One run in full: what it was asked, what each node did, what it made. diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index bb63b99..5957f69 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -334,6 +334,17 @@ export type FlowRollup = { last_error_ts?: (number | null); }; +/** + * How much a flow has been run, for the screen's list of flows. + */ +export type FlowRunsRow = { + flow: string; + runs: number; + running: number; + queued: number; + last_created_at?: unknown; +}; + export type FlowsPublic = { data: Array; count: number; @@ -1600,11 +1611,14 @@ export type RunsReadRunsData = { flow?: (string | null); group?: (string | null); limit?: number; + offset?: number; status?: (string | null); }; export type RunsReadRunsResponse = (Array); +export type RunsReadOverviewResponse = (Array); + export type RunsReadRunData = { runId: string; };