Compare commits

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