fix: count tag-matched servers in the workflows list

This commit is contained in:
2026-08-04 17:28:01 +01:00
parent 3388d2f895
commit 50a9ac5fdc
4 changed files with 63 additions and 17 deletions
+8 -5
View File
@@ -190,11 +190,14 @@ 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.
`web/lib/targets.ts` **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. It is a shared module rather than inline in a component because
the logic had already been written twice, and the second copy — the workflows
list — counted `target_server_ids` alone, so a **tag-only workflow reported zero
targets** while running fine.
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,
+2 -9
View File
@@ -8,6 +8,7 @@ import { api, Workflow, WorkflowStep, WorkflowStepRef, SecretGroupSummary } from
import { Button } from "@/components/ui";
import { EditWorkflowModal } from "@/components/workflows/EditWorkflowModal";
import { StepPickerModal } from "@/components/workflows/StepPickerModal";
import { resolveTargets } from "@/lib/targets";
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";
@@ -125,15 +126,7 @@ export default function WorkflowBuilder() {
const targetTags = wf.target_tags ?? {};
/*
* 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 matched = resolveTargets(servers ?? [], wf.target_server_ids, targetTags);
const sortedSteps = [...wf.steps].sort((a, b) => a.order - b.order);
const selectedRef = selected !== null ? sortedSteps[selected] : null;
+25 -3
View File
@@ -7,6 +7,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api, Workflow } from "@/lib/api";
import { Button, Card } from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
import { resolveTargets } from "@/lib/targets";
export default function WorkflowsPage() {
const qc = useQueryClient();
@@ -22,6 +23,11 @@ export default function WorkflowsPage() {
queryFn: api.listWorkflows,
});
// The fleet, so a tag-only workflow reports the servers it actually reaches.
// target_server_ids.length alone reads 0 for one, which is the count of the
// half of the selector it does not use.
const { data: servers } = useQuery({ queryKey: ["servers"], queryFn: () => api.listServers() });
const { mutate: create, isPending } = useMutation({
mutationFn: () => api.createWorkflow({ name: "Untitled workflow", target_server_ids: [], steps: [] }),
onSuccess: (workflow) => {
@@ -75,9 +81,25 @@ export default function WorkflowsPage() {
<span className="font-medium text-text-primary">{w.name}</span>
</Td>
<Td label="Targets">
<span className="text-text-secondary">
{w.target_server_ids.length} server{w.target_server_ids.length !== 1 ? "s" : ""}
</span>
{servers ? (
(() => {
const matched = resolveTargets(servers, w.target_server_ids, w.target_tags ?? {});
return (
<span className="flex flex-wrap items-center gap-1.5">
<span className={matched.length === 0 ? "text-danger" : "text-text-secondary"} title={matched.map((s) => s.hostname).join("\n")}>
{matched.length} server{matched.length !== 1 ? "s" : ""}
</span>
{Object.entries(w.target_tags ?? {}).map(([k, v]) => (
<span key={k} className="rounded-sm border border-border px-1.5 py-0.5 font-mono text-[10px] text-text-tertiary">
{k}:{v}
</span>
))}
</span>
);
})()
) : (
<span className="text-text-tertiary"></span>
)}
</Td>
<Td label="Steps">
<span className="text-text-secondary">{w.steps.length}</span>
+28
View File
@@ -0,0 +1,28 @@
import { Server } from "@/lib/api";
/*
* The browser's copy of services.ResolveTargets.
*
* This is a deliberate duplication: the authority is UnionTargets/MatchesTags in
* server/internal/services/targets.go, and this exists only so a screen can
* answer "how many servers?" without a round trip, since the browser already
* holds the fleet. It must stay identical in MEANING to the Go version and
* change in the same commit as it.
*
* It lives here rather than in a component because it had already been written
* twice — once in the designer and once, wrongly, on the workflows list, where
* the count ignored tags entirely and a tag-only workflow reported zero targets.
*/
/** True when srv carries every pair in sel. Tag keys AND together. */
export function matchesTags(srv: Server, sel: Record<string, string>): boolean {
// An EMPTY selector matches NOTHING, not everything. The alternative turns a
// cleared field in the editor into a fleet-wide run.
if (Object.keys(sel).length === 0) return false;
return Object.entries(sel).every(([k, v]) => srv.tags?.[k] === v);
}
/** The distinct union of the named servers and the tag matches, in fleet order. */
export function resolveTargets(servers: Server[], ids: string[], sel: Record<string, string>): Server[] {
return servers.filter((s) => ids.includes(s.server_id) || matchesTags(s, sel));
}