From d0442291f5e9d12aa07f48611b69caf730029b95 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 21 Jul 2026 11:27:55 +0100 Subject: [PATCH 1/9] feat(web): workflow builder autosave with last-saved status --- web/app/workflows/[id]/page.tsx | 49 +++++++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/web/app/workflows/[id]/page.tsx b/web/app/workflows/[id]/page.tsx index 3aa0db4..394586a 100644 --- a/web/app/workflows/[id]/page.tsx +++ b/web/app/workflows/[id]/page.tsx @@ -43,6 +43,16 @@ function AdhocBadge() { ); } +function timeAgo(date: Date): string { + const s = Math.floor((Date.now() - date.getTime()) / 1000); + if (s < 5) return "just now"; + if (s < 60) return `${s}s ago`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + return `${h}h ago`; +} + export default function WorkflowBuilder() { const params = useParams<{ id: string }>(); const id = params.id; @@ -53,6 +63,9 @@ export default function WorkflowBuilder() { const [selected, setSelected] = useState(null); const [search, setSearch] = useState(""); const [saving, setSaving] = useState(false); + const [lastSaved, setLastSaved] = useState(null); + const [, setTick] = useState(0); + const savedSnapshotRef = useRef(null); const [running, setRunning] = useState(false); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); @@ -78,7 +91,10 @@ export default function WorkflowBuilder() { }); useEffect(() => { - if (loaded && !wf) setWf(loaded); + if (loaded && !wf) { + setWf(loaded); + savedSnapshotRef.current = JSON.stringify(loaded); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [loaded]); @@ -122,7 +138,11 @@ export default function WorkflowBuilder() { setError("Save failed: server returned an unexpected response."); return; } + // Record the snapshot BEFORE setWf so the autosave effect sees the + // incoming state as already-saved and doesn't re-trigger. + savedSnapshotRef.current = JSON.stringify(updated); setWf(updated); + setLastSaved(new Date()); } catch (e) { setError((e as Error).message); } finally { @@ -130,6 +150,26 @@ export default function WorkflowBuilder() { } }; + // Autosave: debounce 800ms after any change to the workflow (step added, + // removed, reordered, or edited) and persist. Diffing the serialized state + // against the last saved snapshot skips no-op saves and the initial load. + useEffect(() => { + if (!wf || savedSnapshotRef.current === null) return; + if (JSON.stringify(wf) === savedSnapshotRef.current) return; + const t = setTimeout(() => { + save(); + }, 800); + return () => clearTimeout(t); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [wf]); + + // Re-render every 15s so the "Saved … ago" label stays current. + useEffect(() => { + if (!lastSaved) return; + const iv = setInterval(() => setTick((n) => n + 1), 15000); + return () => clearInterval(iv); + }, [lastSaved]); + const run = async () => { setRunning(true); setError(null); @@ -315,7 +355,9 @@ export default function WorkflowBuilder() {
Workflows / {wf.name} - · draft + + · {saving ? "Saving…" : lastSaved ? `Saved ${timeAgo(lastSaved)}` : "draft"} +
@@ -330,9 +372,6 @@ export default function WorkflowBuilder() { - + ); +} + +export function StepPickerModal({ + open, + onClose, + onSelect, + onAddAdhoc, + onImportAdhoc, +}: { + open: boolean; + onClose: () => void; + onSelect: (stepId: string) => void; + onAddAdhoc: () => void; + onImportAdhoc: (file: File) => void; +}) { + const { data: library } = useQuery({ queryKey: ["steps"], queryFn: api.listSteps }); + const [search, setSearch] = useState(""); + const [tab, setTab] = useState("all"); + const fileRef = useRef(null); + + const filtered = useMemo(() => { + const q = search.toLowerCase(); + return (library ?? []).filter( + (s) => + (s.name.toLowerCase().includes(q) || (s.description ?? "").toLowerCase().includes(q)) && + (tab === "all" || tab === "adhoc" ? true : s.interpreter === tab), + ); + }, [library, search, tab]); + + const group = (source: "default" | "shared", interp: "bash" | "powershell") => + filtered.filter( + (s) => s.interpreter === interp && (source === "default" ? s.source === "default" : s.source !== "default"), + ); + + const groups: { label: string; steps: WorkflowStep[] }[] = [ + { label: "Default · Bash", steps: group("default", "bash") }, + { label: "Default · PowerShell", steps: group("default", "powershell") }, + { label: "Shared · Bash", steps: group("shared", "bash") }, + { label: "Shared · PowerShell", steps: group("shared", "powershell") }, + ]; + + const showLibrary = tab !== "adhoc"; + const showAdhocCards = tab === "all" || tab === "adhoc"; + + return ( + +
+ setSearch(e.target.value)} + /> + +
+ {(["all", "bash", "powershell", "adhoc"] as Tab[]).map((t) => ( + + ))} +
+ + {showAdhocCards && ( +
+ + + { + const f = e.target.files?.[0]; + if (f) onImportAdhoc(f); + e.target.value = ""; + }} + /> +
+ )} + + {showLibrary && + groups.map( + (g) => + g.steps.length > 0 && ( +
+
+ {g.label} + +
+
+ {g.steps.map((s) => ( + onSelect(s.step_id)} /> + ))} +
+
+ ), + )} + + {showLibrary && filtered.length === 0 && ( +

No steps match your search.

+ )} + +

+ Click a card to append it to the workflow · manage the library on the{" "} + + Steps + {" "} + page. +

+
+
+ ); +} From e46d0edbf2f368970386cf847295dd5d71aa1488 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 21 Jul 2026 11:41:37 +0100 Subject: [PATCH 6/9] feat(web): replace builder step sidebar with Add-step modal --- web/app/workflows/[id]/page.tsx | 248 +++++--------------------------- 1 file changed, 35 insertions(+), 213 deletions(-) diff --git a/web/app/workflows/[id]/page.tsx b/web/app/workflows/[id]/page.tsx index 394586a..35983e7 100644 --- a/web/app/workflows/[id]/page.tsx +++ b/web/app/workflows/[id]/page.tsx @@ -3,11 +3,11 @@ import { useEffect, useRef, useState } from "react"; import Link from "next/link"; import { useParams, useRouter } from "next/navigation"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { api, Workflow, WorkflowStep, WorkflowStepRef, SecretGroupSummary } from "@/lib/api"; import { Button } from "@/components/ui"; -import { EditStepModal } from "@/components/workflows/EditStepModal"; import { EditWorkflowModal } from "@/components/workflows/EditWorkflowModal"; +import { StepPickerModal } from "@/components/workflows/StepPickerModal"; 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-signal focus:outline-none focus:ring-1 focus:ring-signal"; @@ -27,14 +27,6 @@ function ShellBadge({ interpreter }: { interpreter: "bash" | "powershell" }) { ); } -function DefaultBadge() { - return ( - - default - - ); -} - function AdhocBadge() { return ( @@ -57,11 +49,9 @@ export default function WorkflowBuilder() { const params = useParams<{ id: string }>(); const id = params.id; const router = useRouter(); - const queryClient = useQueryClient(); const [wf, setWf] = useState(null); const [selected, setSelected] = useState(null); - const [search, setSearch] = useState(""); const [saving, setSaving] = useState(false); const [lastSaved, setLastSaved] = useState(null); const [, setTick] = useState(0); @@ -69,16 +59,11 @@ export default function WorkflowBuilder() { const [running, setRunning] = useState(false); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); - const [importing, setImporting] = useState(false); const [importingInline, setImportingInline] = useState(false); - const [syncing, setSyncing] = useState(false); - const fileInputRef = useRef(null); - const inlineFileInputRef = useRef(null); const [groupKeys, setGroupKeys] = useState>({}); const [editWorkflowOpen, setEditWorkflowOpen] = useState(false); - const [editingStep, setEditingStep] = useState(null); - const [editStepOpen, setEditStepOpen] = useState(false); const [dragOverZone, setDragOverZone] = useState(null); + const [pickerOpen, setPickerOpen] = useState(false); const { data: loaded } = useQuery({ queryKey: ["workflow", id], @@ -212,23 +197,6 @@ export default function WorkflowBuilder() { }); }; - const onImportInline = async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - setImportingInline(true); - setError(null); - try { - const doc = JSON.parse(await file.text()); - const step = await api.parseStep(doc); - appendRef({ inline: step, order: wf.steps.length, on_failure: "stop", max_retries: 0 }); - } catch (err) { - setError((err as Error).message); - } finally { - setImportingInline(false); - e.target.value = ""; - } - }; - const moveStep = (from: number, pos: number) => { const next = [...sortedSteps]; const [item] = next.splice(from, 1); @@ -291,42 +259,6 @@ export default function WorkflowBuilder() { updateRef(selectedIdxInWf, { overrides: { ...selectedRef.overrides, secret_refs: next } }); }; - const onImportFile = async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - setImporting(true); - setError(null); - try { - const doc = JSON.parse(await file.text()); - await api.importStep(doc); - await queryClient.invalidateQueries({ queryKey: ["steps"] }); - setNotice("Step imported."); - } catch (err) { - setError((err as Error).message); - } finally { - setImporting(false); - e.target.value = ""; - } - }; - - const onSyncDefaults = async () => { - setSyncing(true); - setError(null); - try { - const { created, updated } = await api.seedDefaults(); - await queryClient.invalidateQueries({ queryKey: ["steps"] }); - setNotice(`${created} created, ${updated} updated`); - } catch (err) { - setError((err as Error).message); - } finally { - setSyncing(false); - } - }; - - const filteredLibrary = (library ?? []).filter((s) => s.name.toLowerCase().includes(search.toLowerCase())); - const bashSteps = filteredLibrary.filter((s) => s.interpreter === "bash"); - const pwshSteps = filteredLibrary.filter((s) => s.interpreter === "powershell"); - const upstreamOutputsFor = (i: number) => Array.from( new Set( @@ -390,104 +322,19 @@ export default function WorkflowBuilder() {
{notice}
)} -
- {/* LEFT: library */} - - +
{/* CENTER: canvas */}
+
+ +
{sortedSteps.map((ref, i) => { @@ -548,9 +395,10 @@ export default function WorkflowBuilder() { )}
@@ -757,55 +605,29 @@ export default function WorkflowBuilder() {
setWf(w)} onClose={() => setEditWorkflowOpen(false)} /> - { - setEditStepOpen(false); - queryClient.invalidateQueries({ queryKey: ["steps"] }); + setPickerOpen(false)} + onSelect={(stepId) => insertLibStep(stepId, sortedSteps.length)} + onAddAdhoc={() => { + addAdhocStep(); + setPickerOpen(false); + }} + onImportAdhoc={async (file) => { + setPickerOpen(false); + setImportingInline(true); + setError(null); + try { + const doc = JSON.parse(await file.text()); + const step = await api.parseStep(doc); + appendRef({ inline: step, order: wf.steps.length, on_failure: "stop", max_retries: 0 }); + } catch (err) { + setError((err as Error).message); + } finally { + setImportingInline(false); + } }} /> ); } - -function LibraryCard({ step, onAdd, onEdit }: { step: WorkflowStep; onAdd: () => void; onEdit: () => void }) { - return ( -
{ - e.dataTransfer.setData("text/plain", JSON.stringify({ kind: "lib", stepId: step.step_id })); - }} - onClick={onAdd} - className="group relative mb-2 cursor-grab rounded-lg border border-border bg-surface-2 p-2 text-left hover:border-signal/50" - > -
- - - {step.source === "default" && } - {step.name} - e.stopPropagation()} - className="ml-auto hidden text-text-secondary hover:text-text-primary group-hover:block" - title="Export step" - > - ⬇ - - -
- {step.description &&

{step.description}

} -
- ); -} From e4c3fc24d306b1a25d7c7afdc01e5380538ee8a1 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 21 Jul 2026 11:43:42 +0100 Subject: [PATCH 7/9] feat(web): standalone Steps management page --- web/app/steps/page.tsx | 214 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 web/app/steps/page.tsx diff --git a/web/app/steps/page.tsx b/web/app/steps/page.tsx new file mode 100644 index 0000000..2ee2c22 --- /dev/null +++ b/web/app/steps/page.tsx @@ -0,0 +1,214 @@ +"use client"; + +import { useMemo, useRef, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { api, WorkflowStep } from "@/lib/api"; +import { Button } from "@/components/ui"; +import { EditStepModal } from "@/components/workflows/EditStepModal"; + +type Tab = "all" | "bash" | "powershell" | "default" | "shared"; + +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-signal focus:outline-none focus:ring-1 focus:ring-signal"; + +function ShellBadge({ interpreter }: { interpreter: "bash" | "powershell" }) { + const isBash = interpreter === "bash"; + return ( + + {isBash ? "bash" : "pwsh"} + + ); +} + +export default function StepsPage() { + const qc = useQueryClient(); + const { data: steps } = useQuery({ queryKey: ["steps"], queryFn: api.listSteps }); + const { data: usage } = useQuery({ queryKey: ["step-usage"], queryFn: api.stepUsage }); + + const [search, setSearch] = useState(""); + const [tab, setTab] = useState("all"); + const [editOpen, setEditOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [importing, setImporting] = useState(false); + const [syncing, setSyncing] = useState(false); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const fileRef = useRef(null); + + const rows = useMemo(() => { + const q = search.toLowerCase(); + return (steps ?? []).filter((s) => { + const matchesText = s.name.toLowerCase().includes(q) || (s.description ?? "").toLowerCase().includes(q); + const matchesTab = + tab === "all" || + (tab === "bash" && s.interpreter === "bash") || + (tab === "powershell" && s.interpreter === "powershell") || + (tab === "default" && s.source === "default") || + (tab === "shared" && s.source !== "default"); + return matchesText && matchesTab; + }); + }, [steps, search, tab]); + + const openNew = () => { + setEditing(null); + setEditOpen(true); + }; + const openEdit = (s: WorkflowStep) => { + setEditing(s); + setEditOpen(true); + }; + + const onImport = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + setImporting(true); + setError(null); + try { + const doc = JSON.parse(await file.text()); + await api.importStep(doc); + qc.invalidateQueries({ queryKey: ["steps"] }); + setNotice("Step imported."); + } catch (err) { + setError((err as Error).message); + } finally { + setImporting(false); + e.target.value = ""; + } + }; + + const onSync = async () => { + setSyncing(true); + setError(null); + try { + const { created, updated } = await api.seedDefaults(); + qc.invalidateQueries({ queryKey: ["steps"] }); + setNotice(`${created} created, ${updated} updated`); + } catch (err) { + setError((err as Error).message); + } finally { + setSyncing(false); + } + }; + + return ( +
+
+
+

Steps

+

Reusable steps shared across all workflows.

+
+
+ + + + +
+
+ + {error &&
{error}
} + {notice &&
{notice}
} + +
+ setSearch(e.target.value)} /> +
+ {(["all", "bash", "powershell", "default", "shared"] as Tab[]).map((t) => ( + + ))} +
+
+ +
+ + + + + + + + + + + + + {rows.map((s) => { + const count = usage?.[s.step_id] ?? 0; + return ( + + + + + + + + + ); + })} + {rows.length === 0 && ( + + + + )} + +
NameShellSourceOutputsUsed byActions
+
{s.name}
+ {s.description &&
{s.description}
} +
+ + + + {s.source === "default" ? "default" : "shared"} + + +
+ {(s.declared_outputs ?? []).map((o) => ( + + {o} + + ))} +
+
+ {count === 0 ? "—" : `${count} workflow${count === 1 ? "" : "s"}`} + +
+ + + Export + + +
+
+ No steps found. +
+
+ + { + setEditOpen(false); + qc.invalidateQueries({ queryKey: ["steps"] }); + qc.invalidateQueries({ queryKey: ["step-usage"] }); + }} + /> +
+ ); +} From 6af0a88841ba51e2a4ed7bc491afe96ec2396f30 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 21 Jul 2026 11:45:43 +0100 Subject: [PATCH 8/9] feat(web): add Steps to main nav --- web/components/Sidebar.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/web/components/Sidebar.tsx b/web/components/Sidebar.tsx index 387bf2d..8f0e838 100644 --- a/web/components/Sidebar.tsx +++ b/web/components/Sidebar.tsx @@ -60,11 +60,20 @@ function SettingsIcon() { ); } +function StepsIcon() { + return ( + + + + ); +} + const navItems: NavItem[] = [ { href: "/servers", label: "Servers", icon: }, { href: "/keys", label: "SSH Keys", icon: }, { href: "/secrets", label: "Secrets", icon: }, { href: "/workflows", label: "Workflows", icon: }, + { href: "/steps", label: "Steps", icon: }, { href: "/audit", label: "Audit Log", icon: }, { href: "/settings", label: "Settings", icon: }, ]; From 3a0116248e642be5eaf04c2da7935beb7db7c310 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 21 Jul 2026 11:50:53 +0100 Subject: [PATCH 9/9] fix(web): guard autosave against in-flight lost-update race --- web/app/workflows/[id]/page.tsx | 49 +++++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/web/app/workflows/[id]/page.tsx b/web/app/workflows/[id]/page.tsx index 35983e7..24302e6 100644 --- a/web/app/workflows/[id]/page.tsx +++ b/web/app/workflows/[id]/page.tsx @@ -56,6 +56,8 @@ export default function WorkflowBuilder() { const [lastSaved, setLastSaved] = useState(null); const [, setTick] = useState(0); const savedSnapshotRef = useRef(null); + const savingRef = useRef(false); + const wfRef = useRef(null); const [running, setRunning] = useState(false); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); @@ -107,6 +109,10 @@ export default function WorkflowBuilder() { return
Loading…
; } + // Keep a ref to the latest workflow so an in-flight save can tell whether + // the user edited again while the request was on the wire. + wfRef.current = wf; + const libById = (sid?: string) => (sid ? library?.find((l) => l.step_id === sid) : undefined); const sortedSteps = [...wf.steps].sort((a, b) => a.order - b.order); @@ -115,23 +121,44 @@ export default function WorkflowBuilder() { const selectedIdxInWf = selectedRef ? wf.steps.indexOf(selectedRef) : -1; const save = async () => { + // Never run two saves concurrently: a request in flight would race the + // next one. The finally block re-triggers if edits landed meanwhile. + if (savingRef.current) return; + const current = wfRef.current; + if (!current) return; + const snapshot = JSON.stringify(current); + if (snapshot === savedSnapshotRef.current) return; + savingRef.current = true; setSaving(true); setError(null); try { - const updated = await api.updateWorkflow(id, wf); + const updated = await api.updateWorkflow(id, current); if (!updated || !Array.isArray(updated.steps)) { setError("Save failed: server returned an unexpected response."); return; } - // Record the snapshot BEFORE setWf so the autosave effect sees the - // incoming state as already-saved and doesn't re-trigger. - savedSnapshotRef.current = JSON.stringify(updated); - setWf(updated); + if (JSON.stringify(wfRef.current) === snapshot) { + // Nothing changed while the request was in flight: adopt the + // server echo as the new saved baseline. + savedSnapshotRef.current = JSON.stringify(updated); + setWf(updated); + } else { + // The user edited again mid-flight. Keep their newer state and + // mark only the SENT snapshot as saved, so the effect re-fires + // and persists the remaining changes. + savedSnapshotRef.current = snapshot; + } setLastSaved(new Date()); } catch (e) { setError((e as Error).message); } finally { + savingRef.current = false; setSaving(false); + // If edits arrived during the save (or a concurrent save was + // skipped), persist them on the next tick. + if (wfRef.current && JSON.stringify(wfRef.current) !== savedSnapshotRef.current) { + setTimeout(() => save(), 0); + } } }; @@ -604,7 +631,17 @@ export default function WorkflowBuilder() {
- setWf(w)} onClose={() => setEditWorkflowOpen(false)} /> + { + // The modal already persisted w; sync the snapshot so + // autosave doesn't fire a redundant follow-up save. + savedSnapshotRef.current = JSON.stringify(w); + setWf(w); + }} + onClose={() => setEditWorkflowOpen(false)} + /> setPickerOpen(false)}