Compare commits
2
Commits
3d59836d0c
...
3388d2f895
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3388d2f895 | ||
|
|
3a77fc2abd |
@@ -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.
|
||||
|
||||
@@ -9,8 +9,7 @@ import { Button } from "@/components/ui";
|
||||
import { EditWorkflowModal } from "@/components/workflows/EditWorkflowModal";
|
||||
import { StepPickerModal } from "@/components/workflows/StepPickerModal";
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder-text-secondary/50 focus:border-signal focus:outline-none focus:ring-1 focus:ring-signal";
|
||||
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";
|
||||
|
||||
type DragPayload = { kind: "lib"; stepId: string } | { kind: "move"; from: number };
|
||||
|
||||
@@ -23,9 +22,6 @@ function AdhocBadge() {
|
||||
return <span className="rounded px-1.5 py-0.5 font-mono text-[10px] uppercase bg-signal/15 text-signal">ad-hoc</span>;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
function snapshotOf(w: Workflow): string {
|
||||
return JSON.stringify({
|
||||
name: w.name,
|
||||
@@ -66,11 +62,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 +71,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,15 +81,8 @@ export default function WorkflowBuilder() {
|
||||
setWf(loaded);
|
||||
savedSnapshotRef.current = snapshotOf(loaded);
|
||||
}
|
||||
if (loaded && !tagRowsSeeded.current) {
|
||||
tagRowsSeeded.current = true;
|
||||
setTagRows(Object.entries(loaded.target_tags ?? {}));
|
||||
}
|
||||
|
||||
}, [loaded]);
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!secretGroups) return;
|
||||
secretGroups.forEach((g: SecretGroupSummary) => {
|
||||
@@ -115,17 +98,10 @@ export default function WorkflowBuilder() {
|
||||
setGroupKeys((prev) => ({ ...prev, [g.group]: [] }));
|
||||
});
|
||||
});
|
||||
|
||||
}, [secretGroups]);
|
||||
|
||||
|
||||
|
||||
wfRef.current = wf;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!wf || savedSnapshotRef.current === null) return;
|
||||
if (snapshotOf(wf) === savedSnapshotRef.current) return;
|
||||
@@ -133,10 +109,8 @@ export default function WorkflowBuilder() {
|
||||
save();
|
||||
}, 800);
|
||||
return () => clearTimeout(t);
|
||||
|
||||
}, [wf]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!lastSaved) return;
|
||||
const iv = setInterval(() => setTick((n) => n + 1), 15000);
|
||||
@@ -151,14 +125,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
|
||||
@@ -167,11 +133,7 @@ export default function WorkflowBuilder() {
|
||||
* NOTHING (a cleared field must not become a fleet-wide run), and multiple
|
||||
* tag keys AND together. Change one, change both.
|
||||
*/
|
||||
const matched = (servers ?? []).filter(
|
||||
(s) =>
|
||||
wf.target_server_ids.includes(s.server_id) ||
|
||||
(Object.keys(targetTags).length > 0 && Object.entries(targetTags).every(([k, v]) => s.tags?.[k] === v)),
|
||||
);
|
||||
const matched = (servers ?? []).filter((s) => wf.target_server_ids.includes(s.server_id) || (Object.keys(targetTags).length > 0 && Object.entries(targetTags).every(([k, v]) => s.tags?.[k] === v)));
|
||||
|
||||
const sortedSteps = [...wf.steps].sort((a, b) => a.order - b.order);
|
||||
const selectedRef = selected !== null ? sortedSteps[selected] : null;
|
||||
@@ -179,8 +141,6 @@ export default function WorkflowBuilder() {
|
||||
const selectedIdxInWf = selectedRef ? wf.steps.indexOf(selectedRef) : -1;
|
||||
|
||||
const save = async () => {
|
||||
|
||||
|
||||
if (savingRef.current) return;
|
||||
const current = wfRef.current;
|
||||
if (!current) return;
|
||||
@@ -196,14 +156,9 @@ export default function WorkflowBuilder() {
|
||||
return;
|
||||
}
|
||||
if (wfRef.current && snapshotOf(wfRef.current) === snapshot) {
|
||||
|
||||
|
||||
savedSnapshotRef.current = snapshotOf(updated);
|
||||
setWf(updated);
|
||||
} else {
|
||||
|
||||
|
||||
|
||||
savedSnapshotRef.current = snapshot;
|
||||
}
|
||||
setLastSaved(new Date());
|
||||
@@ -212,8 +167,7 @@ export default function WorkflowBuilder() {
|
||||
} finally {
|
||||
savingRef.current = false;
|
||||
setSaving(false);
|
||||
|
||||
|
||||
|
||||
if (wfRef.current && snapshotOf(wfRef.current) !== savedSnapshotRef.current) {
|
||||
setTimeout(() => save(), 0);
|
||||
}
|
||||
@@ -367,11 +321,8 @@ export default function WorkflowBuilder() {
|
||||
<div className="flex flex-1 flex-col lg:grid lg:h-[calc(100dvh-53px)] lg:grid-cols-[1fr_320px]">
|
||||
{/* CENTER: canvas */}
|
||||
<main className="overflow-auto bg-background bg-[radial-gradient(circle_at_1px_1px,theme(colors.border)_1px,transparent_0)] bg-[length:22px_22px] p-4 sm:p-6 lg:p-8">
|
||||
<div className="pointer-events-none sticky top-0 z-10 flex justify-center pt-4">
|
||||
<button
|
||||
onClick={() => setPickerOpen(true)}
|
||||
className="pointer-events-auto inline-flex items-center gap-2 rounded bg-signal px-4 py-2.5 text-sm font-semibold text-signal-ink shadow-panel hover:bg-signal/90"
|
||||
>
|
||||
<div className="pointer-events-none sticky top-0 z-10 flex justify-center py-2">
|
||||
<button onClick={() => setPickerOpen(true)} className="pointer-events-auto inline-flex items-center gap-2 rounded bg-signal px-4 py-2.5 text-sm font-semibold text-signal-ink shadow-panel hover:bg-signal/90">
|
||||
<span className="text-base leading-none">+</span> Add step
|
||||
</button>
|
||||
</div>
|
||||
@@ -379,64 +330,27 @@ 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.
|
||||
{wf.target_server_ids.length} named{" "}
|
||||
<button type="button" onClick={() => setEditWorkflowOpen(true)} className="text-signal hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-signal">
|
||||
Edit
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<datalist id="workflow-tag-keys">
|
||||
{Object.keys(knownTags ?? {}).map((k) => (
|
||||
<option key={k} value={k} />
|
||||
<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>
|
||||
))}
|
||||
</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>}
|
||||
{Object.keys(targetTags).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={() => 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"
|
||||
>
|
||||
Add tag
|
||||
</button>
|
||||
)}
|
||||
|
||||
<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"}
|
||||
</p>
|
||||
@@ -482,21 +396,14 @@ export default function WorkflowBuilder() {
|
||||
{lib && !ref.inline && <ShellBadge interpreter={lib.interpreter} />}
|
||||
{ref.inline && <AdhocBadge />}
|
||||
</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>
|
||||
<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)}
|
||||
onClick={() => setPickerOpen(true)}
|
||||
className="mt-2 w-full rounded border border-dashed border-border bg-surface py-6 text-sm text-text-secondary hover:border-signal/50 hover:text-text-primary"
|
||||
>
|
||||
<button onDragOver={(e) => e.preventDefault()} onDrop={(e) => handleDrop(e, 0)} onClick={() => setPickerOpen(true)} className="mt-2 w-full rounded border border-dashed border-border bg-surface py-6 text-sm text-text-secondary hover:border-signal/50 hover:text-text-primary">
|
||||
+ Add your first step
|
||||
</button>
|
||||
)}
|
||||
@@ -504,11 +411,7 @@ export default function WorkflowBuilder() {
|
||||
</main>
|
||||
|
||||
{/* RIGHT: inspector */}
|
||||
<aside
|
||||
className={`overflow-auto border-border bg-surface p-4 lg:block lg:border-l ${
|
||||
selected === null || !selectedRef ? "hidden" : "block max-lg:border-t max-lg:max-h-[60dvh]"
|
||||
}`}
|
||||
>
|
||||
<aside className={`overflow-auto border-border bg-surface p-4 lg:block lg:border-l ${selected === null || !selectedRef ? "hidden" : "block max-lg:border-t max-lg:max-h-[60dvh]"}`}>
|
||||
{selected === null || !selectedRef ? (
|
||||
<p className="text-sm text-text-secondary">Select a step to configure it.</p>
|
||||
) : (
|
||||
@@ -546,14 +449,9 @@ export default function WorkflowBuilder() {
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Command</label>
|
||||
<textarea
|
||||
className={`${inputClass} h-32 font-mono text-xs`}
|
||||
value={selectedRef.inline.script}
|
||||
onChange={(e) => updateInline(selectedIdxInWf, { script: e.target.value })}
|
||||
/>
|
||||
<textarea className={`${inputClass} h-32 font-mono text-xs`} value={selectedRef.inline.script} onChange={(e) => updateInline(selectedIdxInWf, { 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. Outputs are derived
|
||||
automatically on save.
|
||||
Write <code className="text-signal">KEY=value</code> to <code className="text-signal">$WORKFLOW_ENV</code> to expose it to later steps. Outputs are derived automatically on save.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -673,13 +571,7 @@ export default function WorkflowBuilder() {
|
||||
{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) })}
|
||||
/>
|
||||
<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>
|
||||
@@ -696,8 +588,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);
|
||||
}}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user