From 56fa34c2c96dd7717ef737aef63fc86287da6dfa Mon Sep 17 00:00:00 2001 From: stroblme Date: Thu, 15 Oct 2026 16:57:07 +0200 Subject: [PATCH] Home charts execution time per node type on a log scale, from a /observability/node-types rollup --- backend/fluksio/api/routes/observability.py | 63 ++++++++ .../tests/api/routes/test_observability.py | 56 +++++++ docs/interface/index.md | 6 + frontend/src/client/schemas.gen.ts | 29 ++++ frontend/src/client/sdk.gen.ts | 26 ++- frontend/src/client/types.gen.ts | 17 ++ .../src/components/Health/NodeTypeTimes.tsx | 153 ++++++++++++++++++ frontend/src/components/Health/queries.ts | 7 + frontend/src/routes/_layout/index.tsx | 6 +- 9 files changed, 360 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/Health/NodeTypeTimes.tsx diff --git a/backend/fluksio/api/routes/observability.py b/backend/fluksio/api/routes/observability.py index ad315f4..1374406 100644 --- a/backend/fluksio/api/routes/observability.py +++ b/backend/fluksio/api/routes/observability.py @@ -86,6 +86,17 @@ class FlowRollup(BaseModel): last_error_ts: float | None = None +class NodeTypeTiming(BaseModel): + """How long one kind of node takes to run, across every node of it.""" + + type: str + #: How many published nodes of this type ran inside the window. + nodes: int + executions: int + avg_ms: float + max_ms: float + + class RunRow(BaseModel): id: str flow: str @@ -428,6 +439,58 @@ def read_flow_rollups( ] +@router.get("/node-types", response_model=list[NodeTypeTiming]) +def read_node_type_timings( + session: SessionDep, + controller: FlowControllerDep, + hours: Annotated[int, Query(ge=0, le=8760)] = 24, +) -> Any: + """Execution time per node type, slowest first, to point at the hotspots. + + The rollups are kept per node, so the type is joined on from the published + flows. A node deleted since it ran has no type left to count under. + """ + hours = _window_hours(hours) + types = { + f"{flow.name}.{node.id}": node.type + for flow in controller.store.read_all() + for node in flow.nodes + } + statement = ( + sa_select( + col(MetricBucket.node), + func.sum(col(MetricBucket.executions)).label("executions"), + func.sum(col(MetricBucket.duration_sum_ms)).label("duration_sum_ms"), + func.max(col(MetricBucket.duration_max_ms)).label("max_ms"), + ) + .where(col(MetricBucket.bucket) >= _since(hours)) + .group_by(col(MetricBucket.node)) + ) + totals: dict[str, dict[str, float]] = {} + for row in session.execute(statement): + kind = types.get(row.node) + if kind is None or not row.executions: + continue + entry = totals.setdefault( + kind, {"nodes": 0, "executions": 0, "duration_sum_ms": 0.0, "max_ms": 0.0} + ) + entry["nodes"] += 1 + entry["executions"] += row.executions + entry["duration_sum_ms"] += row.duration_sum_ms + entry["max_ms"] = max(entry["max_ms"], row.max_ms) + timings = [ + NodeTypeTiming( + type=kind, + nodes=int(entry["nodes"]), + executions=int(entry["executions"]), + avg_ms=round(entry["duration_sum_ms"] / entry["executions"], 3), + max_ms=round(entry["max_ms"], 3), + ) + for kind, entry in totals.items() + ] + return sorted(timings, key=lambda timing: timing.avg_ms, reverse=True) + + @router.get("/runs", response_model=RunPage) def read_runs( session: SessionDep, diff --git a/backend/tests/api/routes/test_observability.py b/backend/tests/api/routes/test_observability.py index 77dafba..6a2984f 100644 --- a/backend/tests/api/routes/test_observability.py +++ b/backend/tests/api/routes/test_observability.py @@ -512,3 +512,59 @@ def test_a_superuser_can_flush_the_collector_now( ) ).all() assert len(written) == 1 + + +def test_execution_time_is_summed_per_node_type( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + from fluksio.flow.schemas import FlowDef, NodeDef + + flow = "node_type_test" + controller = client.app.state.flow_controller + before = controller.store.read_all + controller.store.read_all = lambda: [ + FlowDef( + name=flow, + nodes=[ + NodeDef(id="a", type="python"), + NodeDef(id="b", type="python"), + NodeDef(id="tick", type="inject"), + ], + ) + ] + now = datetime.now(UTC).replace(second=0, microsecond=0) + for node, executions, total, peak in ( + ("a", 2, 30.0, 20.0), + ("b", 2, 10.0, 6.0), + ("tick", 4, 0.4, 0.2), + # Deleted since it ran: nothing to count it under. + ("gone", 1, 500.0, 500.0), + ): + db.add( + MetricBucket( + flow=flow, + node=f"{flow}.{node}", + bucket=now - timedelta(minutes=2), + executions=executions, + duration_sum_ms=total, + duration_max_ms=peak, + ) + ) + db.commit() + try: + rows = client.get( + f"{PREFIX}/node-types", headers=superuser_token_headers + ).json() + finally: + controller.store.read_all = before + + # Slowest first. + assert [row["type"] for row in rows] == ["python", "inject"] + assert rows[0] == { + "type": "python", + "nodes": 2, + "executions": 4, + "avg_ms": 10.0, + "max_ms": 20.0, + } + assert rows[1]["avg_ms"] == 0.1 diff --git a/docs/interface/index.md b/docs/interface/index.md index 7005ebd..ba86273 100644 --- a/docs/interface/index.md +++ b/docs/interface/index.md @@ -58,6 +58,12 @@ Health shows flow status, node load errors, queue depth and event-loop lag. After you have used batch runs, it also shows running and queued runs. A degraded instance lists the problems that need attention. +### Execution time per node type + +One bar per node type at its average time per run, slowest first, on a log +scale. Each row also shows how often the type ran and its slowest single run, +so a slow or busy kind of node stands out. + ### Activity Choose 1h, 6h, 24h or 7d to review executions and failures, recent runs, diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index fe4797f..4a9df04 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -2657,6 +2657,35 @@ export const NodeTypeInfoSchema = { description: 'A node type the editor can offer, with its parameter schema.' } as const; +export const NodeTypeTimingSchema = { + properties: { + type: { + type: 'string', + title: 'Type' + }, + nodes: { + type: 'integer', + title: 'Nodes' + }, + executions: { + type: 'integer', + title: 'Executions' + }, + avg_ms: { + type: 'number', + title: 'Avg Ms' + }, + max_ms: { + type: 'number', + title: 'Max Ms' + } + }, + type: 'object', + required: ['type', 'nodes', 'executions', 'avg_ms', 'max_ms'], + title: 'NodeTypeTiming', + description: 'How long one kind of node takes to run, across every node of it.' +} as const; + export const OAuthAuthorizeInfoSchema = { properties: { client_name: { diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index a5844ec..bbf17fa 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 { AiReadAiConfigResponse, AiUpdateAiConfigData, AiUpdateAiConfigResponse, AiDeleteAiConfigResponse, AiListAiModelsData, AiListAiModelsResponse, AiReadAiStatusResponse, AiReadControlNodesResponse, AiReadChatResponse, AiChatData, AiChatResponse, AiClearChatResponse, AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsReadWebpushKeyResponse, AlertsAddWebpushSubscriptionData, AlertsAddWebpushSubscriptionResponse, AlertsRemoveWebpushSubscriptionData, AlertsRemoveWebpushSubscriptionResponse, AlertsTestChannelData, AlertsTestChannelResponse, ArtifactsPutArtifactData, ArtifactsPutArtifactResponse, ArtifactsGetArtifactData, ArtifactsGetArtifactResponse, ArtifactsDeleteArtifactData, ArtifactsDeleteArtifactResponse, CloudReadStatusResponse, CloudEnrollData, CloudEnrollResponse, CloudAddRemoteUserData, CloudAddRemoteUserResponse, CloudReadMembersResponse, CloudLinkMemberData, CloudLinkMemberResponse, CloudRevokeMemberData, CloudRevokeMemberResponse, CloudDisconnectResponse, ConnectorsReadConnectorStateData, ConnectorsReadConnectorStateResponse, ConnectorsClearConnectorStateData, ConnectorsClearConnectorStateResponse, ConnectorsDeleteConnectorStateData, ConnectorsDeleteConnectorStateResponse, 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, FlowsIssueSocketTicketResponse, 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, ObservabilityFlushMetricsResponse, PanelsReadPanelsResponse, PanelsSavePanelsData, PanelsSavePanelsResponse, PanelsStartPairingResponse, PanelsPollPairingData, PanelsPollPairingResponse, PanelsPendingDeviceData, PanelsPendingDeviceResponse, PanelsApprovePairingData, PanelsApprovePairingResponse, PanelsUnpairPanelData, PanelsUnpairPanelResponse, PanelsReadPanelData, PanelsReadPanelResponse, PrivateCreateUserData, PrivateCreateUserResponse, RunsCreateRunData, RunsCreateRunResponse, RunsCreateSweepData, RunsCreateSweepResponse, RunsImportRunsData, RunsImportRunsResponse, RunsReadRunsData, RunsReadRunsResponse, RunsDeleteRunsData, RunsDeleteRunsResponse, RunsReadOverviewResponse, RunsExportMetricsData, RunsExportMetricsResponse, RunsExportRunsData, RunsExportRunsResponse, RunsReadMetricNamesData, RunsReadMetricNamesResponse, RunsReadRunData, RunsReadRunResponse, RunsDeleteRunData, RunsDeleteRunResponse, RunsReadNodeOutputsData, RunsReadNodeOutputsResponse, RunsCancelRunData, RunsCancelRunResponse, RunsRetryRunData, RunsRetryRunResponse, 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, UtilsReadConfigResponse, UtilsHealthCheckResponse, UtilsHealthResponse, WorkersReadWorkersResponse, WorkersReadResourcesResponse, WorkersReadTokensResponse, WorkersIssueTokenData, WorkersIssueTokenResponse, WorkersRevokeTokenData, WorkersRevokeTokenResponse, WorkersReadRuntimeResponse } from './types.gen'; +import type { AiReadAiConfigResponse, AiUpdateAiConfigData, AiUpdateAiConfigResponse, AiDeleteAiConfigResponse, AiListAiModelsData, AiListAiModelsResponse, AiReadAiStatusResponse, AiReadControlNodesResponse, AiReadChatResponse, AiChatData, AiChatResponse, AiClearChatResponse, AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsReadWebpushKeyResponse, AlertsAddWebpushSubscriptionData, AlertsAddWebpushSubscriptionResponse, AlertsRemoveWebpushSubscriptionData, AlertsRemoveWebpushSubscriptionResponse, AlertsTestChannelData, AlertsTestChannelResponse, ArtifactsPutArtifactData, ArtifactsPutArtifactResponse, ArtifactsGetArtifactData, ArtifactsGetArtifactResponse, ArtifactsDeleteArtifactData, ArtifactsDeleteArtifactResponse, CloudReadStatusResponse, CloudEnrollData, CloudEnrollResponse, CloudAddRemoteUserData, CloudAddRemoteUserResponse, CloudReadMembersResponse, CloudLinkMemberData, CloudLinkMemberResponse, CloudRevokeMemberData, CloudRevokeMemberResponse, CloudDisconnectResponse, ConnectorsReadConnectorStateData, ConnectorsReadConnectorStateResponse, ConnectorsClearConnectorStateData, ConnectorsClearConnectorStateResponse, ConnectorsDeleteConnectorStateData, ConnectorsDeleteConnectorStateResponse, 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, FlowsIssueSocketTicketResponse, 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, ObservabilityReadNodeTypeTimingsData, ObservabilityReadNodeTypeTimingsResponse, ObservabilityReadRunsData, ObservabilityReadRunsResponse, ObservabilityReadEventsData, ObservabilityReadEventsResponse, ObservabilityReadDeadLettersData, ObservabilityReadDeadLettersResponse, ObservabilityFlushMetricsResponse, PanelsReadPanelsResponse, PanelsSavePanelsData, PanelsSavePanelsResponse, PanelsStartPairingResponse, PanelsPollPairingData, PanelsPollPairingResponse, PanelsPendingDeviceData, PanelsPendingDeviceResponse, PanelsApprovePairingData, PanelsApprovePairingResponse, PanelsUnpairPanelData, PanelsUnpairPanelResponse, PanelsReadPanelData, PanelsReadPanelResponse, PrivateCreateUserData, PrivateCreateUserResponse, RunsCreateRunData, RunsCreateRunResponse, RunsCreateSweepData, RunsCreateSweepResponse, RunsImportRunsData, RunsImportRunsResponse, RunsReadRunsData, RunsReadRunsResponse, RunsDeleteRunsData, RunsDeleteRunsResponse, RunsReadOverviewResponse, RunsExportMetricsData, RunsExportMetricsResponse, RunsExportRunsData, RunsExportRunsResponse, RunsReadMetricNamesData, RunsReadMetricNamesResponse, RunsReadRunData, RunsReadRunResponse, RunsDeleteRunData, RunsDeleteRunResponse, RunsReadNodeOutputsData, RunsReadNodeOutputsResponse, RunsCancelRunData, RunsCancelRunResponse, RunsRetryRunData, RunsRetryRunResponse, 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, UtilsReadConfigResponse, UtilsHealthCheckResponse, UtilsHealthResponse, WorkersReadWorkersResponse, WorkersReadResourcesResponse, WorkersReadTokensResponse, WorkersIssueTokenData, WorkersIssueTokenResponse, WorkersRevokeTokenData, WorkersRevokeTokenResponse, WorkersReadRuntimeResponse } from './types.gen'; export class AiService { /** @@ -1917,6 +1917,30 @@ export class ObservabilityService { }); } + /** + * Read Node Type Timings + * Execution time per node type, slowest first, to point at the hotspots. + * + * The rollups are kept per node, so the type is joined on from the published + * flows. A node deleted since it ran has no type left to count under. + * @param data The data for the request. + * @param data.hours + * @returns NodeTypeTiming Successful Response + * @throws ApiError + */ + public static readNodeTypeTimings(data: ObservabilityReadNodeTypeTimingsData = {}): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/observability/node-types', + query: { + hours: data.hours + }, + errors: { + 422: 'Validation Error' + } + }); + } + /** * Read Runs * Recent cascades, newest first, and how many there were in total. diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 2f5a75a..a599c09 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -919,6 +919,17 @@ export type NodeTypeInfo = { plugin?: (string | null); }; +/** + * How long one kind of node takes to run, across every node of it. + */ +export type NodeTypeTiming = { + type: string; + nodes: number; + executions: number; + avg_ms: number; + max_ms: number; +}; + /** * What the consent page shows, all of it validated server-side. */ @@ -2025,6 +2036,12 @@ export type ObservabilityReadFlowRollupsData = { export type ObservabilityReadFlowRollupsResponse = (Array); +export type ObservabilityReadNodeTypeTimingsData = { + hours?: number; +}; + +export type ObservabilityReadNodeTypeTimingsResponse = (Array); + export type ObservabilityReadRunsData = { flow?: (string | null); limit?: number; diff --git a/frontend/src/components/Health/NodeTypeTimes.tsx b/frontend/src/components/Health/NodeTypeTimes.tsx new file mode 100644 index 0000000..1a133fa --- /dev/null +++ b/frontend/src/components/Health/NodeTypeTimes.tsx @@ -0,0 +1,153 @@ +import { useQuery } from "@tanstack/react-query" + +import type { NodeTypeTiming } from "@/client" +import type { Range } from "@/components/Common/RangePicker" +import { nodeTypesQueryOptions } from "@/components/Flow/queries" +import { PANEL_SECTION } from "@/components/Flow/SidePanel" +import { Skeleton } from "@/components/ui/skeleton" +import { dur, si } from "@/lib/utils" +import { CARD, nodeTypeTimingsQueryOptions } from "./queries" + +/** + * One row's columns: the type, its bar, its average. A phone puts the bar on a + * line of its own under the other two, where it has the card's whole width. + */ +const COLUMNS = + "grid grid-cols-[minmax(0,1fr)_auto] gap-x-3 sm:grid-cols-[12rem_minmax(0,1fr)_5rem]" + +/** The fastest the axis reaches down to, in ms: a microsecond. */ +const FLOOR = 0.001 + +/** + * The axis, in whole decades of milliseconds: from below the fastest type to + * above the slowest, two decades at least so a bar has room to differ. + */ +function decades(timings: NodeTypeTiming[]): number[] { + const measured = timings.map((t) => t.avg_ms).filter((ms) => ms >= FLOOR) + if (!measured.length) return [] + const low = Math.floor(Math.log10(Math.min(...measured))) + const high = Math.max(Math.ceil(Math.log10(Math.max(...measured))), low + 2) + return Array.from({ length: high - low + 1 }, (_, i) => 10 ** (low + i)) +} + +/** Where a duration sits along the axis, as a percentage. */ +function at(ms: number, axis: number[]): number { + const low = Math.log10(axis[0]) + const high = Math.log10(axis[axis.length - 1]) + return ((Math.log10(ms) - low) / (high - low)) * 100 +} + +/** + * How long each kind of node takes per run, on a log scale. + * + * One bar per node type at its average, slowest first, so a hotspot is the top + * row. The scale is logarithmic because node types sit decades apart, a timer + * at microseconds and a model at seconds, and a linear one shows only the + * slowest. A type that ran without a measurable duration has no bar. + */ +export function NodeTypeTimes({ range }: { range: Range }) { + const { data, isPending } = useQuery(nodeTypeTimingsQueryOptions(range)) + const { data: types } = useQuery(nodeTypesQueryOptions()) + const titles = new Map((types ?? []).map((info) => [info.type, info.title])) + + const timings = data ?? [] + const axis = decades(timings) + + return ( +
+
+

Execution time per node type

+

+ average per run over the last {range.label}, log scale +

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

Nothing has run yet.

+ ) : ( +
+ {timings.map((timing) => ( + + ))} + {axis.length ? ( +
+
+ {axis.map((ms, index) => ( + + {dur(ms)} + + ))} +
+
+ ) : null} +
+ )} +
+
+ ) +} + +function Row({ + timing, + title, + axis, +}: { + timing: NodeTypeTiming + title: string + axis: number[] +}) { + const width = + timing.avg_ms >= FLOOR && axis.length ? at(timing.avg_ms, axis) : 0 + return ( +
+
+

{title}

+

+ {si(timing.executions)} runs ยท peak {dur(timing.max_ms)} +

+
+ {/* The decade lines run through every row, so together they read as + the grid; the bar starts at the axis's own floor. */} +
+ {axis.map((ms) => ( + + ))} + +
+ + {dur(timing.avg_ms)} + +
+ ) +} diff --git a/frontend/src/components/Health/queries.ts b/frontend/src/components/Health/queries.ts index 7f970c4..fecc76b 100644 --- a/frontend/src/components/Health/queries.ts +++ b/frontend/src/components/Health/queries.ts @@ -147,3 +147,10 @@ export const clock = (ts: number) => hour: "2-digit", minute: "2-digit", }) + +export const nodeTypeTimingsQueryOptions = (range: Range) => ({ + queryKey: ["observability", "node-types", range.hours] as const, + queryFn: () => + ObservabilityService.readNodeTypeTimings({ hours: range.hours }), + refetchInterval: REFRESH, +}) diff --git a/frontend/src/routes/_layout/index.tsx b/frontend/src/routes/_layout/index.tsx index 02b8251..794a04d 100644 --- a/frontend/src/routes/_layout/index.tsx +++ b/frontend/src/routes/_layout/index.tsx @@ -12,6 +12,7 @@ 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 { NodeTypeTimes } from "@/components/Health/NodeTypeTimes" import { Card } from "@/components/ui/card" export const Route = createFileRoute("/_layout/")({ @@ -35,8 +36,8 @@ const HEADER = * than living at routes of their own. * * Top to bottom it is a widening lens: the whole instance as a graph, what - * has been built on it, whether it is well, then flow by flow and finally - * moment by moment. + * has been built on it, whether it is well, then flow by flow, node type by + * node type, and finally moment by moment. */ function Home() { // The health block's window: one choice, read by the tiles, the flow table, @@ -91,6 +92,7 @@ function Home() { + )