feat(web): three-pane workflow builder

This commit is contained in:
2026-07-20 11:50:00 +01:00
parent 39980581b1
commit 2cd9bc1c89
+470
View File
@@ -0,0 +1,470 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
api,
Workflow,
WorkflowStep,
WorkflowStepRef,
SecretGroupSummary,
} from "@/lib/api";
import { Button, Card } from "@/components/ui";
const inputClass =
"w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder-text-secondary/50 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent";
function NewStepForm({ onClose }: { onClose: () => void }) {
const queryClient = useQueryClient();
const [name, setName] = useState("");
const [interpreter, setInterpreter] = useState<"bash" | "powershell">("bash");
const [script, setScript] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const create = async () => {
setSaving(true);
setError(null);
try {
await api.createStep({
name: name.trim(),
description: "",
interpreter,
script,
declared_outputs: [],
secret_refs: [],
});
queryClient.invalidateQueries({ queryKey: ["steps"] });
onClose();
} catch (e) {
setError((e as Error).message);
} finally {
setSaving(false);
}
};
return (
<div className="mb-3 space-y-2 rounded-lg border border-border bg-surface-2 p-2">
{error && <div className="text-xs text-danger">{error}</div>}
<input
className={inputClass}
placeholder="Step name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<select
className={inputClass}
value={interpreter}
onChange={(e) => setInterpreter(e.target.value as "bash" | "powershell")}
>
<option value="bash">bash</option>
<option value="powershell">powershell</option>
</select>
<textarea
className={`${inputClass} h-20 font-mono text-xs`}
placeholder="script"
value={script}
onChange={(e) => setScript(e.target.value)}
/>
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" onClick={onClose}>
Cancel
</Button>
<Button
variant="primary"
size="sm"
loading={saving}
disabled={!name.trim()}
onClick={create}
>
Add
</Button>
</div>
</div>
);
}
export default function WorkflowBuilder() {
const params = useParams<{ id: string }>();
const id = params.id;
const router = useRouter();
const [wf, setWf] = useState<Workflow | null>(null);
const [selected, setSelected] = useState<number | null>(null);
const [showNewStep, setShowNewStep] = useState(false);
const [saving, setSaving] = useState(false);
const [running, setRunning] = useState(false);
const [error, setError] = useState<string | null>(null);
const [groupKeys, setGroupKeys] = useState<Record<string, string[]>>({});
const { data: loaded } = useQuery({
queryKey: ["workflow", id],
queryFn: () => api.getWorkflow(id),
});
const { data: library } = useQuery({ queryKey: ["steps"], queryFn: api.listSteps });
const { data: servers } = useQuery({ queryKey: ["servers"], queryFn: api.listServers });
const { data: secretGroups } = useQuery({
queryKey: ["secret-groups"],
queryFn: api.listSecretGroups,
});
useEffect(() => {
if (loaded && !wf) setWf(loaded);
}, [loaded, wf]);
// Lazily fetch the keys for every secret group so the inspector's
// secret-ref multiselect can offer "group/KEY" options.
useEffect(() => {
if (!secretGroups) return;
secretGroups.forEach((g: SecretGroupSummary) => {
if (groupKeys[g.group] !== undefined) return;
api
.getSecretGroup(g.group)
.then((res) =>
setGroupKeys((prev) => ({
...prev,
[g.group]: res.secrets.map((s) => s.key),
}))
)
.catch(() => {
setGroupKeys((prev) => ({ ...prev, [g.group]: [] }));
});
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [secretGroups]);
if (!wf) {
return <div className="p-8 text-text-secondary">Loading</div>;
}
const save = async () => {
setSaving(true);
setError(null);
try {
const updated = await api.updateWorkflow(id, wf);
setWf(updated);
} catch (e) {
setError((e as Error).message);
} finally {
setSaving(false);
}
};
const run = async () => {
setRunning(true);
setError(null);
try {
const { run_id } = await api.runWorkflow(id);
router.push(`/workflows/${id}/runs/${run_id}`);
} catch (e) {
setError((e as Error).message);
setRunning(false);
}
};
const addStep = (s: WorkflowStep) =>
setWf({
...wf,
steps: [
...wf.steps,
{ step_id: s.step_id, order: wf.steps.length, on_failure: "stop", max_retries: 0 },
],
});
const updateRef = (idx: number, patch: Partial<WorkflowStepRef>) =>
setWf({
...wf,
steps: wf.steps.map((r, i) => (i === idx ? { ...r, ...patch } : r)),
});
const removeStep = (idx: number) => {
const remaining = wf.steps.filter((_, i) => i !== idx).map((r, i) => ({ ...r, order: i }));
setWf({ ...wf, steps: remaining });
setSelected(null);
};
const moveStep = (idx: number, dir: -1 | 1) => {
const target = idx + dir;
const sorted = [...wf.steps].sort((a, b) => a.order - b.order);
if (target < 0 || target >= sorted.length) return;
const next = sorted.map((r, i) => {
if (i === idx) return { ...r, order: sorted[target].order };
if (i === target) return { ...r, order: sorted[idx].order };
return r;
});
setWf({ ...wf, steps: next });
if (selected === idx) setSelected(target);
else if (selected === target) setSelected(idx);
};
const toggleTargetServer = (serverId: string) => {
const set = new Set(wf.target_server_ids);
if (set.has(serverId)) set.delete(serverId);
else set.add(serverId);
setWf({ ...wf, target_server_ids: Array.from(set) });
};
const libById = (sid: string) => library?.find((l) => l.step_id === sid);
const sortedSteps = [...wf.steps].sort((a, b) => a.order - b.order);
const selectedRef = selected !== null ? sortedSteps[selected] : null;
const selectedLib = selectedRef ? libById(selectedRef.step_id) : null;
const selectedIdxInWf = selectedRef ? wf.steps.indexOf(selectedRef) : -1;
const toggleSecretRef = (ref: string) => {
if (selectedIdxInWf === -1) return;
const current = selectedRef?.overrides?.secret_refs ?? [];
const next = current.includes(ref)
? current.filter((r) => r !== ref)
: [...current, ref];
updateRef(selectedIdxInWf, { overrides: { ...selectedRef?.overrides, secret_refs: next } });
};
return (
<div className="grid h-[calc(100vh-0px)] grid-cols-[264px_1fr_320px]">
{/* LEFT: library */}
<aside className="overflow-auto border-r border-border bg-surface p-3">
<div className="mb-2 flex items-center justify-between">
<h2 className="text-xs font-bold uppercase tracking-wide text-text-secondary">
Step Library
</h2>
<Button variant="ghost" size="sm" onClick={() => setShowNewStep((v) => !v)}>
{showNewStep ? "Close" : "Add"}
</Button>
</div>
{showNewStep && <NewStepForm onClose={() => setShowNewStep(false)} />}
{library?.map((s) => (
<button
key={s.step_id}
onClick={() => addStep(s)}
className="mb-2 block w-full rounded-lg border border-border bg-surface-2 p-2 text-left hover:border-accent"
>
<span className="font-mono text-[10px] uppercase text-accent">{s.interpreter}</span>
<div className="text-sm font-medium text-text-primary">{s.name}</div>
</button>
))}
{library && library.length === 0 && (
<p className="text-xs text-text-secondary">No steps yet. Add one above.</p>
)}
</aside>
{/* CENTER: canvas */}
<main className="overflow-auto p-6">
{error && (
<div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">
{error}
</div>
)}
<div className="mb-4 flex flex-wrap items-center gap-3">
<input
className={`${inputClass} max-w-xs`}
value={wf.name}
onChange={(e) => setWf({ ...wf, name: e.target.value })}
/>
<Button variant="secondary" loading={saving} onClick={save}>
Save
</Button>
<Button variant="primary" loading={running} onClick={run}>
Run
</Button>
</div>
<Card className="mb-4">
<h3 className="mb-2 text-xs font-bold uppercase tracking-wide text-text-secondary">
Target servers
</h3>
<div className="flex flex-wrap gap-2">
{servers?.map((s) => {
const isChecked = wf.target_server_ids.includes(s.server_id);
return (
<label
key={s.server_id}
className={`flex cursor-pointer items-center gap-2 rounded-lg border px-2 py-1 text-sm ${
isChecked ? "border-accent bg-accent/10 text-text-primary" : "border-border text-text-secondary"
}`}
>
<input
type="checkbox"
className="accent-accent"
checked={isChecked}
onChange={() => toggleTargetServer(s.server_id)}
/>
{s.hostname}
</label>
);
})}
{servers && servers.length === 0 && (
<p className="text-xs text-text-secondary">No servers registered.</p>
)}
</div>
</Card>
<div className="mx-auto flex max-w-md flex-col items-center gap-2">
{sortedSteps.map((ref, i) => {
const lib = libById(ref.step_id);
const outs = sortedSteps.slice(0, i).flatMap((r) => libById(r.step_id)?.declared_outputs ?? []);
const script = ref.overrides?.script ?? lib?.script ?? "";
const wfIdx = wf.steps.indexOf(ref);
return (
<div key={i} className="w-full">
{i > 0 && outs.length > 0 && (
<div className="mx-auto my-1 flex w-fit flex-wrap items-center justify-center gap-1 rounded-full border border-dashed border-accent/50 px-3 py-1">
<span className="text-[10px] uppercase text-text-secondary">passes</span>
{outs.map((o) => (
<span
key={o}
className="rounded bg-accent px-2 py-0.5 font-mono text-[11px] text-white"
>
{o}
</span>
))}
</div>
)}
<div
className={`w-full rounded-lg border bg-surface p-3 ${
selected === i ? "border-accent" : "border-border"
}`}
>
<button onClick={() => setSelected(i)} className="block w-full text-left">
<div className="mb-1 flex items-center gap-2">
<span className="font-mono text-[10px] uppercase text-accent">
{lib?.interpreter}
</span>
<span className="font-medium text-text-primary">
{lib?.name ?? ref.step_id}
</span>
<span className="ml-auto rounded bg-surface-2 px-2 py-0.5 text-[10px] uppercase text-text-secondary">
{ref.on_failure}
</span>
</div>
<pre className="max-h-16 overflow-hidden text-ellipsis whitespace-pre-wrap font-mono text-[11px] text-text-secondary">
{script.slice(0, 160)}
</pre>
</button>
<div className="mt-2 flex items-center gap-2">
<Button variant="ghost" size="sm" disabled={i === 0} onClick={() => moveStep(i, -1)}>
</Button>
<Button
variant="ghost"
size="sm"
disabled={i === sortedSteps.length - 1}
onClick={() => moveStep(i, 1)}
>
</Button>
<Button variant="danger" size="sm" onClick={() => removeStep(wfIdx)}>
Remove
</Button>
</div>
</div>
</div>
);
})}
{wf.steps.length === 0 && (
<p className="py-10 text-text-secondary">Click a step on the left to add it.</p>
)}
</div>
</main>
{/* RIGHT: inspector */}
<aside className="overflow-auto border-l border-border bg-surface p-4">
{selected === null || !selectedRef ? (
<p className="text-sm text-text-secondary">Select a step to configure it.</p>
) : (
<div className="space-y-4">
<h2 className="text-sm font-bold text-text-primary">
{selectedLib?.name ?? selectedRef.step_id}
</h2>
<div>
<label className="mb-1 block text-xs uppercase text-text-secondary">Script</label>
<textarea
className={`${inputClass} h-40 font-mono text-xs`}
value={selectedRef.overrides?.script ?? selectedLib?.script ?? ""}
onChange={(e) =>
updateRef(selectedIdxInWf, {
overrides: { ...selectedRef.overrides, script: e.target.value },
})
}
/>
</div>
<div>
<label className="mb-1 block text-xs uppercase text-text-secondary">On failure</label>
<select
className={inputClass}
value={selectedRef.on_failure}
onChange={(e) =>
updateRef(selectedIdxInWf, {
on_failure: e.target.value as WorkflowStepRef["on_failure"],
})
}
>
<option value="stop">Stop workflow</option>
<option value="continue">Continue</option>
<option value="retry">Retry</option>
</select>
</div>
{selectedRef.on_failure === "retry" && (
<div>
<label className="mb-1 block text-xs uppercase text-text-secondary">Max retries</label>
<input
type="number"
min={1}
className={inputClass}
value={selectedRef.max_retries}
onChange={(e) =>
updateRef(selectedIdxInWf, { max_retries: parseInt(e.target.value || "0", 10) })
}
/>
</div>
)}
<div>
<label className="mb-1 block text-xs uppercase text-text-secondary">Secret refs</label>
<div className="max-h-56 space-y-2 overflow-auto rounded-lg border border-border p-2">
{secretGroups?.map((g) => (
<div key={g.group}>
<div className="font-mono text-[11px] font-semibold text-text-secondary">
{g.group}
</div>
{(groupKeys[g.group] ?? []).map((key) => {
const ref = `${g.group}/${key}`;
const checked = (selectedRef.overrides?.secret_refs ?? []).includes(ref);
return (
<label
key={ref}
className="ml-2 flex cursor-pointer items-center gap-2 text-xs text-text-primary"
>
<input
type="checkbox"
className="accent-accent"
checked={checked}
onChange={() => toggleSecretRef(ref)}
/>
<span className="font-mono">{key}</span>
</label>
);
})}
{(groupKeys[g.group] ?? []).length === 0 && (
<p className="ml-2 text-[11px] text-text-secondary">No keys.</p>
)}
</div>
))}
{secretGroups && secretGroups.length === 0 && (
<p className="text-xs text-text-secondary">No secret groups yet.</p>
)}
</div>
</div>
<Button variant="danger" size="sm" onClick={() => removeStep(selectedIdxInWf)}>
Remove step
</Button>
</div>
)}
</aside>
</div>
);
}