diff --git a/server/internal/api/workflows.go b/server/internal/api/workflows.go index a9e77e8..8ca8248 100644 --- a/server/internal/api/workflows.go +++ b/server/internal/api/workflows.go @@ -23,6 +23,7 @@ func registerWorkflowRoutes(g *gin.RouterGroup) { g.GET("/steps/:id/export", exportStep) g.POST("/steps/import", importStep) g.POST("/steps/seed-defaults", seedDefaults) + g.GET("/steps/usage", stepUsage) g.POST("/steps/parse", parseStep) g.GET("/workflows", listWorkflows) @@ -154,6 +155,15 @@ func listSteps(c *gin.Context) { c.JSON(http.StatusOK, steps) } +func stepUsage(c *gin.Context) { + counts, err := services.StepUsageCounts() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, counts) +} + func createStep(c *gin.Context) { var s models.WorkflowStep if err := c.ShouldBindJSON(&s); err != nil { diff --git a/server/internal/services/workflows.go b/server/internal/services/workflows.go index 124d1e1..1a55c1d 100644 --- a/server/internal/services/workflows.go +++ b/server/internal/services/workflows.go @@ -61,6 +61,34 @@ func ListSteps() ([]models.WorkflowStep, error) { return steps, nil } +// StepUsageCounts returns, per library step_id, the number of distinct +// workflows that reference it. Inline steps have no step_id and are ignored. +func StepUsageCounts() (map[string]int, error) { + ctx, cancel := wfCtx() + defer cancel() + cur, err := db.Col("workflows").Find(ctx, bson.M{}) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + var wfs []models.Workflow + if err := cur.All(ctx, &wfs); err != nil { + return nil, err + } + counts := map[string]int{} + for _, w := range wfs { + seen := map[string]bool{} + for _, ref := range w.Steps { + if ref.StepID == "" || seen[ref.StepID] { + continue + } + seen[ref.StepID] = true + counts[ref.StepID]++ + } + } + return counts, nil +} + func CreateStep(s models.WorkflowStep) (*models.WorkflowStep, error) { ctx, cancel := wfCtx() defer cancel() diff --git a/server/internal/services/workflows_usage_test.go b/server/internal/services/workflows_usage_test.go new file mode 100644 index 0000000..aef5c63 --- /dev/null +++ b/server/internal/services/workflows_usage_test.go @@ -0,0 +1,66 @@ +package services + +import ( + "os" + "testing" + + "github.com/mrhid6/vantage/server/internal/db" + "github.com/mrhid6/vantage/server/internal/models" +) + +var mongoAvailable bool + +func TestMain(m *testing.M) { + uri := os.Getenv("VANTAGE_TEST_MONGO_URI") + if uri == "" { + uri = "mongodb://localhost:27117" + } + if err := db.Connect(uri, "vantage_test"); err != nil { + // No MongoDB available in this environment; DB-backed tests will be skipped + // individually, but the rest of the package's tests must still run. + mongoAvailable = false + } else { + mongoAvailable = true + } + os.Exit(m.Run()) +} + +func mkUsageStep(name string) models.WorkflowStep { + return models.WorkflowStep{Name: name, Interpreter: "bash", Script: "echo hi"} +} + +func mkWorkflowWithStep(name, stepID string) models.Workflow { + return models.Workflow{Name: name, Steps: []models.WorkflowStepRef{{StepID: stepID, Order: 0, OnFailure: "stop"}}} +} + +func TestStepUsageCounts(t *testing.T) { + if !mongoAvailable { + t.Skip("mongo unavailable: set VANTAGE_TEST_MONGO_URI") + } + // A step used by two workflows, a step used by none. + used, err := CreateStep(mkUsageStep("used-step")) + if err != nil { + t.Fatal(err) + } + unused, err := CreateStep(mkUsageStep("unused-step")) + if err != nil { + t.Fatal(err) + } + if _, err := CreateWorkflow(mkWorkflowWithStep("wf-a", used.StepID)); err != nil { + t.Fatal(err) + } + if _, err := CreateWorkflow(mkWorkflowWithStep("wf-b", used.StepID)); err != nil { + t.Fatal(err) + } + + counts, err := StepUsageCounts() + if err != nil { + t.Fatal(err) + } + if counts[used.StepID] != 2 { + t.Fatalf("used step: want 2, got %d", counts[used.StepID]) + } + if counts[unused.StepID] != 0 { + t.Fatalf("unused step: want 0, got %d", counts[unused.StepID]) + } +} 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"] }); + }} + /> +
+ ); +} diff --git a/web/app/workflows/[id]/page.tsx b/web/app/workflows/[id]/page.tsx index 3aa0db4..24302e6 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 ( @@ -43,29 +35,37 @@ 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; 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); + 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); - 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], @@ -78,7 +78,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]); @@ -106,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); @@ -114,22 +121,67 @@ 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; } - 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); + } } }; + // 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); @@ -172,23 +224,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); @@ -251,42 +286,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( @@ -315,7 +314,9 @@ export default function WorkflowBuilder() {
Workflows / {wf.name} - · draft + + · {saving ? "Saving…" : lastSaved ? `Saved ${timeAgo(lastSaved)}` : "draft"} +
@@ -330,9 +331,6 @@ export default function WorkflowBuilder() { -
)} -
- {/* LEFT: library */} - - +
{/* CENTER: canvas */}
+
+ +
{sortedSteps.map((ref, i) => { @@ -509,9 +422,10 @@ export default function WorkflowBuilder() { )}
@@ -717,56 +631,40 @@ export default function WorkflowBuilder() {
- setWf(w)} onClose={() => setEditWorkflowOpen(false)} /> - { - setEditStepOpen(false); - queryClient.invalidateQueries({ queryKey: ["steps"] }); + { + // 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)} + 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}

} -
- ); -} 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: }, ]; diff --git a/web/components/workflows/StepPickerModal.tsx b/web/components/workflows/StepPickerModal.tsx new file mode 100644 index 0000000..17a4967 --- /dev/null +++ b/web/components/workflows/StepPickerModal.tsx @@ -0,0 +1,191 @@ +"use client"; + +import { useMemo, useRef, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { api, WorkflowStep } from "@/lib/api"; +import { Modal } from "@/components/ui"; + +type Tab = "all" | "bash" | "powershell" | "adhoc"; + +function ShellBadge({ interpreter }: { interpreter: "bash" | "powershell" }) { + const isBash = interpreter === "bash"; + return ( + + {isBash ? "bash" : "pwsh"} + + ); +} + +function DefaultBadge() { + return ( + + default + + ); +} + +function StepCard({ step, onAdd }: { step: WorkflowStep; onAdd: () => void }) { + return ( + + ); +} + +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. +

+
+
+ ); +} diff --git a/web/lib/api.ts b/web/lib/api.ts index bb726d7..77a66f1 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -446,6 +446,10 @@ export const api = { }); }, + stepUsage(): Promise> { + return request>("/steps/usage"); + }, + // Workflows listWorkflows(): Promise { return request("/workflows");