fix(fleet): scope Stop-by-label to stack labels with a typed suggestion source (#1368)

* fix(fleet): scope Stop-by-label to stack labels with a typed suggestion source

The Fleet Actions "Stop by label" card labelled its target field generically
as "Label", so a same-named node label could look like a valid stop target in
a destructive workflow. The action has always matched stack labels only, but
nothing in the copy or the data flow made that explicit.

Add a stack-label-only suggestions endpoint and make the scope unmistakable:

- New GET /api/fleet/labels/suggestions aggregates the per-node stack labels
  into a name-keyed list with stack and node counts (admin-only, central DB,
  covers every configured node including offline remotes). Node labels are
  never folded in.
- The card now sources its autocomplete from that endpoint and renders each
  suggestion with its stack and node counts via a typed FleetStopLabelSuggestion
  model, so node-label data cannot be fed into this destructive card.
- Copy is explicit throughout: "Stack label" target field with a helper line
  that node labels are not used, a clear "0 matching stacks" readout and a
  "No stacks are assigned to this stack label" empty preview, and confirm and
  result copy that references stacks and the stack label.
- Docs updated (fleet-actions, stack-labels) and tests added on both sides,
  including node-only exclusion, name collision, multi-node counts, the
  zero-stack preview, and the non-fatal suggestions-load path.

* docs: correct stale Stop-by-label button and helper references

The Stop-by-label walkthrough referenced a "Stop matching stacks" button and a
warning callout that no longer exist on the card. Align the docs with the live
card: the primary action is "Stop fleet", and the scope is stated by the helper
line under the input.
This commit is contained in:
Anso
2026-06-12 22:14:04 -04:00
committed by GitHub
parent ef5a3f00a7
commit 4610a433e6
7 changed files with 321 additions and 80 deletions
@@ -24,7 +24,7 @@ export function FleetActionsTab({ nodes }: Props) {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-[18px] auto-rows-fr">
<FleetPruneCard nodes={nodes} />
<BulkLabelAssignCard nodes={nodes} />
<LabelFleetStopCard nodes={nodes} />
<LabelFleetStopCard />
</div>
);
}
@@ -4,6 +4,10 @@
* Locks the destructive-action contract: buttons gated on a label name, the
* confirm modal gates the real stop, dry run bypasses the modal, per-node
* results render, and every failure path surfaces a toast (no silent failure).
*
* Also locks the stack-label scope: suggestions come from the fleet
* stack-label endpoint (never node labels), a node-only name produces a clear
* zero-stack preview, and a non-ok suggestions response is non-fatal.
*/
import { it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, within } from '@testing-library/react';
@@ -31,7 +35,6 @@ vi.mock('@/components/ui/toast-store', () => ({
import { apiFetch, fetchForNode } from '@/lib/api';
import { LabelFleetStopCard } from './LabelFleetStopCard';
import type { FleetNode } from '@/components/FleetView/types';
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
const mockedFetchForNode = fetchForNode as unknown as ReturnType<typeof vi.fn>;
@@ -40,33 +43,127 @@ function jsonResponse(status: number, body: unknown): Response {
return { ok: status >= 200 && status < 300, status, json: async () => body } as unknown as Response;
}
const nodes = [
{ id: 1, name: 'central', status: 'online' },
{ id: 2, name: 'edge-1', status: 'online' },
] as unknown as FleetNode[];
beforeEach(() => {
vi.clearAllMocks();
// Suggestion load on mount: no labels.
mockedFetchForNode.mockResolvedValue(jsonResponse(200, []));
// Default: preview unavailable so the debounce effect never throws.
// Default: every endpoint 404s so the suggestions load lands empty and the
// debounced preview never throws. Individual tests override per URL.
mockedFetch.mockResolvedValue(jsonResponse(404, {}));
});
it('disables both actions until a label name is entered', () => {
render(<LabelFleetStopCard nodes={nodes} />);
render(<LabelFleetStopCard />);
expect(screen.getByRole('button', { name: 'Stop fleet' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Dry run' })).toBeDisabled();
});
it('enables the actions once a label is typed', async () => {
const user = userEvent.setup();
render(<LabelFleetStopCard nodes={nodes} />);
render(<LabelFleetStopCard />);
await user.type(screen.getByPlaceholderText('e.g. production'), 'prod');
expect(screen.getByRole('button', { name: 'Stop fleet' })).toBeEnabled();
expect(screen.getByRole('button', { name: 'Dry run' })).toBeEnabled();
});
it('labels the target as a stack label and explains node labels are excluded', () => {
render(<LabelFleetStopCard />);
expect(screen.getByText('Stack label · target')).toBeInTheDocument();
expect(screen.getByText(/Node labels are not used by this action/i)).toBeInTheDocument();
});
it('sources suggestions from the fleet stack-label endpoint, not from node labels', async () => {
const user = userEvent.setup();
mockedFetch.mockImplementation((url: string) => {
if (url === '/fleet/labels/suggestions') {
return Promise.resolve(jsonResponse(200, {
suggestions: [
{ name: 'production', scope: 'stack', nodeCount: 2, stackCount: 3 },
{ name: 'monitoring', scope: 'stack', nodeCount: 1, stackCount: 1 },
],
}));
}
return Promise.resolve(jsonResponse(404, {}));
});
render(<LabelFleetStopCard />);
// Open the popover and confirm stack labels render with their counts.
await user.click(screen.getByPlaceholderText('e.g. production'));
expect(await screen.findByText('production')).toBeInTheDocument();
expect(screen.getByText('3 stacks · 2 nodes')).toBeInTheDocument();
expect(screen.getByText('1 stack · 1 node')).toBeInTheDocument();
// A node-only label name (never returned by the stack-label endpoint) is absent,
// and the card never reaches for the per-node label list.
expect(screen.queryByText('edge')).not.toBeInTheDocument();
expect(mockedFetchForNode).not.toHaveBeenCalled();
});
it('drops malformed suggestion entries that fail the stack-label shape guard', async () => {
const user = userEvent.setup();
mockedFetch.mockImplementation((url: string) => {
if (url === '/fleet/labels/suggestions') {
return Promise.resolve(jsonResponse(200, {
suggestions: [
{ name: 'missing-scope' },
'garbage',
{ name: 'valid', scope: 'stack', nodeCount: 1, stackCount: 1 },
],
}));
}
return Promise.resolve(jsonResponse(404, {}));
});
render(<LabelFleetStopCard />);
await user.click(screen.getByPlaceholderText('e.g. production'));
expect(await screen.findByText('valid')).toBeInTheDocument();
expect(screen.queryByText('missing-scope')).not.toBeInTheDocument();
});
it('populates the input when a suggestion is selected', async () => {
const user = userEvent.setup();
mockedFetch.mockImplementation((url: string) => {
if (url === '/fleet/labels/suggestions') {
return Promise.resolve(jsonResponse(200, {
suggestions: [{ name: 'production', scope: 'stack', nodeCount: 2, stackCount: 3 }],
}));
}
return Promise.resolve(jsonResponse(404, {}));
});
render(<LabelFleetStopCard />);
const input = screen.getByPlaceholderText('e.g. production') as HTMLInputElement;
await user.click(input);
await user.click(await screen.findByRole('button', { name: /production/ }));
expect(input.value).toBe('production');
expect(screen.getByRole('button', { name: 'Stop fleet' })).toBeEnabled();
});
it('shows a zero-stack preview when a node-only name is typed', async () => {
const user = userEvent.setup();
mockedFetch.mockImplementation((url: string) => {
if (url === '/fleet/labels/match-preview') {
return Promise.resolve(jsonResponse(200, { matchedNodes: 0, matchedStacks: 0, perNode: [] }));
}
return Promise.resolve(jsonResponse(404, {}));
});
render(<LabelFleetStopCard />);
await user.type(screen.getByPlaceholderText('e.g. production'), 'edge');
expect(await screen.findByText('No stacks are assigned to this stack label', undefined, { timeout: 2000 })).toBeInTheDocument();
await waitFor(() => expect(screen.getByText('0 matching stacks')).toBeInTheDocument());
});
it('keeps the card usable when the suggestions endpoint returns 403', async () => {
const user = userEvent.setup();
mockedFetch.mockImplementation((url: string) => {
if (url === '/fleet/labels/suggestions') {
return Promise.resolve(jsonResponse(403, { error: 'Admin required' }));
}
return Promise.resolve(jsonResponse(404, {}));
});
render(<LabelFleetStopCard />);
// No crash, no suggestions, and the operator can still type a name by hand.
await user.type(screen.getByPlaceholderText('e.g. production'), 'prod');
expect(screen.getByRole('button', { name: 'Stop fleet' })).toBeEnabled();
});
it('dry run calls fleet-stop with dryRun:true and never opens the confirm modal', async () => {
const user = userEvent.setup();
mockedFetch.mockImplementation((url: string) => {
@@ -77,7 +174,7 @@ it('dry run calls fleet-stop with dryRun:true and never opens the confirm modal'
}
return Promise.resolve(jsonResponse(404, {}));
});
render(<LabelFleetStopCard nodes={nodes} />);
render(<LabelFleetStopCard />);
await user.type(screen.getByPlaceholderText('e.g. production'), 'prod');
await user.click(screen.getByRole('button', { name: 'Dry run' }));
@@ -103,12 +200,12 @@ it('stop fleet opens the confirm modal, and confirming runs a real stop that ren
}
return Promise.resolve(jsonResponse(404, {}));
});
render(<LabelFleetStopCard nodes={nodes} />);
render(<LabelFleetStopCard />);
await user.type(screen.getByPlaceholderText('e.g. production'), 'prod');
await user.click(screen.getByRole('button', { name: 'Stop fleet' }));
const dialog = await screen.findByRole('alertdialog');
expect(within(dialog).getByText('Stop all stacks labeled "prod"?')).toBeInTheDocument();
expect(within(dialog).getByText('Stop all stacks with the stack label "prod"?')).toBeInTheDocument();
// The real stop must not have fired yet.
expect(mockedFetch.mock.calls.find(c => c[0] === '/fleet/labels/fleet-stop')).toBeFalsy();
@@ -131,7 +228,7 @@ it('surfaces an error toast when fleet-stop returns a non-ok response', async ()
}
return Promise.resolve(jsonResponse(404, {}));
});
render(<LabelFleetStopCard nodes={nodes} />);
render(<LabelFleetStopCard />);
await user.type(screen.getByPlaceholderText('e.g. production'), 'prod');
await user.click(screen.getByRole('button', { name: 'Dry run' }));
await waitFor(() => expect(toastError).toHaveBeenCalledWith('boom'));
@@ -145,7 +242,7 @@ it('populates the blast readout from the debounced match-preview', async () => {
}
return Promise.resolve(jsonResponse(404, {}));
});
render(<LabelFleetStopCard nodes={nodes} />);
render(<LabelFleetStopCard />);
await user.type(screen.getByPlaceholderText('e.g. production'), 'prod');
await waitFor(() => expect(screen.getByText('3 stacks · 2 nodes')).toBeInTheDocument(), { timeout: 2000 });
});
@@ -3,11 +3,9 @@ import { ConfirmModal } from '@/components/ui/modal';
import { Input } from '@/components/ui/input';
import { FleetActionCard } from '@/components/ui/fleet-action-card';
import { SheetSection } from '@/components/ui/system-sheet';
import { apiFetch, fetchForNode } from '@/lib/api';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { cn } from '@/lib/utils';
import type { FleetNode } from '@/components/FleetView/types';
import type { Label } from '@/components/label-types';
import { ResultsList, type ResultRow } from '../ResultsList';
interface NodeStackResult { stackName: string; success: boolean; error?: string; dryRun?: boolean }
@@ -18,6 +16,11 @@ interface FleetStopNodeResult {
stackResults: NodeStackResult[];
}
// Stop-by-label targets stack labels only. The `scope: 'stack'` tag keeps node
// labels (a separate namespace) from ever being fed into this destructive card,
// and the counts make the stack scope tangible in the picker.
interface FleetStopLabelSuggestion { name: string; scope: 'stack'; nodeCount: number; stackCount: number }
interface MatchPreviewNode { nodeId: number; nodeName: string; stackCount: number; stackNames: string[] }
interface MatchPreviewResponse { matchedNodes: number; matchedStacks: number; perNode: MatchPreviewNode[] }
@@ -27,42 +30,45 @@ type PreviewState =
| { kind: 'unavailable' }
| { kind: 'ready'; data: MatchPreviewResponse };
interface Props {
nodes: FleetNode[];
}
const KICKER = 'font-mono text-[10px] uppercase tracking-[0.18em]';
const PREVIEW_ROW_LIMIT = 6;
export function LabelFleetStopCard({ nodes }: Props) {
function isSuggestion(value: unknown): value is FleetStopLabelSuggestion {
if (typeof value !== 'object' || value === null) return false;
const s = value as Record<string, unknown>;
return typeof s.name === 'string' && s.scope === 'stack'
&& typeof s.nodeCount === 'number' && typeof s.stackCount === 'number';
}
export function LabelFleetStopCard() {
const [labelName, setLabelName] = useState('');
const [knownLabelNames, setKnownLabelNames] = useState<string[]>([]);
const [suggestions, setSuggestions] = useState<FleetStopLabelSuggestion[]>([]);
const [confirmOpen, setConfirmOpen] = useState(false);
const [running, setRunning] = useState(false);
const [results, setResults] = useState<ResultRow[]>([]);
const [preview, setPreview] = useState<PreviewState>({ kind: 'idle' });
// Aggregate label names across reachable nodes for autocomplete.
// Stack-label suggestions for the target picker. The fleet endpoint aggregates
// the stack_labels rows across every configured node (central DB), so node
// labels can never appear here. A non-ok or malformed response leaves the list
// empty rather than crashing: the Actions tab renders without an admin gate
// while the endpoint is admin-only, so a viewer simply gets no suggestions and
// can still type a name by hand.
useEffect(() => {
let cancelled = false;
async function loadSuggestions() {
const names = new Set<string>();
const reachable = nodes.filter(n => n.status === 'online');
await Promise.all(reachable.map(async (node) => {
try {
const res = await fetchForNode('/labels', node.id);
if (!res.ok) return;
const list = (await res.json()) as Label[];
for (const l of list) names.add(l.name);
} catch {
/* unreachable node, not user-facing */
}
}));
if (!cancelled) setKnownLabelNames(Array.from(names).sort());
try {
const res = await apiFetch('/fleet/labels/suggestions');
const data = res.ok ? await res.json().catch(() => null) : null;
const list = Array.isArray(data?.suggestions) ? data.suggestions.filter(isSuggestion) : [];
if (!cancelled) setSuggestions(list);
} catch {
if (!cancelled) setSuggestions([]);
}
}
loadSuggestions();
return () => { cancelled = true; };
}, [nodes]);
}, []);
// Debounced live preview. The blast-radius readout and the preview section
// both read from the same state.
@@ -107,7 +113,7 @@ export function LabelFleetStopCard({ nodes }: Props) {
if (preview.kind === 'unavailable') return 'preview unavailable';
if (preview.kind === 'ready') {
const { matchedNodes, matchedStacks } = preview.data;
if (matchedStacks === 0 || matchedNodes === 0) return '0 nodes match';
if (matchedStacks === 0 || matchedNodes === 0) return '0 matching stacks';
return `${matchedStacks} stacks · ${matchedNodes} nodes`;
}
return 'awaiting target';
@@ -119,7 +125,7 @@ export function LabelFleetStopCard({ nodes }: Props) {
const trimmed = labelName.trim();
if (!trimmed) return;
const verb = opts.dryRun ? 'Dry-running' : 'Stopping';
const toastId = toast.loading(`${verb} stacks labeled "${trimmed}" across the fleet…`);
const toastId = toast.loading(`${verb} stacks with the stack label "${trimmed}" across the fleet…`);
setRunning(true);
setResults([]);
try {
@@ -138,9 +144,9 @@ export function LabelFleetStopCard({ nodes }: Props) {
key: `node-${node.nodeId}`,
label: node.matched
? `${node.nodeName} · ${node.stackResults.length} stack${node.stackResults.length === 1 ? '' : 's'}${opts.dryRun ? ' (dry run)' : ''}`
: `${node.nodeName} (no matching label)`,
: `${node.nodeName} (no matching stack label)`,
success: node.matched && node.stackResults.every(s => s.success),
error: node.matched ? undefined : 'Label not present',
error: node.matched ? undefined : 'Stack label not present',
sub: node.stackResults.map((s, i) => ({
key: `${node.nodeId}-${s.stackName}-${i}`,
label: s.stackName,
@@ -153,10 +159,10 @@ export function LabelFleetStopCard({ nodes }: Props) {
const stacksTouched = apiResults.flatMap(n => n.stackResults);
const ok = stacksTouched.filter(s => s.success).length;
const failed = stacksTouched.length - ok;
if (matchedNodes === 0) toast.info('No nodes have a label by that name.');
if (matchedNodes === 0) toast.info('No node carries a stack label by that name.');
else if (opts.dryRun) toast.success(`Dry run: would stop ${ok} stack${ok === 1 ? '' : 's'} across ${matchedNodes} node${matchedNodes === 1 ? '' : 's'}.`);
else if (failed === 0 && ok > 0) toast.success(`Stopped ${ok} stack${ok === 1 ? '' : 's'} across ${matchedNodes} node${matchedNodes === 1 ? '' : 's'}.`);
else if (ok === 0 && failed === 0) toast.info('Label matched but no stacks were assigned to it.');
else if (ok === 0 && failed === 0) toast.info('Stack label matched but no stacks were assigned to it.');
else toast.warning(`${ok} stopped, ${failed} failed. See results below.`);
} catch (err) {
toast.dismiss(toastId);
@@ -198,13 +204,16 @@ export function LabelFleetStopCard({ nodes }: Props) {
footerContext={footerContext}
>
<SheetSection
title="Label · target"
meta={`auto-suggested · ${knownLabelNames.length} known`}
title="Stack label · target"
meta={`stack labels · ${suggestions.length}`}
>
<p className={cn(KICKER, 'text-stat-subtitle mb-2 normal-case tracking-normal text-[11px]')}>
Stops stacks assigned to this stack label across matching nodes. Node labels are not used by this action.
</p>
<LabelAutocomplete
value={labelName}
onChange={setLabelName}
suggestions={knownLabelNames}
suggestions={suggestions}
disabled={running}
placeholder="e.g. production"
/>
@@ -224,8 +233,8 @@ export function LabelFleetStopCard({ nodes }: Props) {
onOpenChange={(open) => { if (!open) setConfirmOpen(false); }}
variant="destructive"
kicker="Fleet stop"
title={`Stop all stacks labeled "${trimmed}"?`}
description="Sencho will stop every stack on every node that has a label with this name. Services will be unavailable until restarted."
title={`Stop all stacks with the stack label "${trimmed}"?`}
description="Sencho will stop every stack assigned this stack label on every node. Node labels are not used by this action. Services will be unavailable until restarted."
confirmLabel="Stop fleet"
confirming={running}
onConfirm={() => run({ dryRun: false })}
@@ -239,7 +248,7 @@ function renderPreviewSection(preview: PreviewState, trimmed: string) {
if (preview.kind === 'loading') {
return (
<SheetSection title="Preview" meta="resolving…">
<div className={cn(KICKER, 'text-stat-icon')}>looking up label across the fleet</div>
<div className={cn(KICKER, 'text-stat-icon')}>looking up stack label across the fleet</div>
</SheetSection>
);
}
@@ -255,7 +264,7 @@ function renderPreviewSection(preview: PreviewState, trimmed: string) {
if (matchedStacks === 0) {
return (
<SheetSection title="Preview" meta="0 stacks">
<div className={cn(KICKER, 'text-stat-icon')}>no node has a label by that name</div>
<div className={cn(KICKER, 'text-stat-icon')}>No stacks are assigned to this stack label</div>
</SheetSection>
);
}
@@ -307,16 +316,17 @@ function PreviewWell({ perNode }: PreviewWellProps) {
interface LabelAutocompleteProps {
value: string;
onChange: (next: string) => void;
suggestions: string[];
suggestions: FleetStopLabelSuggestion[];
disabled?: boolean;
placeholder?: string;
}
// Free-form text input with a Sencho-styled suggestion popover. Replaces the
// browser-native <datalist> so the dropdown matches the rest of the kit (same
// surface tokens as <Combobox>). The operator can still type a label name
// that was not in the suggestions list; the server-side match-preview will
// resolve it or report 0 nodes match.
// surface tokens as <Combobox>). Each suggestion is a stack label and carries
// its stack/node counts so the scope is unmistakable. The operator can still
// type a name that was not suggested; the server-side match-preview resolves it
// or reports 0 matching stacks.
function LabelAutocomplete({ value, onChange, suggestions, disabled, placeholder }: LabelAutocompleteProps) {
const [open, setOpen] = useState(false);
const wrapperRef = useRef<HTMLDivElement>(null);
@@ -324,7 +334,7 @@ function LabelAutocomplete({ value, onChange, suggestions, disabled, placeholder
const filtered = useMemo(() => {
const q = value.trim().toLowerCase();
if (q.length === 0) return suggestions;
return suggestions.filter(s => s.toLowerCase().includes(q));
return suggestions.filter(s => s.name.toLowerCase().includes(q));
}, [value, suggestions]);
useEffect(() => {
@@ -373,15 +383,18 @@ function LabelAutocomplete({ value, onChange, suggestions, disabled, placeholder
<div className="absolute left-0 top-full mt-1 z-50 w-full rounded-md border border-glass-border bg-popover text-popover-foreground shadow-md backdrop-blur-[10px] backdrop-saturate-[1.15]">
<ul className="max-h-[200px] overflow-y-auto overflow-x-hidden p-1">
{filtered.map((s) => (
<li key={s}>
<li key={s.name}>
<button
type="button"
// mousedown + preventDefault keeps the input focused so the
// selection registers before any blur-driven close fires.
onMouseDown={(e) => { e.preventDefault(); handleSelect(s); }}
className="flex w-full items-center rounded-sm px-2 py-1.5 font-mono text-xs text-stat-value hover:bg-accent hover:text-accent-foreground"
onMouseDown={(e) => { e.preventDefault(); handleSelect(s.name); }}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 font-mono text-xs text-stat-value hover:bg-accent hover:text-accent-foreground"
>
{s}
<span className="flex-1 min-w-0 truncate text-left">{s.name}</span>
<span className="shrink-0 text-[10px] text-stat-subtitle">
{s.stackCount} stack{s.stackCount === 1 ? '' : 's'} · {s.nodeCount} node{s.nodeCount === 1 ? '' : 's'}
</span>
</button>
</li>
))}