diff --git a/CLAUDE.md b/CLAUDE.md index b3fd964..945f6e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -183,12 +183,24 @@ are **not** filtered out — the dispatcher already answers 503 per server, and patch run that silently omits an unreachable machine is worse than one that visibly fails on it. -`web/app/(app)/workflows/[id]/page.tsx` **duplicates that match logic in +**Both halves of the selector are edited in `EditWorkflowModal`** — the named +servers in a `DualListBox`, the tag rows directly beneath it — and saved +together by one `updateWorkflow`. The designer's Targets panel is **read-only**: +it reports the count and the tags and links to Edit. Splitting the two halves +across two screens meant a workflow's reach was decided in two places with no +one view showing both. + +`web/app/(app)/workflows/[id]/page.tsx` still **duplicates the match logic in TypeScript** to draw the resolved count without a round trip, since the browser already holds the fleet. It is a second implementation of `UnionTargets` / `MatchesTags` and must change in the same commit as the Go one — the same shape of hazard as the mirrored token blocks. +The server picker is a hand-built two-pane list, not ` commitTagRows(tagRows.map((row, j): [string, string] => (j === i ? [e.target.value, row[1]] : row)))} - placeholder="env" - className="w-28 rounded-lg border border-border bg-surface-2 px-2 py-1 font-mono text-xs text-text-primary focus:border-signal focus:outline-none" - /> - : - commitTagRows(tagRows.map((row, j): [string, string] => (j === i ? [row[0], e.target.value] : row)))} - placeholder="prod" - className="w-32 rounded-lg border border-border bg-surface-2 px-2 py-1 font-mono text-xs text-text-primary focus:border-signal focus:outline-none" - /> - - - ))} - {tagRows.length === 0 &&

No tag selector — only the named servers will run.

} - - - {tagRows.length < 20 && ( + {wf.target_server_ids.length} named{" "} - )} +

+ +
+ {Object.entries(targetTags).map(([k, v]) => ( + + {k} + : + {v} + + ))} + {Object.keys(targetTags).length === 0 &&

No tag selector — only the named servers will run.

} +

s.hostname).join("\n")}> Runs on {matched.length} {matched.length === 1 ? "server" : "servers"} @@ -696,8 +644,8 @@ export default function WorkflowBuilder() { open={editWorkflowOpen} workflow={wf} onSaved={(w) => { - - + // Snapshot first: the modal has just persisted name, targets + // and tags, so the debounced autosave must not re-send them. savedSnapshotRef.current = snapshotOf(w); setWf(w); }} diff --git a/web/components/workflows/EditWorkflowModal.tsx b/web/components/workflows/EditWorkflowModal.tsx index 632d3f5..ffe65df 100644 --- a/web/components/workflows/EditWorkflowModal.tsx +++ b/web/components/workflows/EditWorkflowModal.tsx @@ -14,14 +14,20 @@ export function EditWorkflowModal({ open, workflow, onSaved, onClose }: { open: const router = useRouter(); const [name, setName] = useState(workflow.name); const [targets, setTargets] = useState(workflow.target_server_ids); + // Rows rather than a map: a half-typed key is not a valid map entry, and + // rebuilding the map on every keystroke would drop a row the moment its key + // was cleared. Only complete pairs are written back on save. + const [tagRows, setTagRows] = useState<[string, string][]>(Object.entries(workflow.target_tags ?? {})); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const { data: servers } = useQuery({ queryKey: ["servers"], queryFn: () => api.listServers() }); + const { data: knownTags } = useQuery({ queryKey: ["server-tags"], queryFn: () => api.listKnownTags(), staleTime: 60_000 }); useEffect(() => { if (open) { setName(workflow.name); setTargets(workflow.target_server_ids); + setTagRows(Object.entries(workflow.target_tags ?? {})); } }, [open, workflow]); @@ -29,7 +35,12 @@ export function EditWorkflowModal({ open, workflow, onSaved, onClose }: { open: setBusy(true); setError(null); try { - const updated = await api.updateWorkflow(workflow.workflow_id, { ...workflow, name, target_server_ids: targets }); + const updated = await api.updateWorkflow(workflow.workflow_id, { + ...workflow, + name, + target_server_ids: targets, + target_tags: Object.fromEntries(tagRows.filter(([k, v]) => k && v)), + }); onSaved(updated); onClose(); } catch (e) { @@ -74,7 +85,68 @@ export function EditWorkflowModal({ open, workflow, onSaved, onClose }: { open: emptySelected="No servers targeted." /> )} -

Click to highlight, ctrl-click for several, double-click to move. Tag selectors are set on the workflow page.

+

Click to highlight, ctrl-click for several, double-click to move.

+ + +
+ +

+ Anything carrying every tag below runs too, on top of the servers named above. Leave empty to run only the named ones. +

+ + + {Object.keys(knownTags ?? {}).map((k) => ( + + + {Object.values(knownTags ?? {}) + .flat() + .map((v) => ( + + +
+ {tagRows.map(([k, v], i) => ( +
+ setTagRows(tagRows.map((row, j): [string, string] => (j === i ? [e.target.value, row[1]] : row)))} + placeholder="env" + className="w-32 rounded-lg border border-border bg-surface-2 px-2 py-1 font-mono text-xs text-text-primary focus:border-signal focus:outline-none" + /> + : + setTagRows(tagRows.map((row, j): [string, string] => (j === i ? [row[0], e.target.value] : row)))} + placeholder="prod" + className="w-36 rounded-lg border border-border bg-surface-2 px-2 py-1 font-mono text-xs text-text-primary focus:border-signal focus:outline-none" + /> + +
+ ))} + {tagRows.length === 0 &&

No tag selector — only the named servers will run.

} +
+ + {tagRows.length < 20 && ( + + )}
{/* The schedule saves through its own endpoint, so it sits above the footer rather than under it — the footer's Save covers the