Add New Automation form and refresh Secrets form layout

- Add /automations/new with Basics/Source/Goal/Triggers panels and a
  kebab-case Slug auto-derived from Name until the user edits it
- Wire the "Create Automation" button on /automations to the new page
- Move individual automation URL from /automations/<slug> to
  /automation/<slug>; /automations and /automations/new are unchanged
- Refresh /settings/secrets/new to use the Panel + Row layout pattern:
  breadcrumb header, one field per row, plain footer, no inside-Panel
  stacked form

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-05-24 12:04:50 -04:00
parent c5f57dbc50
commit 2e85a1ec48
No known key found for this signature in database
5 changed files with 524 additions and 82 deletions

View file

@ -11,6 +11,7 @@ import * as ChatsNew from "./routes/chats-new";
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 AutomationDetail from "./routes/automation-detail";
import * as AutomationDefinition from "./routes/automation-definition";
import * as AutomationDiagram from "./routes/automation-diagram";
@ -107,7 +108,8 @@ export const routes: RouteObject[] = [
}),
route("ask-fabro", AskFabro),
route("automations", Automations),
route("automations/:name", AutomationDetail, {
route("automations/new", AutomationsNew),
route("automation/:name", AutomationDetail, {
children: [
indexRoute(AutomationDefinition),
route("diagram", AutomationDiagram),

View file

@ -326,7 +326,7 @@ export default function AutomationDetail() {
const workflowQuery = useWorkflow(name);
const { pathname } = useLocation();
const workflow = resolveWorkflow(name, workflowQuery.data);
const basePath = `/automations/${name}`;
const basePath = `/automation/${name}`;
return (
<div>

View file

@ -0,0 +1,373 @@
import { useState, type ReactNode } from "react";
import { Link, useNavigate } from "react-router";
import { Switch } from "@headlessui/react";
import { ChevronRightIcon, ChevronDownIcon } from "@heroicons/react/20/solid";
import { Panel, Row } from "../components/settings-panel";
import {
INPUT_CLASS,
PRIMARY_BUTTON_CLASS,
SECONDARY_BUTTON_CLASS,
} from "../components/ui";
export function meta() {
return [{ title: "New automation — Fabro" }];
}
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 [slugTouched, setSlugTouched] = useState(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");
function onNameChange(next: string) {
setName(next);
if (!slugTouched) setSlug(kebabify(next));
}
function onSlugChange(next: string) {
setSlug(kebabify(next));
setSlugTouched(true);
}
const canSubmit =
name.trim() !== "" &&
slug.trim() !== "" &&
workflowSlug.trim() !== "" &&
(manualEnabled || scheduleEnabled);
function onSubmit(event: React.FormEvent) {
event.preventDefault();
if (!canSubmit) return;
// Non-functional prototype — just return to the list.
navigate("/automations");
}
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"
value={name}
onChange={(e) => onNameChange(e.target.value)}
placeholder="Fix Build"
autoFocus
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"
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"
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>
<Panel title="Source">
<Row title={<Label required>Repository</Label>} help="The repository this automation will check out and operate on.">
<SelectInput
name="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"
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"
value={workflowSlug}
onChange={(e) => setWorkflowSlug(snakeify(e.target.value))}
placeholder="fix_build"
autoComplete="off"
spellCheck={false}
className={`${INPUT_CLASS} font-mono`}
/>
</Row>
</Panel>
<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"
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"
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")} />
</form>
);
}
function PageHeader() {
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" aria-hidden="true" />
<span>New automation</span>
</nav>
<h2 className="text-xl font-semibold text-fg">New automation</h2>
<p className="mt-2 max-w-prose text-sm leading-relaxed text-fg-3">
Define a workflow that Fabro can run on demand, on a schedule, or via the API.
You can refine the graph and per-stage prompts after it's created.
</p>
</div>
);
}
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,
value,
onChange,
options,
}: {
name: string;
value: string;
onChange: (next: string) => void;
options: ReadonlyArray<string>;
}) {
return (
<div className="relative">
<select
name={name}
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({
canSubmit,
onCancel,
}: {
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}>
Cancel
</button>
<button type="submit" disabled={!canSubmit} className={PRIMARY_BUTTON_CLASS}>
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";
}

View file

@ -24,13 +24,13 @@ export function meta({}: any) {
export const handle = {
headerExtra: (
<div className="relative inline-flex rounded-md">
<button
type="button"
<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
</button>
</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" />
@ -145,7 +145,7 @@ function WorkflowCard({ workflow }: { workflow: Workflow }) {
const Icon = workflow.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={`/automations/${workflow.slug}`} className="flex min-w-0 flex-1 items-center gap-4">
<Link to={`/automation/${workflow.slug}`} 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 }}

View file

@ -1,15 +1,14 @@
import { useState } from "react";
import { useState, type ReactNode } from "react";
import { Link, useNavigate, useSearchParams } from "react-router";
import { useSWRConfig } from "swr";
import { ArrowLeftIcon } from "@heroicons/react/16/solid";
import { ChevronRightIcon, ChevronDownIcon } from "@heroicons/react/20/solid";
import { SecretType } from "@qltysh/fabro-api-client";
import { ApiError, apiData, secretsApi } from "../lib/api-client";
import { queryKeys } from "../lib/query-keys";
import { Panel, SettingsPageIntro } from "../components/settings-panel";
import { Panel, Row } from "../components/settings-panel";
import {
ErrorMessage,
FormField,
INPUT_CLASS,
PRIMARY_BUTTON_CLASS,
SECONDARY_BUTTON_CLASS,
@ -31,19 +30,24 @@ function isFormType(
export default function SettingsSecretsNew() {
return (
<div className="space-y-6">
<Link
to="/settings/secrets"
className="inline-flex items-center gap-1 text-sm text-fg-3 transition-colors hover:text-fg"
>
<ArrowLeftIcon className="size-4" aria-hidden="true" />
Back to secrets
</Link>
<SettingsPageIntro description="Store a new token or file secret on this Fabro server. The value is write-only — it can be replaced or deleted later, but never read back through the UI." />
<PageHeader />
<CreateSecretForm />
</div>
);
}
function PageHeader() {
return (
<nav className="flex items-center gap-1 text-sm text-fg-muted">
<Link to="/settings/secrets" className="text-fg-3 hover:text-fg">
Secrets
</Link>
<ChevronRightIcon className="size-3" aria-hidden="true" />
<span>New secret</span>
</nav>
);
}
function CreateSecretForm() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
@ -95,83 +99,146 @@ function CreateSecretForm() {
}
return (
<Panel title="New secret">
<form onSubmit={onSubmit} className="space-y-4 px-4 py-4">
<div className="grid gap-4 sm:grid-cols-2">
<FormField label="Type" htmlFor="secret-type">
<select
id="secret-type"
value={type}
onChange={(event) => {
if (isFormType(event.target.value)) setType(event.target.value);
}}
className={INPUT_CLASS}
>
<option value={SecretType.TOKEN}>Token (environment variable)</option>
<option value={SecretType.FILE}>File</option>
</select>
</FormField>
<FormField label={nameLabel} htmlFor="secret-name" help={nameHelp}>
<input
id="secret-name"
type="text"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder={namePlaceholder}
autoComplete="off"
spellCheck={false}
className={`${INPUT_CLASS} font-mono`}
/>
</FormField>
</div>
<FormField
label="Value"
htmlFor="secret-value"
help={
isFile
? "File contents. Stored as-is and never shown again."
: "The secret value. Stored as-is and never shown again."
}
<form onSubmit={onSubmit} className="space-y-6">
<Panel title="Secret">
<Row title={<Label required>Type</Label>} help="Tokens are exposed as environment variables; files are written to a path inside the sandbox.">
<SelectInput
name="type"
value={type}
onChange={(next) => {
if (isFormType(next)) setType(next);
}}
options={[
{ value: SecretType.TOKEN, label: "Token (environment variable)" },
{ value: SecretType.FILE, label: "File" },
]}
/>
</Row>
<Row title={<Label required>{nameLabel}</Label>} help={nameHelp}>
<input
type="text"
name="name"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder={namePlaceholder}
autoComplete="off"
spellCheck={false}
className={`${INPUT_CLASS} font-mono`}
/>
</Row>
<Row
title={<Label required>Value</Label>}
help="Stored as-is and never shown again. Replace or delete it later if it needs to change."
>
<textarea
id="secret-value"
name="value"
value={value}
onChange={(event) => setValue(event.target.value)}
rows={isFile ? 4 : 2}
rows={isFile ? 6 : 2}
autoComplete="off"
spellCheck={false}
className={`${INPUT_CLASS} resize-y font-mono`}
/>
</FormField>
<FormField
label="Description"
htmlFor="secret-description"
help="Optional. Helps operators recognize what this secret is for."
>
</Row>
<Row title={<Label optional>Description</Label>} help="Helps operators recognize what this secret is for.">
<input
id="secret-description"
type="text"
name="description"
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="Optional"
className={INPUT_CLASS}
/>
</FormField>
{error ? <ErrorMessage message={error} /> : null}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => navigate("/settings/secrets")}
disabled={submitting}
className={SECONDARY_BUTTON_CLASS}
>
Cancel
</button>
<button type="submit" disabled={!canSubmit} className={PRIMARY_BUTTON_CLASS}>
{submitting ? "Saving…" : "Save secret"}
</button>
</div>
</form>
</Panel>
</Row>
</Panel>
{error ? <ErrorMessage message={error} /> : null}
<FormFooter
submitting={submitting}
canSubmit={canSubmit}
onCancel={() => navigate("/settings/secrets")}
/>
</form>
);
}
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,
value,
onChange,
options,
}: {
name: string;
value: string;
onChange: (next: string) => void;
options: ReadonlyArray<{ value: string; label: string }>;
}) {
return (
<div className="relative">
<select
name={name}
value={value}
onChange={(e) => onChange(e.target.value)}
className={`${INPUT_CLASS} appearance-none pr-9`}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</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 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 secret"}
</button>
</div>
);
}