diff --git a/apps/web/src/pages/routine-detail-page.tsx b/apps/web/src/pages/routine-detail-page.tsx index 4db35bc25..e37b9f7ef 100644 --- a/apps/web/src/pages/routine-detail-page.tsx +++ b/apps/web/src/pages/routine-detail-page.tsx @@ -1,19 +1,4 @@ -import { - Badge, - Button, - PageShell, - RichEmptyState, - RunNowButton, - Skeleton, - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@corbits/react-ui"; -import type { BadgeTone } from "@corbits/react-ui"; -import { WorkflowRunResponse, paginatedSchema } from "@intx/types"; +import { Button, PageShell, RichEmptyState, RunNowButton, Skeleton } from "@corbits/react-ui"; import { type } from "arktype"; import { useState } from "react"; import type { ReactNode } from "react"; @@ -24,91 +9,98 @@ import type { GlobalRoutineRow } from "../global-routines"; import { Link } from "../navigation"; import { WORKFLOWS_PATH_PREFIX } from "../path-ids"; import { StageTopBar } from "../shell/stage-top-bar"; +import { RoutinePill, formatWhen, routineState, useRoutineRuns } from "./routine-ui"; +import type { RunRow } from "./routine-ui"; import { scheduleSentence } from "./routines-page"; -const RunsPageSchema = paginatedSchema(WorkflowRunResponse); -type RunRow = typeof WorkflowRunResponse.infer; - const RunEventSchema = type({ seq: "number", type: "string", body: "Record" }); const RunEventsSchema = type({ runId: "string", events: RunEventSchema.array() }); type RunEvent = typeof RunEventSchema.infer; -function runsPath(tenantId: string, definitionId: string): string { - return `/api/tenants/${tenantId}/workflows/runs?definitionId=${encodeURIComponent(definitionId)}&limit=100`; -} - function runEventsPath(tenantId: string, runId: string): string { return `/api/tenants/${tenantId}/workflows/runs/${encodeURIComponent(runId)}/events`; } -const RUN_STATUS_TONE_BY_STATUS: Readonly> = { - deployed: "success", - running: "info", - updating: "info", - error: "danger", - stopped: "neutral", -}; +function runTone(status: RunRow["status"]): "live" | "failed" | "info" | "idle" { + if (status === "error") return "failed"; + if (status === "running" || status === "updating") return "info"; + return status === "deployed" ? "live" : "idle"; +} + +function runLabel(status: RunRow["status"]): string { + if (status === "error") return "failed"; + return status === "deployed" ? "ok" : status; +} -/** A run's event log, fetched on selection. Rendered inline under the runs - * table rather than a separate route — a definition rarely has enough runs - * to need one. */ -function RunEventsPanel({ +/** The run's own failure text: the newest error-ish event's message. */ +function failureMessage(events: readonly RunEvent[]): string | null { + for (const event of events.toReversed()) { + if (!/error|fail/i.test(event.type)) continue; + const { message, error } = event.body; + if (typeof message === "string") return message; + if (typeof error === "string") return error; + return event.type; + } + return null; +} + +function RunDetail({ tenantId, - runId, + run, + onRetry, }: { readonly tenantId: string; - readonly runId: string; + readonly run: RunRow; + readonly onRetry: () => Promise; }) { - const eventsQuery = useAPIQuery(runEventsPath(tenantId, runId), RunEventsSchema); - if (eventsQuery.kind === "loading") return ; + const eventsQuery = useAPIQuery(runEventsPath(tenantId, run.id), RunEventsSchema); + if (eventsQuery.kind === "loading") return ; if (eventsQuery.kind === "error") { return ; } if (eventsQuery.kind === "unauthenticated") return null; - const events: readonly RunEvent[] = eventsQuery.data.events; - if (events.length === 0) { - return ( - - ); - } + const events = eventsQuery.data.events; + const failure = run.status === "error" ? failureMessage(events) : null; return ( - - - - Seq - Type - - - - {events.map((event) => ( - - {event.seq} - {event.type} - - ))} - -
+
+ {run.status === "error" ? ( + <> +

{failure ?? "The run failed without reporting an error."}

+
+ +
+ + ) : null} + {events.length === 0 ? ( + No events committed to this run's log. + ) : ( +
    + {events.map((event) => ( +
  • + {String(event.seq)} {event.type} +
  • + ))} +
+ )} +
); } -/** Runs for this definition, newest first (the listing's own order), with a - * click-to-expand event log per run. */ function RoutineRunsSection({ tenantId, definitionId, + onRetry, }: { readonly tenantId: string; readonly definitionId: string; + readonly onRetry: () => Promise; }) { const [selectedRunId, setSelectedRunId] = useState(null); - const runsQuery = useAPIQuery(runsPath(tenantId, definitionId), RunsPageSchema); + const runsQuery = useRoutineRuns(tenantId, definitionId, 100); return ( -
-

Runs

+
+

Recent runs

{runsQuery.kind === "loading" ? : null} {runsQuery.kind === "error" ? ( @@ -117,41 +109,29 @@ function RoutineRunsSection({ ) : null} {runsQuery.kind === "ready" && runsQuery.data.data.length > 0 ? ( - - - - Status - Started - Ended - - - - {runsQuery.data.data.map((run) => ( - <> - setSelectedRunId(selectedRunId === run.id ? null : run.id)} - > - - {run.status} - - {run.createdAt} - {run.endedAt ?? "—"} - - {selectedRunId === run.id ? ( - - - - - - ) : null} - - ))} - -
+
+ {runsQuery.data.data.map((run) => ( +
+ + {selectedRunId === run.id ? ( + + ) : null} +
+ ))} +
) : null}
); @@ -194,6 +174,9 @@ export function RoutineDetailPage({ }) { const enabled = row.definition.status === "deployed"; const sentence = scheduleSentence(row.definition.schedule); + const runs = useRoutineRuns(row.tenantId, row.definition.definitionId, 1); + const lastRun = runs.kind === "ready" ? runs.data.data[0] : undefined; + const state = routineState(row, lastRun); return (