Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcc901b0d2 | ||
|
|
99bf093f00 | ||
|
|
619ccd28cb | ||
|
|
f22f0a4729 | ||
|
|
78194daf5f | ||
|
|
f141767fc2 | ||
|
|
05cd8e154b | ||
|
|
004cc03ba6 | ||
|
|
236e89989f | ||
|
|
b0a2de8ca1 | ||
|
|
e9ac7be8c3 | ||
|
|
47690c58d9 |
@@ -0,0 +1,747 @@
|
||||
# Workflow Builder v2 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Rebuild the workflow builder to match the approved mockup with drag-and-drop, base-step editing/deletion (cascade), step input parameters, an edit-workflow modal, runs navigation, and fix the save crash.
|
||||
|
||||
**Architecture:** Backend gains input-parameter fields on step/ref/resolved models, cascade delete, runner env injection, and a workflow-update handler that returns the updated workflow. Frontend adds `bash`/`pwsh`/`signal` tokens and a `Modal` primitive, then rebuilds the builder page (dotted canvas, 340px node cards, wire env chips, kicker/field inspector, HTML5 drag-and-drop), plus Edit-base-step and Edit-workflow modals and a runs list page.
|
||||
|
||||
**Tech Stack:** Go (gin, mongo-driver v2), Next.js 16 app-router + react-query + Tailwind, HTML5 Drag-and-Drop, MongoDB.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **No tests this iteration** — no `*_test.go` or frontend tests. Verify with `go build ./...`, `go vet ./...`, `npm run build`.
|
||||
- Mongo access pattern: `db.Col("collection_name")` + `context.WithTimeout`. Follow `server/internal/services/workflows.go`.
|
||||
- Audit every mutation with `services.LogEvent(action, actor, serverID, targetID, message)`.
|
||||
- Interpreter literals are `"bash"` and `"powershell"`.
|
||||
- Go module path: `github.com/mrhid6/vantage`.
|
||||
- Visual target: approved mockup. Builder palette adds amber signal `#f5a524` (`signal`), `#241800` (`signal-ink`), bash `#3fb950`, pwsh `#5b9bff`; keep existing `surface`/`surface-2`/`border`/`text-primary`/`text-secondary`/`danger`/`accent` tokens for panels. Node cards 340px; dotted-grid canvas; dashed-amber `passes` chips on wires; kicker/field inspector.
|
||||
- Frontend uses `@/lib/api` typed client, `@/components/ui`, react-query. Input styling follows the existing `inputClass` pattern in `web/app/secrets/page.tsx`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Models + step CRUD + cascade delete
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/internal/models/workflow.go`
|
||||
- Modify: `server/internal/services/workflows.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `models.InputParam{Name,Default,Description}`; `WorkflowStep.DeclaredInputs`, `WorkflowStepRef.Inputs`, `ResolvedStep.Inputs`. `DeleteStep(stepID)` now cascades to workflows.
|
||||
|
||||
- [ ] **Step 1: Add the model fields**
|
||||
|
||||
In `server/internal/models/workflow.go` add:
|
||||
|
||||
```go
|
||||
type InputParam struct {
|
||||
Name string `bson:"name" json:"name"`
|
||||
Default string `bson:"default" json:"default"`
|
||||
Description string `bson:"description" json:"description"`
|
||||
}
|
||||
```
|
||||
|
||||
- In `WorkflowStep`, add after `DeclaredOutputs`: `DeclaredInputs []InputParam `bson:"declared_inputs" json:"declared_inputs"``
|
||||
- In `WorkflowStepRef`, add: `Inputs map[string]string `bson:"inputs,omitempty" json:"inputs,omitempty"``
|
||||
- In `ResolvedStep`, add: `Inputs map[string]string `bson:"inputs" json:"inputs"``
|
||||
|
||||
- [ ] **Step 2: Persist declared_inputs in CreateStep/UpdateStep**
|
||||
|
||||
In `server/internal/services/workflows.go`:
|
||||
- In `CreateStep`, after the `SecretRefs` nil-guard add:
|
||||
|
||||
```go
|
||||
if s.DeclaredInputs == nil {
|
||||
s.DeclaredInputs = []models.InputParam{}
|
||||
}
|
||||
```
|
||||
|
||||
- In `UpdateStep`'s `$set`, add: `"declared_inputs": s.DeclaredInputs,`
|
||||
|
||||
- [ ] **Step 3: Cascade DeleteStep to workflows**
|
||||
|
||||
Replace the body of `DeleteStep` with a version that also strips the step from every workflow:
|
||||
|
||||
```go
|
||||
func DeleteStep(stepID string) error {
|
||||
ctx, cancel := wfCtx()
|
||||
defer cancel()
|
||||
if _, err := db.Col("workflow_steps").DeleteOne(ctx, bson.M{"step_id": stepID}); err != nil {
|
||||
return err
|
||||
}
|
||||
// Cascade: remove this step from every workflow that references it, re-sequencing orders.
|
||||
cur, err := db.Col("workflows").Find(ctx, bson.M{"steps.step_id": stepID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cur.Close(ctx)
|
||||
var wfs []models.Workflow
|
||||
if err := cur.All(ctx, &wfs); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, w := range wfs {
|
||||
kept := make([]models.WorkflowStepRef, 0, len(w.Steps))
|
||||
for _, ref := range w.Steps {
|
||||
if ref.StepID == stepID {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, ref)
|
||||
}
|
||||
for i := range kept {
|
||||
kept[i].Order = i
|
||||
}
|
||||
if _, err := db.Col("workflows").UpdateOne(ctx,
|
||||
bson.M{"workflow_id": w.WorkflowID},
|
||||
bson.M{"$set": bson.M{"steps": kept, "updated_at": time.Now()}},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
Confirm `models` and `time` are imported in this file (they are).
|
||||
|
||||
- [ ] **Step 4: Verify build**
|
||||
|
||||
Run: `cd server && go build ./... && go vet ./...`
|
||||
Expected: success.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add server/internal/models/workflow.go server/internal/services/workflows.go
|
||||
git commit -m "feat(workflows): step input params model + cascade step delete"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Runner input injection + update handler returns workflow
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/internal/services/workflow_runner.go`
|
||||
- Modify: `server/internal/api/workflows.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `models.ResolvedStep.Inputs`, `WorkflowStep.DeclaredInputs`, `WorkflowStepRef.Inputs` (Task 1).
|
||||
- Produces: runner injects input env; `PUT /api/workflows/:id` returns the `Workflow`.
|
||||
|
||||
- [ ] **Step 1: Resolve inputs in `resolveSteps`**
|
||||
|
||||
In `server/internal/services/workflow_runner.go`, in `resolveSteps`, after loading `lib` and before/where the `ResolvedStep` is built, compute the input env and set it. Add inside the loop (after `lib, err := getStep(...)` succeeds):
|
||||
|
||||
```go
|
||||
inputs := map[string]string{}
|
||||
for _, p := range lib.DeclaredInputs {
|
||||
if ref.Inputs != nil {
|
||||
if v, ok := ref.Inputs[p.Name]; ok {
|
||||
inputs[p.Name] = v
|
||||
continue
|
||||
}
|
||||
}
|
||||
inputs[p.Name] = p.Default
|
||||
}
|
||||
```
|
||||
|
||||
and set `Inputs: inputs,` on the `models.ResolvedStep{...}` literal.
|
||||
|
||||
- [ ] **Step 2: Inject inputs into cmdEnv in `runServer`**
|
||||
|
||||
In `runServer`, where `cmdEnv` is built (currently: copy `runEnv`, then overlay `secretVals`), change the layering so inputs are the base layer:
|
||||
|
||||
```go
|
||||
cmdEnv := map[string]string{}
|
||||
for k, v := range step.Inputs {
|
||||
cmdEnv[k] = v
|
||||
}
|
||||
for k, v := range runEnv {
|
||||
cmdEnv[k] = v
|
||||
}
|
||||
for k, v := range secretVals {
|
||||
cmdEnv[k] = v
|
||||
}
|
||||
```
|
||||
|
||||
(Inputs first = lowest precedence; upstream outputs override; secrets win. Keep the existing `secretVals`/`allSecrets` masking logic unchanged.)
|
||||
|
||||
- [ ] **Step 3: `updateWorkflow` handler returns the workflow**
|
||||
|
||||
In `server/internal/api/workflows.go`, change `updateWorkflow` so that after a successful `services.UpdateWorkflow`, it re-fetches and returns the workflow:
|
||||
|
||||
```go
|
||||
func updateWorkflow(c *gin.Context) {
|
||||
var w models.Workflow
|
||||
if err := c.ShouldBindJSON(&w); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := services.UpdateWorkflow(c.Param("id"), w); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent("workflow.updated", actorFromCtx(c), "", c.Param("id"), "workflow updated")
|
||||
updated, err := services.GetWorkflow(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, updated)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify build**
|
||||
|
||||
Run: `cd server && go build ./... && go vet ./...`
|
||||
Expected: success.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add server/internal/services/workflow_runner.go server/internal/api/workflows.go
|
||||
git commit -m "feat(workflows): inject step inputs into env; update returns workflow"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Frontend tokens, Modal primitive, API types
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/tailwind.config.ts`
|
||||
- Create: `web/components/ui/Modal.tsx`
|
||||
- Modify: `web/components/ui/index.ts`
|
||||
- Modify: `web/lib/api.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: tokens `bash`/`pwsh`/`signal`/`signal-ink`; `<Modal open title onClose>children</Modal>`; TS `InputParam`, `WorkflowStep.declared_inputs`, `WorkflowStepRef.inputs`.
|
||||
|
||||
- [ ] **Step 1: Add tokens**
|
||||
|
||||
In `web/tailwind.config.ts`, add to `theme.extend.colors`:
|
||||
|
||||
```ts
|
||||
bash: "#3fb950",
|
||||
pwsh: "#5b9bff",
|
||||
signal: "#f5a524",
|
||||
"signal-ink": "#241800",
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create the Modal primitive**
|
||||
|
||||
`web/components/ui/Modal.tsx`:
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function Modal({
|
||||
open,
|
||||
title,
|
||||
onClose,
|
||||
children,
|
||||
wide,
|
||||
}: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
wide?: boolean;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
|
||||
<div
|
||||
className={`relative z-10 w-full ${wide ? "max-w-2xl" : "max-w-md"} max-h-[90vh] overflow-auto rounded-xl border border-border bg-surface shadow-2xl`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-border px-5 py-3">
|
||||
<h2 className="text-sm font-bold text-text-primary">{title}</h2>
|
||||
<button onClick={onClose} className="text-text-secondary hover:text-text-primary" aria-label="Close">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Export it**
|
||||
|
||||
In `web/components/ui/index.ts` add: `export { Modal } from "./Modal";`
|
||||
|
||||
- [ ] **Step 4: API types**
|
||||
|
||||
In `web/lib/api.ts`:
|
||||
- Add interface:
|
||||
|
||||
```ts
|
||||
export interface InputParam {
|
||||
name: string;
|
||||
default: string;
|
||||
description: string;
|
||||
}
|
||||
```
|
||||
|
||||
- In `WorkflowStep`, add `declared_inputs: InputParam[];`
|
||||
- In `WorkflowStepRef`, add `inputs?: Record<string, string>;`
|
||||
|
||||
(`updateWorkflow` already typed to return `Workflow`; the backend now honors it.)
|
||||
|
||||
- [ ] **Step 5: Verify build**
|
||||
|
||||
Run: `cd web && npm run build`
|
||||
Expected: success.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add web/tailwind.config.ts web/components/ui/Modal.tsx web/components/ui/index.ts web/lib/api.ts
|
||||
git commit -m "feat(web): builder tokens, Modal primitive, input-param types"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Edit-base-step modal
|
||||
|
||||
**Files:**
|
||||
- Create: `web/components/workflows/EditStepModal.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `api.createStep`, `api.updateStep`, `api.deleteStep`, `Modal`, types (Task 3).
|
||||
- Produces: `<EditStepModal open step onClose />` where `step` is a `WorkflowStep` (edit) or `null` (new).
|
||||
|
||||
- [ ] **Step 1: Write the modal**
|
||||
|
||||
Client component. Local form state seeded from `step` (or blank for new). Fields:
|
||||
- **Name** (text), **Interpreter** (select bash/powershell), **Script** (`<textarea>` mono).
|
||||
- **Outputs** (`declared_outputs`): a list of text chips with add/remove — an input + "Add" appends a name; each name shows an `✕` to remove.
|
||||
- **Inputs** (`declared_inputs`): rows, each with `name` / `default` / `description` inputs and a remove button; an "Add input" button appends a blank `{name:"",default:"",description:""}`.
|
||||
- Hint: "Reusable steps are shared across all workflows. Editing here changes it everywhere."
|
||||
|
||||
Actions:
|
||||
- **Save**: build the `WorkflowStep` payload (`declared_outputs`, `declared_inputs`, `secret_refs: step?.secret_refs ?? []`, `description: ""` if absent). If editing (`step` truthy) call `api.updateStep(step.step_id, payload)`, else `api.createStep(payload)`. On success `queryClient.invalidateQueries({queryKey:["steps"]})` and `onClose()`.
|
||||
- **Delete** (edit mode only): a confirm (`window.confirm`) then `api.deleteStep(step.step_id)`, invalidate `["steps"]` AND `["workflow"]` (broad — deleted step is pulled from workflows server-side), `onClose()`.
|
||||
|
||||
Use the `inputClass` styling pattern and `Button` variants (`primary` save, `danger` delete, `ghost` cancel). Follow existing token names. Filter empty input rows (blank `name`) out of the payload on save.
|
||||
|
||||
Skeleton:
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { api, WorkflowStep, InputParam } from "@/lib/api";
|
||||
import { Button, Modal } 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-signal focus:outline-none focus:ring-1 focus:ring-signal";
|
||||
|
||||
export function EditStepModal({ open, step, onClose }: { open: boolean; step: WorkflowStep | null; onClose: () => void }) {
|
||||
const qc = useQueryClient();
|
||||
const [name, setName] = useState(step?.name ?? "");
|
||||
const [interpreter, setInterpreter] = useState<"bash" | "powershell">(step?.interpreter ?? "bash");
|
||||
const [script, setScript] = useState(step?.script ?? "");
|
||||
const [outputs, setOutputs] = useState<string[]>(step?.declared_outputs ?? []);
|
||||
const [inputs, setInputs] = useState<InputParam[]>(step?.declared_inputs ?? []);
|
||||
const [newOut, setNewOut] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// NOTE: because state is seeded from props, render the modal conditionally
|
||||
// (parent mounts it only when opening) OR key it by step_id so it re-seeds.
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
const payload = {
|
||||
name: name.trim(), description: step?.description ?? "", interpreter, script,
|
||||
declared_outputs: outputs, declared_inputs: inputs.filter((i) => i.name.trim() !== ""),
|
||||
secret_refs: step?.secret_refs ?? [],
|
||||
};
|
||||
if (step) await api.updateStep(step.step_id, payload);
|
||||
else await api.createStep(payload);
|
||||
qc.invalidateQueries({ queryKey: ["steps"] });
|
||||
onClose();
|
||||
} catch (e) { setError((e as Error).message); } finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const del = async () => {
|
||||
if (!step || !window.confirm("Delete this step? It will be removed from every workflow that uses it.")) return;
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
await api.deleteStep(step.step_id);
|
||||
qc.invalidateQueries({ queryKey: ["steps"] });
|
||||
qc.invalidateQueries({ queryKey: ["workflow"] });
|
||||
onClose();
|
||||
} catch (e) { setError((e as Error).message); } finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={step ? "Edit base step" : "New step"} wide>
|
||||
<div className="space-y-4">
|
||||
{error && <div className="rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{error}</div>}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Name</label>
|
||||
<input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Interpreter</label>
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Script</label>
|
||||
<textarea className={`${inputClass} h-40 font-mono text-xs`} value={script} onChange={(e) => setScript(e.target.value)} />
|
||||
<p className="mt-1 text-xs text-text-secondary">Write <code className="text-signal">KEY=value</code> to <code className="text-signal">$WORKFLOW_ENV</code> to expose it to later steps.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Outputs</label>
|
||||
<div className="mb-2 flex flex-wrap gap-1">
|
||||
{outputs.map((o) => (
|
||||
<span key={o} className="flex items-center gap-1 rounded bg-signal px-2 py-0.5 font-mono text-[11px] text-signal-ink">
|
||||
{o}<button onClick={() => setOutputs(outputs.filter((x) => x !== o))}>✕</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input className={inputClass} placeholder="OUTPUT_NAME" value={newOut} onChange={(e) => setNewOut(e.target.value)} />
|
||||
<Button variant="ghost" size="sm" onClick={() => { if (newOut.trim()) { setOutputs([...outputs, newOut.trim()]); setNewOut(""); } }}>Add</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Inputs</label>
|
||||
<div className="space-y-2">
|
||||
{inputs.map((inp, i) => (
|
||||
<div key={i} className="flex gap-2">
|
||||
<input className={inputClass} placeholder="name" value={inp.name} onChange={(e) => setInputs(inputs.map((x, j) => j === i ? { ...x, name: e.target.value } : x))} />
|
||||
<input className={inputClass} placeholder="default" value={inp.default} onChange={(e) => setInputs(inputs.map((x, j) => j === i ? { ...x, default: e.target.value } : x))} />
|
||||
<input className={inputClass} placeholder="description" value={inp.description} onChange={(e) => setInputs(inputs.map((x, j) => j === i ? { ...x, description: e.target.value } : x))} />
|
||||
<Button variant="ghost" size="sm" onClick={() => setInputs(inputs.filter((_, j) => j !== i))}>✕</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="mt-2" onClick={() => setInputs([...inputs, { name: "", default: "", description: "" }])}>Add input</Button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
{step ? <Button variant="danger" onClick={del} loading={busy}>Delete step</Button> : <span />}
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
||||
<Button variant="primary" onClick={save} loading={busy} disabled={!name.trim()}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Important:** because the form state seeds from `step` props, the parent must either mount `EditStepModal` only while open, or pass a React `key={step?.step_id ?? "new"}` so the state re-initializes each time a different step is edited. Note this in the component with a comment; the builder (Task 6) will mount it with a `key`.
|
||||
|
||||
- [ ] **Step 2: Verify build**
|
||||
|
||||
Run: `cd web && npm run build`
|
||||
Expected: success (the component may be unused until Task 6 — that's fine, but an unused import-free component builds).
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add web/components/workflows/EditStepModal.tsx
|
||||
git commit -m "feat(web): edit-base-step modal with inputs/outputs editor"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Edit-workflow modal
|
||||
|
||||
**Files:**
|
||||
- Create: `web/components/workflows/EditWorkflowModal.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `api.updateWorkflow`, `api.deleteWorkflow`, `api.listServers`, `Modal`, `Workflow` (Task 3).
|
||||
- Produces: `<EditWorkflowModal open workflow onSaved onClose />` — saves name/targets immediately, returns the updated workflow via `onSaved`.
|
||||
|
||||
- [ ] **Step 1: Write the modal**
|
||||
|
||||
Client component. Seeds name + selected target servers from `workflow`. Uses `api.listServers` (react-query) for the multiselect (checkbox chips keyed by `server_id`, labelled `hostname`). Actions:
|
||||
- **Save**: `const updated = await api.updateWorkflow(workflow.workflow_id, { ...workflow, name, target_server_ids })`; call `onSaved(updated)`; `onClose()`. (Backend now returns the workflow.)
|
||||
- **Delete workflow**: confirm, `api.deleteWorkflow(workflow.workflow_id)`, then `router.push("/workflows")`.
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api, Workflow } from "@/lib/api";
|
||||
import { Button, Modal } from "@/components/ui";
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-signal focus:outline-none focus:ring-1 focus:ring-signal";
|
||||
|
||||
export function EditWorkflowModal({ open, workflow, onSaved, onClose }: { open: boolean; workflow: Workflow; onSaved: (w: Workflow) => void; onClose: () => void }) {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState(workflow.name);
|
||||
const [targets, setTargets] = useState<string[]>(workflow.target_server_ids);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { data: servers } = useQuery({ queryKey: ["servers"], queryFn: api.listServers });
|
||||
|
||||
const toggle = (id: string) => setTargets((t) => (t.includes(id) ? t.filter((x) => x !== id) : [...t, id]));
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
const updated = await api.updateWorkflow(workflow.workflow_id, { ...workflow, name, target_server_ids: targets });
|
||||
onSaved(updated); onClose();
|
||||
} catch (e) { setError((e as Error).message); } finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const del = async () => {
|
||||
if (!window.confirm("Delete this workflow? This cannot be undone.")) return;
|
||||
setBusy(true); setError(null);
|
||||
try { await api.deleteWorkflow(workflow.workflow_id); router.push("/workflows"); }
|
||||
catch (e) { setError((e as Error).message); setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title="Edit workflow">
|
||||
<div className="space-y-4">
|
||||
{error && <div className="rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{error}</div>}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Name</label>
|
||||
<input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Target servers</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{servers?.map((s) => {
|
||||
const on = targets.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 ${on ? "border-signal bg-signal/10 text-text-primary" : "border-border text-text-secondary"}`}>
|
||||
<input type="checkbox" className="accent-signal" checked={on} onChange={() => toggle(s.server_id)} />
|
||||
{s.hostname}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{servers && servers.length === 0 && <p className="text-xs text-text-secondary">No servers registered.</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Button variant="danger" onClick={del} loading={busy}>Delete workflow</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
||||
<Button variant="primary" onClick={save} loading={busy} disabled={!name.trim()}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify build**
|
||||
|
||||
Run: `cd web && npm run build`
|
||||
Expected: success.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add web/components/workflows/EditWorkflowModal.tsx
|
||||
git commit -m "feat(web): edit-workflow modal (name/targets/delete)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Builder rewrite — mockup styling + drag-and-drop + inspector
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/app/workflows/[id]/page.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `api.getWorkflow/updateWorkflow/listSteps/runWorkflow`, `EditStepModal` (Task 4), `EditWorkflowModal` (Task 5), tokens (Task 3).
|
||||
|
||||
Rebuild the page to the approved mockup. Reference values from the mockup:
|
||||
- Canvas dotted grid: `background: radial-gradient(circle at 1px 1px, <border> 1px, transparent 0) 0 0 / 22px 22px` over `bg-background`.
|
||||
- Node card: width `340px`, `rounded-[10px] border border-border bg-surface`, selected → `border-signal` + `ring-2 ring-signal/40`. Top row: index badge (mono, boxed), title, shell badge, then body with a mono script preview (`bg-surface-2 border border-border rounded p-2 text-xs`).
|
||||
- Shell badges: bash → `text-bash bg-bash/15`, pwsh (powershell) → `text-pwsh bg-pwsh/15`, mono uppercase `text-[10px] px-1.5 py-0.5 rounded`.
|
||||
- Wire: a 2px vertical `bg-border` segment ~26px tall between nodes.
|
||||
- `passes` chip row: dashed amber pill `border border-dashed border-signal/55 bg-surface rounded-full px-3 py-1`, label "passes" (`text-[10px] uppercase text-text-secondary`), each output name a chip `bg-signal text-signal-ink font-mono text-[11px] rounded px-2 py-0.5`.
|
||||
- Inspector fields: kicker (`text-[11px] uppercase tracking-wide text-text-secondary font-bold`), title with shell badge; each field block separated by `border-b border-border` with an uppercase label.
|
||||
|
||||
- [ ] **Step 1: Rewrite the builder page**
|
||||
|
||||
Requirements (keep everything in React state; **Save** persists via `api.updateWorkflow` and sets `wf` to the returned workflow):
|
||||
|
||||
1. **State/data:** load workflow (`["workflow", id]`), library (`["steps"]`), servers (`["servers"]`). Seed local `wf` from the query once. Keep `selected` (index in sorted order). `sortedSteps = [...wf.steps].sort(order)`.
|
||||
2. **Topbar:** brand dot + `Workflows /` crumb + `wf.name` + `· draft`. Right: a **Targets** chip showing `${wf.target_server_ids.length} servers`; a **Runs** link (`<Link href={`/workflows/${id}/runs`}>`); an **Edit** button (opens `EditWorkflowModal`); **Save** (`variant="secondary"`, persists, `setWf(returned)`); **Run workflow** (amber: `className` using `bg-signal text-signal-ink`, or `variant="primary"` acceptable) → `api.runWorkflow` then route to the run detail.
|
||||
3. **Library (left):** header "Step Library" + `+` button opening `EditStepModal` in new mode (`step={null}`). A search input filters `library` by name (case-insensitive). Group by interpreter with labels `Shared · Bash` / `Shared · PowerShell`. Each card: shell badge, name, `description`, a grip glyph (`⠿`), `cursor-grab`, and `draggable`. On `dragstart` set `dataTransfer` to `JSON.stringify({kind:"lib", stepId})`. Clicking the card still appends the step. A small edit affordance (e.g. a pencil `✎` button on hover) opens `EditStepModal` with that library step.
|
||||
4. **Canvas (center):** dotted grid. For each `sortedSteps[i]`: render a drop target above it (a thin zone; on `drop` insert at `i`), then the node card. Node card is `draggable` (`dragstart` sets `{kind:"move", from:i}`). Between consecutive nodes render the wire + `passes` chips = union of `sortedSteps.slice(0,i)` `declared_outputs` (deduped). After the last node render an end drop zone styled `+ Drop a step here` (dashed). Handle `drop`:
|
||||
- parse `dataTransfer`; if `kind==="lib"` insert a new `WorkflowStepRef{step_id, order:<pos>, on_failure:"stop", max_retries:0}` at the drop position; if `kind==="move"` move `from`→`pos`; then re-sequence all `order` to array index. Update `wf`.
|
||||
- `dragover` must `e.preventDefault()` on drop zones to allow dropping.
|
||||
- Selecting a node (click) sets `selected`.
|
||||
5. **Inspector (right):** for the selected placement (`selectedRef` = `sortedSteps[selected]`, `selectedLib` = library by `step_id`):
|
||||
- Kicker `Step ${selected+1} · Inspector`, title = shell badge + `selectedLib.name`.
|
||||
- **Command** `<textarea>` bound to `selectedRef.overrides?.script ?? selectedLib.script`; edits write `overrides.script` on the ref (per-placement override). Hint about `$WORKFLOW_ENV`.
|
||||
- **Inputs** (this step's `declared_inputs`): one row per param — label = `param.name` (+ description as sub-text), an input bound to `selectedRef.inputs?.[name] ?? ""` with `placeholder={param.default}`; edits write `ref.inputs[name]`.
|
||||
- **Inputs · from upstream** (read-only): the union of prior steps' `declared_outputs` as `IN` rows.
|
||||
- **Outputs · to $WORKFLOW_ENV** (read-only): this step's `declared_outputs` as `OUT` rows.
|
||||
- **Secret refs**: keep the existing group/KEY checklist behavior (port it over) writing `overrides.secret_refs`.
|
||||
- **On failure** select + **Max retries** (when retry).
|
||||
- **Remove from workflow** button (danger) → remove the ref, re-sequence orders, clear selection.
|
||||
6. **Modals:** render `<EditWorkflowModal>` (open state) and `<EditStepModal key={editingStep?.step_id ?? "new"} open step={editingStep} onClose>`; the library `+` and per-card edit set `editingStep`.
|
||||
7. **Bug fix:** `save()` sets `wf` to the awaited `updateWorkflow` result (now a full workflow); no direct crash. Additionally guard: if the returned object lacks `steps`, keep the prior `wf` and surface an error.
|
||||
|
||||
Provide a complete, working implementation (this is a full rewrite of the file). Preserve the secret-group lazy-fetch (`api.getSecretGroup`) logic from the current file for the secret-refs checklist. Use `inputClass` styling. Do not leave TODOs.
|
||||
|
||||
- [ ] **Step 2: Verify build**
|
||||
|
||||
Run: `cd web && npm run build`
|
||||
Expected: type-checks and builds. Manually confirm no `st.stdout`-style dead references and no unused imports.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add web/app/workflows/[id]/page.tsx
|
||||
git commit -m "feat(web): rebuild workflow builder — mockup styling, drag-and-drop, inputs inspector"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Runs list page + navigation links
|
||||
|
||||
**Files:**
|
||||
- Create: `web/app/workflows/[id]/runs/page.tsx`
|
||||
- Modify: `web/app/workflows/page.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `api.listRuns`, `api.getWorkflow`.
|
||||
|
||||
- [ ] **Step 1: Runs list page**
|
||||
|
||||
`web/app/workflows/[id]/runs/page.tsx` — client component. Load `api.getWorkflow(id)` (for the name) and `api.listRuns(id)`. Render a header "Runs · <name>" with a "← Back to builder" `Link` to `/workflows/${id}`, and a table (`@/components/ui` Table) of runs: short run id, a status `Badge` (map success→success, failed→danger, running→accent/warning, cancelled→neutral), `started_at` (localized), `triggered_by`, and server count (`server_runs.length`). Each row links to `/workflows/${id}/runs/${run.run_id}`. Empty state "No runs yet."
|
||||
|
||||
Use the existing Badge variants (success/warning/danger/neutral/accent) — verify names in `web/components/ui/Badge.tsx`.
|
||||
|
||||
Skeleton:
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api, WorkflowRun } from "@/lib/api";
|
||||
import { Card, Table, Thead, Tbody, Tr, Th, Td, Badge } from "@/components/ui";
|
||||
|
||||
const statusVariant: Record<string, string> = {
|
||||
success: "success", failed: "danger", running: "warning", cancelled: "neutral", queued: "neutral",
|
||||
};
|
||||
|
||||
export default function WorkflowRunsPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { data: wf } = useQuery({ queryKey: ["workflow", id], queryFn: () => api.getWorkflow(id) });
|
||||
const { data: runs } = useQuery({ queryKey: ["runs", id], queryFn: () => api.listRuns(id) });
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="mb-6">
|
||||
<Link href={`/workflows/${id}`} className="text-sm text-text-secondary hover:text-text-primary">← Back to builder</Link>
|
||||
<h1 className="mt-2 text-2xl font-bold text-text-primary">Runs · {wf?.name ?? ""}</h1>
|
||||
</div>
|
||||
<Card padding={false}>
|
||||
{runs && runs.length > 0 ? (
|
||||
<Table>
|
||||
<Thead><Tr><Th>Run</Th><Th>Status</Th><Th>Started</Th><Th>By</Th><Th>Servers</Th></Tr></Thead>
|
||||
<Tbody>
|
||||
{runs.map((r: WorkflowRun) => (
|
||||
<Tr key={r.run_id}>
|
||||
<Td><Link href={`/workflows/${id}/runs/${r.run_id}`} className="font-mono text-text-primary hover:text-signal">{r.run_id.slice(0, 8)}</Link></Td>
|
||||
<Td><Badge variant={(statusVariant[r.status] ?? "neutral") as never}>{r.status}</Badge></Td>
|
||||
<Td className="text-text-secondary">{new Date(r.started_at).toLocaleString()}</Td>
|
||||
<Td className="text-text-secondary">{r.triggered_by}</Td>
|
||||
<Td className="text-text-secondary">{r.server_runs.length}</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="py-16 text-center text-text-secondary">No runs yet.</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Adapt `Card padding={false}` / Badge variant prop to the real component signatures (check `Card.tsx`/`Badge.tsx`).
|
||||
|
||||
- [ ] **Step 2: Add a Runs link on the workflows list**
|
||||
|
||||
In `web/app/workflows/page.tsx`, in each workflow row add a "Runs" link/button next to "Open" → `Link href={`/workflows/${w.workflow_id}/runs`}`. Match the existing row action styling.
|
||||
|
||||
- [ ] **Step 3: Verify build**
|
||||
|
||||
Run: `cd web && npm run build`
|
||||
Expected: success.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add web/app/workflows/[id]/runs/page.tsx web/app/workflows/page.tsx
|
||||
git commit -m "feat(web): workflow runs list page and navigation links"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: End-to-end verification
|
||||
|
||||
**Files:** none (verification only).
|
||||
|
||||
- [ ] **Step 1: Build everything**
|
||||
|
||||
Run: `cd server && go build ./... && go vet ./... && cd ../web && npm run build`
|
||||
Expected: all succeed.
|
||||
|
||||
- [ ] **Step 2: Manual smoke (documented, run if an environment is available)**
|
||||
|
||||
1. Open a workflow builder; confirm it matches the mockup (dotted canvas, node cards, amber passes chips, kicker/field inspector).
|
||||
2. Drag a library step onto the canvas; drag to reorder; confirm order persists after Save (no "steps is not iterable" crash).
|
||||
3. Open Edit base step; add an input param (name/default/description) and an output; Save. Place the step; set the input value in the inspector; Run; confirm the script sees the input env var and downstream steps see the output.
|
||||
4. Delete a shared step from the Edit-base-step modal; confirm it disappears from every workflow that used it.
|
||||
5. Open Edit workflow; rename, change target servers, Save; confirm persisted. Delete a throwaway workflow; confirm redirect to `/workflows`.
|
||||
6. From the workflows list and the builder topbar, navigate to Runs; open a run.
|
||||
|
||||
- [ ] **Step 3: Commit any fixes found**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix: workflow builder v2 e2e fixes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Notes
|
||||
|
||||
- **Spec coverage:** §3 model → T1; §4.1 cascade/CRUD → T1; §4.2 update-returns-workflow → T2; §4.3 runner inputs → T2; §6.1 tokens/Modal → T3; §6.2 api types → T3; §6.4 edit-step modal → T4; §6.5 edit-workflow modal → T5; §6.3 builder restyle/DnD/inspector → T6; §6.6 runs page + nav → T7. Save crash fixed by T2 (server) + T6 (client guard). Tests omitted per Global Constraints.
|
||||
- **Dependency order:** modals (T4, T5) land before the builder (T6) that imports them; tokens/Modal/api (T3) first.
|
||||
- **Interpreter literals** `"bash"`/`"powershell"` consistent across T1/T4/T6.
|
||||
- **Modal state seeding:** EditStepModal re-seeds via `key` in the builder (documented in T4/T6).
|
||||
- **Open follow-ups (out of scope):** typed/required inputs, drag-to-trash removal, live status in canvas, keyboard reordering.
|
||||
```
|
||||
@@ -0,0 +1,150 @@
|
||||
# Workflow Builder v2 — Design
|
||||
|
||||
**Date:** 2026-07-20
|
||||
**Status:** Approved (design) — ready for implementation planning
|
||||
**Scope:** Overhaul the workflow builder UI to match the approved mockup, add drag-and-drop (library→canvas + reorder), base-step editing/deletion with cascade, step input parameters, an Edit-Workflow modal, runs navigation, and fix the save crash. Enhancement to the merged Server Workflows feature. Independent of the in-flight log-streaming work.
|
||||
|
||||
---
|
||||
|
||||
## 1. Summary
|
||||
|
||||
The shipped builder diverges from the approved mockup and is missing interactions. This iteration:
|
||||
|
||||
1. **Restyle** the builder (`/workflows/[id]`) to the approved mockup: dotted-grid canvas, 340px node cards with index badge + shell badge + status, wire connectors with dashed-amber "passes" env chips, a kicker/field inspector, and a library of grabbable step cards with descriptions.
|
||||
2. **Drag-and-drop**: drag a library step onto the canvas to add it; drag nodes to reorder. Remove the up/down/remove buttons.
|
||||
3. **Base-step editing**: an "Edit base step" modal edits the shared library step (name/interpreter/script/outputs/inputs/secret refs) and **saves**; deleting a shared step **cascades** — it is pulled from every workflow that references it.
|
||||
4. **Input parameters**: a base step can declare inputs (`name` + `default` + `description`); when placed, each placement sets values; the runner injects them into the step's environment.
|
||||
5. **Env visibility**: show the output variables passed between steps as chips on the wires (already partially present — align to the mockup).
|
||||
6. **Edit-Workflow modal**: edit name, target servers, delete the workflow, and other workflow settings.
|
||||
7. **Runs navigation**: a runs list page per workflow, linked from the builder and the workflows list.
|
||||
8. **Bug fix**: `updateWorkflow` returns `{updated:true}`, which the builder stores as the workflow and then crashes on `[...wf.steps]` ("d.steps is not iterable"). Fix the endpoint to return the updated workflow and harden the client.
|
||||
|
||||
---
|
||||
|
||||
## 2. Locked decisions
|
||||
|
||||
| Topic | Decision |
|
||||
|-------|----------|
|
||||
| Visual target | The approved mockup (artifact `61ab5256`). Adopt its layout + **amber (`#f5a524`) as the builder signal/focus color**, plus bash-green (`#3fb950`) / pwsh-blue (`#5b9bff`) badge colors. Keep the app's existing `surface`/`border`/`text-*` tokens for panels so it integrates with the dark theme. |
|
||||
| Drag-and-drop | Native HTML5 DnD. Library cards are `draggable`; the canvas has drop targets (between nodes + end zone) to insert; nodes are `draggable` to reorder. Clicking a library card still appends (keyboard/fallback). |
|
||||
| Step removal | No per-node buttons. Remove a placed step from the **inspector** ("Remove from workflow"). |
|
||||
| Input params | `WorkflowStep.declared_inputs: [{name, default, description}]`. `WorkflowStepRef.inputs: map[name]value`. Runner resolves `value = ref.inputs[name] ?? default` and injects as env vars. |
|
||||
| Edit scope | Inspector script edit = **per-placement override** (existing `overrides`, "forks a local copy"). A separate **Edit base step** modal updates the shared library step for all workflows. |
|
||||
| Cascade delete | Deleting a library step pulls its `step_id` from every `workflow.steps` and re-sequences remaining `order`s. |
|
||||
| Env chips | Names passed between steps = union of prior steps' `declared_outputs`. Shown on the wire between nodes. |
|
||||
| Edit workflow | Modal launched from the topbar: name, target-servers multiselect, delete workflow. |
|
||||
| Runs nav | New page `/workflows/[id]/runs` (list); links from the builder topbar and the workflows list page. |
|
||||
| Save fix | `PUT /api/workflows/:id` returns the full updated `Workflow`. Client also guards against non-workflow responses. |
|
||||
| Proto | **No proto change** — input params travel through the existing `RunStepCmd.Env`. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Data model (`server/internal/models/workflow.go`)
|
||||
|
||||
Add an input-parameter type and fields:
|
||||
|
||||
```go
|
||||
type InputParam struct {
|
||||
Name string `bson:"name" json:"name"`
|
||||
Default string `bson:"default" json:"default"`
|
||||
Description string `bson:"description" json:"description"`
|
||||
}
|
||||
```
|
||||
|
||||
- `WorkflowStep` gains: `DeclaredInputs []InputParam `bson:"declared_inputs" json:"declared_inputs"``.
|
||||
- `WorkflowStepRef` gains: `Inputs map[string]string `bson:"inputs,omitempty" json:"inputs,omitempty"`` (per-placement values).
|
||||
- `ResolvedStep` gains: `Inputs map[string]string `bson:"inputs" json:"inputs"`` (frozen resolved input env for the run).
|
||||
|
||||
`declared_inputs` defaults to `[]` on create (like `declared_outputs`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Services
|
||||
|
||||
### 4.1 Step CRUD (`server/internal/services/workflows.go`)
|
||||
|
||||
- `CreateStep`: default `DeclaredInputs` to `[]InputParam{}` when nil; persist it.
|
||||
- `UpdateStep`: add `declared_inputs` to the `$set`.
|
||||
- `DeleteStep` → **cascade**. New behavior: within the delete, also update every workflow that references the step:
|
||||
1. `DeleteOne` on `workflow_steps` by `step_id` (as today).
|
||||
2. Load all workflows containing the step (`workflows` where `steps.step_id == stepID`); for each, remove the matching `WorkflowStepRef`(s), re-sequence remaining `order` values to `0..n-1`, and `UpdateWorkflow`.
|
||||
Keep it a single service call `DeleteStep(stepID)` so the handler is unchanged. Audit both the step deletion and each affected workflow via `LogEvent`.
|
||||
|
||||
### 4.2 Workflow update returns the workflow (`server/internal/services/workflows.go` + handler)
|
||||
|
||||
- `UpdateWorkflow(id, w)` stays, but the **handler** `updateWorkflow` re-fetches and returns the full workflow: after `services.UpdateWorkflow`, call `services.GetWorkflow(id)` and return it (200 with the `Workflow` JSON) instead of `{"updated": true}`. This is the crash fix's server half.
|
||||
|
||||
### 4.3 Runner input injection (`server/internal/services/workflow_runner.go`)
|
||||
|
||||
- `resolveSteps`: when freezing each `ResolvedStep`, compute `Inputs`: for each `InputParam` on the library step, `value = ref.Inputs[name]` if present else `param.Default`; store the resulting `map[string]string` on `ResolvedStep.Inputs`.
|
||||
- `runServer`: when building `cmdEnv`, merge `step.Inputs` **first** (base layer), then `runEnv` (upstream outputs), then `secretVals` (highest precedence). Input values are not secrets and are not masked (they are user-provided config, not secret material) — unless a value coincidentally equals a secret literal, existing masking still catches it in logs.
|
||||
|
||||
---
|
||||
|
||||
## 5. REST API
|
||||
|
||||
No new endpoints. Changes:
|
||||
- `updateWorkflow` handler returns the `Workflow` (see §4.2).
|
||||
- `deleteStep` handler unchanged (cascade lives in the service).
|
||||
- Existing `GET /api/workflows/:id/runs`, `GET /api/runs/:runId` power the runs pages.
|
||||
- `createStep`/`updateStep` accept `declared_inputs` via the existing `ShouldBindJSON(&models.WorkflowStep)` (no handler change once the model has the field).
|
||||
|
||||
---
|
||||
|
||||
## 6. Frontend
|
||||
|
||||
### 6.1 Tokens + primitives
|
||||
|
||||
- `tailwind.config.ts`: add `bash: "#3fb950"`, `pwsh: "#5b9bff"`. Amber signal reuses a new `signal: "#f5a524"` token (add it) for the builder's focus ring / env chips / run button; `signal-ink: "#241800"` for text on amber chips.
|
||||
- Add a `Modal` primitive to `components/ui` (overlay + centered panel, `onClose`, ESC + backdrop click, `title`, children, exported from `index.ts`). Used by the Edit-base-step and Edit-workflow modals.
|
||||
|
||||
### 6.2 API client (`web/lib/api.ts`)
|
||||
|
||||
- `WorkflowStep`: add `declared_inputs: InputParam[]`.
|
||||
- New `InputParam { name: string; default: string; description: string }`.
|
||||
- `WorkflowStepRef`: add `inputs?: Record<string, string>`.
|
||||
- `updateWorkflow` return type stays `Workflow` (now actually returns one). Add a client guard: if a mutation is expected to return a workflow but the body lacks `steps`, treat it as an error / refetch.
|
||||
- No other method changes; `deleteStep`, `updateStep`, `createStep`, `listRuns`, `getRun` already exist.
|
||||
|
||||
### 6.3 Builder restyle + DnD (`web/app/workflows/[id]/page.tsx`)
|
||||
|
||||
Rebuild the three-pane builder to the mockup:
|
||||
|
||||
- **Topbar:** brand dot + `Workflows /` crumb + workflow name + `· draft · edited …`; right side a **Targets** chip (read-only summary, e.g. "3 servers"), a **Runs** link (→ `/workflows/[id]/runs`), an **Edit** button (opens Edit-workflow modal), **Save**, **Run workflow** (amber). Remove the inline name input and the in-canvas Target-servers card (both move into the Edit-workflow modal).
|
||||
- **Library (left):** section header with `+` (opens Edit-base-step modal in "new" mode), a search box that filters by name, grouped `Shared · Bash` / `Shared · PowerShell`, each a `.step-card` with shell badge, name, description, and a grip glyph; `draggable`. Clicking still appends. Each card has an edit affordance (pencil / context) that opens the Edit-base-step modal for that step.
|
||||
- **Canvas (center):** dotted-grid background. Render placed steps as 340px node cards: index badge, title, shell badge, (build-time) no status pill — status is a run concern; in the builder show the interpreter badge only. Node body shows a syntax-lite script preview. Between nodes render a wire + a dashed-amber `passes` chip row listing the union of prior `declared_outputs` (names only in the builder). Nodes are `draggable` to reorder; drop targets sit on the wires and at the end ("+ Drop a step here"). Selecting a node opens it in the inspector.
|
||||
- **Inspector (right):** kicker "Step N · Inspector", title with shell badge + name. Fields: **Step name** (edits the placement label? — placements don't have a name; show the library name read-only, edit happens in the base-step modal), **Command** (script `<textarea>` — this is the per-placement override; empty = inherit base), a hint "Write `KEY=value` to `$WORKFLOW_ENV`…", **Inputs** (one row per `declared_input` with an input to set the placement value, showing the default as placeholder), **Inputs · from upstream** (read-only list of upstream `declared_outputs` available to this step), **Outputs · to $WORKFLOW_ENV** (read-only list of this step's `declared_outputs`), **Secret refs** (existing group/KEY checklist), **On failure** (stop/continue/retry + max retries), and **Remove from workflow**.
|
||||
|
||||
DnD detail: use `dataTransfer` with a payload discriminating "library step" (carries `step_id`) vs "reorder" (carries the placement index). On drop at position `k`, insert/move and re-sequence `order`. Keep everything in React state; **Save** persists via `updateWorkflow` (which now returns the workflow → `setWf(returned)` no longer crashes).
|
||||
|
||||
### 6.4 Edit base step modal
|
||||
|
||||
A `Modal` with fields: name, interpreter (bash/powershell), script (`<textarea>` mono), declared **outputs** (chip/list editor — add/remove names), declared **inputs** (rows of name/default/description, add/remove), secret refs (optional). Actions: **Save** (`api.createStep` in new mode / `api.updateStep` in edit mode) then invalidate `["steps"]`; **Delete** (edit mode only) → confirm, `api.deleteStep` (cascades server-side), invalidate `["steps"]` and `["workflow", id]` (a deleted step vanishes from the canvas after refetch). Editing here changes the shared step for all workflows (surface the "shared across all workflows" hint).
|
||||
|
||||
### 6.5 Edit workflow modal
|
||||
|
||||
A `Modal` launched from the topbar **Edit** button: workflow **name** input, **target servers** multiselect (the chips currently in the canvas), and a **Delete workflow** action (confirm → `api.deleteWorkflow` → route to `/workflows`). Save applies name/targets to local `wf` state (persisted on the builder's Save) or immediately via `updateWorkflow` — immediate is simpler and avoids losing the change; use immediate save for the modal, then `setWf(returned)`.
|
||||
|
||||
### 6.6 Runs list page (`web/app/workflows/[id]/runs/page.tsx`)
|
||||
|
||||
New page: header "Runs · <workflow name>", a table of `api.listRuns(id)` rows — run id (short), status badge, started_at, triggered_by, server count — each linking to `/workflows/[id]/runs/[runId]`. A "Back to builder" link. Also add a **Runs** action/link on the workflows list page (`web/app/workflows/page.tsx`) per row and the **Runs** link in the builder topbar.
|
||||
|
||||
---
|
||||
|
||||
## 7. Security
|
||||
|
||||
- Input parameter values are user config, injected as env; not masked (not secret). Secret masking (existing) still applies to logs and to any value equal to a secret literal.
|
||||
- Cascade delete is an authenticated mutation; audited via `LogEvent` for the step and each affected workflow.
|
||||
- Modals perform the same session-authed API calls; no new trust boundary.
|
||||
|
||||
---
|
||||
|
||||
## 8. Out of scope
|
||||
|
||||
- Live run status pills inside the builder canvas (status belongs to the run detail page).
|
||||
- Typed inputs (all inputs are strings), required/validation rules, secret-typed inputs.
|
||||
- Multi-select drag of several steps, copy/paste of steps, undo/redo.
|
||||
- Reworking the run-detail page (covered by the separate log-streaming iteration).
|
||||
- Reordering via keyboard.
|
||||
- Tests (skipped, consistent with prior iterations).
|
||||
```
|
||||
@@ -119,7 +119,12 @@ func updateWorkflow(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
services.LogEvent("workflow.updated", actorFromCtx(c), "", c.Param("id"), "workflow updated")
|
||||
c.JSON(http.StatusOK, gin.H{"updated": true})
|
||||
updated, err := services.GetWorkflow(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, updated)
|
||||
}
|
||||
|
||||
func deleteWorkflow(c *gin.Context) {
|
||||
|
||||
@@ -1,26 +1,38 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type InputParam struct {
|
||||
Name string `bson:"name" json:"name"`
|
||||
Default string `bson:"default" json:"default"`
|
||||
Description string `bson:"description" json:"description"`
|
||||
}
|
||||
|
||||
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"`
|
||||
DeclaredInputs []InputParam `bson:"declared_inputs" json:"declared_inputs"`
|
||||
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 {
|
||||
StepID string `bson:"step_id" json:"step_id"`
|
||||
Order int `bson:"order" json:"order"`
|
||||
OnFailure string `bson:"on_failure" json:"on_failure"` // "stop" | "continue" | "retry"
|
||||
MaxRetries int `bson:"max_retries" json:"max_retries"`
|
||||
Overrides *StepOverride `bson:"overrides,omitempty" json:"overrides,omitempty"`
|
||||
StepID string `bson:"step_id" json:"step_id"`
|
||||
Order int `bson:"order" json:"order"`
|
||||
OnFailure string `bson:"on_failure" json:"on_failure"` // "stop" | "continue" | "retry"
|
||||
MaxRetries int `bson:"max_retries" json:"max_retries"`
|
||||
Overrides *StepOverride `bson:"overrides,omitempty" json:"overrides,omitempty"`
|
||||
Inputs map[string]string `bson:"inputs,omitempty" json:"inputs,omitempty"`
|
||||
}
|
||||
|
||||
type StepOverride struct {
|
||||
@@ -29,7 +41,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"`
|
||||
@@ -44,9 +56,10 @@ type ResolvedStep struct {
|
||||
Name string `bson:"name" json:"name"`
|
||||
Interpreter string `bson:"interpreter" json:"interpreter"`
|
||||
Script string `bson:"script" json:"script"`
|
||||
SecretRefs []string `bson:"secret_refs" json:"secret_refs"`
|
||||
OnFailure string `bson:"on_failure" json:"on_failure"`
|
||||
MaxRetries int `bson:"max_retries" json:"max_retries"`
|
||||
SecretRefs []string `bson:"secret_refs" json:"secret_refs"`
|
||||
OnFailure string `bson:"on_failure" json:"on_failure"`
|
||||
MaxRetries int `bson:"max_retries" json:"max_retries"`
|
||||
Inputs map[string]string `bson:"inputs" json:"inputs"`
|
||||
}
|
||||
|
||||
type StepRun struct {
|
||||
@@ -73,7 +86,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"`
|
||||
|
||||
@@ -86,6 +86,16 @@ func resolveSteps(wf *models.Workflow) ([]models.ResolvedStep, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inputs := map[string]string{}
|
||||
for _, p := range lib.DeclaredInputs {
|
||||
if ref.Inputs != nil {
|
||||
if v, ok := ref.Inputs[p.Name]; ok {
|
||||
inputs[p.Name] = v
|
||||
continue
|
||||
}
|
||||
}
|
||||
inputs[p.Name] = p.Default
|
||||
}
|
||||
rs := models.ResolvedStep{
|
||||
Order: ref.Order,
|
||||
Name: lib.Name,
|
||||
@@ -94,6 +104,7 @@ func resolveSteps(wf *models.Workflow) ([]models.ResolvedStep, error) {
|
||||
SecretRefs: lib.SecretRefs,
|
||||
OnFailure: ref.OnFailure,
|
||||
MaxRetries: ref.MaxRetries,
|
||||
Inputs: inputs,
|
||||
}
|
||||
if ref.Overrides != nil {
|
||||
if ref.Overrides.Script != nil {
|
||||
@@ -174,6 +185,9 @@ func runServer(runID string, srvIdx int, steps []models.ResolvedStep, serverID s
|
||||
allSecrets[k] = v
|
||||
}
|
||||
cmdEnv := map[string]string{}
|
||||
for k, v := range step.Inputs {
|
||||
cmdEnv[k] = v
|
||||
}
|
||||
for k, v := range runEnv {
|
||||
cmdEnv[k] = v
|
||||
}
|
||||
|
||||
@@ -66,6 +66,9 @@ func CreateStep(s models.WorkflowStep) (*models.WorkflowStep, error) {
|
||||
if s.SecretRefs == nil {
|
||||
s.SecretRefs = []string{}
|
||||
}
|
||||
if s.DeclaredInputs == nil {
|
||||
s.DeclaredInputs = []models.InputParam{}
|
||||
}
|
||||
if _, err := db.Col("workflow_steps").InsertOne(ctx, s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -81,6 +84,7 @@ func UpdateStep(stepID string, s models.WorkflowStep) error {
|
||||
"interpreter": s.Interpreter,
|
||||
"script": s.Script,
|
||||
"declared_outputs": s.DeclaredOutputs,
|
||||
"declared_inputs": s.DeclaredInputs,
|
||||
"secret_refs": s.SecretRefs,
|
||||
"updated_at": time.Now(),
|
||||
}})
|
||||
@@ -90,8 +94,38 @@ func UpdateStep(stepID string, s models.WorkflowStep) error {
|
||||
func DeleteStep(stepID string) error {
|
||||
ctx, cancel := wfCtx()
|
||||
defer cancel()
|
||||
_, err := db.Col("workflow_steps").DeleteOne(ctx, bson.M{"step_id": stepID})
|
||||
return err
|
||||
if _, err := db.Col("workflow_steps").DeleteOne(ctx, bson.M{"step_id": stepID}); err != nil {
|
||||
return err
|
||||
}
|
||||
// Cascade: remove this step from every workflow that references it, re-sequencing orders.
|
||||
cur, err := db.Col("workflows").Find(ctx, bson.M{"steps.step_id": stepID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cur.Close(ctx)
|
||||
var wfs []models.Workflow
|
||||
if err := cur.All(ctx, &wfs); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, w := range wfs {
|
||||
kept := make([]models.WorkflowStepRef, 0, len(w.Steps))
|
||||
for _, ref := range w.Steps {
|
||||
if ref.StepID == stepID {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, ref)
|
||||
}
|
||||
for i := range kept {
|
||||
kept[i].Order = i
|
||||
}
|
||||
if _, err := db.Col("workflows").UpdateOne(ctx,
|
||||
bson.M{"workflow_id": w.WorkflowID},
|
||||
bson.M{"$set": bson.M{"steps": kept, "updated_at": time.Now()}},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getStep(ctx context.Context, stepID string) (*models.WorkflowStep, error) {
|
||||
|
||||
+514
-424
@@ -1,470 +1,560 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
api,
|
||||
Workflow,
|
||||
WorkflowStep,
|
||||
WorkflowStepRef,
|
||||
SecretGroupSummary,
|
||||
} from "@/lib/api";
|
||||
import { Button, Card } from "@/components/ui";
|
||||
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";
|
||||
|
||||
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-signal focus:outline-none focus:ring-1 focus:ring-signal";
|
||||
|
||||
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);
|
||||
type DragPayload = { kind: "lib"; stepId: string } | { kind: "move"; from: number };
|
||||
|
||||
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}
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
{isBash ? "bash" : "pwsh"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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 queryClient = useQueryClient();
|
||||
|
||||
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 [wf, setWf] = useState<Workflow | null>(null);
|
||||
const [selected, setSelected] = useState<number | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
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 [editWorkflowOpen, setEditWorkflowOpen] = useState(false);
|
||||
const [editingStep, setEditingStep] = useState<WorkflowStep | null>(null);
|
||||
const [editStepOpen, setEditStepOpen] = useState(false);
|
||||
const [dragOverZone, setDragOverZone] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (loaded && !wf) setWf(loaded);
|
||||
}, [loaded, wf]);
|
||||
const { data: loaded } = useQuery({
|
||||
queryKey: ["workflow", id],
|
||||
queryFn: () => api.getWorkflow(id),
|
||||
});
|
||||
const { data: library } = useQuery({ queryKey: ["steps"], queryFn: api.listSteps });
|
||||
const { data: secretGroups } = useQuery({
|
||||
queryKey: ["secret-groups"],
|
||||
queryFn: api.listSecretGroups,
|
||||
});
|
||||
|
||||
// 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]: [] }));
|
||||
useEffect(() => {
|
||||
if (loaded && !wf) setWf(loaded);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [loaded]);
|
||||
|
||||
// Lazily fetch the keys for every secret group so the inspector's
|
||||
// secret-ref checklist 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 libById = (sid: string) => library?.find((l) => l.step_id === sid);
|
||||
|
||||
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 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 updateRef = (idx: number, patch: Partial<WorkflowStepRef>) =>
|
||||
setWf({
|
||||
...wf,
|
||||
steps: wf.steps.map((r, i) => (i === idx ? { ...r, ...patch } : r)),
|
||||
});
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await api.updateWorkflow(id, wf);
|
||||
if (!updated || !Array.isArray(updated.steps)) {
|
||||
setError("Save failed: server returned an unexpected response.");
|
||||
return;
|
||||
}
|
||||
setWf(updated);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
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 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 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 resequence = (steps: WorkflowStepRef[]) => steps.map((r, i) => ({ ...r, order: i }));
|
||||
|
||||
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 insertLibStep = (stepId: string, pos: number) => {
|
||||
const next = [...sortedSteps];
|
||||
next.splice(pos, 0, { step_id: stepId, order: 0, on_failure: "stop", max_retries: 0 });
|
||||
setWf({ ...wf, steps: resequence(next) });
|
||||
};
|
||||
|
||||
const libById = (sid: string) => library?.find((l) => l.step_id === sid);
|
||||
const moveStep = (from: number, pos: number) => {
|
||||
const next = [...sortedSteps];
|
||||
const [item] = next.splice(from, 1);
|
||||
const target = from < pos ? pos - 1 : pos;
|
||||
next.splice(target, 0, item);
|
||||
setWf({ ...wf, steps: resequence(next) });
|
||||
if (selected === from) setSelected(target);
|
||||
else if (selected !== null) {
|
||||
if (from < selected && target >= selected) setSelected(selected - 1);
|
||||
else if (from > selected && target <= selected) setSelected(selected + 1);
|
||||
}
|
||||
};
|
||||
|
||||
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 handleDrop = (e: React.DragEvent, pos: number) => {
|
||||
e.preventDefault();
|
||||
setDragOverZone(null);
|
||||
const raw = e.dataTransfer.getData("text/plain");
|
||||
if (!raw) return;
|
||||
let payload: DragPayload;
|
||||
try {
|
||||
payload = JSON.parse(raw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (payload.kind === "lib") {
|
||||
insertLibStep(payload.stepId, pos);
|
||||
} else if (payload.kind === "move") {
|
||||
moveStep(payload.from, pos);
|
||||
}
|
||||
};
|
||||
|
||||
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 updateRef = (idx: number, patch: Partial<WorkflowStepRef>) =>
|
||||
setWf({
|
||||
...wf,
|
||||
steps: wf.steps.map((r, i) => (i === idx ? { ...r, ...patch } : r)),
|
||||
});
|
||||
|
||||
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 removeStep = (idx: number) => {
|
||||
const remaining = resequence(wf.steps.filter((_, i) => i !== idx));
|
||||
setWf({ ...wf, steps: remaining });
|
||||
setSelected(null);
|
||||
};
|
||||
|
||||
{/* 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>
|
||||
const toggleSecretRef = (ref: string) => {
|
||||
if (selectedIdxInWf === -1 || !selectedRef) 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 } });
|
||||
};
|
||||
|
||||
<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>
|
||||
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");
|
||||
|
||||
<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)}>
|
||||
↑
|
||||
const upstreamOutputsFor = (i: number) =>
|
||||
Array.from(new Set(sortedSteps.slice(0, i).flatMap((r) => libById(r.step_id)?.declared_outputs ?? [])));
|
||||
|
||||
const DropZone = ({ pos }: { pos: number }) => (
|
||||
<div
|
||||
className={`h-3 w-full transition-all ${dragOverZone === pos ? "h-8 rounded bg-signal/15 border border-dashed border-signal/50" : ""}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragOverZone(pos);
|
||||
}}
|
||||
onDragLeave={() => setDragOverZone((z) => (z === pos ? null : z))}
|
||||
onDrop={(e) => handleDrop(e, pos)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-3 border-b border-border bg-surface px-4 py-3">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-signal" />
|
||||
<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>
|
||||
</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">
|
||||
{wf.target_server_ids.length} servers
|
||||
</span>
|
||||
<Link
|
||||
href={`/workflows/${id}/runs`}
|
||||
className="rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary"
|
||||
>
|
||||
Runs
|
||||
</Link>
|
||||
<Button variant="secondary" size="sm" onClick={() => setEditWorkflowOpen(true)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" loading={saving} onClick={save}>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={i === sortedSteps.length - 1}
|
||||
onClick={() => moveStep(i, 1)}
|
||||
size="sm"
|
||||
loading={running}
|
||||
onClick={run}
|
||||
className="bg-signal text-signal-ink border-transparent hover:bg-signal/90"
|
||||
>
|
||||
↓
|
||||
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>
|
||||
{error && (
|
||||
<div className="border-b border-danger/30 bg-danger/10 px-4 py-2 text-sm text-danger">{error}</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"
|
||||
<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);
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
+
|
||||
</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);
|
||||
}} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{secretGroups && secretGroups.length === 0 && (
|
||||
<p className="text-xs text-text-secondary">No secret groups yet.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{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>
|
||||
|
||||
{/* 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="mx-auto flex w-[340px] flex-col items-center">
|
||||
<DropZone pos={0} />
|
||||
{sortedSteps.map((ref, i) => {
|
||||
const lib = libById(ref.step_id);
|
||||
const outs = upstreamOutputsFor(i);
|
||||
const script = ref.overrides?.script ?? lib?.script ?? "";
|
||||
const wfIdx = wf.steps.indexOf(ref);
|
||||
const isSelected = selected === i;
|
||||
return (
|
||||
<div key={wfIdx} className="w-full">
|
||||
{i > 0 && (
|
||||
<div className="flex flex-col items-center py-1">
|
||||
<div className="h-[13px] w-0.5 bg-border" />
|
||||
{outs.length > 0 && (
|
||||
<div className="flex w-fit max-w-[300px] flex-wrap items-center justify-center gap-1 rounded-full border border-dashed border-signal/55 bg-surface px-3 py-1">
|
||||
<span className="text-[10px] uppercase text-text-secondary">passes</span>
|
||||
{outs.map((o) => (
|
||||
<span
|
||||
key={o}
|
||||
className="rounded bg-signal px-2 py-0.5 font-mono text-[11px] text-signal-ink"
|
||||
>
|
||||
{o}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="h-[13px] w-0.5 bg-border" />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData("text/plain", JSON.stringify({ kind: "move", from: i }));
|
||||
}}
|
||||
onClick={() => setSelected(i)}
|
||||
className={`w-[340px] cursor-pointer rounded-[10px] border bg-surface p-3 ${
|
||||
isSelected ? "border-signal ring-2 ring-signal/40" : "border-border"
|
||||
}`}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="grid h-5 w-5 place-items-center rounded border border-border font-mono text-[10px] text-text-secondary">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-text-primary">{lib?.name ?? ref.step_id}</span>
|
||||
{lib && <ShellBadge interpreter={lib.interpreter} />}
|
||||
</div>
|
||||
<pre className="max-h-16 overflow-hidden text-ellipsis whitespace-pre-wrap rounded border border-border bg-surface-2 p-2 font-mono text-xs text-text-secondary">
|
||||
{script.slice(0, 200)}
|
||||
</pre>
|
||||
</div>
|
||||
<DropZone pos={i + 1} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{sortedSteps.length === 0 && (
|
||||
<button
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => handleDrop(e, 0)}
|
||||
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
|
||||
</button>
|
||||
)}
|
||||
</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">
|
||||
<div>
|
||||
<div className="mb-1 text-[11px] font-bold uppercase tracking-wide text-text-secondary">
|
||||
Step {selected + 1} · Inspector
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedLib && <ShellBadge interpreter={selectedLib.interpreter} />}
|
||||
<h2 className="text-sm font-bold text-text-primary">{selectedLib?.name ?? selectedRef.step_id}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-border pb-4">
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Command</label>
|
||||
<textarea
|
||||
className={`${inputClass} h-32 font-mono text-xs`}
|
||||
value={selectedRef.overrides?.script ?? selectedLib?.script ?? ""}
|
||||
onChange={(e) =>
|
||||
updateRef(selectedIdxInWf, {
|
||||
overrides: { ...selectedRef.overrides, script: e.target.value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-text-secondary">
|
||||
Write <code className="text-signal">KEY=value</code> to <code className="text-signal">$WORKFLOW_ENV</code> to expose
|
||||
it to later steps.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{(selectedLib?.declared_inputs ?? []).length > 0 && (
|
||||
<div className="border-b border-border pb-4">
|
||||
<label className="mb-2 block text-xs uppercase text-text-secondary">Inputs</label>
|
||||
<div className="space-y-2">
|
||||
{selectedLib?.declared_inputs.map((param) => (
|
||||
<div key={param.name}>
|
||||
<div className="mb-1 font-mono text-xs text-text-primary">{param.name}</div>
|
||||
{param.description && (
|
||||
<div className="mb-1 text-[11px] text-text-secondary">{param.description}</div>
|
||||
)}
|
||||
<input
|
||||
className={inputClass}
|
||||
placeholder={param.default}
|
||||
value={selectedRef.inputs?.[param.name] ?? ""}
|
||||
onChange={(e) =>
|
||||
updateRef(selectedIdxInWf, {
|
||||
inputs: { ...selectedRef.inputs, [param.name]: e.target.value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-b border-border pb-4">
|
||||
<label className="mb-2 block text-xs uppercase text-text-secondary">Inputs · from upstream</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{upstreamOutputsFor(selected).length === 0 && (
|
||||
<p className="text-xs text-text-secondary">No upstream outputs.</p>
|
||||
)}
|
||||
{upstreamOutputsFor(selected).map((o) => (
|
||||
<span key={o} className="flex items-center gap-1 rounded bg-surface-2 border border-border px-2 py-0.5 font-mono text-[11px] text-text-primary">
|
||||
<span className="text-[9px] uppercase text-text-secondary">in</span>
|
||||
{o}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-border pb-4">
|
||||
<label className="mb-2 block text-xs uppercase text-text-secondary">Outputs · to $WORKFLOW_ENV</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(selectedLib?.declared_outputs ?? []).length === 0 && (
|
||||
<p className="text-xs text-text-secondary">No declared outputs.</p>
|
||||
)}
|
||||
{(selectedLib?.declared_outputs ?? []).map((o) => (
|
||||
<span key={o} className="flex items-center gap-1 rounded bg-signal px-2 py-0.5 font-mono text-[11px] text-signal-ink">
|
||||
<span className="text-[9px] uppercase">out</span>
|
||||
{o}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-border pb-4">
|
||||
<label className="mb-2 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-signal"
|
||||
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>
|
||||
|
||||
<div className="border-b border-border pb-4">
|
||||
<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>
|
||||
{selectedRef.on_failure === "retry" && (
|
||||
<div className="mt-2">
|
||||
<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>
|
||||
|
||||
<Button variant="danger" size="sm" onClick={() => removeStep(selectedIdxInWf)}>
|
||||
Remove from workflow
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<Button variant="danger" size="sm" onClick={() => removeStep(selectedIdxInWf)}>
|
||||
Remove step
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</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"] });
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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} />
|
||||
<span className="text-sm font-medium text-text-primary">{step.name}</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
className="ml-auto 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api, WorkflowRun } from "@/lib/api";
|
||||
import { Card, Table, Thead, Tbody, Tr, Th, Td, Badge } from "@/components/ui";
|
||||
|
||||
type BadgeVariant = "success" | "warning" | "danger" | "neutral" | "accent";
|
||||
|
||||
const statusVariant: Record<string, BadgeVariant> = {
|
||||
success: "success",
|
||||
failed: "danger",
|
||||
running: "warning",
|
||||
cancelled: "neutral",
|
||||
queued: "neutral",
|
||||
};
|
||||
|
||||
export default function WorkflowRunsPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { data: wf } = useQuery({ queryKey: ["workflow", id], queryFn: () => api.getWorkflow(id) });
|
||||
const { data: runs, isLoading, error } = useQuery({ queryKey: ["runs", id], queryFn: () => api.listRuns(id) });
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="mb-6">
|
||||
<Link href={`/workflows/${id}`} className="text-sm text-text-secondary hover:text-text-primary">
|
||||
← Back to builder
|
||||
</Link>
|
||||
<h1 className="mt-2 text-2xl font-bold text-text-primary">Runs · {wf?.name ?? ""}</h1>
|
||||
</div>
|
||||
|
||||
<Card padding={false}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="py-20 text-center text-danger">Failed to load runs. Is the backend running?</div>
|
||||
) : runs && runs.length > 0 ? (
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Run</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>Started</Th>
|
||||
<Th>By</Th>
|
||||
<Th>Servers</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{runs.map((r: WorkflowRun) => (
|
||||
<Tr key={r.run_id}>
|
||||
<Td>
|
||||
<Link
|
||||
href={`/workflows/${id}/runs/${r.run_id}`}
|
||||
className="font-mono text-text-primary hover:text-signal"
|
||||
>
|
||||
{r.run_id.slice(0, 8)}
|
||||
</Link>
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge variant={statusVariant[r.status] ?? "neutral"}>{r.status}</Badge>
|
||||
</Td>
|
||||
<Td className="text-text-secondary">{new Date(r.started_at).toLocaleString()}</Td>
|
||||
<Td className="text-text-secondary">{r.triggered_by}</Td>
|
||||
<Td className="text-text-secondary">{r.server_runs.length}</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="py-16 text-center text-text-secondary">No runs yet.</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -82,9 +82,14 @@ export default function WorkflowsPage() {
|
||||
<span className="text-text-secondary">{w.steps.length}</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<Link href={`/workflows/${w.workflow_id}`}>
|
||||
<Button variant="ghost" size="sm">Open →</Button>
|
||||
</Link>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Link href={`/workflows/${w.workflow_id}/runs`}>
|
||||
<Button variant="ghost" size="sm">Runs</Button>
|
||||
</Link>
|
||||
<Link href={`/workflows/${w.workflow_id}`}>
|
||||
<Button variant="ghost" size="sm">Open →</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function Modal({
|
||||
open,
|
||||
title,
|
||||
onClose,
|
||||
children,
|
||||
wide,
|
||||
}: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
wide?: boolean;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
|
||||
<div
|
||||
className={`relative z-10 w-full ${wide ? "max-w-2xl" : "max-w-md"} max-h-[90vh] overflow-auto rounded-xl border border-border bg-surface shadow-2xl`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-border px-5 py-3">
|
||||
<h2 className="text-sm font-bold text-text-primary">{title}</h2>
|
||||
<button onClick={onClose} className="text-text-secondary hover:text-text-primary" aria-label="Close">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,3 +2,4 @@ export { Button } from "./Button";
|
||||
export { Badge } from "./Badge";
|
||||
export { Card, CardHeader, CardTitle } from "./Card";
|
||||
export { Table, Thead, Tbody, Tr, Th, Td } from "./Table";
|
||||
export { Modal } from "./Modal";
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { api, WorkflowStep, InputParam } from "@/lib/api";
|
||||
import { Button, Modal } 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-signal focus:outline-none focus:ring-1 focus:ring-signal";
|
||||
|
||||
export function EditStepModal({ open, step, onClose }: { open: boolean; step: WorkflowStep | null; onClose: () => void }) {
|
||||
const qc = useQueryClient();
|
||||
const [name, setName] = useState(step?.name ?? "");
|
||||
const [interpreter, setInterpreter] = useState<"bash" | "powershell">(step?.interpreter ?? "bash");
|
||||
const [script, setScript] = useState(step?.script ?? "");
|
||||
const [outputs, setOutputs] = useState<string[]>(step?.declared_outputs ?? []);
|
||||
const [inputs, setInputs] = useState<InputParam[]>(step?.declared_inputs ?? []);
|
||||
const [newOut, setNewOut] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// NOTE: because state is seeded from props, render the modal conditionally
|
||||
// (parent mounts it only when opening) OR key it by step_id so it re-seeds.
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
const payload: Partial<WorkflowStep> = {
|
||||
name: name.trim(), description: step?.description ?? "", interpreter, script,
|
||||
declared_outputs: outputs, declared_inputs: inputs.filter((i) => i.name.trim() !== ""),
|
||||
secret_refs: step?.secret_refs ?? [],
|
||||
};
|
||||
if (step) await api.updateStep(step.step_id, payload);
|
||||
else await api.createStep(payload);
|
||||
qc.invalidateQueries({ queryKey: ["steps"] });
|
||||
onClose();
|
||||
} catch (e) { setError((e as Error).message); } finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const del = async () => {
|
||||
if (!step || !window.confirm("Delete this step? It will be removed from every workflow that uses it.")) return;
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
await api.deleteStep(step.step_id);
|
||||
qc.invalidateQueries({ queryKey: ["steps"] });
|
||||
qc.invalidateQueries({ queryKey: ["workflow"] });
|
||||
onClose();
|
||||
} catch (e) { setError((e as Error).message); } finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={step ? "Edit base step" : "New step"} wide>
|
||||
<div className="space-y-4">
|
||||
{error && <div className="rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{error}</div>}
|
||||
<p className="text-xs text-text-secondary">Reusable steps are shared across all workflows. Editing here changes it everywhere.</p>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Name</label>
|
||||
<input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Interpreter</label>
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Script</label>
|
||||
<textarea className={`${inputClass} h-40 font-mono text-xs`} value={script} onChange={(e) => setScript(e.target.value)} />
|
||||
<p className="mt-1 text-xs text-text-secondary">Write <code className="text-signal">KEY=value</code> to <code className="text-signal">$WORKFLOW_ENV</code> to expose it to later steps.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Outputs</label>
|
||||
<div className="mb-2 flex flex-wrap gap-1">
|
||||
{outputs.map((o) => (
|
||||
<span key={o} className="flex items-center gap-1 rounded bg-signal px-2 py-0.5 font-mono text-[11px] text-signal-ink">
|
||||
{o}<button onClick={() => setOutputs(outputs.filter((x) => x !== o))}>✕</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input className={inputClass} placeholder="OUTPUT_NAME" value={newOut} onChange={(e) => setNewOut(e.target.value)} />
|
||||
<Button variant="ghost" size="sm" onClick={() => { if (newOut.trim()) { setOutputs([...outputs, newOut.trim()]); setNewOut(""); } }}>Add</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Inputs</label>
|
||||
<div className="space-y-2">
|
||||
{inputs.map((inp, i) => (
|
||||
<div key={i} className="flex gap-2">
|
||||
<input className={inputClass} placeholder="name" value={inp.name} onChange={(e) => setInputs(inputs.map((x, j) => j === i ? { ...x, name: e.target.value } : x))} />
|
||||
<input className={inputClass} placeholder="default" value={inp.default} onChange={(e) => setInputs(inputs.map((x, j) => j === i ? { ...x, default: e.target.value } : x))} />
|
||||
<input className={inputClass} placeholder="description" value={inp.description} onChange={(e) => setInputs(inputs.map((x, j) => j === i ? { ...x, description: e.target.value } : x))} />
|
||||
<Button variant="ghost" size="sm" onClick={() => setInputs(inputs.filter((_, j) => j !== i))}>✕</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="mt-2" onClick={() => setInputs([...inputs, { name: "", default: "", description: "" }])}>Add input</Button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
{step ? <Button variant="danger" onClick={del} loading={busy}>Delete step</Button> : <span />}
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
||||
<Button variant="primary" onClick={save} loading={busy} disabled={!name.trim()}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api, Workflow } from "@/lib/api";
|
||||
import { Button, Modal } from "@/components/ui";
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-signal focus:outline-none focus:ring-1 focus:ring-signal";
|
||||
|
||||
export function EditWorkflowModal({ open, workflow, onSaved, onClose }: { open: boolean; workflow: Workflow; onSaved: (w: Workflow) => void; onClose: () => void }) {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState(workflow.name);
|
||||
const [targets, setTargets] = useState<string[]>(workflow.target_server_ids);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { data: servers } = useQuery({ queryKey: ["servers"], queryFn: api.listServers });
|
||||
|
||||
const toggle = (id: string) => setTargets((t) => (t.includes(id) ? t.filter((x) => x !== id) : [...t, id]));
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
const updated = await api.updateWorkflow(workflow.workflow_id, { ...workflow, name, target_server_ids: targets });
|
||||
onSaved(updated); onClose();
|
||||
} catch (e) { setError((e as Error).message); } finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const del = async () => {
|
||||
if (!window.confirm("Delete this workflow? This cannot be undone.")) return;
|
||||
setBusy(true); setError(null);
|
||||
try { await api.deleteWorkflow(workflow.workflow_id); router.push("/workflows"); }
|
||||
catch (e) { setError((e as Error).message); setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title="Edit workflow">
|
||||
<div className="space-y-4">
|
||||
{error && <div className="rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{error}</div>}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Name</label>
|
||||
<input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Target servers</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{servers?.map((s) => {
|
||||
const on = targets.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 ${on ? "border-signal bg-signal/10 text-text-primary" : "border-border text-text-secondary"}`}>
|
||||
<input type="checkbox" className="accent-signal" checked={on} onChange={() => toggle(s.server_id)} />
|
||||
{s.hostname}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{servers && servers.length === 0 && <p className="text-xs text-text-secondary">No servers registered.</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Button variant="danger" onClick={del} loading={busy}>Delete workflow</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
||||
<Button variant="primary" onClick={save} loading={busy} disabled={!name.trim()}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -132,6 +132,12 @@ export interface ServerWithKeys extends Server {
|
||||
keys: (Assignment & { key: Key })[];
|
||||
}
|
||||
|
||||
export interface InputParam {
|
||||
name: string;
|
||||
default: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface WorkflowStep {
|
||||
step_id: string;
|
||||
name: string;
|
||||
@@ -139,6 +145,7 @@ export interface WorkflowStep {
|
||||
interpreter: "bash" | "powershell";
|
||||
script: string;
|
||||
declared_outputs: string[];
|
||||
declared_inputs: InputParam[];
|
||||
secret_refs: string[];
|
||||
}
|
||||
|
||||
@@ -148,6 +155,7 @@ export interface WorkflowStepRef {
|
||||
on_failure: "stop" | "continue" | "retry";
|
||||
max_retries: number;
|
||||
overrides?: { script?: string; secret_refs?: string[] };
|
||||
inputs?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface Workflow {
|
||||
|
||||
@@ -21,6 +21,10 @@ const config: Config = {
|
||||
warning: "#f59e0b",
|
||||
danger: "#ef4444",
|
||||
"danger-hover": "#dc2626",
|
||||
bash: "#3fb950",
|
||||
pwsh: "#5b9bff",
|
||||
signal: "#f5a524",
|
||||
"signal-ink": "#241800",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user