mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-17 14:08:19 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user