Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5f30bc7c8 | ||
|
|
3a0116248e | ||
|
|
6af0a88841 | ||
|
|
e4c3fc24d3 | ||
|
|
e46d0edbf2 | ||
|
|
a4c4a72dbc | ||
|
|
67d729b360 | ||
|
|
da6d825f45 | ||
|
|
93423e32e6 | ||
|
|
d0442291f5 |
@@ -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 {
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user