From bdb5d8962dc7f935cd080f5197ca7cb4c7dc3bd3 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 29 Sep 2026 22:34:39 -0700 Subject: [PATCH 1/3] feat(web): Workflows list and routine page match the mockups (CL-9582) --- apps/web/src/pages/routine-detail-page.tsx | 213 ++++++++++---------- apps/web/src/pages/routine-ui.tsx | 62 ++++++ apps/web/src/pages/routines-page.tsx | 143 +++++++------- apps/web/src/pages/routines.css | 214 +++++++++++++++++++++ apps/web/src/shell/page-crumbs.tsx | 6 + 5 files changed, 455 insertions(+), 183 deletions(-) create mode 100644 apps/web/src/pages/routine-ui.tsx create mode 100644 apps/web/src/pages/routines.css 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 (
} @@ -249,13 +238,5 @@ export function RoutineDetailRoute({ segment }: { readonly segment: string }) { ); } - return ( - { - void actions.setEnabled(resolved, enabled); - }} - onRunNow={() => actions.runNow(resolved)} - /> - ); + return actions.runNow(resolved)} />; } diff --git a/apps/web/src/pages/routines-page.tsx b/apps/web/src/pages/routines-page.tsx index 88528024a..4e2179942 100644 --- a/apps/web/src/pages/routines-page.tsx +++ b/apps/web/src/pages/routines-page.tsx @@ -1,5 +1,5 @@ // Workflows: an ops table of deployed workflow definitions, including -// paused (`stopped`) ones. Pause/resume and run-now are the only writes. +// paused (`stopped`) ones. Run-now is the only write. import { EmptyState, RichEmptyState, RunNowButton } from "@corbits/react-ui"; import { cronSentence } from "@corbits/workflows/client"; import { useState } from "react"; diff --git a/apps/web/src/routines-api.ts b/apps/web/src/routines-api.ts index ac1af1146..1a5f1fade 100644 --- a/apps/web/src/routines-api.ts +++ b/apps/web/src/routines-api.ts @@ -1,6 +1,6 @@ // The Routines page's seam to stock workflow deployments. See // docs/routines-scheduling.md for the schedule join and why run-now/ -// pause/resume stay rejected promises. +// run-now goes through the stock trigger route. import { type } from "arktype"; import { useQuery } from "@tanstack/react-query"; @@ -183,35 +183,39 @@ export async function listScheduledWorkflows( }); } -/** No stock route reruns a deployment on demand yet. */ -export function runScheduledWorkflowNow( - tenantId: string, - definitionId: string, -): Promise<{ runId: string }> { - return Promise.reject( - new ApiQueryError( - "Running a workflow now has no stock route yet.", - undefined, - `/api/tenants/${tenantId}/workflows/deployments/${encodeURIComponent(definitionId)}/run`, - ), - ); -} +const TriggerResponse = type({ runId: "string", address: "string", messageId: "string" }); -/** No stock route pauses or resumes a deployment yet. */ -export function setScheduledWorkflowStatus( +/** Fires the deployment through the stock trigger route. The deployment id + * is its anchor run id; the message mirrors the workflow's cron row when one + * exists so a manual run carries what the schedule would. */ +export async function runScheduledWorkflowNow( tenantId: string, definitionId: string, - status: "deployed" | "stopped", -): Promise<{ readonly status: string }> { - return Promise.reject( - new ApiQueryError( - status === "deployed" - ? "Resuming a deployment has no stock route yet." - : "Pausing a deployment has no stock route yet.", - undefined, - `/api/tenants/${tenantId}/workflows/deployments/${encodeURIComponent(definitionId)}`, - ), + definitionName?: string, +): Promise<{ runId: string }> { + const path = `/api/tenants/${tenantId}/workflows/${encodeURIComponent(definitionId)}/mail`; + const schedules = await listCronSchedules(tenantId); + const row = schedules.find( + (schedule) => schedule.stoppedAt === null && schedule.definitionName === definitionName, ); + const content = row === undefined ? "Run now" : `${row.subject}\n\n${row.body}`; + const response = await fetch(path, { + method: "POST", + headers: { "content-type": "application/json", accept: "application/json" }, + body: JSON.stringify({ content }), + }); + if (response.status === 401) throw new UnauthenticatedError(); + if (response.status === 409) { + throw new ApiQueryError("This workflow has ended; redeploy to run it again.", 409, path); + } + if (!response.ok) { + throw new ApiQueryError(`The server answered ${response.status}.`, response.status, path); + } + const parsed = TriggerResponse(await response.json()); + if (parsed instanceof type.errors) { + throw new ApiQueryError(`Unexpected response shape: ${parsed.summary}`, undefined, path); + } + return { runId: parsed.runId }; } // Keys must be stable arrays under `["tenant", tenantId, ...]` so a bench diff --git a/apps/web/src/shell/context-menu/items.tsx b/apps/web/src/shell/context-menu/items.tsx index d45816c20..79d5ab4a6 100644 --- a/apps/web/src/shell/context-menu/items.tsx +++ b/apps/web/src/shell/context-menu/items.tsx @@ -121,7 +121,7 @@ function routineMenu( onSelect: () => { void (async () => { try { - await runScheduledWorkflowNow(tenantId, target.id); + await runScheduledWorkflowNow(tenantId, target.id, target.name); toast(`${target.name} started`); actions.onRoutineRan(tenantId); } catch (cause) { diff --git a/docs/routines-scheduling.md b/docs/routines-scheduling.md index b1ef1370d..2f60a3fbd 100644 --- a/docs/routines-scheduling.md +++ b/docs/routines-scheduling.md @@ -30,6 +30,8 @@ for a name no definition carries is rejected the same way ## Run-now and pause/resume -Neither has a backing stock route (`/deployments` is list/create only; no -per-deployment PATCH or trigger route exists), so both stay rejected -promises naming the missing route. +Run now posts to the stock trigger route +(`POST /workflows/:runId/mail`, `:runId` = the deployment id), carrying the +cron row's subject and body when one exists. A 409 means the run has ended. +Pause/resume has no stock or `@corbits/cron` primitive, so no control is +shown.