Compare commits

...
10 Commits
8 changed files with 641 additions and 221 deletions
+10
View File
@@ -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 {
+28
View File
@@ -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()
@@ -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])
}
}
+214
View File
@@ -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 (
<span className={`rounded px-1.5 py-0.5 font-mono text-[10px] uppercase ${isBash ? "bg-bash/15 text-bash" : "bg-pwsh/15 text-pwsh"}`}>
{isBash ? "bash" : "pwsh"}
</span>
);
}
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<Tab>("all");
const [editOpen, setEditOpen] = useState(false);
const [editing, setEditing] = useState<WorkflowStep | null>(null);
const [importing, setImporting] = useState(false);
const [syncing, setSyncing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const fileRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<div className="p-8">
<div className="mb-6 flex items-center gap-3">
<div>
<h1 className="text-xl font-semibold text-text-primary">Steps</h1>
<p className="text-sm text-text-secondary">Reusable steps shared across all workflows.</p>
</div>
<div className="ml-auto flex items-center gap-2">
<input ref={fileRef} type="file" accept="application/json" className="hidden" onChange={onImport} />
<Button variant="secondary" size="sm" loading={syncing} onClick={onSync}>
Sync defaults
</Button>
<Button variant="secondary" size="sm" loading={importing} onClick={() => fileRef.current?.click()}>
Import
</Button>
<Button size="sm" onClick={openNew}>
+ New step
</Button>
</div>
</div>
{error && <div className="mb-4 rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{error}</div>}
{notice && <div className="mb-4 rounded border border-signal/30 bg-signal/10 px-3 py-2 text-sm text-signal">{notice}</div>}
<div className="mb-4 flex items-center gap-2">
<input className={`${inputClass} max-w-sm`} placeholder="Search steps…" value={search} onChange={(e) => setSearch(e.target.value)} />
<div className="flex gap-1.5">
{(["all", "bash", "powershell", "default", "shared"] as Tab[]).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`rounded-full border px-3 py-1 text-xs capitalize ${
tab === t ? "border-signal/50 bg-signal/15 text-signal" : "border-border bg-surface-2 text-text-secondary hover:text-text-primary"
}`}
>
{t === "powershell" ? "PowerShell" : t}
</button>
))}
</div>
</div>
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-[11px] uppercase tracking-wide text-text-secondary">
<th className="px-4 py-2.5 font-bold">Name</th>
<th className="px-4 py-2.5 font-bold">Shell</th>
<th className="px-4 py-2.5 font-bold">Source</th>
<th className="px-4 py-2.5 font-bold">Outputs</th>
<th className="px-4 py-2.5 font-bold">Used by</th>
<th className="px-4 py-2.5 text-right font-bold">Actions</th>
</tr>
</thead>
<tbody>
{rows.map((s) => {
const count = usage?.[s.step_id] ?? 0;
return (
<tr key={s.step_id} className="border-b border-border last:border-0">
<td className="px-4 py-3">
<div className="font-medium text-text-primary">{s.name}</div>
{s.description && <div className="text-xs text-text-secondary">{s.description}</div>}
</td>
<td className="px-4 py-3">
<ShellBadge interpreter={s.interpreter} />
</td>
<td className="px-4 py-3">
<span className="rounded bg-surface-2 px-1.5 py-0.5 font-mono text-[10px] uppercase text-text-secondary">
{s.source === "default" ? "default" : "shared"}
</span>
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{(s.declared_outputs ?? []).map((o) => (
<span key={o} className="rounded border border-signal/35 px-1.5 py-0.5 font-mono text-[10px] text-signal">
{o}
</span>
))}
</div>
</td>
<td className="px-4 py-3 text-text-secondary">
{count === 0 ? "—" : `${count} workflow${count === 1 ? "" : "s"}`}
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-3 text-text-secondary">
<button onClick={() => openEdit(s)} className="hover:text-text-primary">
Edit
</button>
<a href={api.exportStepUrl(s.step_id)} download className="hover:text-text-primary">
Export
</a>
<button onClick={() => openEdit(s)} className="hover:text-danger">
Delete
</button>
</div>
</td>
</tr>
);
})}
{rows.length === 0 && (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-sm text-text-secondary">
No steps found.
</td>
</tr>
)}
</tbody>
</table>
</div>
<EditStepModal
key={editing?.step_id ?? "new"}
open={editOpen}
step={editing}
onClose={() => {
setEditOpen(false);
qc.invalidateQueries({ queryKey: ["steps"] });
qc.invalidateQueries({ queryKey: ["step-usage"] });
}}
/>
</div>
);
}
+119 -221
View File
@@ -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 (
<span className="rounded px-1.5 py-0.5 font-mono text-[10px] uppercase bg-surface-2 text-text-secondary">
default
</span>
);
}
function AdhocBadge() {
return (
<span className="rounded px-1.5 py-0.5 font-mono text-[10px] uppercase bg-signal/15 text-signal">
@@ -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<Workflow | null>(null);
const [selected, setSelected] = useState<number | null>(null);
const [search, setSearch] = useState("");
const [saving, setSaving] = useState(false);
const [lastSaved, setLastSaved] = useState<Date | null>(null);
const [, setTick] = useState(0);
const savedSnapshotRef = useRef<string | null>(null);
const savingRef = useRef(false);
const wfRef = useRef<Workflow | null>(null);
const [running, setRunning] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [importing, setImporting] = useState(false);
const [importingInline, setImportingInline] = useState(false);
const [syncing, setSyncing] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const inlineFileInputRef = useRef<HTMLInputElement>(null);
const [groupKeys, setGroupKeys] = useState<Record<string, string[]>>({});
const [editWorkflowOpen, setEditWorkflowOpen] = useState(false);
const [editingStep, setEditingStep] = useState<WorkflowStep | null>(null);
const [editStepOpen, setEditStepOpen] = useState(false);
const [dragOverZone, setDragOverZone] = useState<number | null>(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 <div className="p-8 text-text-secondary">Loading</div>;
}
// 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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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() {
<div className="flex items-center gap-1.5 text-sm">
<span className="text-text-secondary">Workflows /</span>
<span className="font-medium text-text-primary">{wf.name}</span>
<span className="text-text-secondary">· draft</span>
<span className="text-text-secondary">
· {saving ? "Saving…" : lastSaved ? `Saved ${timeAgo(lastSaved)}` : "draft"}
</span>
</div>
<div className="ml-auto flex items-center gap-2">
<span className="rounded-full border border-border bg-surface-2 px-3 py-1 text-xs text-text-secondary">
@@ -330,9 +331,6 @@ export default function WorkflowBuilder() {
<Button variant="secondary" size="sm" onClick={() => setEditWorkflowOpen(true)}>
Edit
</Button>
<Button variant="secondary" size="sm" loading={saving} onClick={save}>
Save
</Button>
<Button
size="sm"
loading={running}
@@ -351,104 +349,19 @@ export default function WorkflowBuilder() {
<div className="border-b border-signal/30 bg-signal/10 px-4 py-2 text-sm text-signal">{notice}</div>
)}
<div className="grid h-[calc(100vh-53px)] 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={() => {
setEditingStep(null);
setEditStepOpen(true);
}}
>
+
</Button>
</div>
<div className="mb-2 flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
accept="application/json"
className="hidden"
onChange={onImportFile}
/>
<Button
variant="ghost"
size="sm"
loading={importing}
onClick={() => fileInputRef.current?.click()}
>
Import
</Button>
<Button variant="ghost" size="sm" loading={syncing} onClick={onSyncDefaults}>
Sync defaults
</Button>
</div>
<div className="mb-3 flex items-center gap-2">
<input
ref={inlineFileInputRef}
type="file"
accept="application/json"
className="hidden"
onChange={onImportInline}
/>
<Button variant="ghost" size="sm" onClick={addAdhocStep}>
+ Add ad-hoc step
</Button>
<Button
variant="ghost"
size="sm"
loading={importingInline}
onClick={() => inlineFileInputRef.current?.click()}
>
Import ad-hoc
</Button>
</div>
<input
className={`${inputClass} mb-3`}
placeholder="Search steps…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{bashSteps.length > 0 && (
<>
<h3 className="mb-1 mt-2 text-[11px] font-bold uppercase tracking-wide text-text-secondary">
Shared · Bash
</h3>
{bashSteps.map((s) => (
<LibraryCard key={s.step_id} step={s} onAdd={() => insertLibStep(s.step_id, sortedSteps.length)} onEdit={() => {
setEditingStep(s);
setEditStepOpen(true);
}} />
))}
</>
)}
{pwshSteps.length > 0 && (
<>
<h3 className="mb-1 mt-3 text-[11px] font-bold uppercase tracking-wide text-text-secondary">
Shared · PowerShell
</h3>
{pwshSteps.map((s) => (
<LibraryCard key={s.step_id} step={s} onAdd={() => insertLibStep(s.step_id, sortedSteps.length)} onEdit={() => {
setEditingStep(s);
setEditStepOpen(true);
}} />
))}
</>
)}
{filteredLibrary.length === 0 && <p className="mt-2 text-xs text-text-secondary">No steps found.</p>}
</aside>
<div className="grid h-[calc(100vh-53px)] grid-cols-[1fr_320px]">
{/* CENTER: canvas */}
<main
className="overflow-auto bg-background bg-[radial-gradient(circle_at_1px_1px,theme(colors.border)_1px,transparent_0)] bg-[length:22px_22px] p-8"
>
<div className="pointer-events-none sticky top-0 z-10 flex justify-center pt-4">
<button
onClick={() => setPickerOpen(true)}
className="pointer-events-auto inline-flex items-center gap-2 rounded-[9px] bg-signal px-4 py-2.5 text-sm font-semibold text-signal-ink shadow-[0_6px_20px_rgba(245,165,36,0.28)] hover:bg-signal/90"
>
<span className="text-base leading-none">+</span> Add step
</button>
</div>
<div className="mx-auto flex w-[340px] flex-col items-center">
<DropZone pos={0} />
{sortedSteps.map((ref, i) => {
@@ -509,9 +422,10 @@ export default function WorkflowBuilder() {
<button
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => handleDrop(e, 0)}
onClick={() => setPickerOpen(true)}
className="mt-2 w-full rounded-[10px] border border-dashed border-border bg-surface py-6 text-sm text-text-secondary hover:border-signal/50 hover:text-text-primary"
>
+ Drop a step here
+ Add your first step
</button>
)}
</div>
@@ -717,56 +631,40 @@ export default function WorkflowBuilder() {
</aside>
</div>
<EditWorkflowModal open={editWorkflowOpen} workflow={wf} onSaved={(w) => setWf(w)} onClose={() => setEditWorkflowOpen(false)} />
<EditStepModal
key={editingStep?.step_id ?? "new"}
open={editStepOpen}
step={editingStep}
onClose={() => {
setEditStepOpen(false);
queryClient.invalidateQueries({ queryKey: ["steps"] });
<EditWorkflowModal
open={editWorkflowOpen}
workflow={wf}
onSaved={(w) => {
// 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)}
/>
<StepPickerModal
open={pickerOpen}
onClose={() => 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 (
<div
draggable
onDragStart={(e) => {
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"
>
<div className="mb-1 flex items-center gap-2">
<span className="text-text-secondary"></span>
<ShellBadge interpreter={step.interpreter} />
{step.source === "default" && <DefaultBadge />}
<span className="text-sm font-medium text-text-primary">{step.name}</span>
<a
href={api.exportStepUrl(step.step_id)}
download
onClick={(e) => e.stopPropagation()}
className="ml-auto hidden text-text-secondary hover:text-text-primary group-hover:block"
title="Export step"
>
</a>
<button
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
className="hidden text-text-secondary hover:text-text-primary group-hover:block"
title="Edit step"
>
</button>
</div>
{step.description && <p className="text-xs text-text-secondary">{step.description}</p>}
</div>
);
}
+9
View File
@@ -60,11 +60,20 @@ function SettingsIcon() {
);
}
function StepsIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 6.75A.75.75 0 016.75 6h10.5a.75.75 0 010 1.5H6.75A.75.75 0 016 6.75zm0 5.25a.75.75 0 01.75-.75h10.5a.75.75 0 010 1.5H6.75A.75.75 0 016 12zm0 5.25a.75.75 0 01.75-.75h10.5a.75.75 0 010 1.5H6.75A.75.75 0 016 17.25zM3 6.75a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM3 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm0 5.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0z" />
</svg>
);
}
const navItems: NavItem[] = [
{ href: "/servers", label: "Servers", icon: <ServerIcon /> },
{ href: "/keys", label: "SSH Keys", icon: <KeyIcon /> },
{ href: "/secrets", label: "Secrets", icon: <SecretIcon /> },
{ href: "/workflows", label: "Workflows", icon: <WorkflowIcon /> },
{ href: "/steps", label: "Steps", icon: <StepsIcon /> },
{ href: "/audit", label: "Audit Log", icon: <AuditIcon /> },
{ href: "/settings", label: "Settings", icon: <SettingsIcon /> },
];
@@ -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 (
<span
className={`rounded px-1.5 py-0.5 font-mono text-[10px] uppercase ${
isBash ? "bg-bash/15 text-bash" : "bg-pwsh/15 text-pwsh"
}`}
>
{isBash ? "bash" : "pwsh"}
</span>
);
}
function DefaultBadge() {
return (
<span className="rounded bg-surface-2 px-1.5 py-0.5 font-mono text-[10px] uppercase text-text-secondary">
default
</span>
);
}
function StepCard({ step, onAdd }: { step: WorkflowStep; onAdd: () => void }) {
return (
<button
onClick={onAdd}
className="group relative rounded-[10px] border border-border bg-surface-2 p-3 text-left transition-colors hover:border-signal/55"
>
<span className="absolute right-3 top-3 text-xs font-semibold text-signal opacity-0 group-hover:opacity-100">
+ Add
</span>
<div className="mb-1.5 flex items-center gap-2">
<ShellBadge interpreter={step.interpreter} />
{step.source === "default" && <DefaultBadge />}
<span className="text-sm font-medium text-text-primary">{step.name}</span>
</div>
{step.description && <p className="text-xs text-text-secondary">{step.description}</p>}
<div className="mt-2 flex flex-wrap gap-1.5">
{(step.declared_inputs ?? []).map((p) => (
<span key={p.name} className="rounded border border-border bg-background px-1.5 py-0.5 font-mono text-[10px] text-text-secondary">
in {p.name}
</span>
))}
{(step.declared_outputs ?? []).map((o) => (
<span key={o} className="rounded border border-signal/35 bg-background px-1.5 py-0.5 font-mono text-[10px] text-signal">
out {o}
</span>
))}
</div>
</button>
);
}
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<Tab>("all");
const fileRef = useRef<HTMLInputElement>(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 (
<Modal open={open} onClose={onClose} title="Add a step" wide>
<div className="space-y-4">
<input
autoFocus
className="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"
placeholder="Search steps by name or description…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="flex gap-1.5">
{(["all", "bash", "powershell", "adhoc"] as Tab[]).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`rounded-full border px-3 py-1 text-xs capitalize ${
tab === t
? "border-signal/50 bg-signal/15 text-signal"
: "border-border bg-surface-2 text-text-secondary hover:text-text-primary"
}`}
>
{t === "all" ? "All" : t === "powershell" ? "PowerShell" : t === "adhoc" ? "Ad-hoc" : "Bash"}
</button>
))}
</div>
{showAdhocCards && (
<div className="grid grid-cols-2 gap-2.5">
<button
onClick={onAddAdhoc}
className="flex min-h-[74px] items-center justify-center gap-2 rounded-[10px] border border-dashed border-border text-sm text-text-secondary hover:border-signal/55 hover:text-signal"
>
+ New ad-hoc step
</button>
<button
onClick={() => fileRef.current?.click()}
className="flex min-h-[74px] items-center justify-center gap-2 rounded-[10px] border border-dashed border-border text-sm text-text-secondary hover:border-signal/55 hover:text-signal"
>
Import ad-hoc from file
</button>
<input
ref={fileRef}
type="file"
accept="application/json"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) onImportAdhoc(f);
e.target.value = "";
}}
/>
</div>
)}
{showLibrary &&
groups.map(
(g) =>
g.steps.length > 0 && (
<div key={g.label}>
<div className="mb-2.5 flex items-center gap-2 text-[11px] font-bold uppercase tracking-wide text-text-secondary">
{g.label}
<span className="h-px flex-1 bg-border" />
</div>
<div className="grid grid-cols-2 gap-2.5">
{g.steps.map((s) => (
<StepCard key={s.step_id} step={s} onAdd={() => onSelect(s.step_id)} />
))}
</div>
</div>
),
)}
{showLibrary && filtered.length === 0 && (
<p className="text-sm text-text-secondary">No steps match your search.</p>
)}
<p className="text-xs text-text-secondary">
Click a card to append it to the workflow · manage the library on the{" "}
<a href="/steps" className="text-signal hover:underline">
Steps
</a>{" "}
page.
</p>
</div>
</Modal>
);
}
+4
View File
@@ -446,6 +446,10 @@ export const api = {
});
},
stepUsage(): Promise<Record<string, number>> {
return request<Record<string, number>>("/steps/usage");
},
// Workflows
listWorkflows(): Promise<Workflow[]> {
return request<Workflow[]>("/workflows");