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
@@ -86,6 +86,9 @@ beforeEach(() => {
activeBulkActions.clear();
db.getDb().prepare('DELETE FROM stack_label_assignments').run();
db.getDb().prepare('DELETE FROM stack_labels').run();
// Suggestion tests seed node labels to prove they are excluded; clear them so
// those rows do not leak into later assertions.
db.getDb().prepare('DELETE FROM node_labels').run();
});
async function createAssignedLabel(name: string, stacks: string[]) {
@@ -163,6 +166,102 @@ describe('POST /api/fleet/labels/match-preview', () => {
});
});
describe('GET /api/fleet/labels/suggestions', () => {
it('returns 401 without auth', async () => {
const res = await request(app).get('/api/fleet/labels/suggestions');
expect(res.status).toBe(401);
});
it('returns 403 for a non-admin (viewer) user', async () => {
const viewerName = `viewer-sugg-${++labelCounter}`;
db.addUser({ username: viewerName, password_hash: 'x', role: 'viewer' });
const viewerAuth = `Bearer ${jwt.sign({ username: viewerName }, TEST_JWT_SECRET, { expiresIn: '1m' })}`;
const res = await request(app)
.get('/api/fleet/labels/suggestions')
.set('Authorization', viewerAuth);
expect(res.status).toBe(403);
});
it('is reachable on community tier for admins (no PAID_REQUIRED)', async () => {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
const res = await request(app)
.get('/api/fleet/labels/suggestions')
.set('Authorization', authHeader);
expect(res.status).toBe(200);
expect(res.body.code).not.toBe('PAID_REQUIRED');
expect(Array.isArray(res.body.suggestions)).toBe(true);
});
it('aggregates stack labels across nodes, includes unassigned ones, and excludes node labels', async () => {
const localId = db.getNodes().find(n => n.is_default)!.id;
const remoteId = db.addNode({
name: 'sugg-remote', type: 'remote', api_url: 'http://sugg.example:1852',
api_token: 'tok', compose_dir: '/app/compose', is_default: false,
});
try {
// Same-named stack label on both nodes, each with one assigned stack.
const local = db.createLabel(localId, 'shared-prod', 'teal');
db.setStackLabels('alpha', localId, [local.id]);
const remote = db.createLabel(remoteId, 'shared-prod', 'teal');
db.setStackLabels('beta', remoteId, [remote.id]);
// A stack label with no assignments still belongs to the picker.
db.createLabel(localId, 'unused-stack-label', 'sky');
// A node-only label that must never surface as a stop target.
db.addNodeLabel(remoteId, 'edge-only');
const res = await request(app)
.get('/api/fleet/labels/suggestions')
.set('Authorization', authHeader);
expect(res.status).toBe(200);
const names = res.body.suggestions.map((s: { name: string }) => s.name);
expect(names).toContain('shared-prod');
expect(names).toContain('unused-stack-label');
expect(names).not.toContain('edge-only');
// Sorted by name.
expect(names).toEqual([...names].sort((a: string, b: string) => a.localeCompare(b)));
const shared = res.body.suggestions.find((s: { name: string }) => s.name === 'shared-prod');
expect(shared.scope).toBe('stack');
expect(shared.nodeCount).toBe(2);
expect(shared.stackCount).toBe(2);
const unused = res.body.suggestions.find((s: { name: string }) => s.name === 'unused-stack-label');
expect(unused.nodeCount).toBe(1);
expect(unused.stackCount).toBe(0);
} finally {
db.deleteNode(remoteId);
}
});
it('counts only the stack-label side when a node label shares the name', async () => {
const localId = db.getNodes().find(n => n.is_default)!.id;
const remoteId = db.addNode({
name: 'collision-remote', type: 'remote', api_url: 'http://collision.example:1852',
api_token: 'tok', compose_dir: '/app/compose', is_default: false,
});
try {
// A stack label 'prod' lives on the local node only...
const stackLabel = db.createLabel(localId, 'prod', 'teal');
db.setStackLabels('alpha', localId, [stackLabel.id]);
// ...while a node label of the same name lives on the remote node.
db.addNodeLabel(remoteId, 'prod');
const res = await request(app)
.get('/api/fleet/labels/suggestions')
.set('Authorization', authHeader);
expect(res.status).toBe(200);
const prod = res.body.suggestions.find((s: { name: string }) => s.name === 'prod');
// The node label must not inflate the counts: only the local stack label counts.
expect(prod.nodeCount).toBe(1);
expect(prod.stackCount).toBe(1);
} finally {
db.deleteNode(remoteId);
}
});
});
describe('POST /api/fleet/prune/estimate', () => {
it('returns 401 without auth', async () => {
const res = await request(app)
+32
View File
@@ -1570,6 +1570,38 @@ fleetRouter.post('/labels/match-preview', authMiddleware, async (req: Request, r
}
});
// Stack-label suggestions for the Stop-by-label target picker. Aggregates the
// per-node stack-label rows (the `stack_labels` table, via getLabels) into one
// name-keyed list with stack/node counts. This is the only source the card's
// autocomplete consumes; node labels (the separate `/api/node-labels`
// namespace) are deliberately never folded in, because fleet-stop targets stack
// labels only. The `scope: 'stack'` tag and the counts make that explicit at the
// type level and in the UI. Central-DB only, same as match-preview, so it covers
// every configured node including offline remotes.
fleetRouter.get('/labels/suggestions', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
try {
const db = DatabaseService.getInstance();
const agg = new Map<string, { nodeCount: number; stackCount: number }>();
for (const node of db.getNodes()) {
for (const label of db.getLabels(node.id)) {
const stackCount = db.getStacksForLabel(label.id, node.id).length;
const entry = agg.get(label.name) ?? { nodeCount: 0, stackCount: 0 };
entry.nodeCount += 1;
entry.stackCount += stackCount;
agg.set(label.name, entry);
}
}
const suggestions = Array.from(agg.entries())
.map(([name, counts]) => ({ name, scope: 'stack' as const, nodeCount: counts.nodeCount, stackCount: counts.stackCount }))
.sort((a, b) => a.name.localeCompare(b.name));
res.json({ suggestions });
} catch (error) {
console.error('[Fleet] label-suggestions error:', error);
res.status(500).json({ error: getErrorMessage(error, 'Failed to load stack-label suggestions') });
}
});
// Fleet-wide prune size estimate. Local node uses the controller estimate
// helper; remote nodes hit `/api/system/prune/estimate` per target. Same
// fan-out shape as `/labels/fleet-prune` minus the locks (estimation is read
+12 -12
View File
@@ -41,30 +41,30 @@ The two fan-out cards (Stop and Prune) iterate every node in **Settings → Node
## Stop fleet by label
Stop every stack that carries a given label name on every node where that label exists. Labels are matched **by name** across the fleet, so a label called `production` on one node and an independently-authored `production` label on another node both match. See [Stack Labels](/features/stack-labels) for how to author the selector taxonomy.
Stop every stack assigned a given **stack label** on every node where that stack label exists. Stack labels are matched **by name** across the fleet, so a stack label called `production` on one node and an independently-authored `production` label on another node both match. This action targets stack labels only; node labels (used for node grouping) are never used here. See [Stack Labels](/features/stack-labels) for how to author the selector taxonomy.
### Step by step
1. Open **Fleet → Actions**.
2. Type a label name in the **Label name** field. The input autocompletes against label names that already exist on any **online** node; an offline node with an unseen label still receives the request, it just won't show up in the suggestions.
3. Click **Stop matching stacks**.
4. A confirmation appears with the kicker **Fleet stop** and the title `Stop all stacks labeled "<name>"?`. Click **Stop fleet** to commit.
2. Type a name in the **Stack label** field. The picker suggests stack labels from across the fleet, each with its stack and node counts, so the scope is unmistakable. Suggestions are read from the control instance, so stack labels on offline nodes appear too. You can also type a name by hand.
3. Click **Stop fleet**.
4. A confirmation appears with the kicker **Fleet stop** and the title `Stop all stacks with the stack label "<name>"?`. Click **Stop fleet** to commit.
<Frame>
<img src="/images/fleet-actions/fleet-actions-stop-confirm.png" alt="Fleet stop confirmation dialog. Kicker 'Fleet stop' in red mono, italic title 'Stop all stacks labeled &quot;docs-preview&quot;?', Cancel and Stop fleet buttons in the footer." />
<img src="/images/fleet-actions/fleet-actions-stop-confirm.png" alt="Fleet stop confirmation dialog. Kicker 'Fleet stop' in red mono, italic title 'Stop all stacks with the stack label &quot;docs-preview&quot;?', Cancel and Stop fleet buttons in the footer." />
</Frame>
### Reading the per-node breakdown
When the request finishes, the results render below the form, grouped by node. Each node row carries a colored icon and either a stack count or a `(no matching label)` annotation; the indented children below each row are the per-stack results.
When the request finishes, the results render below the form, grouped by node. Each node row carries a colored icon and either a stack count or a `(no matching stack label)` annotation; the indented children below each row are the per-stack results.
<Frame>
<img src="/images/fleet-actions/fleet-actions-stop-results.png" alt="Per-node breakdown after running fleet stop against a label that no node has. The header reads PER-NODE BREAKDOWN with two badges, '0 ok' and '7 failed'. Seven rows follow, one per node (Local, Opsix, Pitt-Moba, SLX-Mars, sencho-pilot-test, sencho-test-01, sencho-test-02), each annotated '(no matching label) · Label not present'." />
<img src="/images/fleet-actions/fleet-actions-stop-results.png" alt="Per-node breakdown after running fleet stop against a label that no node has. The header reads PER-NODE BREAKDOWN with two badges, '0 ok' and '7 failed'. Seven rows follow, one per node (Local, Opsix, Pitt-Moba, SLX-Mars, sencho-pilot-test, sencho-test-01, sencho-test-02), each annotated '(no matching stack label) · Stack label not present'." />
</Frame>
A few quirks worth knowing:
- A node that has no label by that name appears as `<node> (no matching label)` and is counted in the **failed** badge. This is not a transport failure, it just means the label was not present on that node.
- A node that has no stack label by that name appears as `<node> (no matching stack label)` and is counted in the **failed** badge. This is not a transport failure, it just means the stack label was not present on that node.
- A node where the label exists but no stacks are assigned to it appears with a matched count of zero stacks. No per-stack rows render.
- When the control instance reaches a remote node, the per-stack result you see comes from the remote node's own response. If the remote returns a non-2xx for the whole label, every stack on that node renders with the same error message.
@@ -169,11 +169,11 @@ Run **Prune Docker resources fleet-wide** with **Images** selected and **Managed
## Troubleshooting
<AccordionGroup>
<Accordion title="A node shows '(no matching label)' but I created the label there">
The fleet-stop match is by **label name**, not label ID. Confirm the label name on the affected node under **Settings → Labels**; a typo, a case mismatch, or a trailing space will leave the node out. Labels are scoped per node, so renaming the label on one node does not propagate to the others.
<Accordion title="A node shows '(no matching stack label)' but I created the label there">
The fleet-stop match is by **stack label name**, not label ID. Confirm the stack label name on the affected node under **Settings → Labels**; a typo, a case mismatch, or a trailing space will leave the node out. Stack labels are scoped per node, so renaming the label on one node does not propagate to the others.
</Accordion>
<Accordion title="The autocomplete didn't suggest a label I know exists">
The autocomplete fans out to **online** nodes only and aggregates label names from their label list response. A label that exists only on an offline node won't appear in the suggestions. The fleet-stop request still iterates every configured node, so typing the name by hand and submitting will reach the offline node when it returns.
<Accordion title="The autocomplete didn't suggest a stack label I know exists">
The picker reads stack labels from the control instance across every configured node, including offline ones, so a name is missing only when no node has a **stack label** by that name. Node labels never appear here, because this action targets stack labels only. You can always type a name by hand; the fleet-stop request still iterates every configured node.
</Accordion>
<Accordion title="Bulk label assign reports 'Invalid stack name' for one row">
Stack names must match the standard validator: alphanumeric plus dash and underscore, no spaces, no path separators. The endpoint validates each assignment independently, so a single bad name does not block the rest of the batch. Fix the offending entry and re-run; the rows that already succeeded won't be re-applied.
+5 -5
View File
@@ -88,14 +88,14 @@ A stack can carry multiple labels and will then appear under each label's group
Two cards in the **Fleet · Actions** tab use labels to drive cross-node operations. See [Fleet Actions](/features/fleet-actions) for the full reference.
<Frame>
<img src="/images/stack-labels/fleet-actions.png" alt="Fleet Actions tab with two cards side by side. Left card 'Stop fleet by label' (rose accent rail) has a Label name combobox containing 'Media' and a Stop matching stacks button beneath. Right card 'Bulk label assign' (purple accent rail) has a node selector reading Local (local), a stacks checklist showing plex and radarr ticked, a Labels row with a highlighted Media pill plus inactive Network and Utilities, and an Apply to 3 stacks button." />
<img src="/images/stack-labels/fleet-actions.png" alt="Fleet Actions tab with two cards side by side. Left card 'Stop fleet by label' (rose accent rail) has a Stack label combobox containing 'Media' and a Stop fleet button beneath. Right card 'Bulk label assign' (purple accent rail) has a node selector reading Local (local), a stacks checklist showing plex and radarr ticked, a Labels row with a highlighted Media pill plus inactive Network and Utilities, and an Apply to 3 stacks button." />
</Frame>
### Stop fleet by label
Type a label name; Sencho fans the request out to every online node and stops every stack on that node that carries a label with the same name. The card autocompletes the input from the union of label names on every reachable node, so you do not need to remember whose label rows exist where. The result list shows a per-node breakdown with success and failure counts, and the warning callout under the input restates the fleet-wide semantics: `Different nodes can have their own label rows. Stops are dispatched per node and report per-stack results below.` A confirmation modal titled `Stop all stacks labeled "<name>"?` with the **Stop fleet** primary action runs the action.
Type a stack label name; Sencho fans the request out to every node and stops every stack on that node assigned a stack label with the same name. This action targets stack labels only, never node labels. The picker suggests stack labels from across the fleet, read from the control instance so labels on offline nodes appear too, each with its stack and node counts so the scope is unmistakable. The result list shows a per-node breakdown with success and failure counts, and the helper line under the input states the scope: `Stops stacks assigned to this stack label across matching nodes. Node labels are not used by this action.` A confirmation modal titled `Stop all stacks with the stack label "<name>"?` with the **Stop fleet** primary action runs the action.
Offline nodes are skipped during autocomplete loading and are reported as failures during the actual run, so a partial-fleet stop is observable rather than silent.
A node with no stack label by that name is reported as a failure during the run, so a partial-fleet stop is observable rather than silent.
### Bulk label assign
@@ -128,8 +128,8 @@ The **Bulk label assign** card is per-node only by design. To re-tag stacks on a
<Accordion title="The Tags filter does not list a label I just created">
The Tags filter aggregates labels across every node in the fleet by name. If the new label only exists on one node and that node was offline at the moment the page loaded, the dropdown may not include it. Refresh **Fleet · Overview** with the **Refresh** button in the toolbar to repull node state.
</Accordion>
<Accordion title="`Stop fleet by label` reports `No nodes have a label by that name`">
Labels are per-node, so the fleet-stop matches by name across nodes. If the label you typed only exists on the active node and you typed the wrong case (`prod` versus `Prod`), no node will match. The combobox autocompletes from the union of label names on reachable nodes; pick from the suggestion list rather than typing freehand to avoid case mistakes.
<Accordion title="`Stop fleet by label` reports `No node carries a stack label by that name`">
Stack labels are per-node, so the fleet-stop matches by name across nodes. If the stack label you typed only exists on one node and you typed the wrong case (`prod` versus `Prod`), no node will match. The picker suggests stack labels read from the control instance across every configured node, including offline ones; node labels never appear there. Pick from the suggestion list rather than typing freehand to avoid case mistakes.
</Accordion>
<Accordion title="`Bulk label assign` cleared every label on my stacks unexpectedly">
The card replaces, it does not merge. Selecting no labels and clicking **Apply to N stack(s)** is the documented way to clear assignments, and the confirmation copy on the **Bulk label assign** modal restates this: `No labels selected, this will clear existing assignments on the selected stacks.` Re-pick the labels you want and run the action again to restore them.
@@ -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>
))}