feat: filter the fleet by tag and target workflows by tag selector
This commit is contained in:
@@ -22,7 +22,7 @@ function AssignModal({
|
||||
|
||||
const { data: servers } = useQuery({
|
||||
queryKey: ["servers"],
|
||||
queryFn: api.listServers,
|
||||
queryFn: () => api.listServers(),
|
||||
});
|
||||
|
||||
const { mutate: assign, isPending, error } = useMutation({
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { api, Server } from "@/lib/api";
|
||||
import { Button, Card } from "@/components/ui";
|
||||
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
|
||||
import { TagChips } from "@/components/servers/TagChips";
|
||||
import { TagFilterBar } from "@/components/servers/TagFilterBar";
|
||||
|
||||
|
||||
type DotStatus = "offline" | "needs-update" | "has-package-updates" | "ok";
|
||||
@@ -56,10 +60,31 @@ function formatLastSeen(dateStr: string): string {
|
||||
return `${diffDay}d ago`;
|
||||
}
|
||||
|
||||
export default function ServersPage() {
|
||||
// useSearchParams opts this page into client-side bailout, which the App
|
||||
// Router only permits inside a Suspense boundary — hence the wrapper at the
|
||||
// bottom of this file rather than a bare default export.
|
||||
function ServersPageBody() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
// The filter lives in the URL so a filtered fleet view is a shareable link.
|
||||
const selected = Object.fromEntries(
|
||||
searchParams
|
||||
.getAll("tag")
|
||||
.map((t) => t.split(":"))
|
||||
.filter((p) => p.length === 2),
|
||||
) as Record<string, string>;
|
||||
|
||||
function setSelected(next: Record<string, string>) {
|
||||
const qs = Object.entries(next)
|
||||
.map(([k, v]) => `tag=${encodeURIComponent(`${k}:${v}`)}`)
|
||||
.join("&");
|
||||
router.replace(qs ? `/servers?${qs}` : "/servers");
|
||||
}
|
||||
|
||||
const { data: servers, isLoading, error } = useQuery({
|
||||
queryKey: ["servers"],
|
||||
queryFn: api.listServers,
|
||||
queryKey: ["servers", selected],
|
||||
queryFn: () => api.listServers(selected),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
@@ -89,6 +114,8 @@ export default function ServersPage() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<TagFilterBar value={selected} onChange={setSelected} />
|
||||
|
||||
<Card padding={false}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
@@ -105,6 +132,7 @@ export default function ServersPage() {
|
||||
<Th>Hostname</Th>
|
||||
<Th>IP Address</Th>
|
||||
<Th>OS</Th>
|
||||
<Th>Tags</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>Last Seen</Th>
|
||||
<Th />
|
||||
@@ -126,6 +154,9 @@ export default function ServersPage() {
|
||||
<Td label="OS">
|
||||
<span className="text-text-secondary">{server.os_info}</span>
|
||||
</Td>
|
||||
<Td label="Tags">
|
||||
<TagChips serverId={server.server_id} tags={server.tags} />
|
||||
</Td>
|
||||
<Td label="Status">
|
||||
<StatusDot status={resolveStatus(server, latestVersion)} />
|
||||
</Td>
|
||||
@@ -166,3 +197,17 @@ export default function ServersPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ServersPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center p-20">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ServersPageBody />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ function snapshotOf(w: Workflow): string {
|
||||
return JSON.stringify({
|
||||
name: w.name,
|
||||
target_server_ids: w.target_server_ids,
|
||||
target_tags: w.target_tags ?? {},
|
||||
steps: w.steps,
|
||||
});
|
||||
}
|
||||
@@ -65,12 +66,21 @@ 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],
|
||||
queryFn: () => api.getWorkflow(id),
|
||||
});
|
||||
const { data: library } = useQuery({ queryKey: ["steps"], queryFn: api.listSteps });
|
||||
// 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,
|
||||
@@ -81,6 +91,10 @@ export default function WorkflowBuilder() {
|
||||
setWf(loaded);
|
||||
savedSnapshotRef.current = snapshotOf(loaded);
|
||||
}
|
||||
if (loaded && !tagRowsSeeded.current) {
|
||||
tagRowsSeeded.current = true;
|
||||
setTagRows(Object.entries(loaded.target_tags ?? {}));
|
||||
}
|
||||
|
||||
}, [loaded]);
|
||||
|
||||
@@ -135,6 +149,30 @@ export default function WorkflowBuilder() {
|
||||
|
||||
const libById = (sid?: string) => (sid ? library?.find((l) => l.step_id === sid) : undefined);
|
||||
|
||||
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
|
||||
* only exists so the designer can answer "how many servers?" without a
|
||||
* round trip. It must stay identical in meaning — an EMPTY selector matches
|
||||
* 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 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;
|
||||
@@ -310,7 +348,7 @@ export default function WorkflowBuilder() {
|
||||
<span className="text-text-secondary">· {saving ? "Saving…" : lastSaved ? `Saved ${timeAgo(lastSaved)}` : ""}</span>
|
||||
</div>
|
||||
<div className="ml-auto flex flex-wrap 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>
|
||||
<span className="rounded-full border border-border bg-surface-2 px-3 py-1 text-xs text-text-secondary">{matched.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>
|
||||
@@ -338,6 +376,72 @@ export default function WorkflowBuilder() {
|
||||
</button>
|
||||
</div>
|
||||
<div className="mx-auto flex w-full max-w-[340px] flex-col items-center">
|
||||
<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>
|
||||
|
||||
<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 && (
|
||||
<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>
|
||||
{matched.length === 0 && <p className="text-xs text-danger">This workflow matches no servers and cannot run.</p>}
|
||||
</div>
|
||||
<DropZone pos={0} />
|
||||
{sortedSteps.map((ref, i) => {
|
||||
const lib = libById(ref.step_id);
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
export function TagFilterBar({ value, onChange }: { value: Record<string, string>; onChange: (v: Record<string, string>) => void }) {
|
||||
const { data: known } = useQuery({ queryKey: ["server-tags"], queryFn: () => api.listKnownTags(), staleTime: 60_000 });
|
||||
|
||||
const keys = Object.keys(known ?? {}).sort();
|
||||
if (keys.length === 0) return null;
|
||||
|
||||
const active = Object.entries(value);
|
||||
|
||||
return (
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
{keys.map((k) => (
|
||||
<select
|
||||
key={k}
|
||||
value={value[k] ?? ""}
|
||||
onChange={(e) => {
|
||||
const next = { ...value };
|
||||
if (e.target.value) next[k] = e.target.value;
|
||||
else delete next[k];
|
||||
onChange(next);
|
||||
}}
|
||||
className="rounded-lg border border-border bg-surface-2 px-2 py-1 font-mono text-xs text-text-primary focus:border-accent/50 focus:outline-none"
|
||||
>
|
||||
<option value="">{k}: any</option>
|
||||
{(known?.[k] ?? []).map((v) => (
|
||||
<option key={v} value={v}>{`${k}: ${v}`}</option>
|
||||
))}
|
||||
</select>
|
||||
))}
|
||||
{active.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange({})}
|
||||
className="text-xs text-text-secondary hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
Clear {active.length} {active.length === 1 ? "filter" : "filters"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export function EditWorkflowModal({ open, workflow, onSaved, onClose }: { open:
|
||||
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 { data: servers } = useQuery({ queryKey: ["servers"], queryFn: () => api.listServers() });
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
|
||||
Reference in New Issue
Block a user