From 87516c25cec4777675dca79c7416893336caba0a Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 28 May 2026 20:24:09 -0400 Subject: [PATCH] feat(automations): wire UI to API and auto-start runs from API trigger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the Automations area in the web UI functional end-to-end against the real Automation API, and fix the backend so runs created by an automation's API trigger actually start instead of sitting in Submitted forever. Web: - Reveal the Automations nav tab outside demo mode; drop the now-empty demoOnly mechanism. - List page: render via listAutomations (was workflows mock data); wire ellipsis menu to Edit and Delete, with ConfirmDialog + If-Match revision. Move Create Automation into the toolbar, switch the trigger select to a shared FilterButton, hide the redundant page-header title via a new hideTitle handle flag. - Play button on each card fires createAutomationRun with spinner + toast and navigates to the new run. - New automation form: drop the dead Goal panel and hardcoded repository list, post to createAutomation with real triggers. - Edit automation: new /automations/:id/edit route reusing a shared AutomationFormFields component, PUT via replaceAutomation with If-Match. - Show page: rebuild like a run detail page — breadcrumb, title, chips (enabled status, repo+ref, workflow, schedule), Edit + Run actions (Run hits createAutomationRun), and a Runs panel using RunsListView with URL-driven search/sort/pagination/column-picker like the Children sub-tab. Drop the obsolete Definition/Diagram/Runs child routes. Backend (fabro-server): - create_automation_run now calls lifecycle::queue_run_start after the run is persisted, so the run transitions Submitted → Runnable and the scheduler picks it up. Logs a warn and returns the created response if start fails (no worse than the prior always-stuck behavior). - queue_run_start in lifecycle.rs is promoted to pub(super) so sibling handlers can reuse it. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/components/automation-form.tsx | 356 +++++++++ apps/fabro-web/app/layouts/app-shell.test.tsx | 18 - apps/fabro-web/app/layouts/app-shell.tsx | 14 +- apps/fabro-web/app/layouts/navigation.ts | 12 +- apps/fabro-web/app/lib/api-client.ts | 6 + apps/fabro-web/app/lib/queries.ts | 30 + apps/fabro-web/app/lib/query-keys.ts | 6 + apps/fabro-web/app/router.tsx | 13 +- .../app/routes/automation-definition.tsx | 34 - .../app/routes/automation-detail.tsx | 705 +++++++++--------- .../app/routes/automation-diagram.tsx | 211 ------ apps/fabro-web/app/routes/automation-runs.tsx | 122 --- .../fabro-web/app/routes/automations-edit.tsx | 150 ++++ apps/fabro-web/app/routes/automations-new.tsx | 383 ++-------- apps/fabro-web/app/routes/automations.tsx | 373 +++++---- .../src/server/handler/automations.rs | 27 +- .../src/server/handler/lifecycle.rs | 2 +- 17 files changed, 1225 insertions(+), 1237 deletions(-) create mode 100644 apps/fabro-web/app/components/automation-form.tsx delete mode 100644 apps/fabro-web/app/layouts/app-shell.test.tsx delete mode 100644 apps/fabro-web/app/routes/automation-definition.tsx delete mode 100644 apps/fabro-web/app/routes/automation-diagram.tsx delete mode 100644 apps/fabro-web/app/routes/automation-runs.tsx create mode 100644 apps/fabro-web/app/routes/automations-edit.tsx diff --git a/apps/fabro-web/app/components/automation-form.tsx b/apps/fabro-web/app/components/automation-form.tsx new file mode 100644 index 000000000..b4c6b3115 --- /dev/null +++ b/apps/fabro-web/app/components/automation-form.tsx @@ -0,0 +1,356 @@ +import { useRef, type ReactNode } from "react"; +import { Switch } from "@headlessui/react"; +import type { Automation, AutomationTrigger } from "@qltysh/fabro-api-client"; + +import { Panel, Row } from "./settings-panel"; +import { INPUT_CLASS } from "./ui"; + +export interface AutomationFormValues { + id: string; + name: string; + description: string; + enabled: boolean; + repository: string; + ref: string; + workflow: string; + manualEnabled: boolean; + scheduleEnabled: boolean; + cron: string; +} + +export const EMPTY_AUTOMATION_FORM: AutomationFormValues = { + id: "", + name: "", + description: "", + enabled: true, + repository: "", + ref: "main", + workflow: "", + manualEnabled: true, + scheduleEnabled: false, + cron: "0 9 * * 1-5", +}; + +const CRON_PRESETS: ReadonlyArray<{ label: string; value: string }> = [ + { label: "Every hour", value: "0 * * * *" }, + { label: "Daily 9:00 UTC", value: "0 9 * * *" }, + { label: "Weekdays 9:00", value: "0 9 * * 1-5" }, + { label: "Mondays 8:00", value: "0 8 * * 1" }, +]; + +export function automationToFormValues(automation: Automation): AutomationFormValues { + const apiTrigger = automation.triggers.find((t) => t.type === "api"); + const scheduleTrigger = automation.triggers.find((t) => t.type === "schedule"); + return { + id: automation.id, + name: automation.name, + description: automation.description ?? "", + enabled: automation.enabled, + repository: automation.target.repository, + ref: automation.target.ref, + workflow: automation.target.workflow, + manualEnabled: apiTrigger?.enabled ?? false, + scheduleEnabled: scheduleTrigger?.enabled ?? false, + cron: scheduleTrigger?.expression ?? "0 9 * * 1-5", + }; +} + +export function triggersFromFormValues(values: AutomationFormValues): AutomationTrigger[] { + const triggers: AutomationTrigger[] = []; + if (values.manualEnabled) { + triggers.push({ id: "manual", type: "api", enabled: true }); + } + if (values.scheduleEnabled) { + triggers.push({ + id: "schedule", + type: "schedule", + enabled: true, + expression: values.cron.trim(), + }); + } + return triggers; +} + +export function isFormValid(values: AutomationFormValues): boolean { + return ( + values.id.trim() !== "" && + values.name.trim() !== "" && + values.repository.trim() !== "" && + values.ref.trim() !== "" && + values.workflow.trim() !== "" && + (values.manualEnabled || values.scheduleEnabled) + ); +} + +export function kebabify(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); +} + +export function snakeify(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9_]+/g, "_") + .replace(/_+/g, "_") + .replace(/^_|_$/g, ""); +} + +function describeCron(expression: string): string { + const trimmed = expression.trim(); + const preset = CRON_PRESETS.find((p) => p.value === trimmed); + if (preset) return preset.label; + if (!/^[\d*/,\-\s]+$/.test(trimmed) || trimmed.split(/\s+/).length !== 5) { + return "Waiting for a valid expression…"; + } + return "Computed when saved"; +} + +interface AutomationFormFieldsProps { + values: AutomationFormValues; + onChange: (values: AutomationFormValues) => void; + lockIdAndTarget?: boolean; +} + +export function AutomationFormFields({ + values, + onChange, + lockIdAndTarget = false, +}: AutomationFormFieldsProps) { + const slugTouchedRef = useRef(values.id.length > 0); + + function patch(partial: Partial) { + onChange({ ...values, ...partial }); + } + + function onNameChange(next: string) { + if (slugTouchedRef.current || lockIdAndTarget) { + patch({ name: next }); + } else { + patch({ name: next, id: kebabify(next) }); + } + } + + function onSlugChange(next: string) { + slugTouchedRef.current = true; + patch({ id: kebabify(next) }); + } + + return ( + <> + + Name} help="Shown wherever this automation is listed."> + onNameChange(e.target.value)} + placeholder="Fix Build" + autoComplete="off" + className={INPUT_CLASS} + /> + + Slug} + help={ + lockIdAndTarget ? ( + "The slug cannot be changed after creation." + ) : ( + <> + Identifier used in the URL:{" "} + /automation/{values.id || ""} + + ) + } + > + {lockIdAndTarget ? ( +
{values.id}
+ ) : ( + onSlugChange(e.target.value)} + placeholder="fix-build" + autoComplete="off" + spellCheck={false} + className={`${INPUT_CLASS} font-mono`} + /> + )} +
+ Description} help="A short summary teammates will see when browsing automations."> +