diff --git a/web/app/(app)/keys/[id]/page.tsx b/web/app/(app)/keys/[id]/page.tsx index 0e68f12..4bea932 100644 --- a/web/app/(app)/keys/[id]/page.tsx +++ b/web/app/(app)/keys/[id]/page.tsx @@ -22,7 +22,7 @@ function AssignModal({ const { data: servers } = useQuery({ queryKey: ["servers"], - queryFn: api.listServers, + queryFn: () => api.listServers(), }); const { mutate: assign, isPending, error } = useMutation({ diff --git a/web/app/(app)/servers/page.tsx b/web/app/(app)/servers/page.tsx index ab842e1..3473d41 100644 --- a/web/app/(app)/servers/page.tsx +++ b/web/app/(app)/servers/page.tsx @@ -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; + + function setSelected(next: Record) { + 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() { + + {isLoading ? (
@@ -105,6 +132,7 @@ export default function ServersPage() { Hostname IP Address OS + Tags Status Last Seen @@ -126,6 +154,9 @@ export default function ServersPage() { {server.os_info} + + + @@ -166,3 +197,17 @@ export default function ServersPage() {
); } + +export default function ServersPage() { + return ( + +
+
+ } + > + +
+ ); +} diff --git a/web/app/(app)/workflows/[id]/page.tsx b/web/app/(app)/workflows/[id]/page.tsx index 7bf7309..c2a6888 100644 --- a/web/app/(app)/workflows/[id]/page.tsx +++ b/web/app/(app)/workflows/[id]/page.tsx @@ -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(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() { · {saving ? "Saving…" : lastSaved ? `Saved ${timeAgo(lastSaved)}` : ""}
- {wf.target_server_ids.length} servers + {matched.length} servers Runs @@ -338,6 +376,72 @@ export default function WorkflowBuilder() {
+
+
Targets
+ +

+ {wf.target_server_ids.length} named in , plus anything matching every tag below. +

+ + + {Object.keys(knownTags ?? {}).map((k) => ( + + + {Object.values(knownTags ?? {}) + .flat() + .map((v) => ( + + +
+ {tagRows.map(([k, v], i) => ( +
+ 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 && ( + + )} + +

s.hostname).join("\n")}> + Runs on {matched.length} {matched.length === 1 ? "server" : "servers"} +

+ {matched.length === 0 &&

This workflow matches no servers and cannot run.

} +
{sortedSteps.map((ref, i) => { const lib = libById(ref.step_id); diff --git a/web/components/servers/TagFilterBar.tsx b/web/components/servers/TagFilterBar.tsx new file mode 100644 index 0000000..60523f4 --- /dev/null +++ b/web/components/servers/TagFilterBar.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { api } from "@/lib/api"; + +export function TagFilterBar({ value, onChange }: { value: Record; onChange: (v: Record) => 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 ( +
+ {keys.map((k) => ( + + ))} + {active.length > 0 && ( + + )} +
+ ); +} diff --git a/web/components/workflows/EditWorkflowModal.tsx b/web/components/workflows/EditWorkflowModal.tsx index 3dbb5e0..9a8443a 100644 --- a/web/components/workflows/EditWorkflowModal.tsx +++ b/web/components/workflows/EditWorkflowModal.tsx @@ -15,7 +15,7 @@ export function EditWorkflowModal({ open, workflow, onSaved, onClose }: { open: const [targets, setTargets] = useState(workflow.target_server_ids); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); - const { data: servers } = useQuery({ queryKey: ["servers"], queryFn: api.listServers }); + const { data: servers } = useQuery({ queryKey: ["servers"], queryFn: () => api.listServers() }); useEffect(() => { if (open) {