mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-10 03:30:59 +00:00
feat(automations): wire UI to API and auto-start runs from API trigger
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) <noreply@anthropic.com>
This commit is contained in:
parent
0106711170
commit
87516c25ce
17 changed files with 1225 additions and 1237 deletions
356
apps/fabro-web/app/components/automation-form.tsx
Normal file
356
apps/fabro-web/app/components/automation-form.tsx
Normal file
|
|
@ -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<AutomationFormValues>) {
|
||||
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 (
|
||||
<>
|
||||
<Panel title="Basics">
|
||||
<Row title={<Label required>Name</Label>} help="Shown wherever this automation is listed.">
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
aria-label="Automation name"
|
||||
value={values.name}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
placeholder="Fix Build"
|
||||
autoComplete="off"
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
</Row>
|
||||
<Row
|
||||
title={<Label required>Slug</Label>}
|
||||
help={
|
||||
lockIdAndTarget ? (
|
||||
"The slug cannot be changed after creation."
|
||||
) : (
|
||||
<>
|
||||
Identifier used in the URL:{" "}
|
||||
<span className="font-mono text-fg-2">/automation/{values.id || "<slug>"}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
{lockIdAndTarget ? (
|
||||
<div className="font-mono text-sm text-fg">{values.id}</div>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
name="slug"
|
||||
aria-label="Automation slug"
|
||||
value={values.id}
|
||||
onChange={(e) => onSlugChange(e.target.value)}
|
||||
placeholder="fix-build"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={`${INPUT_CLASS} font-mono`}
|
||||
/>
|
||||
)}
|
||||
</Row>
|
||||
<Row title={<Label optional>Description</Label>} help="A short summary teammates will see when browsing automations.">
|
||||
<textarea
|
||||
name="description"
|
||||
aria-label="Automation description"
|
||||
value={values.description}
|
||||
onChange={(e) => patch({ description: e.target.value })}
|
||||
rows={2}
|
||||
placeholder="Diagnose and fix CI build failures by analyzing logs and applying targeted patches."
|
||||
className={`${INPUT_CLASS} resize-y`}
|
||||
/>
|
||||
</Row>
|
||||
<Row title="Enabled" help="Disabled automations skip scheduled triggers and reject API runs.">
|
||||
<ToggleSwitch
|
||||
checked={values.enabled}
|
||||
onChange={(enabled) => patch({ enabled })}
|
||||
label="Enable automation"
|
||||
/>
|
||||
</Row>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Source">
|
||||
<Row title={<Label required>Repository</Label>} help="GitHub repository in owner/repo form.">
|
||||
<input
|
||||
type="text"
|
||||
name="repository"
|
||||
aria-label="Repository"
|
||||
value={values.repository}
|
||||
onChange={(e) => patch({ repository: e.target.value })}
|
||||
placeholder="acme/orders-api"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={`${INPUT_CLASS} font-mono`}
|
||||
/>
|
||||
</Row>
|
||||
<Row title={<Label required>Branch</Label>} help="Default branch to run against.">
|
||||
<input
|
||||
type="text"
|
||||
name="branch"
|
||||
aria-label="Default branch"
|
||||
value={values.ref}
|
||||
onChange={(e) => patch({ ref: e.target.value })}
|
||||
placeholder="main"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={`${INPUT_CLASS} font-mono`}
|
||||
/>
|
||||
</Row>
|
||||
<Row
|
||||
title={<Label required>Workflow slug</Label>}
|
||||
help="Snake-case identifier used in the workflow file name (e.g. fix_build.fabro)."
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
name="workflow_slug"
|
||||
aria-label="Workflow slug"
|
||||
value={values.workflow}
|
||||
onChange={(e) => patch({ workflow: snakeify(e.target.value) })}
|
||||
placeholder="fix_build"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={`${INPUT_CLASS} font-mono`}
|
||||
/>
|
||||
</Row>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Triggers">
|
||||
<Row title="Manual / API" help="Start a run by clicking Run in the UI or calling the API.">
|
||||
<ToggleSwitch
|
||||
checked={values.manualEnabled}
|
||||
onChange={(manualEnabled) => patch({ manualEnabled })}
|
||||
label="Enable manual and API triggers"
|
||||
/>
|
||||
</Row>
|
||||
<Row title="Schedule" help="Start runs automatically on a recurring cron schedule.">
|
||||
<ToggleSwitch
|
||||
checked={values.scheduleEnabled}
|
||||
onChange={(scheduleEnabled) => patch({ scheduleEnabled })}
|
||||
label="Enable scheduled triggers"
|
||||
/>
|
||||
</Row>
|
||||
{values.scheduleEnabled ? (
|
||||
<Row
|
||||
title="Cron expression"
|
||||
help={
|
||||
<>
|
||||
Five-field POSIX cron in UTC. Next run:{" "}
|
||||
<span className="text-fg-2">{describeCron(values.cron)}</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
name="cron"
|
||||
aria-label="Cron expression"
|
||||
value={values.cron}
|
||||
onChange={(e) => patch({ cron: e.target.value })}
|
||||
placeholder="0 9 * * 1-5"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={`${INPUT_CLASS} font-mono`}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{CRON_PRESETS.map((preset) => {
|
||||
const active = preset.value === values.cron;
|
||||
return (
|
||||
<button
|
||||
key={preset.value}
|
||||
type="button"
|
||||
onClick={() => patch({ cron: preset.value })}
|
||||
aria-pressed={active}
|
||||
className={`rounded-full px-2.5 py-1 text-xs transition-colors ${
|
||||
active
|
||||
? "bg-teal-500/15 text-teal-300 outline-1 -outline-offset-1 outline-teal-500/40"
|
||||
: "bg-overlay text-fg-3 hover:bg-overlay-strong hover:text-fg-2"
|
||||
}`}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Row>
|
||||
) : null}
|
||||
</Panel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Label({
|
||||
children,
|
||||
required,
|
||||
optional,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
required?: boolean;
|
||||
optional?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span className="inline-flex items-baseline gap-1.5">
|
||||
<span>{children}</span>
|
||||
{required ? (
|
||||
<span aria-label="required" className="text-coral">
|
||||
*
|
||||
</span>
|
||||
) : null}
|
||||
{optional ? <span className="text-xs font-normal text-fg-muted">Optional</span> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleSwitch({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (next: boolean) => void;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<Switch
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
aria-label={label}
|
||||
className="group relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full bg-overlay-strong outline-1 -outline-offset-1 outline-line-strong transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500 data-checked:bg-teal-500"
|
||||
>
|
||||
<span className="pointer-events-none inline-block size-4 translate-x-0.5 rounded-full bg-fg shadow-sm transition-transform duration-150 group-data-checked:translate-x-[1.125rem]" />
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import { getVisibleNavigation } from "./navigation";
|
||||
|
||||
describe("getVisibleNavigation", () => {
|
||||
test("shows Automations first in demo mode followed by Runs and Settings", () => {
|
||||
const items = getVisibleNavigation(true);
|
||||
const names = items.map((i) => i.name);
|
||||
expect(names).toEqual(["Automations", "Runs", "Settings"]);
|
||||
});
|
||||
|
||||
test("hides Automations in production mode", () => {
|
||||
const items = getVisibleNavigation(false);
|
||||
const names = items.map((i) => i.name);
|
||||
expect(names).not.toContain("Automations");
|
||||
expect(names).toContain("Runs");
|
||||
expect(names).toContain("Settings");
|
||||
});
|
||||
});
|
||||
|
|
@ -17,9 +17,9 @@ import { ErrorState } from "../components/state";
|
|||
import { TooltipProvider } from "../components/ui";
|
||||
import { DemoModeProvider } from "../lib/demo-mode";
|
||||
import { useAuthMe } from "../lib/queries";
|
||||
import { allNavigation, getVisibleNavigation } from "./navigation";
|
||||
import { navigation } from "./navigation";
|
||||
|
||||
function activeFor(item: (typeof allNavigation)[number], pathname: string): boolean {
|
||||
function activeFor(item: (typeof navigation)[number], pathname: string): boolean {
|
||||
return pathname.startsWith(item.href);
|
||||
}
|
||||
|
||||
|
|
@ -48,13 +48,13 @@ export default function AppShell() {
|
|||
}
|
||||
|
||||
const { user, provider, demoMode } = auth;
|
||||
const navigation = getVisibleNavigation(demoMode);
|
||||
const currentNav = navigation.find((item) => activeFor(item, pathname));
|
||||
const title = currentNav?.name ?? "";
|
||||
const lastMatch = matches[matches.length - 1];
|
||||
const handle = lastMatch?.handle as { headerExtra?: React.ReactNode } | undefined;
|
||||
const headerExtra = handle?.headerExtra;
|
||||
const hideHeader = matches.some((m) => (m.handle as { hideHeader?: boolean } | undefined)?.hideHeader);
|
||||
const hideTitle = matches.some((m) => (m.handle as { hideTitle?: boolean } | undefined)?.hideTitle);
|
||||
const wide = matches.some((m) => (m.handle as { wide?: boolean } | undefined)?.wide);
|
||||
const fullHeight = matches.some(
|
||||
(m) => (m.handle as { fullHeight?: boolean } | undefined)?.fullHeight,
|
||||
|
|
@ -236,9 +236,11 @@ export default function AppShell() {
|
|||
>
|
||||
<div className={`mx-auto ${maxWidth} px-4 py-4 sm:px-6 lg:px-8`}>
|
||||
<div className="flex items-center">
|
||||
<h1 className="text-xl font-semibold tracking-tight text-fg">
|
||||
{title}
|
||||
</h1>
|
||||
{!hideTitle && (
|
||||
<h1 className="text-xl font-semibold tracking-tight text-fg">
|
||||
{title}
|
||||
</h1>
|
||||
)}
|
||||
{headerExtra && <div className="ml-auto">{headerExtra}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,12 +4,8 @@ import {
|
|||
PlayIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
|
||||
export const allNavigation = [
|
||||
{ name: "Automations", href: "/automations", icon: ClockIcon, demoOnly: true },
|
||||
{ name: "Runs", href: "/runs", icon: PlayIcon, demoOnly: false },
|
||||
{ name: "Settings", href: "/settings", icon: Cog6ToothIcon, demoOnly: false },
|
||||
export const navigation = [
|
||||
{ name: "Automations", href: "/automations", icon: ClockIcon },
|
||||
{ name: "Runs", href: "/runs", icon: PlayIcon },
|
||||
{ name: "Settings", href: "/settings", icon: Cog6ToothIcon },
|
||||
];
|
||||
|
||||
export function getVisibleNavigation(demoMode: boolean) {
|
||||
return allNavigation.filter((item) => !item.demoOnly || demoMode);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import axios, {
|
|||
} from "axios";
|
||||
import {
|
||||
AuthApi,
|
||||
AutomationsApi,
|
||||
Configuration,
|
||||
HumanInTheLoopApi,
|
||||
InsightsApi,
|
||||
|
|
@ -75,6 +76,11 @@ export const authApi = new AuthApi(
|
|||
"",
|
||||
generatedAxios,
|
||||
);
|
||||
export const automationsApi = new AutomationsApi(
|
||||
generatedApiConfiguration,
|
||||
"",
|
||||
generatedAxios,
|
||||
);
|
||||
export const humanInTheLoopApi = new HumanInTheLoopApi(
|
||||
generatedApiConfiguration,
|
||||
"",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import type {
|
|||
AuthConfigResponse,
|
||||
AuthMeResponse,
|
||||
AuthSessionsResponse,
|
||||
Automation,
|
||||
AutomationListResponse,
|
||||
BoardColumn,
|
||||
CommandLogResponse,
|
||||
EventEnvelope,
|
||||
|
|
@ -42,6 +44,7 @@ import {
|
|||
apiNullableData,
|
||||
apiResponse,
|
||||
authApi,
|
||||
automationsApi,
|
||||
fetchAllPages,
|
||||
fetchAllStageEvents,
|
||||
generatedAxios,
|
||||
|
|
@ -397,6 +400,33 @@ export function useRunStageLog(
|
|||
);
|
||||
}
|
||||
|
||||
export function useAutomations() {
|
||||
return useSWR<AutomationListResponse>(
|
||||
queryKeys.automations.list(),
|
||||
() => apiData(() => automationsApi.listAutomations()),
|
||||
);
|
||||
}
|
||||
|
||||
export function useAutomation(id: string | undefined) {
|
||||
return useSWR<Automation | null>(
|
||||
id ? queryKeys.automations.detail(id) : null,
|
||||
() => apiNullableData(() => automationsApi.retrieveAutomation(id!)),
|
||||
);
|
||||
}
|
||||
|
||||
export interface AutomationRunsPageOptions {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export function useAutomationRuns(id: string | undefined, opts: AutomationRunsPageOptions = {}) {
|
||||
return useSWR<PaginatedRunList | null>(
|
||||
id ? queryKeys.automations.runs(id, opts) : null,
|
||||
() => apiNullableData(() => automationsApi.listAutomationRuns(id!, opts.limit, opts.offset)),
|
||||
{ keepPreviousData: true },
|
||||
);
|
||||
}
|
||||
|
||||
export function useWorkflows() {
|
||||
return useSWR<PaginatedWorkflowListResponse | null>(
|
||||
queryKeys.workflows.list(),
|
||||
|
|
|
|||
|
|
@ -90,6 +90,12 @@ export const queryKeys = {
|
|||
detail: (name: string) => ["workflows", "detail", name] as const,
|
||||
runs: (name: string) => ["workflows", "runs", name] as const,
|
||||
},
|
||||
automations: {
|
||||
list: () => ["automations", "list"] as const,
|
||||
detail: (id: string) => ["automations", "detail", id] as const,
|
||||
runs: (id: string, opts: { limit?: number; offset?: number } = {}) =>
|
||||
["automations", "runs", id, opts.limit ?? null, opts.offset ?? null] as const,
|
||||
},
|
||||
insights: {
|
||||
queries: () => ["insights", "queries"] as const,
|
||||
history: () => ["insights", "history"] as const,
|
||||
|
|
|
|||
|
|
@ -12,10 +12,8 @@ import * as ChatsDetail from "./routes/chats-detail";
|
|||
import * as AskFabro from "./routes/ask-fabro";
|
||||
import * as Automations from "./routes/automations";
|
||||
import * as AutomationsNew from "./routes/automations-new";
|
||||
import * as AutomationsEdit from "./routes/automations-edit";
|
||||
import * as AutomationDetail from "./routes/automation-detail";
|
||||
import * as AutomationDefinition from "./routes/automation-definition";
|
||||
import * as AutomationDiagram from "./routes/automation-diagram";
|
||||
import * as AutomationRuns from "./routes/automation-runs";
|
||||
import * as Runs from "./routes/runs";
|
||||
import * as RunDetail from "./routes/run-detail";
|
||||
import * as RunOverview from "./routes/run-overview";
|
||||
|
|
@ -114,13 +112,8 @@ export const routes: RouteObject[] = [
|
|||
route("ask-fabro", AskFabro),
|
||||
route("automations", Automations),
|
||||
route("automations/new", AutomationsNew),
|
||||
route("automation/:name", AutomationDetail, {
|
||||
children: [
|
||||
indexRoute(AutomationDefinition),
|
||||
route("diagram", AutomationDiagram),
|
||||
route("runs", AutomationRuns),
|
||||
],
|
||||
}),
|
||||
route("automations/:id/edit", AutomationsEdit),
|
||||
route("automation/:id", AutomationDetail),
|
||||
route("runs", Runs),
|
||||
route("runs/:id", RunDetail, {
|
||||
children: [
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
import { useOutletContext, useParams } from "react-router";
|
||||
import type { BundledLanguage } from "@pierre/diffs";
|
||||
import { workflowData, type WorkflowEntry } from "./automation-detail";
|
||||
import { CollapsibleFile } from "../components/collapsible-file";
|
||||
import { useDotLanguageReady } from "../hooks/use-dot-language-ready";
|
||||
|
||||
export default function AutomationDefinition() {
|
||||
const { name } = useParams();
|
||||
const context = useOutletContext<{ workflow?: WorkflowEntry } | null>();
|
||||
const workflow = context?.workflow ?? workflowData[name ?? ""];
|
||||
const dotReady = useDotLanguageReady();
|
||||
|
||||
if (workflow == null) {
|
||||
return <p className="text-sm text-fg-muted">No settings found.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<CollapsibleFile
|
||||
file={{ name: "settings.json", contents: JSON.stringify(workflow.settings, null, 2), lang: "json" }}
|
||||
defaultOpen={false}
|
||||
/>
|
||||
{dotReady && (
|
||||
<CollapsibleFile
|
||||
file={{
|
||||
name: workflow.filename,
|
||||
contents: workflow.graph,
|
||||
lang: "dot" as BundledLanguage,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,386 +1,353 @@
|
|||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Link, useNavigate, useParams, useSearchParams } from "react-router";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { ChevronRightIcon } from "@heroicons/react/20/solid";
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
ClockIcon,
|
||||
FolderIcon,
|
||||
MagnifyingGlassIcon,
|
||||
PlayIcon,
|
||||
RectangleStackIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import type {
|
||||
EnvironmentSettings,
|
||||
WorkflowDetailResponse as ApiWorkflowDetail,
|
||||
WorkflowSettings as WorkflowSettingsSnapshot,
|
||||
Automation,
|
||||
ListRunsSortEnum,
|
||||
} from "@qltysh/fabro-api-client";
|
||||
import { Link, Outlet, useLocation, useParams } from "react-router";
|
||||
import { useWorkflow } from "../lib/queries";
|
||||
|
||||
export interface WorkflowEntry {
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
filename: string;
|
||||
settings: WorkflowSettingsSnapshot;
|
||||
graph: string;
|
||||
}
|
||||
import { ApiError, apiData, automationsApi } from "../lib/api-client";
|
||||
import { useAutomation, useAutomationRuns } from "../lib/queries";
|
||||
import { queryKeys } from "../lib/query-keys";
|
||||
import { useDataUpdatedAt } from "../hooks/use-data-updated-at";
|
||||
import { useTickingNow } from "../lib/time";
|
||||
import { formatRelativeTime } from "../lib/format";
|
||||
import { ColumnPickerButton } from "../components/runs-list/column-picker-button";
|
||||
import {
|
||||
hiddenColumnsFromSearchParams,
|
||||
parseDirection,
|
||||
parsePage,
|
||||
parsePageSize,
|
||||
parseSort,
|
||||
} from "../components/runs-list/preferences";
|
||||
import { RunsListView } from "../components/runs-list/runs-list-view";
|
||||
import {
|
||||
serializeHiddenColumns,
|
||||
type ToggleableColumn,
|
||||
} from "../components/runs-list/toggleable-column";
|
||||
import { EmptyState, ErrorState } from "../components/state";
|
||||
import { useToast } from "../components/toast";
|
||||
import {
|
||||
PRIMARY_BUTTON_CLASS,
|
||||
SECONDARY_BUTTON_CLASS,
|
||||
} from "../components/ui";
|
||||
|
||||
function sampleSettings({
|
||||
name,
|
||||
description,
|
||||
goal,
|
||||
inputs,
|
||||
labels,
|
||||
snapshot,
|
||||
autoStopInterval,
|
||||
cpu,
|
||||
memoryGb,
|
||||
diskGb,
|
||||
prepareCommands = [],
|
||||
}: {
|
||||
name: string;
|
||||
description: string;
|
||||
goal: string;
|
||||
inputs: Record<string, unknown>;
|
||||
labels: Record<string, string>;
|
||||
snapshot: string;
|
||||
autoStopInterval: number;
|
||||
cpu: number;
|
||||
memoryGb: number;
|
||||
diskGb: number;
|
||||
prepareCommands?: string[];
|
||||
}): WorkflowSettingsSnapshot {
|
||||
const environmentId = labels.project ?? snapshot;
|
||||
const environment = {
|
||||
provider: "daytona",
|
||||
image: { docker: null, dockerfile: null },
|
||||
resources: {
|
||||
cpu,
|
||||
memory: `${memoryGb}GB`,
|
||||
disk: `${diskGb}GB`,
|
||||
},
|
||||
network: { mode: "allow_all", allow: [] },
|
||||
lifecycle: {
|
||||
preserve: false,
|
||||
stop_on_terminal: true,
|
||||
auto_stop: `${autoStopInterval}m`,
|
||||
},
|
||||
labels,
|
||||
volumes: [],
|
||||
env: {},
|
||||
} satisfies EnvironmentSettings;
|
||||
|
||||
return {
|
||||
project: {
|
||||
name: null,
|
||||
description: null,
|
||||
metadata: {},
|
||||
},
|
||||
workflow: {
|
||||
name,
|
||||
description,
|
||||
graph: "",
|
||||
metadata: {},
|
||||
},
|
||||
environments: {
|
||||
[environmentId]: environment,
|
||||
},
|
||||
run: {
|
||||
goal: { type: "inline", value: goal },
|
||||
working_dir: null,
|
||||
metadata: {},
|
||||
inputs,
|
||||
model: { provider: null, name: "claude-sonnet", fallbacks: [] },
|
||||
git: { author: null },
|
||||
prepare: { commands: prepareCommands, timeout_ms: 120_000 },
|
||||
execution: { mode: "normal", approval: "prompt" },
|
||||
checkpoint: { exclude_globs: [], skip_git_hooks: false },
|
||||
clone: { enabled: true },
|
||||
run_branch: { enabled: true, push: true },
|
||||
meta_branch: { enabled: true, push: true },
|
||||
environment: { id: environmentId, ...environment },
|
||||
notifications: {},
|
||||
interviews: { provider: null, slack: null },
|
||||
agent: { permissions: null, mcps: {} },
|
||||
hooks: [],
|
||||
scm: { provider: null, owner: null, repository: null, github: null },
|
||||
pull_request: null,
|
||||
artifacts: { include: [] },
|
||||
integrations: { github: { permissions: {} } },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const workflowData: Record<string, WorkflowEntry> = {
|
||||
fix_build: {
|
||||
name: "Fix Build",
|
||||
slug: "fix_build",
|
||||
filename: "fix_build.fabro",
|
||||
description: "Automatically diagnoses and fixes CI build failures by analyzing error logs, identifying root causes, and applying targeted code changes.",
|
||||
settings: sampleSettings({
|
||||
name: "Fix Build",
|
||||
description: "Automatically diagnoses and fixes CI build failures by analyzing error logs, identifying root causes, and applying targeted code changes.",
|
||||
goal: "Diagnose and fix CI build failures",
|
||||
inputs: { repo_url: "https://github.com/org/service", branch: "main" },
|
||||
labels: { project: "fix-build" },
|
||||
snapshot: "fix-build-dev",
|
||||
autoStopInterval: 60,
|
||||
cpu: 4,
|
||||
memoryGb: 8,
|
||||
diskGb: 10,
|
||||
}),
|
||||
graph: `digraph fix_build {
|
||||
graph [
|
||||
goal="Diagnose and fix CI build failures",
|
||||
label="Fix Build"
|
||||
]
|
||||
rankdir=LR
|
||||
|
||||
start [shape=Mdiamond, label="Start"]
|
||||
exit [shape=Msquare, label="Exit"]
|
||||
|
||||
diagnose [label="Diagnose Failure", prompt="@prompts/fix_build/diagnose.md", reasoning_effort="high"]
|
||||
fix [label="Apply Fix", prompt="@prompts/fix_build/fix.md"]
|
||||
validate [label="Run Build", prompt="@prompts/fix_build/validate.md", goal_gate=true]
|
||||
gate [shape=diamond, label="Build passing?"]
|
||||
|
||||
start -> diagnose -> fix -> validate -> gate
|
||||
gate -> exit [label="Yes", condition="outcome=succeeded"]
|
||||
gate -> diagnose [label="No", condition="outcome!=succeeded", max_visits=3]
|
||||
}
|
||||
`,
|
||||
},
|
||||
implement: {
|
||||
name: "Implement Feature",
|
||||
slug: "implement",
|
||||
filename: "implement.fabro",
|
||||
description: "Generates production-ready code from a technical blueprint, including tests, documentation, and a pull request ready for review.",
|
||||
settings: sampleSettings({
|
||||
name: "Implement Feature",
|
||||
description: "Generates production-ready code from a technical blueprint, including tests, documentation, and a pull request ready for review.",
|
||||
goal: "Implement feature from technical blueprint",
|
||||
inputs: { spec_path: "specs/feature.md", test_framework: "vitest" },
|
||||
labels: { project: "implement", team: "engineering" },
|
||||
snapshot: "implement-dev",
|
||||
autoStopInterval: 120,
|
||||
cpu: 4,
|
||||
memoryGb: 8,
|
||||
diskGb: 20,
|
||||
prepareCommands: ["bun install", "bun run typecheck"],
|
||||
}),
|
||||
graph: `digraph implement {
|
||||
graph [
|
||||
goal="",
|
||||
label="Implement"
|
||||
]
|
||||
rankdir=LR
|
||||
|
||||
start [shape=Mdiamond, label="Start"]
|
||||
exit [shape=Msquare, label="Exit"]
|
||||
|
||||
strategy [shape=hexagon, label="Choose decomposition strategy:"]
|
||||
|
||||
subgraph cluster_impl {
|
||||
label="Implementation Loop"
|
||||
node [fidelity="full", thread_id="impl"]
|
||||
|
||||
plan [label="Plan Implementation", prompt="@prompts/implement/plan.md", reasoning_effort="high"]
|
||||
implement [label="Implement", prompt="@prompts/implement/implement.md"]
|
||||
review [label="Review", prompt="@prompts/implement/review.md"]
|
||||
validate [label="Validate", prompt="@prompts/implement/validate.md", goal_gate=true]
|
||||
fix [label="Fix Failures", prompt="@prompts/implement/fix.md", max_visits=3]
|
||||
}
|
||||
|
||||
start -> strategy
|
||||
strategy -> plan [label="[L] Layer-by-layer"]
|
||||
strategy -> plan [label="[F] Feature slice"]
|
||||
strategy -> plan [label="[P] Embarrassingly parallel"]
|
||||
strategy -> plan [label="[S] Sequential / linear"]
|
||||
plan -> implement -> review -> validate
|
||||
validate -> exit [condition="outcome=succeeded"]
|
||||
validate -> fix [condition="outcome!=succeeded", label="Fix"]
|
||||
fix -> validate
|
||||
}
|
||||
`,
|
||||
},
|
||||
sync_drift: {
|
||||
name: "Sync Drift",
|
||||
slug: "sync_drift",
|
||||
filename: "sync_drift.fabro",
|
||||
description: "Detects configuration and code drift between environments, then generates reconciliation patches to bring everything back in sync.",
|
||||
settings: sampleSettings({
|
||||
name: "Sync Drift",
|
||||
description: "Detects configuration and code drift between environments, then generates reconciliation patches to bring everything back in sync.",
|
||||
goal: "Detect and reconcile configuration drift across environments",
|
||||
inputs: { source_env: "production", target_env: "staging", drift_threshold: "warn" },
|
||||
labels: { project: "sync-drift", team: "platform" },
|
||||
snapshot: "sync-drift-dev",
|
||||
autoStopInterval: 120,
|
||||
cpu: 2,
|
||||
memoryGb: 4,
|
||||
diskGb: 10,
|
||||
}),
|
||||
graph: `digraph sync {
|
||||
graph [
|
||||
goal="Detect and resolve drift between product docs, architecture docs, and code",
|
||||
label="Sync"
|
||||
]
|
||||
rankdir=LR
|
||||
|
||||
start [shape=Mdiamond, label="Start"]
|
||||
exit [shape=Msquare, label="Exit"]
|
||||
|
||||
detect [label="Detect Drift", prompt="@prompts/sync/detect.md", reasoning_effort="high"]
|
||||
propose [label="Propose Changes", prompt="@prompts/sync/propose.md"]
|
||||
review [shape=hexagon, label="Review Changes"]
|
||||
apply [label="Apply Changes", prompt="@prompts/sync/apply.md"]
|
||||
|
||||
start -> detect
|
||||
detect -> exit [condition="context.drift_found=false", label="No drift"]
|
||||
detect -> propose [condition="context.drift_found=true", label="Drift found"]
|
||||
propose -> review
|
||||
review -> apply [label="[A] Accept"]
|
||||
review -> propose [label="[R] Revise"]
|
||||
apply -> exit
|
||||
}
|
||||
`,
|
||||
},
|
||||
expand: {
|
||||
name: "Expand Product",
|
||||
slug: "expand",
|
||||
filename: "expand.fabro",
|
||||
description: "Evolves the product by analyzing usage patterns and specifications to propose and implement incremental improvements.",
|
||||
settings: sampleSettings({
|
||||
name: "Expand Product",
|
||||
description: "Evolves the product by analyzing usage patterns and specifications to propose and implement incremental improvements.",
|
||||
goal: "Propose and implement incremental product improvements",
|
||||
inputs: { analytics_window: "30d", min_confidence: "0.8" },
|
||||
labels: { project: "expand", team: "product" },
|
||||
snapshot: "expand-dev",
|
||||
autoStopInterval: 180,
|
||||
cpu: 2,
|
||||
memoryGb: 4,
|
||||
diskGb: 10,
|
||||
}),
|
||||
graph: `digraph expand {
|
||||
graph [
|
||||
goal="",
|
||||
label="Expand"
|
||||
]
|
||||
rankdir=LR
|
||||
|
||||
start [shape=Mdiamond, label="Start"]
|
||||
exit [shape=Msquare, label="Exit"]
|
||||
|
||||
propose [label="Propose Changes", prompt="@prompts/expand/propose.md", reasoning_effort="high"]
|
||||
approve [shape=hexagon, label="Approve Changes"]
|
||||
execute [label="Execute Changes", prompt="@prompts/expand/execute.md"]
|
||||
|
||||
start -> propose -> approve
|
||||
approve -> execute [label="[A] Accept"]
|
||||
approve -> propose [label="[R] Revise"]
|
||||
execute -> exit
|
||||
}
|
||||
`,
|
||||
},
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ name: "Definition", path: "" },
|
||||
{ name: "Diagram", path: "/diagram" },
|
||||
{ name: "Runs", path: "/runs" },
|
||||
];
|
||||
|
||||
export const handle = { hideHeader: true };
|
||||
|
||||
function resolveWorkflow(name: string | undefined, apiWorkflow: ApiWorkflowDetail | null | undefined): WorkflowEntry {
|
||||
const workflowName = name ?? "";
|
||||
return apiWorkflow
|
||||
? {
|
||||
name: apiWorkflow.name,
|
||||
slug: apiWorkflow.slug,
|
||||
description: apiWorkflow.description,
|
||||
filename: apiWorkflow.filename,
|
||||
settings: apiWorkflow.settings,
|
||||
graph: apiWorkflow.graph,
|
||||
}
|
||||
: workflowData[workflowName] ?? {
|
||||
name: workflowName,
|
||||
slug: workflowName,
|
||||
description: "",
|
||||
filename: `${workflowName}.fabro`,
|
||||
settings: sampleSettings({
|
||||
name: workflowName,
|
||||
description: "",
|
||||
goal: "",
|
||||
inputs: {},
|
||||
labels: {},
|
||||
snapshot: "default",
|
||||
autoStopInterval: 120,
|
||||
cpu: 2,
|
||||
memoryGb: 4,
|
||||
diskGb: 10,
|
||||
}),
|
||||
graph: "",
|
||||
};
|
||||
}
|
||||
export const handle = { hideHeader: true, wide: true };
|
||||
|
||||
export function meta({ data }: any) {
|
||||
const title = data?.workflow?.name ?? "Automation";
|
||||
const title = data?.automation?.name ?? "Automation";
|
||||
return [{ title: `${title} — Fabro` }];
|
||||
}
|
||||
|
||||
export default function AutomationDetail() {
|
||||
const { name } = useParams();
|
||||
const workflowQuery = useWorkflow(name);
|
||||
const { pathname } = useLocation();
|
||||
const workflow = resolveWorkflow(name, workflowQuery.data);
|
||||
const basePath = `/automation/${name}`;
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const automationQuery = useAutomation(id);
|
||||
|
||||
if (automationQuery.error) {
|
||||
return (
|
||||
<div className="py-12">
|
||||
<ErrorState
|
||||
title="Couldn't load this automation"
|
||||
description="It may have been deleted, or the server returned an error."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!automationQuery.data) {
|
||||
return <div className="h-1" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<nav className="mb-4 flex items-center gap-1 text-sm text-fg-muted">
|
||||
<Link to="/automations" className="text-fg-3 hover:text-fg">Automations</Link>
|
||||
<ChevronRightIcon className="size-3" />
|
||||
<span>{workflow.name}</span>
|
||||
</nav>
|
||||
|
||||
<div className="mb-6 flex items-center gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-xl font-semibold text-fg">{workflow.name}</h2>
|
||||
<span className="font-mono text-xs text-fg-muted">{workflow.filename}</span>
|
||||
</div>
|
||||
<p className="mt-2 max-w-prose text-sm leading-relaxed text-fg-3">{workflow.description}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
title="Run automation"
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-md border border-mint/20 px-3 py-1.5 text-sm font-medium text-mint transition-colors hover:border-mint/50 hover:bg-mint/10 hover:text-fg"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className="size-3.5" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M4.5 5.653c0-1.427 1.529-2.33 2.779-1.643l11.54 6.347c1.295.712 1.295 2.573 0 3.286L7.28 19.99c-1.25.687-2.779-.217-2.779-1.643V5.653Z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Run
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-line">
|
||||
<nav className="-mb-px flex gap-6">
|
||||
{tabs.map((tab) => {
|
||||
const tabPath = `${basePath}${tab.path}`;
|
||||
const isActive = pathname === tabPath;
|
||||
return (
|
||||
<Link
|
||||
key={tab.name}
|
||||
to={tabPath}
|
||||
className={`border-b-2 pb-3 text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? "border-teal-500 text-fg"
|
||||
: "border-transparent text-fg-muted hover:border-line-strong hover:text-fg-3"
|
||||
}`}
|
||||
>
|
||||
{tab.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Outlet context={{ workflow }} />
|
||||
</div>
|
||||
<AutomationHeader automation={automationQuery.data} />
|
||||
<AutomationRunsList automationId={automationQuery.data.id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AutomationHeader({ automation }: { automation: Automation }) {
|
||||
const navigate = useNavigate();
|
||||
const { mutate } = useSWRConfig();
|
||||
const toast = useToast();
|
||||
const [running, setRunning] = useState(false);
|
||||
|
||||
const scheduleTrigger = automation.triggers.find((t) => t.type === "schedule");
|
||||
const apiTrigger = automation.triggers.find((t) => t.type === "api");
|
||||
const canRun = apiTrigger?.enabled === true && automation.enabled;
|
||||
|
||||
async function onRun() {
|
||||
if (!canRun || running) return;
|
||||
setRunning(true);
|
||||
try {
|
||||
const run = await apiData(() => automationsApi.createAutomationRun(automation.id));
|
||||
await mutate(
|
||||
(key) =>
|
||||
Array.isArray(key) &&
|
||||
key[0] === "automations" &&
|
||||
key[1] === "runs" &&
|
||||
key[2] === automation.id,
|
||||
);
|
||||
toast.push({ message: `Started run for “${automation.name}”.` });
|
||||
navigate(`/runs/${run.id}`);
|
||||
} catch (cause) {
|
||||
toast.push({
|
||||
tone: "error",
|
||||
message:
|
||||
cause instanceof ApiError && cause.message
|
||||
? cause.message
|
||||
: "Couldn't start a run. Please try again.",
|
||||
});
|
||||
setRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="mb-4 flex items-center gap-1 text-sm text-fg-muted">
|
||||
<Link to="/automations" className="text-fg-3 hover:text-fg">
|
||||
Automations
|
||||
</Link>
|
||||
<ChevronRightIcon className="size-3" aria-hidden="true" />
|
||||
<span>{automation.name}</span>
|
||||
</nav>
|
||||
|
||||
<div className="mb-6 flex flex-wrap items-start gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-xl font-semibold text-fg">{automation.name}</h2>
|
||||
<span className="font-mono text-xs text-fg-muted">{automation.id}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-5 gap-y-2 text-sm">
|
||||
<StatusChip enabled={automation.enabled} />
|
||||
<Chip icon={FolderIcon}>
|
||||
{automation.target.repository}
|
||||
<span className="text-fg-muted/70"> · {automation.target.ref}</span>
|
||||
</Chip>
|
||||
<Chip icon={RectangleStackIcon}>{automation.target.workflow}</Chip>
|
||||
{scheduleTrigger ? (
|
||||
<Chip icon={ClockIcon}>{scheduleTrigger.expression}</Chip>
|
||||
) : null}
|
||||
</div>
|
||||
{automation.description ? (
|
||||
<p className="mt-3 max-w-prose text-sm leading-relaxed text-fg-3">
|
||||
{automation.description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Link
|
||||
to={`/automations/${automation.id}/edit`}
|
||||
className={SECONDARY_BUTTON_CLASS}
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRun}
|
||||
disabled={!canRun || running}
|
||||
title={
|
||||
!automation.enabled
|
||||
? "Enable the automation to run it"
|
||||
: !apiTrigger?.enabled
|
||||
? "Enable the API trigger to run it"
|
||||
: undefined
|
||||
}
|
||||
className={PRIMARY_BUTTON_CLASS}
|
||||
>
|
||||
<PlayIcon className="size-4" aria-hidden="true" />
|
||||
{running ? "Starting…" : "Run"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusChip({ enabled }: { enabled: boolean }) {
|
||||
return (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={`size-2 rounded-full ${enabled ? "bg-teal-500" : "bg-fg-muted"}`}
|
||||
/>
|
||||
<span className={`font-medium ${enabled ? "text-teal-500" : "text-fg-muted"}`}>
|
||||
{enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Chip({
|
||||
icon: Icon,
|
||||
children,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<span className="flex items-center gap-1.5 font-mono text-xs text-fg-muted">
|
||||
<Icon className="size-3.5" aria-hidden="true" />
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function AutomationRunsList({ automationId }: { automationId: string }) {
|
||||
const [urlSearchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const query = urlSearchParams.get("search") ?? "";
|
||||
const sort = parseSort(urlSearchParams.get("sort"));
|
||||
const direction = parseDirection(urlSearchParams.get("direction"));
|
||||
const page = parsePage(urlSearchParams.get("page"));
|
||||
const pageSize = parsePageSize(urlSearchParams.get("size"));
|
||||
const hiddenColumns = useMemo(
|
||||
() => hiddenColumnsFromSearchParams(urlSearchParams),
|
||||
[urlSearchParams],
|
||||
);
|
||||
|
||||
const updateParams = useCallback(
|
||||
(updater: (params: URLSearchParams) => void) => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
updater(next);
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
const setQuery = (value: string) =>
|
||||
updateParams((p) => {
|
||||
if (value) p.set("search", value);
|
||||
else p.delete("search");
|
||||
p.delete("page");
|
||||
});
|
||||
const setPage = (next: number) =>
|
||||
updateParams((p) => {
|
||||
if (next > 1) p.set("page", String(next));
|
||||
else p.delete("page");
|
||||
});
|
||||
const setPageSize = (next: number) =>
|
||||
updateParams((p) => {
|
||||
p.set("size", String(next));
|
||||
p.delete("page");
|
||||
});
|
||||
const setHiddenColumns = (next: Set<ToggleableColumn>) =>
|
||||
updateParams((p) => {
|
||||
const serialized = serializeHiddenColumns(next);
|
||||
if (serialized) p.set("hide", serialized);
|
||||
else p.set("hide", "");
|
||||
});
|
||||
const onSortClick = (key: ListRunsSortEnum) =>
|
||||
updateParams((p) => {
|
||||
if (sort === key) {
|
||||
p.set("direction", direction === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
p.set("sort", key);
|
||||
p.set("direction", "desc");
|
||||
}
|
||||
p.delete("page");
|
||||
});
|
||||
|
||||
const runsQuery = useAutomationRuns(automationId, {
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize,
|
||||
});
|
||||
|
||||
const now = useTickingNow(true, 15_000);
|
||||
const updatedAt = useDataUpdatedAt(runsQuery.data);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
void runsQuery.mutate();
|
||||
}, [runsQuery]);
|
||||
|
||||
const apiError = runsQuery.error instanceof ApiError ? runsQuery.error : null;
|
||||
if (apiError && !runsQuery.data) {
|
||||
return (
|
||||
<ErrorState
|
||||
title="Couldn't load runs"
|
||||
description={`Server returned ${apiError.status}.`}
|
||||
onRetry={handleRefresh}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const lowerQuery = query.toLowerCase();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative w-64">
|
||||
<MagnifyingGlassIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-fg-muted" />
|
||||
<input
|
||||
type="text"
|
||||
name="search"
|
||||
aria-label="Search runs"
|
||||
placeholder="Search runs…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="w-full rounded-md border border-line bg-panel/80 py-2 pl-9 pr-3 text-sm text-fg-2 placeholder-fg-muted outline-none transition-colors focus:border-focus focus:ring-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{updatedAt != null ? (
|
||||
<span className="font-mono text-xs text-fg-muted">
|
||||
Updated {formatRelativeTime(new Date(updatedAt).toISOString(), now)}
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefresh}
|
||||
disabled={runsQuery.isValidating}
|
||||
aria-label={runsQuery.isValidating ? "Refreshing runs" : "Refresh runs"}
|
||||
title="Refresh"
|
||||
className="inline-flex size-9 items-center justify-center rounded-md border border-line bg-panel/80 text-fg-3 transition-colors hover:bg-panel hover:text-fg disabled:cursor-default disabled:opacity-60 disabled:hover:bg-panel/80 disabled:hover:text-fg-3"
|
||||
>
|
||||
<ArrowPathIcon
|
||||
className={`size-4 ${runsQuery.isValidating ? "animate-spin [animation-duration:450ms]" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<ColumnPickerButton hidden={hiddenColumns} onChange={setHiddenColumns} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RunsListView
|
||||
data={runsQuery.data ?? undefined}
|
||||
isLoading={runsQuery.data == null && runsQuery.isLoading}
|
||||
emptyState={
|
||||
<EmptyState
|
||||
title="No runs yet"
|
||||
description="When this automation runs, the runs will appear here."
|
||||
/>
|
||||
}
|
||||
sort={sort}
|
||||
direction={direction}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
hiddenColumns={hiddenColumns}
|
||||
onSortClick={onSortClick}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
query={lowerQuery}
|
||||
repoFilter="all"
|
||||
workflowFilter="all"
|
||||
createdCutoffMs={null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,211 +0,0 @@
|
|||
import { useCallback, useRef, useState } from "react";
|
||||
import { ArrowDownIcon, ArrowRightIcon, MinusIcon, PlusIcon } from "@heroicons/react/20/solid";
|
||||
import { graphTheme } from "../lib/graph-theme";
|
||||
import { useRenderedVizDiagram } from "../hooks/use-rendered-viz-diagram";
|
||||
|
||||
type Direction = "LR" | "TB";
|
||||
|
||||
function buildDot(direction: Direction) {
|
||||
return `digraph fix_build {
|
||||
graph [
|
||||
label="Fix Build"
|
||||
]
|
||||
rankdir=${direction}
|
||||
bgcolor="transparent"
|
||||
pad=0.5
|
||||
|
||||
node [
|
||||
fontname="ui-monospace, monospace"
|
||||
fontsize=11
|
||||
fontcolor="${graphTheme.nodeText}"
|
||||
color="${graphTheme.edgeColor}"
|
||||
fillcolor="${graphTheme.nodeFill}"
|
||||
style=filled
|
||||
penwidth=1.2
|
||||
]
|
||||
edge [
|
||||
fontname="ui-monospace, monospace"
|
||||
fontsize=9
|
||||
fontcolor="${graphTheme.fontcolor}"
|
||||
color="${graphTheme.edgeColor}"
|
||||
arrowsize=0.7
|
||||
penwidth=1.2
|
||||
]
|
||||
|
||||
start [shape=Mdiamond, label="Start", fillcolor="${graphTheme.startFill}", color="${graphTheme.startBorder}", fontcolor="${graphTheme.startText}"]
|
||||
exit [shape=Msquare, label="Exit", fillcolor="${graphTheme.startFill}", color="${graphTheme.startBorder}", fontcolor="${graphTheme.startText}"]
|
||||
|
||||
analyze [label="Analyze\\nBuild Errors"]
|
||||
diagnose [label="Diagnose\\nRoot Cause"]
|
||||
fix [label="Apply\\nFix"]
|
||||
validate [label="Validate\\nBuild"]
|
||||
approve [shape=hexagon, label="Review\\nChanges", fillcolor="${graphTheme.gateFill}", color="${graphTheme.gateBorder}", fontcolor="${graphTheme.gateText}"]
|
||||
|
||||
start -> analyze
|
||||
analyze -> diagnose
|
||||
diagnose -> fix
|
||||
fix -> validate
|
||||
validate -> exit [label="Pass"]
|
||||
validate -> diagnose [label="Fail", style=dashed, color="#f87171"]
|
||||
validate -> approve [label="Needs review", color="${graphTheme.gateBorder}"]
|
||||
approve -> exit [label="Accept"]
|
||||
approve -> fix [label="Revise", style=dashed]
|
||||
}`;
|
||||
}
|
||||
|
||||
function stripGraphTitle(svg: SVGSVGElement) {
|
||||
const title = svg.querySelector(".graph > title");
|
||||
if (!title) return;
|
||||
let sibling = title.nextElementSibling;
|
||||
while (sibling && sibling.tagName === "text") {
|
||||
const next = sibling.nextElementSibling;
|
||||
sibling.remove();
|
||||
sibling = next;
|
||||
}
|
||||
title.remove();
|
||||
}
|
||||
|
||||
const ZOOM_STEPS = [25, 50, 75, 100, 150, 200];
|
||||
const DEFAULT_ZOOM_INDEX = 2; // 75%
|
||||
|
||||
export default function AutomationDiagram() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const innerRef = useRef<HTMLDivElement>(null);
|
||||
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||
const [zoomIndex, setZoomIndex] = useState(DEFAULT_ZOOM_INDEX);
|
||||
const [direction, setDirection] = useState<Direction>("LR");
|
||||
const [pan, setPan] = useState({ x: 0, y: 0 });
|
||||
const dragState = useRef<{ startX: number; startY: number; startPanX: number; startPanY: number } | null>(null);
|
||||
const zoom = ZOOM_STEPS[zoomIndex];
|
||||
const resetPan = useCallback(() => setPan({ x: 0, y: 0 }), []);
|
||||
const error = useRenderedVizDiagram({
|
||||
buildDot,
|
||||
identity: direction,
|
||||
innerRef,
|
||||
onRenderStart: resetPan,
|
||||
prepareSvg: stripGraphTitle,
|
||||
svgRef,
|
||||
});
|
||||
|
||||
const onPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
if ((e.target as HTMLElement).closest("button")) return;
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
dragState.current = { startX: e.clientX, startY: e.clientY, startPanX: pan.x, startPanY: pan.y };
|
||||
}, [pan]);
|
||||
|
||||
const onPointerMove = useCallback((e: React.PointerEvent) => {
|
||||
const drag = dragState.current;
|
||||
if (!drag) return;
|
||||
setPan({
|
||||
x: drag.startPanX + e.clientX - drag.startX,
|
||||
y: drag.startPanY + e.clientY - drag.startY,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onPointerUp = useCallback(() => {
|
||||
dragState.current = null;
|
||||
}, []);
|
||||
|
||||
const fitToWindow = useCallback(() => {
|
||||
const svg = svgRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!svg || !container) return;
|
||||
|
||||
const svgW = svg.viewBox.baseVal.width || svg.getBoundingClientRect().width;
|
||||
const svgH = svg.viewBox.baseVal.height || svg.getBoundingClientRect().height;
|
||||
const padPx = 48; // p-6 on each side
|
||||
const containerW = container.clientWidth - padPx;
|
||||
const containerH = container.clientHeight - padPx;
|
||||
|
||||
const fitPct = Math.min(containerW / svgW, containerH / svgH) * 100;
|
||||
// Pick the largest zoom step that fits
|
||||
let best = 0;
|
||||
for (let i = ZOOM_STEPS.length - 1; i >= 0; i--) {
|
||||
if (ZOOM_STEPS[i] <= fitPct) { best = i; break; }
|
||||
}
|
||||
setZoomIndex(best);
|
||||
setPan({ x: 0, y: 0 });
|
||||
}, []);
|
||||
|
||||
if (error) {
|
||||
return <p className="text-sm text-coral">{error}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative rounded-md border border-line bg-panel-alt/40">
|
||||
<div className="absolute right-3 top-3 z-10 flex items-center gap-2">
|
||||
<div className="flex items-center gap-0.5 rounded-md border border-line bg-panel/90 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
title="Left to right"
|
||||
onClick={() => setDirection("LR")}
|
||||
className={`flex size-7 items-center justify-center rounded transition-colors ${direction === "LR" ? "bg-overlay-strong text-fg-3" : "text-fg-muted hover:bg-overlay hover:text-fg-3"}`}
|
||||
>
|
||||
<ArrowRightIcon className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Top to bottom"
|
||||
onClick={() => setDirection("TB")}
|
||||
className={`flex size-7 items-center justify-center rounded transition-colors ${direction === "TB" ? "bg-overlay-strong text-fg-3" : "text-fg-muted hover:bg-overlay hover:text-fg-3"}`}
|
||||
>
|
||||
<ArrowDownIcon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center rounded-md border border-line bg-panel/90 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
title="Fit to window"
|
||||
aria-label="Fit diagram to window"
|
||||
onClick={fitToWindow}
|
||||
className="flex size-7 items-center justify-center rounded text-fg-muted transition-colors hover:bg-overlay hover:text-fg-3"
|
||||
>
|
||||
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" className="size-3.5" aria-hidden="true">
|
||||
<rect x="1" y="1" width="12" height="12" rx="1.5" strokeWidth="1.5" strokeDasharray="3 2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-0.5 rounded-md border border-line bg-panel/90 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
title="Zoom out"
|
||||
onClick={() => setZoomIndex((i) => Math.max(0, i - 1))}
|
||||
disabled={zoomIndex === 0}
|
||||
className="flex size-7 items-center justify-center rounded text-fg-muted transition-colors hover:bg-overlay hover:text-fg-3 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-fg-muted"
|
||||
>
|
||||
<MinusIcon className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Zoom in"
|
||||
onClick={() => setZoomIndex((i) => Math.min(ZOOM_STEPS.length - 1, i + 1))}
|
||||
disabled={zoomIndex === ZOOM_STEPS.length - 1}
|
||||
className="flex size-7 items-center justify-center rounded text-fg-muted transition-colors hover:bg-overlay hover:text-fg-3 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-fg-muted"
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="overflow-hidden p-6"
|
||||
style={{ cursor: dragState.current ? "grabbing" : "grab" }}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
>
|
||||
<div
|
||||
ref={innerRef}
|
||||
className="flex items-center justify-center"
|
||||
style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom / 100})`, transformOrigin: "center center" }}
|
||||
>
|
||||
<p className="text-sm text-fg-muted">Loading diagram…</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
import { useState } from "react";
|
||||
import { ChevronDownIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
|
||||
import { Link, useParams } from "react-router";
|
||||
import { InlineMarkdown } from "../components/inline-markdown";
|
||||
import { PullRequestChip } from "../components/pull-request-chip";
|
||||
import { ciConfig, columnForRun, columnStatusDisplay, deriveCiStatus, mapRunToRunItem } from "../data/runs";
|
||||
import type { RunWithStatus } from "../data/runs";
|
||||
import { useWorkflowRuns } from "../lib/queries";
|
||||
import type { BoardColumn, PaginatedRunList } from "@qltysh/fabro-api-client";
|
||||
|
||||
function mapWorkflowRuns(result: PaginatedRunList | null | undefined): RunWithStatus[] {
|
||||
const apiRuns = result?.data ?? [];
|
||||
return apiRuns
|
||||
.map((r) => {
|
||||
const column = columnForRun(r);
|
||||
if (column == null) return null;
|
||||
return {
|
||||
...mapRunToRunItem(r),
|
||||
status: column,
|
||||
statusLabel: columnStatusDisplay[column].label,
|
||||
};
|
||||
})
|
||||
.filter((run): run is RunWithStatus => run != null);
|
||||
}
|
||||
|
||||
function RunRow({ run }: { run: RunWithStatus }) {
|
||||
const colors = columnStatusDisplay[run.status];
|
||||
return (
|
||||
<div className="grid items-center rounded-md border border-line bg-panel/80 px-4 py-3 transition-all duration-200 hover:border-line-strong hover:bg-panel" style={{ gridColumn: "1 / -1", gridTemplateColumns: "subgrid" }}>
|
||||
<Link to={`/runs/${run.id}`} className="contents">
|
||||
<span className="flex items-center gap-2 pr-2">
|
||||
<span className={`size-2 shrink-0 rounded-full ${colors.dot}`} />
|
||||
<span className={`text-xs font-medium ${colors.text}`}>{run.statusLabel}</span>
|
||||
</span>
|
||||
|
||||
<span className="font-mono text-xs pr-2 text-fg-muted">
|
||||
{run.elapsed}
|
||||
</span>
|
||||
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<InlineMarkdown content={run.title} className="truncate text-sm text-fg-2" />
|
||||
{run.comments != null && run.comments > 0 && (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 font-mono text-xs text-fg-muted">
|
||||
<svg viewBox="0 0 16 16" fill="currentColor" className="size-3" aria-hidden="true">
|
||||
<path d="M1 2.75C1 1.784 1.784 1 2.75 1h10.5c.966 0 1.75.784 1.75 1.75v7.5A1.75 1.75 0 0 1 13.25 12H9.06l-2.573 2.573A1.458 1.458 0 0 1 4 13.543V12H2.75A1.75 1.75 0 0 1 1 10.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h4.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z" />
|
||||
</svg>
|
||||
{run.comments}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<span className="flex items-center justify-end gap-2 pr-4 font-mono text-xs tabular-nums">
|
||||
{run.additions != null && <span className="text-mint">+{run.additions.toLocaleString()}</span>}
|
||||
{run.deletions != null && <span className="text-coral">-{run.deletions.toLocaleString()}</span>}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<span className="inline-flex items-center justify-end gap-1.5 font-mono text-xs text-fg-muted">
|
||||
{run.pullRequestUrl && run.number != null && (
|
||||
<PullRequestChip number={run.number} url={run.pullRequestUrl}>
|
||||
{run.checks != null && <span className={`size-1.5 rounded-full ${ciConfig[deriveCiStatus(run.checks)].dot}`} />}
|
||||
</PullRequestChip>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AutomationRuns() {
|
||||
const { name } = useParams();
|
||||
const runsQuery = useWorkflowRuns(name);
|
||||
const runs = mapWorkflowRuns(runsQuery.data);
|
||||
const [query, setQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<BoardColumn | "all">("all");
|
||||
const filtered = runs.filter(
|
||||
(r) =>
|
||||
(statusFilter === "all" || r.status === statusFilter) &&
|
||||
(r.title.toLowerCase().includes(query.toLowerCase()) ||
|
||||
r.statusLabel.toLowerCase().includes(query.toLowerCase()) ||
|
||||
(r.number != null && `#${r.number}`.includes(query))),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-3">
|
||||
<div className="relative flex-1">
|
||||
<MagnifyingGlassIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-fg-muted" />
|
||||
<input
|
||||
type="text"
|
||||
aria-label="Search runs"
|
||||
placeholder="Search runs..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="w-full rounded-md border border-line bg-panel/80 py-2 pl-9 pr-3 text-sm text-fg-2 placeholder-fg-muted outline-none transition-colors focus:border-focus focus:ring-0"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<select
|
||||
aria-label="Run status filter"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as BoardColumn | "all")}
|
||||
className="appearance-none rounded-md border border-line bg-panel/80 py-2 pl-3 pr-8 text-sm text-fg-2 outline-none transition-colors focus:border-focus focus:ring-0"
|
||||
>
|
||||
<option value="all">All statuses</option>
|
||||
{(Object.entries(columnStatusDisplay) as [BoardColumn, { label: string }][]).map(([id, { label }]) => (
|
||||
<option key={id} value={id}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDownIcon className="pointer-events-none absolute right-2 top-1/2 size-4 -translate-y-1/2 text-fg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2" style={{ gridTemplateColumns: "auto 5rem 1fr 8rem auto" }}>
|
||||
{filtered.map((run) => (
|
||||
<RunRow key={run.id} run={run} />
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<p className="py-8 text-center text-sm text-fg-muted">No runs match "{query}"</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
150
apps/fabro-web/app/routes/automations-edit.tsx
Normal file
150
apps/fabro-web/app/routes/automations-edit.tsx
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import { useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { ChevronRightIcon } from "@heroicons/react/20/solid";
|
||||
import type { Automation } from "@qltysh/fabro-api-client";
|
||||
|
||||
import { ApiError, apiData, automationsApi } from "../lib/api-client";
|
||||
import { useAutomation } from "../lib/queries";
|
||||
import { queryKeys } from "../lib/query-keys";
|
||||
import {
|
||||
AutomationFormFields,
|
||||
automationToFormValues,
|
||||
isFormValid,
|
||||
triggersFromFormValues,
|
||||
type AutomationFormValues,
|
||||
} from "../components/automation-form";
|
||||
import { Panel, PanelSkeleton } from "../components/settings-panel";
|
||||
import {
|
||||
ErrorMessage,
|
||||
PRIMARY_BUTTON_CLASS,
|
||||
SECONDARY_BUTTON_CLASS,
|
||||
} from "../components/ui";
|
||||
import { useToast } from "../components/toast";
|
||||
|
||||
export function meta() {
|
||||
return [{ title: "Edit automation — Fabro" }];
|
||||
}
|
||||
|
||||
export const handle = { hideHeader: true };
|
||||
|
||||
export default function AutomationsEdit() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const query = useAutomation(id);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader id={id ?? ""} name={query.data?.name} />
|
||||
{query.data ? (
|
||||
<EditAutomationForm key={query.data.id} automation={query.data} />
|
||||
) : query.error ? (
|
||||
<Panel title="Automation">
|
||||
<div className="px-4 py-6 text-sm text-fg-2">
|
||||
Couldn't load this automation. It may have been deleted.
|
||||
</div>
|
||||
</Panel>
|
||||
) : (
|
||||
<PanelSkeleton />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PageHeader({ id, name }: { id: string; name: string | undefined }) {
|
||||
return (
|
||||
<nav className="flex items-center gap-1 text-sm text-fg-muted">
|
||||
<Link to="/automations" className="text-fg-3 hover:text-fg">
|
||||
Automations
|
||||
</Link>
|
||||
<ChevronRightIcon className="size-3" aria-hidden="true" />
|
||||
<span>{name ?? id}</span>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function EditAutomationForm({ automation }: { automation: Automation }) {
|
||||
const navigate = useNavigate();
|
||||
const { mutate } = useSWRConfig();
|
||||
const toast = useToast();
|
||||
const [values, setValues] = useState<AutomationFormValues>(
|
||||
automationToFormValues(automation),
|
||||
);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const canSubmit = isFormValid(values) && !submitting;
|
||||
|
||||
async function onSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
const trimmedName = values.name.trim();
|
||||
try {
|
||||
await apiData(() =>
|
||||
automationsApi.replaceAutomation(automation.id, automation.revision, {
|
||||
name: trimmedName,
|
||||
description: values.description.trim() || null,
|
||||
enabled: values.enabled,
|
||||
target: {
|
||||
repository: values.repository.trim(),
|
||||
ref: values.ref.trim(),
|
||||
workflow: values.workflow.trim(),
|
||||
},
|
||||
triggers: triggersFromFormValues(values),
|
||||
}),
|
||||
);
|
||||
await mutate(queryKeys.automations.list());
|
||||
await mutate(queryKeys.automations.detail(automation.id));
|
||||
toast.push({ message: `Automation “${trimmedName}” updated.` });
|
||||
navigate("/automations");
|
||||
} catch (cause) {
|
||||
setError(
|
||||
cause instanceof ApiError && cause.message
|
||||
? cause.message
|
||||
: "Couldn't update the automation. Please try again.",
|
||||
);
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="space-y-6">
|
||||
<AutomationFormFields values={values} onChange={setValues} lockIdAndTarget />
|
||||
|
||||
{error ? <ErrorMessage message={error} /> : null}
|
||||
|
||||
<FormFooter
|
||||
submitting={submitting}
|
||||
canSubmit={canSubmit}
|
||||
onCancel={() => navigate("/automations")}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function FormFooter({
|
||||
submitting,
|
||||
canSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
submitting: boolean;
|
||||
canSubmit: boolean;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={submitting}
|
||||
className={SECONDARY_BUTTON_CLASS}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={!canSubmit} className={PRIMARY_BUTTON_CLASS}>
|
||||
{submitting ? "Saving…" : "Save changes"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,14 +1,23 @@
|
|||
import { useRef, useState, type ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { Switch } from "@headlessui/react";
|
||||
import { ChevronRightIcon, ChevronDownIcon } from "@heroicons/react/20/solid";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { ChevronRightIcon } from "@heroicons/react/20/solid";
|
||||
|
||||
import { Panel, Row } from "../components/settings-panel";
|
||||
import { ApiError, apiData, automationsApi } from "../lib/api-client";
|
||||
import { queryKeys } from "../lib/query-keys";
|
||||
import {
|
||||
INPUT_CLASS,
|
||||
AutomationFormFields,
|
||||
EMPTY_AUTOMATION_FORM,
|
||||
isFormValid,
|
||||
triggersFromFormValues,
|
||||
type AutomationFormValues,
|
||||
} from "../components/automation-form";
|
||||
import {
|
||||
ErrorMessage,
|
||||
PRIMARY_BUTTON_CLASS,
|
||||
SECONDARY_BUTTON_CLASS,
|
||||
} from "../components/ui";
|
||||
import { useToast } from "../components/toast";
|
||||
|
||||
export function meta() {
|
||||
return [{ title: "New automation — Fabro" }];
|
||||
|
|
@ -16,225 +25,63 @@ export function meta() {
|
|||
|
||||
export const handle = { hideHeader: true };
|
||||
|
||||
const SAMPLE_REPOSITORIES = [
|
||||
"qltysh/fabro",
|
||||
"qltysh/fabro-cloud",
|
||||
"acme/orders-api",
|
||||
"acme/billing",
|
||||
"acme/web",
|
||||
];
|
||||
|
||||
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 default function AutomationsNew() {
|
||||
const navigate = useNavigate();
|
||||
const [name, setName] = useState("");
|
||||
const [slug, setSlug] = useState("");
|
||||
const slugTouchedRef = useRef(false);
|
||||
const [description, setDescription] = useState("");
|
||||
const [repository, setRepository] = useState(SAMPLE_REPOSITORIES[0]);
|
||||
const [branch, setBranch] = useState("main");
|
||||
const [workflowSlug, setWorkflowSlug] = useState("");
|
||||
const [goal, setGoal] = useState("");
|
||||
const [manualEnabled, setManualEnabled] = useState(true);
|
||||
const [scheduleEnabled, setScheduleEnabled] = useState(false);
|
||||
const [cron, setCron] = useState("0 9 * * 1-5");
|
||||
const { mutate } = useSWRConfig();
|
||||
const toast = useToast();
|
||||
const [values, setValues] = useState<AutomationFormValues>(EMPTY_AUTOMATION_FORM);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function onNameChange(next: string) {
|
||||
setName(next);
|
||||
if (!slugTouchedRef.current) setSlug(kebabify(next));
|
||||
}
|
||||
const canSubmit = isFormValid(values) && !submitting;
|
||||
|
||||
function onSlugChange(next: string) {
|
||||
setSlug(kebabify(next));
|
||||
slugTouchedRef.current = true;
|
||||
}
|
||||
|
||||
const canSubmit =
|
||||
name.trim() !== "" &&
|
||||
slug.trim() !== "" &&
|
||||
workflowSlug.trim() !== "" &&
|
||||
(manualEnabled || scheduleEnabled);
|
||||
|
||||
function onSubmit(event: React.FormEvent) {
|
||||
async function onSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
// Non-functional prototype — just return to the list.
|
||||
navigate("/automations");
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
const trimmedName = values.name.trim();
|
||||
try {
|
||||
await apiData(() =>
|
||||
automationsApi.createAutomation({
|
||||
id: values.id.trim(),
|
||||
name: trimmedName,
|
||||
description: values.description.trim() || null,
|
||||
enabled: values.enabled,
|
||||
target: {
|
||||
repository: values.repository.trim(),
|
||||
ref: values.ref.trim(),
|
||||
workflow: values.workflow.trim(),
|
||||
},
|
||||
triggers: triggersFromFormValues(values),
|
||||
}),
|
||||
);
|
||||
await mutate(queryKeys.automations.list());
|
||||
toast.push({ message: `Automation “${trimmedName}” created.` });
|
||||
navigate("/automations");
|
||||
} catch (cause) {
|
||||
setError(
|
||||
cause instanceof ApiError && cause.message
|
||||
? cause.message
|
||||
: "Couldn't create the automation. Please try again.",
|
||||
);
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="space-y-6">
|
||||
<PageHeader />
|
||||
|
||||
<Panel title="Basics">
|
||||
<Row title={<Label required>Name</Label>} help="Shown wherever this automation is listed.">
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
aria-label="Automation name"
|
||||
value={name}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
placeholder="Fix Build"
|
||||
autoComplete="off"
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
</Row>
|
||||
<Row
|
||||
title={<Label required>Slug</Label>}
|
||||
help={
|
||||
<>
|
||||
Identifier used in the URL: <span className="font-mono text-fg-2">/automation/{slug || "<slug>"}</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
name="slug"
|
||||
aria-label="Automation slug"
|
||||
value={slug}
|
||||
onChange={(e) => onSlugChange(e.target.value)}
|
||||
placeholder="fix-build"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={`${INPUT_CLASS} font-mono`}
|
||||
/>
|
||||
</Row>
|
||||
<Row title={<Label optional>Description</Label>} help="A short summary teammates will see when browsing automations.">
|
||||
<textarea
|
||||
name="description"
|
||||
aria-label="Automation description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Diagnose and fix CI build failures by analyzing logs and applying targeted patches."
|
||||
className={`${INPUT_CLASS} resize-y`}
|
||||
/>
|
||||
</Row>
|
||||
</Panel>
|
||||
<AutomationFormFields values={values} onChange={setValues} />
|
||||
|
||||
<Panel title="Source">
|
||||
<Row title={<Label required>Repository</Label>} help="The repository this automation will check out and operate on.">
|
||||
<SelectInput
|
||||
name="repository"
|
||||
label="Repository"
|
||||
value={repository}
|
||||
onChange={setRepository}
|
||||
options={SAMPLE_REPOSITORIES}
|
||||
/>
|
||||
</Row>
|
||||
<Row title={<Label required>Branch</Label>} help="Default branch to run against.">
|
||||
<input
|
||||
type="text"
|
||||
name="branch"
|
||||
aria-label="Default branch"
|
||||
value={branch}
|
||||
onChange={(e) => setBranch(e.target.value)}
|
||||
placeholder="main"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={`${INPUT_CLASS} font-mono`}
|
||||
/>
|
||||
</Row>
|
||||
<Row
|
||||
title={<Label required>Workflow slug</Label>}
|
||||
help="Snake-case identifier used in the workflow file name (e.g. fix_build.fabro)."
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
name="workflow_slug"
|
||||
aria-label="Workflow slug"
|
||||
value={workflowSlug}
|
||||
onChange={(e) => setWorkflowSlug(snakeify(e.target.value))}
|
||||
placeholder="fix_build"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={`${INPUT_CLASS} font-mono`}
|
||||
/>
|
||||
</Row>
|
||||
</Panel>
|
||||
{error ? <ErrorMessage message={error} /> : null}
|
||||
|
||||
<Panel title="Goal">
|
||||
<Row title={<Label optional>Goal</Label>} help="Plain-English objective the agent uses to decide when the workflow has succeeded.">
|
||||
<textarea
|
||||
name="goal"
|
||||
aria-label="Automation goal"
|
||||
value={goal}
|
||||
onChange={(e) => setGoal(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Diagnose and fix the failing build so CI is green on the target branch."
|
||||
className={`${INPUT_CLASS} resize-y`}
|
||||
/>
|
||||
</Row>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Triggers">
|
||||
<Row title="Manual / API" help="Start a run by clicking Run in the UI or calling the API.">
|
||||
<ToggleSwitch
|
||||
checked={manualEnabled}
|
||||
onChange={setManualEnabled}
|
||||
label="Enable manual and API triggers"
|
||||
/>
|
||||
</Row>
|
||||
<Row title="Schedule" help="Start runs automatically on a recurring cron schedule.">
|
||||
<ToggleSwitch
|
||||
checked={scheduleEnabled}
|
||||
onChange={setScheduleEnabled}
|
||||
label="Enable scheduled triggers"
|
||||
/>
|
||||
</Row>
|
||||
{scheduleEnabled ? (
|
||||
<Row
|
||||
title="Cron expression"
|
||||
help={
|
||||
<>
|
||||
Five-field POSIX cron in UTC. Next run: <span className="text-fg-2">{describeCron(cron)}</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
name="cron"
|
||||
aria-label="Cron expression"
|
||||
value={cron}
|
||||
onChange={(e) => setCron(e.target.value)}
|
||||
placeholder="0 9 * * 1-5"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={`${INPUT_CLASS} font-mono`}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{CRON_PRESETS.map((preset) => {
|
||||
const active = preset.value === cron;
|
||||
return (
|
||||
<button
|
||||
key={preset.value}
|
||||
type="button"
|
||||
onClick={() => setCron(preset.value)}
|
||||
aria-pressed={active}
|
||||
className={`rounded-full px-2.5 py-1 text-xs transition-colors ${
|
||||
active
|
||||
? "bg-teal-500/15 text-teal-300 outline-1 -outline-offset-1 outline-teal-500/40"
|
||||
: "bg-overlay text-fg-3 hover:bg-overlay-strong hover:text-fg-2"
|
||||
}`}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Row>
|
||||
) : null}
|
||||
</Panel>
|
||||
|
||||
<FormFooter canSubmit={canSubmit} onCancel={() => navigate("/automations")} />
|
||||
<FormFooter
|
||||
submitting={submitting}
|
||||
canSubmit={canSubmit}
|
||||
onCancel={() => navigate("/automations")}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -258,126 +105,28 @@ function PageHeader() {
|
|||
);
|
||||
}
|
||||
|
||||
function Label({
|
||||
children,
|
||||
required,
|
||||
optional,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
required?: boolean;
|
||||
optional?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span className="inline-flex items-baseline gap-1.5">
|
||||
<span>{children}</span>
|
||||
{required ? (
|
||||
<span aria-label="required" className="text-coral">
|
||||
*
|
||||
</span>
|
||||
) : null}
|
||||
{optional ? <span className="text-xs font-normal text-fg-muted">Optional</span> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectInput({
|
||||
name,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
name: string;
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
options: ReadonlyArray<string>;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<select
|
||||
name={name}
|
||||
aria-label={label}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={`${INPUT_CLASS} appearance-none pr-9 font-mono`}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDownIcon
|
||||
className="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-fg-muted"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleSwitch({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (next: boolean) => void;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<Switch
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
aria-label={label}
|
||||
className="group relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full bg-overlay-strong outline-1 -outline-offset-1 outline-line-strong transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500 data-checked:bg-teal-500"
|
||||
>
|
||||
<span className="pointer-events-none inline-block size-4 translate-x-0.5 rounded-full bg-fg shadow-sm transition-transform duration-150 group-data-checked:translate-x-[1.125rem]" />
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
function FormFooter({
|
||||
submitting,
|
||||
canSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
submitting: boolean;
|
||||
canSubmit: boolean;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<button type="button" onClick={onCancel} className={SECONDARY_BUTTON_CLASS}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={submitting}
|
||||
className={SECONDARY_BUTTON_CLASS}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={!canSubmit} className={PRIMARY_BUTTON_CLASS}>
|
||||
Create automation
|
||||
{submitting ? "Creating…" : "Create automation"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function kebabify(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { useState, type ComponentType } from "react";
|
||||
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
|
||||
import { ChevronDownIcon, PlusIcon } from "@heroicons/react/20/solid";
|
||||
import { ChevronDownIcon as ChevronDownOutline } from "@heroicons/react/24/outline";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { PlusIcon } from "@heroicons/react/20/solid";
|
||||
import { EllipsisVerticalIcon } from "@heroicons/react/20/solid";
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
ArrowsRightLeftIcon,
|
||||
ClockIcon,
|
||||
CodeBracketIcon,
|
||||
|
|
@ -12,68 +14,42 @@ import {
|
|||
ShieldCheckIcon,
|
||||
WrenchIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import type { PaginatedWorkflowListResponse } from "@qltysh/fabro-api-client";
|
||||
import { Link } from "react-router";
|
||||
import { useWorkflows } from "../lib/queries";
|
||||
import { timeAgo, timeUntil } from "../lib/time";
|
||||
import { FilterButton } from "../components/runs-list/filter-button";
|
||||
import type { Automation, AutomationListResponse } from "@qltysh/fabro-api-client";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { ApiError, apiData, automationsApi } from "../lib/api-client";
|
||||
import { useAutomations } from "../lib/queries";
|
||||
import { queryKeys } from "../lib/query-keys";
|
||||
import { ConfirmDialog } from "../components/ui";
|
||||
import { useToast } from "../components/toast";
|
||||
|
||||
export function meta({}: any) {
|
||||
return [{ title: "Automations — Fabro" }];
|
||||
}
|
||||
|
||||
export const handle = {
|
||||
headerExtra: (
|
||||
<div className="relative inline-flex rounded-md">
|
||||
<Link
|
||||
to="/automations/new"
|
||||
className="inline-flex items-center gap-1.5 rounded-l-md border border-r-0 border-mint/20 px-3 py-1.5 text-sm font-medium text-mint transition-colors hover:border-mint/50 hover:bg-mint/10 hover:text-fg"
|
||||
>
|
||||
<PlusIcon className="size-3.5" aria-hidden="true" />
|
||||
Create Automation
|
||||
</Link>
|
||||
<Menu as="div" className="relative -ml-px flex">
|
||||
<MenuButton className="inline-flex items-center rounded-r-md border border-mint/20 px-1.5 text-mint transition-colors hover:border-mint/50 hover:bg-mint/10 hover:text-fg">
|
||||
<ChevronDownIcon className="size-3.5" aria-hidden="true" />
|
||||
</MenuButton>
|
||||
<MenuItems
|
||||
transition
|
||||
className="absolute right-0 top-full z-10 mt-2 w-48 origin-top-right rounded-md bg-panel py-1 outline-1 -outline-offset-1 outline-line-strong transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in"
|
||||
>
|
||||
<MenuItem>
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full px-4 py-2 text-left text-sm text-fg-3 data-focus:bg-overlay data-focus:outline-hidden"
|
||||
>
|
||||
Import from file
|
||||
</button>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full px-4 py-2 text-left text-sm text-fg-3 data-focus:bg-overlay data-focus:outline-hidden"
|
||||
>
|
||||
Duplicate existing
|
||||
</button>
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</Menu>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
export const handle = { hideHeader: true };
|
||||
|
||||
interface Workflow {
|
||||
name: string;
|
||||
slug: string;
|
||||
filename: string;
|
||||
lastRun: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
color: string;
|
||||
schedule?: string;
|
||||
nextRun?: string;
|
||||
function CreateAutomationButton() {
|
||||
return (
|
||||
<Link
|
||||
to="/automations/new"
|
||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-mint/20 px-3 py-2 text-sm font-medium text-mint transition-colors hover:border-mint/50 hover:bg-mint/10 hover:text-fg"
|
||||
>
|
||||
<PlusIcon className="size-3.5" aria-hidden="true" />
|
||||
Create Automation
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function getSlugIcon(slug: string): ComponentType<{ className?: string }> {
|
||||
return slugIconMap[slug] ?? CodeBracketIcon;
|
||||
interface AutomationRow {
|
||||
id: string;
|
||||
revision: string;
|
||||
name: string;
|
||||
workflow: string;
|
||||
repository: string;
|
||||
schedule?: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const slugIconMap: Record<string, ComponentType<{ className?: string }>> = {
|
||||
|
|
@ -94,34 +70,28 @@ const slugColorMap: Record<string, string> = {
|
|||
dep_audit: "var(--color-amber)",
|
||||
};
|
||||
|
||||
interface WorkflowData {
|
||||
name: string;
|
||||
slug: string;
|
||||
filename: string;
|
||||
lastRun: string;
|
||||
color: string;
|
||||
schedule?: string;
|
||||
nextRun?: string;
|
||||
const MENU_ITEM_CLASS =
|
||||
"flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-fg-3 transition-colors data-focus:bg-overlay data-focus:text-fg data-focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-60";
|
||||
|
||||
const MENU_ITEM_DANGER_CLASS =
|
||||
"flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-coral transition-colors data-focus:bg-coral/10 data-focus:text-coral data-focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-60";
|
||||
|
||||
function scheduleFor(automation: Automation): string | undefined {
|
||||
const schedule = automation.triggers.find((t) => t.type === "schedule");
|
||||
return schedule?.expression;
|
||||
}
|
||||
|
||||
function mapWorkflows(result: PaginatedWorkflowListResponse | null | undefined) {
|
||||
const apiWorkflows = result?.data ?? [];
|
||||
const workflows: WorkflowData[] = apiWorkflows.map((w) => ({
|
||||
name: w.name,
|
||||
slug: w.slug,
|
||||
filename: w.filename,
|
||||
lastRun: w.last_run?.ran_at ? timeAgo(w.last_run.ran_at) : "never",
|
||||
color: slugColorMap[w.slug] ?? "var(--color-teal-500)",
|
||||
schedule: w.schedule?.expression,
|
||||
nextRun: w.schedule?.next_run ? timeUntil(w.schedule.next_run) : undefined,
|
||||
}));
|
||||
return workflows;
|
||||
}
|
||||
|
||||
function enrichWorkflows(data: WorkflowData[]): Workflow[] {
|
||||
return data.map((w) => ({
|
||||
...w,
|
||||
icon: getSlugIcon(w.slug),
|
||||
function mapAutomations(result: AutomationListResponse | undefined): AutomationRow[] {
|
||||
const automations = result?.data ?? [];
|
||||
return automations.map((a) => ({
|
||||
id: a.id,
|
||||
revision: a.revision,
|
||||
name: a.name,
|
||||
workflow: a.target.workflow,
|
||||
repository: a.target.repository,
|
||||
schedule: scheduleFor(a),
|
||||
icon: slugIconMap[a.target.workflow] ?? CodeBracketIcon,
|
||||
color: slugColorMap[a.target.workflow] ?? "var(--color-teal-500)",
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
@ -133,44 +103,46 @@ function PlayIcon({ className }: { className?: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
function EllipsisIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M10.5 12a1.5 1.5 0 1 1 3 0 1.5 1.5 0 0 1-3 0Zm6 0a1.5 1.5 0 1 1 3 0 1.5 1.5 0 0 1-3 0Zm-12 0a1.5 1.5 0 1 1 3 0 1.5 1.5 0 0 1-3 0Z" clipRule="evenodd" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkflowCard({ workflow }: { workflow: Workflow }) {
|
||||
const Icon = workflow.icon;
|
||||
function AutomationCard({
|
||||
automation,
|
||||
disabled,
|
||||
running,
|
||||
onRun,
|
||||
onDelete,
|
||||
}: {
|
||||
automation: AutomationRow;
|
||||
disabled: boolean;
|
||||
running: boolean;
|
||||
onRun: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const Icon = automation.icon;
|
||||
return (
|
||||
<div className="group flex items-center gap-4 rounded-md border border-line bg-panel/80 p-4 transition-all duration-200 hover:border-line-strong hover:bg-panel hover:shadow-lg hover:shadow-black/20">
|
||||
<Link to={`/automation/${workflow.slug}`} className="flex min-w-0 flex-1 items-center gap-4">
|
||||
<Link to={`/automation/${automation.id}`} className="flex min-w-0 flex-1 items-center gap-4">
|
||||
<div
|
||||
className="flex size-9 shrink-0 items-center justify-center rounded-md border bg-panel-alt/60"
|
||||
style={{ borderColor: `color-mix(in srgb, ${workflow.color} 20%, transparent)`, color: workflow.color }}
|
||||
style={{ borderColor: `color-mix(in srgb, ${automation.color} 20%, transparent)`, color: automation.color }}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-fg-2 group-hover:text-fg">{workflow.name}</span>
|
||||
<span className="font-mono text-xs text-fg-muted">{workflow.filename}</span>
|
||||
{workflow.schedule && (
|
||||
<span className="text-sm font-medium text-fg-2 group-hover:text-fg">{automation.name}</span>
|
||||
<span className="font-mono text-xs text-fg-muted">{automation.workflow}</span>
|
||||
{automation.schedule && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-teal-500/10 border border-teal-500/20 px-2 py-0.5 text-[11px] font-medium text-teal-300">
|
||||
<ClockIcon className="size-3" />
|
||||
{workflow.schedule}
|
||||
{automation.schedule}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-fg-muted">
|
||||
{workflow.nextRun ?? `Last run ${workflow.lastRun}`}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-fg-muted">{automation.repository}</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{workflow.schedule ? (
|
||||
{automation.schedule ? (
|
||||
<button
|
||||
type="button"
|
||||
title="Pause schedule"
|
||||
|
|
@ -181,76 +153,203 @@ function WorkflowCard({ workflow }: { workflow: Workflow }) {
|
|||
) : (
|
||||
<button
|
||||
type="button"
|
||||
title="Run automation"
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-full border border-mint/20 text-mint transition-colors hover:border-mint/50 hover:bg-mint/10 hover:text-fg"
|
||||
onClick={onRun}
|
||||
disabled={running || disabled}
|
||||
aria-label={running ? "Starting run…" : "Run automation"}
|
||||
title={running ? "Starting run…" : "Run automation"}
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-full border border-mint/20 text-mint transition-colors hover:border-mint/50 hover:bg-mint/10 hover:text-fg disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-transparent disabled:hover:text-mint"
|
||||
>
|
||||
<PlayIcon className="size-3.5" />
|
||||
{running ? (
|
||||
<ArrowPathIcon className="size-3.5 animate-spin [animation-duration:450ms]" aria-hidden="true" />
|
||||
) : (
|
||||
<PlayIcon className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
title="Actions"
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-overlay hover:text-fg-3"
|
||||
>
|
||||
<EllipsisIcon className="size-5" />
|
||||
</button>
|
||||
<RowMenu automation={automation} disabled={disabled} onDelete={onDelete} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RowMenu({
|
||||
automation,
|
||||
disabled,
|
||||
onDelete,
|
||||
}: {
|
||||
automation: AutomationRow;
|
||||
disabled: boolean;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Menu as="div" className="relative inline-block">
|
||||
<MenuButton
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-label={`Actions for ${automation.name}`}
|
||||
title="Actions"
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-overlay hover:text-fg-3 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<EllipsisVerticalIcon className="size-5" aria-hidden="true" />
|
||||
</MenuButton>
|
||||
<MenuItems
|
||||
transition
|
||||
anchor={{ to: "bottom end", gap: 4 }}
|
||||
className="z-30 w-36 origin-top-right rounded-md bg-panel py-1 outline-1 -outline-offset-1 outline-line-strong transition data-closed:scale-95 data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in"
|
||||
>
|
||||
<MenuItem>
|
||||
<Link
|
||||
to={`/automations/${encodeURIComponent(automation.id)}/edit`}
|
||||
className={MENU_ITEM_CLASS}
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
</MenuItem>
|
||||
<hr className="my-1 h-px border-0 bg-line" />
|
||||
<MenuItem>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
disabled={disabled}
|
||||
className={MENU_ITEM_DANGER_CLASS}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
type TriggerFilter = "all" | "scheduled" | "manual";
|
||||
|
||||
const TRIGGER_FILTER_OPTIONS: { value: TriggerFilter; label: string }[] = [
|
||||
{ value: "all", label: "All triggers" },
|
||||
{ value: "scheduled", label: "Scheduled" },
|
||||
{ value: "manual", label: "Manual" },
|
||||
];
|
||||
|
||||
export default function Automations() {
|
||||
const workflowsQuery = useWorkflows();
|
||||
const workflows = enrichWorkflows(mapWorkflows(workflowsQuery.data));
|
||||
const { mutate } = useSWRConfig();
|
||||
const toast = useToast();
|
||||
const navigate = useNavigate();
|
||||
const automationsQuery = useAutomations();
|
||||
const automations = mapAutomations(automationsQuery.data);
|
||||
const [query, setQuery] = useState("");
|
||||
const [triggerFilter, setTriggerFilter] = useState<TriggerFilter>("all");
|
||||
const filtered = workflows.filter(
|
||||
(w) =>
|
||||
const [pendingDelete, setPendingDelete] = useState<AutomationRow | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [runningId, setRunningId] = useState<string | null>(null);
|
||||
|
||||
async function runAutomation(automation: AutomationRow) {
|
||||
if (runningId) return;
|
||||
setRunningId(automation.id);
|
||||
try {
|
||||
const run = await apiData(() => automationsApi.createAutomationRun(automation.id));
|
||||
toast.push({ message: `Started run for “${automation.name}”.` });
|
||||
navigate(`/runs/${run.id}`);
|
||||
} catch (cause) {
|
||||
toast.push({
|
||||
tone: "error",
|
||||
message:
|
||||
cause instanceof ApiError && cause.message
|
||||
? cause.message
|
||||
: "Couldn't start a run. Please try again.",
|
||||
});
|
||||
setRunningId(null);
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = automations.filter(
|
||||
(a) =>
|
||||
(triggerFilter === "all" ||
|
||||
(triggerFilter === "scheduled" && w.schedule != null) ||
|
||||
(triggerFilter === "manual" && w.schedule == null)) &&
|
||||
(w.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||
w.filename.toLowerCase().includes(query.toLowerCase())),
|
||||
(triggerFilter === "scheduled" && a.schedule != null) ||
|
||||
(triggerFilter === "manual" && a.schedule == null)) &&
|
||||
(a.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||
a.workflow.toLowerCase().includes(query.toLowerCase()) ||
|
||||
a.repository.toLowerCase().includes(query.toLowerCase())),
|
||||
);
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!pendingDelete) return;
|
||||
const { id, revision, name } = pendingDelete;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await apiData(() => automationsApi.deleteAutomation(id, revision));
|
||||
await mutate(queryKeys.automations.list());
|
||||
toast.push({ message: `Automation “${name}” deleted.` });
|
||||
setPendingDelete(null);
|
||||
} catch (cause) {
|
||||
toast.push({
|
||||
tone: "error",
|
||||
message:
|
||||
cause instanceof ApiError && cause.message
|
||||
? cause.message
|
||||
: "Couldn't delete the automation. Please try again.",
|
||||
});
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-3">
|
||||
<div className="relative flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative w-64">
|
||||
<MagnifyingGlassIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-fg-muted" />
|
||||
<input
|
||||
type="text"
|
||||
aria-label="Search automations"
|
||||
placeholder="Search automations..."
|
||||
placeholder="Search automations…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="w-full rounded-md border border-line bg-panel/80 py-2 pl-9 pr-3 text-sm text-fg-2 placeholder-fg-muted outline-none transition-colors focus:border-focus focus:ring-0"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<select
|
||||
aria-label="Automation trigger filter"
|
||||
value={triggerFilter}
|
||||
onChange={(e) => setTriggerFilter(e.target.value as TriggerFilter)}
|
||||
className="appearance-none rounded-md border border-line bg-panel/80 py-2 pl-3 pr-8 text-sm text-fg-2 outline-none transition-colors focus:border-focus focus:ring-0"
|
||||
>
|
||||
<option value="all">All triggers</option>
|
||||
<option value="scheduled">Scheduled</option>
|
||||
<option value="manual">Manual</option>
|
||||
</select>
|
||||
<ChevronDownOutline className="pointer-events-none absolute right-2 top-1/2 size-4 -translate-y-1/2 text-fg-muted" />
|
||||
<FilterButton<TriggerFilter>
|
||||
label="Trigger"
|
||||
value={triggerFilter}
|
||||
allValue="all"
|
||||
options={TRIGGER_FILTER_OPTIONS}
|
||||
onChange={(next) => setTriggerFilter(next)}
|
||||
/>
|
||||
<div className="ml-auto">
|
||||
<CreateAutomationButton />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{filtered.map((workflow) => (
|
||||
<WorkflowCard key={workflow.filename} workflow={workflow} />
|
||||
{filtered.map((automation) => (
|
||||
<AutomationCard
|
||||
key={automation.id}
|
||||
automation={automation}
|
||||
disabled={deleting || (runningId !== null && runningId !== automation.id)}
|
||||
running={runningId === automation.id}
|
||||
onRun={() => runAutomation(automation)}
|
||||
onDelete={() => setPendingDelete(automation)}
|
||||
/>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<p className="py-8 text-center text-sm text-fg-muted">No automations match "{query}"</p>
|
||||
)}
|
||||
</div>
|
||||
<ConfirmDialog
|
||||
open={pendingDelete !== null}
|
||||
title="Delete automation"
|
||||
description={
|
||||
<>
|
||||
Delete{" "}
|
||||
<span className="font-mono text-fg-2">{pendingDelete?.name}</span>? This
|
||||
removes the automation and stops any scheduled triggers. Existing runs are not affected.
|
||||
</>
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
pendingLabel="Deleting…"
|
||||
pending={deleting}
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => {
|
||||
if (!deleting) setPendingDelete(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ use super::super::{
|
|||
ApiError, AppState, IntoResponse, Json, PaginationParams, Path, RequiredUser, Response, Router,
|
||||
State, StatusCode, get, paginate_items,
|
||||
};
|
||||
use super::lifecycle;
|
||||
use super::runs;
|
||||
use crate::automation_materializer::AutomationRunMaterializeInput;
|
||||
use crate::principal_middleware::RequiredRunToolActor;
|
||||
|
|
@ -167,19 +168,37 @@ async fn create_automation_run(
|
|||
trigger_id: Some(api_trigger_id),
|
||||
};
|
||||
|
||||
Box::pin(runs::create_run_from_manifest(
|
||||
state,
|
||||
let response = Box::pin(runs::create_run_from_manifest(
|
||||
Arc::clone(&state),
|
||||
runs::CreateRunFromManifestRequest {
|
||||
manifest: materialized.manifest,
|
||||
submitted_manifest_bytes: materialized.submitted_manifest_bytes,
|
||||
explicit_run_id: Some(run_id),
|
||||
explicit_title_supplied,
|
||||
actor,
|
||||
actor: actor.clone(),
|
||||
headers,
|
||||
automation: Some(automation_ref),
|
||||
},
|
||||
))
|
||||
.await
|
||||
.await;
|
||||
|
||||
// An automation's API trigger should both create and start the run; otherwise
|
||||
// the run sits in `Submitted` forever because the scheduler only claims
|
||||
// `Runnable`. Mirror what the UI does for a manual create-then-start flow.
|
||||
if response.status().is_success() {
|
||||
if let Err(err) =
|
||||
lifecycle::queue_run_start(state.as_ref(), run_id, false, actor).await
|
||||
{
|
||||
tracing::warn!(
|
||||
%run_id,
|
||||
automation_id = %automation.id,
|
||||
error = ?err,
|
||||
"Created automation run but failed to start it",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
async fn create_automation(
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ async fn start_run(
|
|||
}
|
||||
}
|
||||
|
||||
async fn queue_run_start(
|
||||
pub(super) async fn queue_run_start(
|
||||
state: &AppState,
|
||||
id: RunId,
|
||||
resume: bool,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue