diff --git a/apps/fabro-web/app/router.tsx b/apps/fabro-web/app/router.tsx
index 53c20b2eb..727f6257c 100644
--- a/apps/fabro-web/app/router.tsx
+++ b/apps/fabro-web/app/router.tsx
@@ -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),
diff --git a/apps/fabro-web/app/routes/automation-detail.tsx b/apps/fabro-web/app/routes/automation-detail.tsx
index c87f8e182..fe6ebe3a0 100644
--- a/apps/fabro-web/app/routes/automation-detail.tsx
+++ b/apps/fabro-web/app/routes/automation-detail.tsx
@@ -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 (
diff --git a/apps/fabro-web/app/routes/automations-new.tsx b/apps/fabro-web/app/routes/automations-new.tsx
new file mode 100644
index 000000000..e3caae215
--- /dev/null
+++ b/apps/fabro-web/app/routes/automations-new.tsx
@@ -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 (
+
+ );
+}
+
+function PageHeader() {
+ return (
+
+
+
+ Automations
+
+
+ New automation
+
+
New automation
+
+ 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.
+
+
+ );
+}
+
+function Label({
+ children,
+ required,
+ optional,
+}: {
+ children: ReactNode;
+ required?: boolean;
+ optional?: boolean;
+}) {
+ return (
+
+ {children}
+ {required ? (
+
+ *
+
+ ) : null}
+ {optional ? Optional : null}
+
+ );
+}
+
+function SelectInput({
+ name,
+ value,
+ onChange,
+ options,
+}: {
+ name: string;
+ value: string;
+ onChange: (next: string) => void;
+ options: ReadonlyArray
;
+}) {
+ return (
+
+ onChange(e.target.value)}
+ className={`${INPUT_CLASS} appearance-none pr-9 font-mono`}
+ >
+ {options.map((option) => (
+
+ {option}
+
+ ))}
+
+
+
+ );
+}
+
+function ToggleSwitch({
+ checked,
+ onChange,
+ label,
+}: {
+ checked: boolean;
+ onChange: (next: boolean) => void;
+ label: string;
+}) {
+ return (
+
+
+
+ );
+}
+
+function FormFooter({
+ canSubmit,
+ onCancel,
+}: {
+ canSubmit: boolean;
+ onCancel: () => void;
+}) {
+ return (
+
+
+ Cancel
+
+
+ Create automation
+
+
+ );
+}
+
+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";
+}
diff --git a/apps/fabro-web/app/routes/automations.tsx b/apps/fabro-web/app/routes/automations.tsx
index b2c9aa571..13dde9307 100644
--- a/apps/fabro-web/app/routes/automations.tsx
+++ b/apps/fabro-web/app/routes/automations.tsx
@@ -24,13 +24,13 @@ export function meta({}: any) {
export const handle = {
headerExtra: (
-
Create Automation
-
+
@@ -145,7 +145,7 @@ function WorkflowCard({ workflow }: { workflow: Workflow }) {
const Icon = workflow.icon;
return (
-
+
-
-
- Back to secrets
-
-
+
);
}
+function PageHeader() {
+ return (
+
+
+ Secrets
+
+
+ New secret
+
+ );
+}
+
function CreateSecretForm() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
@@ -95,83 +99,146 @@ function CreateSecretForm() {
}
return (
-
-
-
-
- {
- if (isFormType(event.target.value)) setType(event.target.value);
- }}
- className={INPUT_CLASS}
- >
- Token (environment variable)
- File
-
-
-
- setName(event.target.value)}
- placeholder={namePlaceholder}
- autoComplete="off"
- spellCheck={false}
- className={`${INPUT_CLASS} font-mono`}
- />
-
-
-
+
+ Type} help="Tokens are exposed as environment variables; files are written to a path inside the sandbox.">
+ {
+ if (isFormType(next)) setType(next);
+ }}
+ options={[
+ { value: SecretType.TOKEN, label: "Token (environment variable)" },
+ { value: SecretType.FILE, label: "File" },
+ ]}
+ />
+
+ {nameLabel}} help={nameHelp}>
+ setName(event.target.value)}
+ placeholder={namePlaceholder}
+ autoComplete="off"
+ spellCheck={false}
+ className={`${INPUT_CLASS} font-mono`}
+ />
+
+ Value}
+ help="Stored as-is and never shown again. Replace or delete it later if it needs to change."
>
setValue(event.target.value)}
- rows={isFile ? 4 : 2}
+ rows={isFile ? 6 : 2}
autoComplete="off"
spellCheck={false}
className={`${INPUT_CLASS} resize-y font-mono`}
/>
-
-
+
+ Description} help="Helps operators recognize what this secret is for.">
setDescription(event.target.value)}
- placeholder="Optional"
className={INPUT_CLASS}
/>
-
- {error ? : null}
-
- navigate("/settings/secrets")}
- disabled={submitting}
- className={SECONDARY_BUTTON_CLASS}
- >
- Cancel
-
-
- {submitting ? "Saving…" : "Save secret"}
-
-
-
-
+
+
+
+ {error ?
: null}
+
+
navigate("/settings/secrets")}
+ />
+
+ );
+}
+
+function Label({
+ children,
+ required,
+ optional,
+}: {
+ children: ReactNode;
+ required?: boolean;
+ optional?: boolean;
+}) {
+ return (
+
+ {children}
+ {required ? (
+
+ *
+
+ ) : null}
+ {optional ? Optional : null}
+
+ );
+}
+
+function SelectInput({
+ name,
+ value,
+ onChange,
+ options,
+}: {
+ name: string;
+ value: string;
+ onChange: (next: string) => void;
+ options: ReadonlyArray<{ value: string; label: string }>;
+}) {
+ return (
+
+ onChange(e.target.value)}
+ className={`${INPUT_CLASS} appearance-none pr-9`}
+ >
+ {options.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+ );
+}
+
+function FormFooter({
+ submitting,
+ canSubmit,
+ onCancel,
+}: {
+ submitting: boolean;
+ canSubmit: boolean;
+ onCancel: () => void;
+}) {
+ return (
+
+
+ Cancel
+
+
+ {submitting ? "Saving…" : "Save secret"}
+
+
);
}