fix: count tag-matched servers in the workflows list
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user