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