feat: dual list box for workflow target servers
Chart Release / chart (push) Successful in 11s
Server Deploy / deploy (push) Successful in 5m19s

This commit is contained in:
2026-08-04 17:13:50 +01:00
parent d9184312aa
commit 3d59836d0c
2 changed files with 166 additions and 17 deletions
+152
View File
@@ -0,0 +1,152 @@
"use client";
import { useMemo, useState } from "react";
/*
* A two-pane picker: everything available on the left, everything chosen on the
* right, moved across with the four buttons between them.
*
* The panes are divs rather than <select multiple>. A native multi-select draws
* its selected rows with the platform's own highlight colour, which cannot be
* restyled reliably across browsers — on a dark ground it renders as a pale
* band that belongs to no palette. Rebuilding the widget is the only way to
* keep it inside the token system.
*/
export interface DualItem {
id: string;
label: string;
hint?: string;
}
function Pane({
items,
marked,
onToggle,
onCommit,
empty,
}: {
items: DualItem[];
marked: string[];
onToggle: (id: string, additive: boolean) => void;
onCommit: (id: string) => void;
empty: string;
}) {
return (
<div className="h-56 overflow-y-auto rounded-lg border border-border bg-surface-2" role="listbox" aria-multiselectable>
{items.length === 0 ? (
<p className="px-3 py-2 text-xs text-text-tertiary">{empty}</p>
) : (
items.map((it) => {
const on = marked.includes(it.id);
return (
<button
key={it.id}
type="button"
role="option"
aria-selected={on}
onClick={(e) => onToggle(it.id, e.ctrlKey || e.metaKey || e.shiftKey)}
onDoubleClick={() => onCommit(it.id)}
className={`flex w-full items-baseline gap-2 px-3 py-1.5 text-left text-sm transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-accent ${
on ? "bg-accent text-accent-ink" : "text-text-primary hover:bg-surface"
}`}
>
<span className="truncate">{it.label}</span>
{it.hint && <span className={`truncate font-mono text-[11px] ${on ? "opacity-70" : "text-text-tertiary"}`}>{it.hint}</span>}
</button>
);
})
)}
</div>
);
}
function MoveButton({ children, onClick, disabled, label }: { children: React.ReactNode; onClick: () => void; disabled: boolean; label: string }) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={label}
title={label}
className="rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-sm text-text-secondary transition-colors hover:border-accent/40 hover:text-text-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:border-border disabled:hover:text-text-secondary"
>
{children}
</button>
);
}
export function DualListBox({
items,
selected,
onChange,
availableLabel = "Available",
selectedLabel = "Selected",
emptyAvailable = "Nothing left to add.",
emptySelected = "Nothing selected.",
}: {
items: DualItem[];
selected: string[];
onChange: (next: string[]) => void;
availableLabel?: string;
selectedLabel?: string;
emptyAvailable?: string;
emptySelected?: string;
}) {
// Which rows are highlighted in each pane, not which are chosen. Highlight
// is transient and per-pane; membership is the `selected` prop.
const [markedLeft, setMarkedLeft] = useState<string[]>([]);
const [markedRight, setMarkedRight] = useState<string[]>([]);
const byLabel = (a: DualItem, b: DualItem) => a.label.localeCompare(b.label);
const available = useMemo(() => items.filter((i) => !selected.includes(i.id)).sort(byLabel), [items, selected]);
// Ordered by the same rule as the left pane rather than by the order things
// were clicked, so a workflow's targets read the same way every time.
const chosen = useMemo(() => items.filter((i) => selected.includes(i.id)).sort(byLabel), [items, selected]);
const mark = (setter: typeof setMarkedLeft) => (id: string, additive: boolean) =>
setter((m) => (additive ? (m.includes(id) ? m.filter((x) => x !== id) : [...m, id]) : m.length === 1 && m[0] === id ? [] : [id]));
const add = (ids: string[]) => {
if (ids.length === 0) return;
onChange([...selected, ...ids.filter((id) => !selected.includes(id))]);
setMarkedLeft([]);
};
const remove = (ids: string[]) => {
if (ids.length === 0) return;
onChange(selected.filter((id) => !ids.includes(id)));
setMarkedRight([]);
};
return (
<div className="grid grid-cols-[1fr_auto_1fr] items-start gap-3">
<div>
<p className="mb-1 text-[11px] uppercase tracking-wide text-text-tertiary">{availableLabel}</p>
<Pane items={available} marked={markedLeft} onToggle={mark(setMarkedLeft)} onCommit={(id) => add([id])} empty={emptyAvailable} />
</div>
<div className="flex flex-col gap-2 pt-6">
<MoveButton label={`Add all to ${selectedLabel}`} disabled={available.length === 0} onClick={() => add(available.map((i) => i.id))}>
»
</MoveButton>
<MoveButton label={`Add to ${selectedLabel}`} disabled={markedLeft.length === 0} onClick={() => add(markedLeft)}>
</MoveButton>
<MoveButton label={`Remove from ${selectedLabel}`} disabled={markedRight.length === 0} onClick={() => remove(markedRight)}>
</MoveButton>
<MoveButton label={`Remove all from ${selectedLabel}`} disabled={chosen.length === 0} onClick={() => remove(chosen.map((i) => i.id))}>
«
</MoveButton>
</div>
<div>
<p className="mb-1 text-[11px] uppercase tracking-wide text-text-tertiary">
{selectedLabel} · {chosen.length}
</p>
<Pane items={chosen} marked={markedRight} onToggle={mark(setMarkedRight)} onCommit={(id) => remove([id])} empty={emptySelected} />
</div>
</div>
);
}
+14 -17
View File
@@ -6,6 +6,7 @@ import { useQuery } from "@tanstack/react-query";
import { api, Workflow } from "@/lib/api";
import { Button, Modal } from "@/components/ui";
import { ScheduleCard } from "./ScheduleCard";
import { DualListBox } from "./DualListBox";
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";
@@ -24,8 +25,6 @@ export function EditWorkflowModal({ open, workflow, onSaved, onClose }: { open:
}
}, [open, workflow]);
const toggle = (id: string) => setTargets((t) => (t.includes(id) ? t.filter((x) => x !== id) : [...t, id]));
const save = async () => {
setBusy(true);
setError(null);
@@ -63,21 +62,19 @@ export function EditWorkflowModal({ open, workflow, onSaved, onClose }: { open:
</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>
{servers && servers.length === 0 ? (
<p className="text-xs text-text-secondary">No servers registered.</p>
) : (
<DualListBox
items={(servers ?? []).map((s) => ({ id: s.server_id, label: s.hostname, hint: s.status === "active" ? undefined : s.status }))}
selected={targets}
onChange={setTargets}
selectedLabel="Targets"
emptyAvailable="Every server is a target."
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>
</div>
{/* The schedule saves through its own endpoint, so it sits above
the footer rather than under it — the footer's Save covers the