fabro/apps/fabro-web/app/routes/automations.tsx
Scott Werner 50fed849f3 Simplify automation run-target plumbing
- Materializer derives the manifest GitContext from RunTarget::validate()
  instead of hand-building it and re-parsing the repository slug
- Drop parse_github_repository_slug and InvalidRepositorySlug, now unused
- Store reuses Automation::git_target() instead of a private duplicate
- Legacy TOML import returns the target directly rather than a tuple
- Automation target migration updates columns with a single UPDATE ... FROM
- Web: share gitTarget(), targetFromFormValues(), and one SHA validator
  across the automation form, list, detail, new, and edit views

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 18:17:22 -04:00

396 lines
14 KiB
TypeScript

import { useState, type ComponentType } from "react";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import { useSWRConfig } from "swr";
import { PlusIcon } from "@heroicons/react/20/solid";
import { EllipsisVerticalIcon } from "@heroicons/react/20/solid";
import {
ArrowPathIcon,
ArrowsRightLeftIcon,
ClockIcon,
CodeBracketIcon,
MagnifyingGlassIcon,
PauseIcon,
RocketLaunchIcon,
ShieldCheckIcon,
WrenchIcon,
} from "@heroicons/react/24/outline";
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 {
UNSUPPORTED_TARGET_LABEL,
findScheduleTrigger,
gitTarget,
hasEnabledApiTrigger,
} from "../lib/automation";
import { useAutomations } from "../lib/queries";
import { queryKeys } from "../lib/query-keys";
import { ConfirmDialog, PRIMARY_BUTTON_CLASS } from "../components/ui";
import { EmptyState, ErrorState, LoadingState } from "../components/state";
import { useToast } from "../components/toast";
export function meta({}: any) {
return [{ title: "Automations — Fabro" }];
}
export const handle = { hideHeader: true };
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>
);
}
interface AutomationRow {
id: string;
revision: string;
name: string;
workflow: string;
repository: string;
schedule?: string;
apiEnabled: boolean;
icon: ComponentType<{ className?: string }>;
color: string;
}
const slugIconMap: Record<string, ComponentType<{ className?: string }>> = {
fix_build: WrenchIcon,
implement: CodeBracketIcon,
sync_drift: ArrowsRightLeftIcon,
expand: RocketLaunchIcon,
security_scan: ShieldCheckIcon,
dep_audit: ClockIcon,
};
const slugColorMap: Record<string, string> = {
fix_build: "var(--color-amber)",
implement: "var(--color-teal-500)",
sync_drift: "var(--color-mint)",
expand: "var(--color-coral)",
security_scan: "var(--color-teal-500)",
dep_audit: "var(--color-amber)",
};
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 mapAutomations(result: AutomationListResponse | undefined): AutomationRow[] {
const automations = result?.data ?? [];
return automations.map((a) => {
const target = gitTarget(a.target);
return {
id: a.id,
revision: a.revision,
name: a.name,
workflow: a.workflow,
repository: target?.repo ?? UNSUPPORTED_TARGET_LABEL,
schedule: findScheduleTrigger(a)?.expression,
apiEnabled: hasEnabledApiTrigger(a),
icon: slugIconMap[a.workflow] ?? CodeBracketIcon,
color: slugColorMap[a.workflow] ?? "var(--color-teal-500)",
};
});
}
function PlayIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={className} 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>
);
}
function AutomationCard({
automation,
busy,
running,
onRun,
onDelete,
}: {
automation: AutomationRow;
busy: boolean;
running: boolean;
onRun: () => void;
onDelete: () => void;
}) {
const Icon = automation.icon;
const runDisabled = busy || running || !automation.apiEnabled;
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={`/automations/${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, ${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">{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" />
{automation.schedule}
</span>
)}
</div>
<p className="mt-1 text-xs text-fg-muted">{automation.repository}</p>
</div>
</Link>
{automation.schedule ? (
<button
type="button"
title="Pause schedule"
className="flex size-8 shrink-0 items-center justify-center rounded-full border border-amber/20 text-amber transition-colors hover:border-amber/50 hover:bg-amber/10 hover:text-fg"
>
<PauseIcon className="size-3.5" />
</button>
) : (
<button
type="button"
onClick={onRun}
disabled={runDisabled}
aria-label={running ? "Starting run…" : "Run automation"}
title={
running
? "Starting run..."
: automation.apiEnabled
? "Run automation"
: "Enable the API trigger to run it"
}
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"
>
{running ? (
<ArrowPathIcon className="size-3.5 animate-spin [animation-duration:450ms]" aria-hidden="true" />
) : (
<PlayIcon className="size-3.5" />
)}
</button>
)}
<RowMenu automation={automation} disabled={busy} 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 { 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 [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 lowerQuery = query.toLowerCase();
const filtered = automations.filter(
(a) =>
(triggerFilter === "all" ||
(triggerFilter === "scheduled" && a.schedule != null) ||
(triggerFilter === "manual" && a.schedule == null)) &&
(a.name.toLowerCase().includes(lowerQuery) ||
a.workflow.toLowerCase().includes(lowerQuery) ||
a.repository.toLowerCase().includes(lowerQuery)),
);
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">
{automationsQuery.isLoading ? (
<LoadingState label="Loading automations…" />
) : automationsQuery.error ? (
<ErrorState
title="Couldn't load automations"
description="Something went wrong while loading your automations."
onRetry={() => mutate(queryKeys.automations.list())}
/>
) : automations.length === 0 ? (
<EmptyState
icon={RocketLaunchIcon}
title="Create your first automation"
description="Automations run a workflow on a schedule or on demand, so recurring work happens without you kicking it off by hand."
action={
<Link to="/automations/new" className={PRIMARY_BUTTON_CLASS}>
<PlusIcon className="size-4" aria-hidden="true" />
Create Automation
</Link>
}
/>
) : (
<>
<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…"
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>
<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((automation) => (
<AutomationCard
key={automation.id}
automation={automation}
busy={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 your filters.
</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>
);
}