feat: edit target servers and tags together in the workflow modal

This commit is contained in:
2026-08-04 17:21:12 +01:00
parent 3d59836d0c
commit 3a77fc2abd
3 changed files with 108 additions and 76 deletions
+13 -1
View File
@@ -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 `<select multiple>`: a
native multi-select paints its selected rows with the platform highlight colour,
which cannot be restyled across browsers and lands outside the token palette on
a dark ground.
### Monitors
HTTP, TCP, ICMP and TLS checks. Each monitor has a `runner`: `"server"` (executed by the server-side scheduler) or a `server_id` (pushed to that agent, which runs it locally and reports results). Consecutive failures beyond `retries` flip state to `down`, open an `Incident`, and notify. Hourly `Rollup` documents back the uptime graphs.
+21 -73
View File
@@ -66,11 +66,6 @@ export default function WorkflowBuilder() {
const [editWorkflowOpen, setEditWorkflowOpen] = useState(false);
const [dragOverZone, setDragOverZone] = useState<number | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
// Rows rather than a map so a half-typed pair (a key with no value yet)
// survives a keystroke. Only complete pairs are written into wf.target_tags,
// which is what the debounced save persists.
const [tagRows, setTagRows] = useState<[string, string][]>([]);
const tagRowsSeeded = useRef(false);
const { data: loaded } = useQuery({
queryKey: ["workflow", id],
@@ -80,7 +75,6 @@ export default function WorkflowBuilder() {
// The whole fleet, so the "runs on N servers" readout can be computed in the
// browser rather than asking the server to resolve targets on every keystroke.
const { data: servers } = useQuery({ queryKey: ["servers"], queryFn: () => api.listServers() });
const { data: knownTags } = useQuery({ queryKey: ["server-tags"], queryFn: () => api.listKnownTags(), staleTime: 60_000 });
const { data: secretGroups } = useQuery({
queryKey: ["secret-groups"],
queryFn: api.listSecretGroups,
@@ -91,11 +85,6 @@ export default function WorkflowBuilder() {
setWf(loaded);
savedSnapshotRef.current = snapshotOf(loaded);
}
if (loaded && !tagRowsSeeded.current) {
tagRowsSeeded.current = true;
setTagRows(Object.entries(loaded.target_tags ?? {}));
}
}, [loaded]);
@@ -151,14 +140,6 @@ export default function WorkflowBuilder() {
const targetTags = wf.target_tags ?? {};
// Rows are the editing surface; the map is what is saved. Incomplete rows
// are dropped rather than saved half-written, which is also what keeps the
// readout below honest while someone is still typing a key.
const commitTagRows = (rows: [string, string][]) => {
setTagRows(rows);
setWf({ ...wf, target_tags: Object.fromEntries(rows.filter(([k, v]) => k && v)) });
};
/*
* This is the other half of a deliberate duplication: the authority is
* UnionTargets/MatchesTags in server/internal/services/targets.go, and this
@@ -379,63 +360,30 @@ export default function WorkflowBuilder() {
<div className="mb-2 w-full rounded border border-border bg-surface p-3">
<div className="mb-2 text-[11px] font-bold uppercase tracking-wide text-text-secondary">Targets</div>
{/* Read-only. Both halves of the selector are edited in
EditWorkflowModal so there is one place to change what
a workflow touches; this panel only reports the result. */}
<p className="mb-2 text-xs text-text-secondary">
{wf.target_server_ids.length} named in <button type="button" onClick={() => setEditWorkflowOpen(true)} className="text-signal hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-signal">Edit</button>, plus anything matching every tag below.
</p>
<datalist id="workflow-tag-keys">
{Object.keys(knownTags ?? {}).map((k) => (
<option key={k} value={k} />
))}
</datalist>
<datalist id="workflow-tag-values">
{Object.values(knownTags ?? {})
.flat()
.map((v) => (
<option key={v} value={v} />
))}
</datalist>
<div className="flex flex-col gap-2">
{tagRows.map(([k, v], i) => (
<div key={i} className="flex items-center gap-2">
<input
list="workflow-tag-keys"
value={k}
onChange={(e) => 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"
/>
<span className="font-mono text-text-tertiary">:</span>
<input
list="workflow-tag-values"
value={v}
onChange={(e) => 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"
/>
<button
type="button"
onClick={() => commitTagRows(tagRows.filter((_, j) => j !== i))}
className="text-xs text-text-tertiary hover:text-danger focus:outline-none focus-visible:ring-2 focus-visible:ring-signal"
aria-label={`Remove ${k || "tag"}`}
>
Remove
</button>
</div>
))}
{tagRows.length === 0 && <p className="text-xs text-text-secondary">No tag selector only the named servers will run.</p>}
</div>
{tagRows.length < 20 && (
{wf.target_server_ids.length} named{" "}
<button
type="button"
onClick={() => commitTagRows([...tagRows, ["", ""] as [string, string]])}
className="mt-2 text-xs text-signal hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-signal"
onClick={() => setEditWorkflowOpen(true)}
className="text-signal hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-signal"
>
Add tag
Edit
</button>
)}
</p>
<div className="flex flex-wrap gap-1.5">
{Object.entries(targetTags).map(([k, v]) => (
<span key={k} className="rounded-sm border border-border bg-surface-2 px-1.5 py-0.5 font-mono text-[11px]">
<span className="text-text-tertiary">{k}</span>
<span className="text-text-tertiary">:</span>
<span className="text-text-secondary">{v}</span>
</span>
))}
{Object.keys(targetTags).length === 0 && <p className="text-xs text-text-secondary">No tag selector only the named servers will run.</p>}
</div>
<p className="mt-3 font-mono text-xs text-text-secondary" title={matched.map((s) => 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);
}}
+74 -2
View File
@@ -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<string[]>(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<string | null>(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."
/>
)}
<p className="mt-1.5 text-[11px] text-text-tertiary">Click to highlight, ctrl-click for several, double-click to move. Tag selectors are set on the workflow page.</p>
<p className="mt-1.5 text-[11px] text-text-tertiary">Click to highlight, ctrl-click for several, double-click to move.</p>
</div>
<div className="border-t border-border-soft pt-4">
<label className="mb-1 block text-xs uppercase text-text-secondary">Target tags</label>
<p className="mb-2 text-[11px] text-text-tertiary">
Anything carrying <em>every</em> tag below runs too, on top of the servers named above. Leave empty to run only the named ones.
</p>
<datalist id="workflow-tag-keys">
{Object.keys(knownTags ?? {}).map((k) => (
<option key={k} value={k} />
))}
</datalist>
<datalist id="workflow-tag-values">
{Object.values(knownTags ?? {})
.flat()
.map((v) => (
<option key={v} value={v} />
))}
</datalist>
<div className="flex flex-col gap-2">
{tagRows.map(([k, v], i) => (
<div key={i} className="flex items-center gap-2">
<input
list="workflow-tag-keys"
value={k}
onChange={(e) => 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"
/>
<span className="font-mono text-text-tertiary">:</span>
<input
list="workflow-tag-values"
value={v}
onChange={(e) => 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"
/>
<button
type="button"
onClick={() => setTagRows(tagRows.filter((_, j) => j !== i))}
className="text-xs text-text-tertiary hover:text-danger focus:outline-none focus-visible:ring-2 focus-visible:ring-signal"
aria-label={`Remove ${k || "tag"}`}
>
Remove
</button>
</div>
))}
{tagRows.length === 0 && <p className="text-xs text-text-secondary">No tag selector only the named servers will run.</p>}
</div>
{tagRows.length < 20 && (
<button
type="button"
onClick={() => setTagRows([...tagRows, ["", ""] as [string, string]])}
className="mt-2 text-xs text-signal hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-signal"
>
Add tag
</button>
)}
</div>
{/* The schedule saves through its own endpoint, so it sits above
the footer rather than under it — the footer's Save covers the