feat(fleet): cross-node bulk label assign with authoritative label discovery (#1389)

* feat(fleet): cross-node bulk label assign with authoritative label discovery

Make Fleet Actions > Bulk label assign work across the fleet. Pick a stack
label that exists anywhere in the fleet, select stacks on one or more nodes,
and the control orchestrates: each target node resolves the label by name,
creating it with the same name and color if missing, then adds it to the
selected stacks while preserving their existing labels. The local node runs
in process; each remote runs its own admin-only local-assign receiver over
the node proxy. Per-node failures (unknown node, no proxy target, unreachable,
mixed-version remote) degrade that node only and are reported per node in the
result. Assignment writes use a transactional INSERT OR IGNORE so the
add-preserve path is idempotent and race-free.

Also make the shared fleet label discovery authoritative: suggestions,
match-preview, and the fleet-stop remote leg now read each node's labels live
over the proxy instead of the control database, which does not mirror remote
labels. A propagated label therefore appears in, and is stoppable by,
Stop-by-label across the fleet, and unreachable nodes are surfaced rather than
silently dropped.

Fleet Actions runs against the unfiltered node list, so overview filters no
longer narrow its scope. The previous node-scoped, replace-by-id bulk-assign
endpoint is removed.

* fix(fleet): treat malformed remote label responses as per-node failures

A 200 response from a remote node whose body is not the expected shape was
treated as a benign empty result, so a malformed remote could read as a clean
zero-stack assign or a "matched, nothing to stop" no-op and even surface a
success toast. Validate the wire shape in the bulk-assign and fleet-stop remote
legs and in the authoritative label discovery fan-out; on a malformed body,
report the node as a per-node failure with the error attributed to its stacks
instead of silently dropping it.

* chore: drop accidentally committed temp file
This commit is contained in:
Anso
2026-06-20 11:52:28 -04:00
committed by GitHub
parent cd9247db1e
commit d26ab58189
13 changed files with 1209 additions and 338 deletions
+426 -36
View File
@@ -1,6 +1,9 @@
/**
* Tests for the Fleet Actions tab endpoints. Covers auth, tier gating, input
* validation, and orchestration shape across the two routes.
* validation, and cross-node orchestration shape across the fleet label routes:
* the authoritative discovery reads (suggestions / match-preview / fleet-stop),
* the bulk-assign orchestrator, and the per-node local-stop / local-assign
* receivers.
*/
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
import fs from 'fs';
@@ -28,9 +31,20 @@ function mockTier(tier: 'paid' | 'community') {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue(tier);
}
function makeStack(name: string): void {
const composeDir = process.env.COMPOSE_DIR as string;
fs.mkdirSync(path.join(composeDir, name), { recursive: true });
fs.writeFileSync(path.join(composeDir, name, 'docker-compose.yml'), 'services: {}\n');
}
describe('Fleet Actions endpoints require authentication', () => {
it('POST /api/fleet-actions/labels/bulk-assign returns 401 without auth', async () => {
const res = await request(app).post('/api/fleet-actions/labels/bulk-assign').send({ assignments: [] });
it('POST /api/fleet/labels/bulk-assign returns 401 without auth', async () => {
const res = await request(app).post('/api/fleet/labels/bulk-assign').send({ label: { name: 'x', color: 'teal' }, targets: [] });
expect(res.status).toBe(401);
});
it('POST /api/fleet-actions/labels/local-assign returns 401 without auth', async () => {
const res = await request(app).post('/api/fleet-actions/labels/local-assign').send({ label: { name: 'x', color: 'teal' }, stackNames: [] });
expect(res.status).toBe(401);
});
@@ -54,15 +68,26 @@ describe('Fleet Actions tier gating (Community + admin)', () => {
expect(Array.isArray(res.body.results)).toBe(true);
});
it('POST /api/fleet-actions/labels/bulk-assign is reachable on community tier for admins', async () => {
it('POST /api/fleet/labels/bulk-assign is reachable on community tier for admins', async () => {
mockTier('community');
const res = await request(app)
.post('/api/fleet-actions/labels/bulk-assign')
.post('/api/fleet/labels/bulk-assign')
.set('Authorization', authHeader)
.send({ assignments: [] });
.send({ label: { name: 'community-ok', color: 'teal' }, targets: [{ nodeId: 999999, stackNames: ['nope'] }] });
expect(res.status).toBe(200);
expect(res.body.code).not.toBe('PAID_REQUIRED');
expect(res.body.results).toEqual([]);
expect(Array.isArray(res.body.results)).toBe(true);
});
it('POST /api/fleet-actions/labels/local-assign is reachable on community tier for admins', async () => {
mockTier('community');
const res = await request(app)
.post('/api/fleet-actions/labels/local-assign')
.set('Authorization', authHeader)
.send({ label: { name: 'community-recv', color: 'teal' }, stackNames: [] });
expect(res.status).toBe(200);
expect(res.body.code).not.toBe('PAID_REQUIRED');
expect(res.body).toEqual({ created: expect.any(Boolean), results: [] });
});
});
@@ -86,21 +111,76 @@ describe('Fleet Actions input validation', () => {
expect(res.status).toBe(400);
});
it('POST /api/fleet-actions/labels/bulk-assign rejects non-array assignments', async () => {
it('POST /api/fleet/labels/bulk-assign rejects an invalid label color', async () => {
const res = await request(app)
.post('/api/fleet-actions/labels/bulk-assign')
.post('/api/fleet/labels/bulk-assign')
.set('Authorization', authHeader)
.send({ assignments: 'oops' });
.send({ label: { name: 'media', color: 'not-a-color' }, targets: [{ nodeId: 0, stackNames: ['x'] }] });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/assignments must be an array/);
expect(res.body.error).toMatch(/color/);
});
it('POST /api/fleet-actions/labels/bulk-assign rejects oversized payload', async () => {
const big = Array.from({ length: 1001 }, (_, i) => ({ stackName: `s${i}`, labelIds: [] }));
it('POST /api/fleet/labels/bulk-assign rejects a missing label name', async () => {
const res = await request(app)
.post('/api/fleet-actions/labels/bulk-assign')
.post('/api/fleet/labels/bulk-assign')
.set('Authorization', authHeader)
.send({ assignments: big });
.send({ label: { color: 'teal' }, targets: [{ nodeId: 0, stackNames: ['x'] }] });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/label\.name/);
});
it('POST /api/fleet/labels/bulk-assign rejects empty targets', async () => {
const res = await request(app)
.post('/api/fleet/labels/bulk-assign')
.set('Authorization', authHeader)
.send({ label: { name: 'media', color: 'teal' }, targets: [] });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/targets/);
});
it('POST /api/fleet/labels/bulk-assign rejects a non-integer nodeId', async () => {
const res = await request(app)
.post('/api/fleet/labels/bulk-assign')
.set('Authorization', authHeader)
.send({ label: { name: 'media', color: 'teal' }, targets: [{ nodeId: 'abc', stackNames: ['x'] }] });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/nodeId/);
});
it('POST /api/fleet/labels/bulk-assign rejects when all target groups are empty', async () => {
const res = await request(app)
.post('/api/fleet/labels/bulk-assign')
.set('Authorization', authHeader)
.send({ label: { name: 'media', color: 'teal' }, targets: [{ nodeId: 0, stackNames: [] }] });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/no target stacks/);
});
it('POST /api/fleet/labels/bulk-assign rejects an oversized total', async () => {
const big = Array.from({ length: 1001 }, (_, i) => `s${i}`);
const res = await request(app)
.post('/api/fleet/labels/bulk-assign')
.set('Authorization', authHeader)
.send({ label: { name: 'media', color: 'teal' }, targets: [{ nodeId: 0, stackNames: big }] });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/may not exceed/);
});
it('POST /api/fleet-actions/labels/local-assign rejects non-array stackNames', async () => {
const res = await request(app)
.post('/api/fleet-actions/labels/local-assign')
.set('Authorization', authHeader)
.send({ label: { name: 'media', color: 'teal' }, stackNames: 'oops' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/stackNames must be an array/);
});
it('POST /api/fleet-actions/labels/local-assign rejects an oversized payload', async () => {
const big = Array.from({ length: 1001 }, (_, i) => `s${i}`);
const res = await request(app)
.post('/api/fleet-actions/labels/local-assign')
.set('Authorization', authHeader)
.send({ label: { name: 'media', color: 'teal' }, stackNames: big });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/may not exceed/);
});
@@ -122,22 +202,20 @@ describe('Fleet Actions orchestration shape', () => {
}
});
it('POST /api/fleet-actions/labels/bulk-assign accepts empty assignments and returns empty results', async () => {
it('POST /api/fleet/labels/bulk-assign reports an unknown node per-node without failing the request', async () => {
const res = await request(app)
.post('/api/fleet-actions/labels/bulk-assign')
.post('/api/fleet/labels/bulk-assign')
.set('Authorization', authHeader)
.send({ assignments: [] });
.send({ label: { name: 'media', color: 'teal' }, targets: [{ nodeId: 999999, stackNames: ['a', 'b'] }] });
expect(res.status).toBe(200);
expect(res.body.results).toEqual([]);
});
it('POST /api/fleet-actions/labels/bulk-assign rejects an entry with bad stack name in-line', async () => {
const res = await request(app)
.post('/api/fleet-actions/labels/bulk-assign')
.set('Authorization', authHeader)
.send({ assignments: [{ stackName: 'has spaces!', labelIds: [1] }] });
expect(res.status).toBe(200);
expect(res.body.results[0]).toMatchObject({ success: false, error: 'Invalid stack name' });
expect(res.body.results).toHaveLength(1);
const row = res.body.results[0];
expect(row.reachable).toBe(false);
expect(row.error).toBe('Unknown node');
expect(row.stackResults).toEqual([
{ stackName: 'a', success: false, error: 'Unknown node' },
{ stackName: 'b', success: false, error: 'Unknown node' },
]);
});
});
@@ -196,7 +274,7 @@ describe('local-stop behavior', () => {
afterEach(() => vi.restoreAllMocks());
it('matched:true with empty results when the label exists but has no stacks', async () => {
db.createLabel(nodeId, 'no-stacks-label', '#ffffff');
db.createLabel(nodeId, 'no-stacks-label', 'slate');
const res = await request(app)
.post('/api/fleet-actions/labels/local-stop')
.set('Authorization', authHeader)
@@ -206,7 +284,7 @@ describe('local-stop behavior', () => {
});
it('reports per-stack lock contention when a bulk action is already running on the node', async () => {
const label = db.createLabel(nodeId, 'busy-label', '#ffffff');
const label = db.createLabel(nodeId, 'busy-label', 'slate');
db.setStackLabels('busy-stack', nodeId, [label.id]);
const { activeBulkActions } = await import('../routes/labels');
activeBulkActions.add(`bulk:${nodeId}`);
@@ -226,10 +304,8 @@ describe('local-stop behavior', () => {
});
it('dry run returns dryRun:true per on-disk stack without touching Docker', async () => {
const composeDir = process.env.COMPOSE_DIR as string;
fs.mkdirSync(path.join(composeDir, 'dry-stack'), { recursive: true });
fs.writeFileSync(path.join(composeDir, 'dry-stack', 'docker-compose.yml'), 'services: {}\n');
const label = db.createLabel(nodeId, 'dry-label', '#ffffff');
makeStack('dry-stack');
const label = db.createLabel(nodeId, 'dry-label', 'slate');
db.setStackLabels('dry-stack', nodeId, [label.id]);
const res = await request(app)
.post('/api/fleet-actions/labels/local-stop')
@@ -241,7 +317,7 @@ describe('local-stop behavior', () => {
});
it('filters out assigned stacks that are not present on disk', async () => {
const label = db.createLabel(nodeId, 'ghost-label', '#ffffff');
const label = db.createLabel(nodeId, 'ghost-label', 'slate');
db.setStackLabels('ghost-stack', nodeId, [label.id]);
const res = await request(app)
.post('/api/fleet-actions/labels/local-stop')
@@ -252,6 +328,320 @@ describe('local-stop behavior', () => {
});
});
describe('local-assign receiver behavior', () => {
let db: import('../services/DatabaseService').DatabaseService;
let nodeId: number;
beforeAll(async () => {
const { DatabaseService } = await import('../services/DatabaseService');
const { NodeRegistry } = await import('../services/NodeRegistry');
db = DatabaseService.getInstance();
nodeId = NodeRegistry.getInstance().getDefaultNodeId();
});
afterEach(() => vi.restoreAllMocks());
it('creates the label when missing and assigns it to the stack', async () => {
makeStack('recv-create');
const res = await request(app)
.post('/api/fleet-actions/labels/local-assign')
.set('Authorization', authHeader)
.send({ label: { name: 'recv-created', color: 'blue' }, stackNames: ['recv-create'] });
expect(res.status).toBe(200);
expect(res.body.created).toBe(true);
expect(res.body.results).toEqual([{ stackName: 'recv-create', success: true }]);
const created = db.getLabels(nodeId).find(l => l.name === 'recv-created');
expect(created).toBeTruthy();
expect(db.getLabelsForStacks(nodeId)['recv-create'].map(l => l.name)).toContain('recv-created');
});
it('reuses an existing label (created:false) and preserves existing assignments', async () => {
makeStack('recv-reuse');
const existing = db.createLabel(nodeId, 'recv-existing', 'green');
db.setStackLabels('recv-reuse', nodeId, [existing.id]);
db.createLabel(nodeId, 'recv-reused', 'purple');
const res = await request(app)
.post('/api/fleet-actions/labels/local-assign')
.set('Authorization', authHeader)
.send({ label: { name: 'recv-reused', color: 'purple' }, stackNames: ['recv-reuse'] });
expect(res.status).toBe(200);
expect(res.body.created).toBe(false);
const names = db.getLabelsForStacks(nodeId)['recv-reuse'].map(l => l.name).sort();
expect(names).toEqual(['recv-existing', 'recv-reused']);
});
it('reports a per-stack error for a stack that is not on disk', async () => {
const res = await request(app)
.post('/api/fleet-actions/labels/local-assign')
.set('Authorization', authHeader)
.send({ label: { name: 'recv-ghost', color: 'rose' }, stackNames: ['recv-not-on-disk'] });
expect(res.status).toBe(200);
expect(res.body.results).toEqual([{ stackName: 'recv-not-on-disk', success: false, error: 'Stack not found' }]);
});
it('fails all stacks with the cap message when the node is at the label limit', async () => {
makeStack('recv-cap');
vi.spyOn(db, 'getLabelCount').mockReturnValue(50);
const res = await request(app)
.post('/api/fleet-actions/labels/local-assign')
.set('Authorization', authHeader)
.send({ label: { name: 'recv-over-cap', color: 'teal' }, stackNames: ['recv-cap'] });
expect(res.status).toBe(200);
expect(res.body.created).toBe(false);
expect(res.body.results[0]).toMatchObject({ stackName: 'recv-cap', success: false });
expect(res.body.results[0].error).toMatch(/Maximum of 50/);
});
it('reuses an existing label after a concurrent-create unique violation', async () => {
makeStack('recv-race');
// First read sees no label (so a create is attempted); create throws a unique
// violation as if another request won the race; the re-fetch then finds it.
const raced = { id: 9991, node_id: nodeId, name: 'recv-raced', color: 'teal' as const };
vi.spyOn(db, 'getLabels').mockReturnValueOnce([]).mockReturnValue([raced]);
const uniqueErr = Object.assign(new Error('UNIQUE constraint failed'), { code: 'SQLITE_CONSTRAINT_UNIQUE' });
vi.spyOn(db, 'createLabel').mockImplementation(() => { throw uniqueErr; });
const addSpy = vi.spyOn(db, 'addStackLabels').mockImplementation(() => {});
const res = await request(app)
.post('/api/fleet-actions/labels/local-assign')
.set('Authorization', authHeader)
.send({ label: { name: 'recv-raced', color: 'teal' }, stackNames: ['recv-race'] });
expect(res.status).toBe(200);
expect(res.body.created).toBe(false);
expect(res.body.results).toEqual([{ stackName: 'recv-race', success: true }]);
expect(addSpy).toHaveBeenCalledWith('recv-race', nodeId, [9991]);
});
});
describe('DatabaseService.addStackLabels', () => {
let db: import('../services/DatabaseService').DatabaseService;
let nodeId: number;
beforeAll(async () => {
db = (await import('../services/DatabaseService')).DatabaseService.getInstance();
nodeId = (await import('../services/NodeRegistry')).NodeRegistry.getInstance().getDefaultNodeId();
});
it('adds labels while preserving existing assignments', () => {
const a = db.createLabel(nodeId, 'add-pre-a', 'teal');
const b = db.createLabel(nodeId, 'add-pre-b', 'blue');
db.setStackLabels('add-pre-stack', nodeId, [a.id]);
db.addStackLabels('add-pre-stack', nodeId, [b.id]);
const names = db.getLabelsForStacks(nodeId)['add-pre-stack'].map(l => l.name).sort();
expect(names).toEqual(['add-pre-a', 'add-pre-b']);
});
it('is idempotent on re-add (no duplicate row, no throw)', () => {
const a = db.createLabel(nodeId, 'idem-a', 'teal');
db.addStackLabels('idem-stack', nodeId, [a.id]);
db.addStackLabels('idem-stack', nodeId, [a.id]);
expect(db.getLabelsForStacks(nodeId)['idem-stack'].filter(l => l.name === 'idem-a')).toHaveLength(1);
});
it('throws when a label id does not belong to the node', () => {
expect(() => db.addStackLabels('bad-id-stack', nodeId, [999999])).toThrow(/invalid for this node/);
});
it('no-ops on an empty id list', () => {
expect(() => db.addStackLabels('empty-id-stack', nodeId, [])).not.toThrow();
expect(db.getLabelsForStacks(nodeId)['empty-id-stack']).toBeUndefined();
});
});
describe('bulk-assign orchestrator: local node', () => {
let db: import('../services/DatabaseService').DatabaseService;
let nodeId: number;
beforeAll(async () => {
const { DatabaseService } = await import('../services/DatabaseService');
const { NodeRegistry } = await import('../services/NodeRegistry');
db = DatabaseService.getInstance();
nodeId = NodeRegistry.getInstance().getDefaultNodeId();
});
afterEach(() => vi.restoreAllMocks());
it('creates the label on the local node and assigns it, preserving existing labels', async () => {
makeStack('orch-local');
const existing = db.createLabel(nodeId, 'orch-existing', 'amber');
db.setStackLabels('orch-local', nodeId, [existing.id]);
const res = await request(app)
.post('/api/fleet/labels/bulk-assign')
.set('Authorization', authHeader)
.send({ label: { name: 'orch-media', color: 'teal' }, targets: [{ nodeId, stackNames: ['orch-local'] }] });
expect(res.status).toBe(200);
expect(res.body.results).toHaveLength(1);
const row = res.body.results[0];
expect(row.reachable).toBe(true);
expect(row.created).toBe(true);
expect(row.stackResults).toEqual([{ stackName: 'orch-local', success: true }]);
const names = db.getLabelsForStacks(nodeId)['orch-local'].map(l => l.name).sort();
expect(names).toEqual(['orch-existing', 'orch-media']);
});
it('reuses an existing local label (created:false)', async () => {
makeStack('orch-reuse');
db.createLabel(nodeId, 'orch-reused', 'cyan');
const res = await request(app)
.post('/api/fleet/labels/bulk-assign')
.set('Authorization', authHeader)
.send({ label: { name: 'orch-reused', color: 'cyan' }, targets: [{ nodeId, stackNames: ['orch-reuse'] }] });
expect(res.status).toBe(200);
expect(res.body.results[0].created).toBe(false);
expect(res.body.results[0].stackResults).toEqual([{ stackName: 'orch-reuse', success: true }]);
});
it('dedupes repeated stack names within a target', async () => {
makeStack('orch-dedupe');
const res = await request(app)
.post('/api/fleet/labels/bulk-assign')
.set('Authorization', authHeader)
.send({ label: { name: 'orch-dd', color: 'pink' }, targets: [{ nodeId, stackNames: ['orch-dedupe', 'orch-dedupe'] }] });
expect(res.status).toBe(200);
expect(res.body.results[0].stackResults).toEqual([{ stackName: 'orch-dedupe', success: true }]);
});
});
describe('bulk-assign orchestrator: remote fan-out', () => {
let db: import('../services/DatabaseService').DatabaseService;
let NodeRegistry: typeof import('../services/NodeRegistry').NodeRegistry;
beforeAll(async () => {
db = (await import('../services/DatabaseService')).DatabaseService.getInstance();
({ NodeRegistry } = await import('../services/NodeRegistry'));
});
afterEach(() => {
vi.restoreAllMocks();
for (const n of db.getNodes().filter(n => n.type === 'remote')) db.deleteNode(n.id);
});
function addRemote(name: string, mode: 'proxy' | 'pilot_agent' = 'proxy'): number {
return db.addNode({
name, type: 'remote', mode,
compose_dir: '/tmp', is_default: false,
api_url: mode === 'proxy' ? 'https://remote.example.com:1852' : '',
api_token: mode === 'proxy' ? 'remote-tok' : '',
});
}
it('fans out to the remote local-assign receiver with Bearer auth and the template body', async () => {
const remoteId = addRemote('assign-remote-ok');
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok' });
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(
JSON.stringify({ created: true, results: [{ stackName: 'r1', success: true }] }),
{ status: 200, headers: { 'content-type': 'application/json' } },
));
const res = await request(app)
.post('/api/fleet/labels/bulk-assign')
.set('Authorization', authHeader)
.send({ label: { name: 'media', color: 'teal' }, targets: [{ nodeId: remoteId, stackNames: ['r1'] }] });
expect(res.status).toBe(200);
const row = res.body.results.find((r: { nodeId: number }) => r.nodeId === remoteId);
expect(row.reachable).toBe(true);
expect(row.created).toBe(true);
expect(row.stackResults).toEqual([{ stackName: 'r1', success: true }]);
expect(fetchMock).toHaveBeenCalledTimes(1);
const call = fetchMock.mock.calls[0];
expect(String(call[0])).toBe('https://remote.example.com:1852/api/fleet-actions/labels/local-assign');
const init = call[1] as { method: string; headers: Record<string, string>; body: string };
expect(init.method).toBe('POST');
expect(init.headers['Authorization']).toBe('Bearer remote-tok');
expect(JSON.parse(init.body)).toEqual({ label: { name: 'media', color: 'teal' }, stackNames: ['r1'] });
});
it('omits the Authorization header for a pilot-agent remote with an empty token', async () => {
const remoteId = addRemote('assign-remote-pilot', 'pilot_agent');
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'http://127.0.0.1:9', apiToken: '' });
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(
JSON.stringify({ created: false, results: [{ stackName: 'p1', success: true }] }),
{ status: 200, headers: { 'content-type': 'application/json' } },
));
const res = await request(app)
.post('/api/fleet/labels/bulk-assign')
.set('Authorization', authHeader)
.send({ label: { name: 'media', color: 'teal' }, targets: [{ nodeId: remoteId, stackNames: ['p1'] }] });
expect(res.status).toBe(200);
const init = fetchMock.mock.calls[0][1] as { headers: Record<string, string> };
expect(init.headers).not.toHaveProperty('Authorization');
});
it('reports no-proxy-target as a per-node failure without blocking the request', async () => {
const remoteId = addRemote('assign-remote-down', 'pilot_agent');
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue(null);
const fetchSpy = vi.spyOn(globalThis, 'fetch');
const res = await request(app)
.post('/api/fleet/labels/bulk-assign')
.set('Authorization', authHeader)
.send({ label: { name: 'media', color: 'teal' }, targets: [{ nodeId: remoteId, stackNames: ['r1'] }] });
expect(res.status).toBe(200);
const row = res.body.results.find((r: { nodeId: number }) => r.nodeId === remoteId);
expect(row.reachable).toBe(false);
expect(row.error).toBeTruthy();
expect(row.stackResults).toEqual([{ stackName: 'r1', success: false, error: row.error }]);
expect(fetchSpy).not.toHaveBeenCalled();
});
it('treats a mixed-version remote (404 on local-assign) as a per-node failure', async () => {
const remoteId = addRemote('assign-remote-404');
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok' });
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('Not Found', { status: 404 }));
const res = await request(app)
.post('/api/fleet/labels/bulk-assign')
.set('Authorization', authHeader)
.send({ label: { name: 'media', color: 'teal' }, targets: [{ nodeId: remoteId, stackNames: ['r1'] }] });
expect(res.status).toBe(200);
const row = res.body.results.find((r: { nodeId: number }) => r.nodeId === remoteId);
expect(row.reachable).toBe(false);
expect(row.error).toMatch(/404/);
expect(row.stackResults[0]).toMatchObject({ stackName: 'r1', success: false });
});
it('reports a transport failure as a per-node failure', async () => {
const remoteId = addRemote('assign-remote-transport');
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok' });
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('ECONNREFUSED'));
const res = await request(app)
.post('/api/fleet/labels/bulk-assign')
.set('Authorization', authHeader)
.send({ label: { name: 'media', color: 'teal' }, targets: [{ nodeId: remoteId, stackNames: ['r1'] }] });
expect(res.status).toBe(200);
const row = res.body.results.find((r: { nodeId: number }) => r.nodeId === remoteId);
expect(row.reachable).toBe(false);
expect(row.stackResults[0]).toMatchObject({ stackName: 'r1', success: false });
});
it('reports a malformed 200 body as a per-node failure', async () => {
const remoteId = addRemote('assign-remote-malformed');
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok' });
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(
JSON.stringify({ created: true, results: 'not-an-array' }),
{ status: 200, headers: { 'content-type': 'application/json' } },
));
const res = await request(app)
.post('/api/fleet/labels/bulk-assign')
.set('Authorization', authHeader)
.send({ label: { name: 'media', color: 'teal' }, targets: [{ nodeId: remoteId, stackNames: ['r1'] }] });
expect(res.status).toBe(200);
const row = res.body.results.find((r: { nodeId: number }) => r.nodeId === remoteId);
expect(row.reachable).toBe(false);
expect(row.error).toMatch(/malformed/);
expect(row.stackResults).toEqual([{ stackName: 'r1', success: false, error: 'Remote returned a malformed response' }]);
});
});
describe('fleet-stop degrades the local leg per-node instead of failing the whole fan-out', () => {
let db: import('../services/DatabaseService').DatabaseService;
let nodeId: number;
@@ -266,7 +656,7 @@ describe('fleet-stop degrades the local leg per-node instead of failing the whol
afterEach(() => vi.restoreAllMocks());
it('returns 200 with per-stack errors when the control filesystem read throws', async () => {
const label = db.createLabel(nodeId, 'degrade-label', '#ffffff');
const label = db.createLabel(nodeId, 'degrade-label', 'slate');
db.setStackLabels('degrade-stack', nodeId, [label.id]);
const { FileSystemService } = await import('../services/FileSystemService');
vi.spyOn(FileSystemService.prototype, 'getStacks').mockRejectedValue(new Error('compose dir unreadable'));
+5
View File
@@ -14,6 +14,11 @@ export const BCRYPT_SALT_ROUNDS = 10;
export const VALID_LABEL_COLORS = ['teal', 'blue', 'purple', 'rose', 'amber', 'green', 'orange', 'pink', 'cyan', 'slate'] as const;
export type LabelColor = typeof VALID_LABEL_COLORS[number];
export const MAX_LABELS_PER_NODE = 50;
// Hard cap on stack assignments a single bulk-assign request may carry, summed
// across all target nodes. A node typically has tens of stacks, not thousands;
// the cap bounds the DB writes one request can force. Shared by the per-node
// receiver and the fleet orchestrator so they cannot drift.
export const MAX_ASSIGNMENTS = 1000;
// Session cookies
export const COOKIE_NAME = 'sencho_token';
+137
View File
@@ -0,0 +1,137 @@
import { DatabaseService } from '../services/DatabaseService';
import { FileSystemService } from '../services/FileSystemService';
import { VALID_LABEL_COLORS, MAX_LABELS_PER_NODE } from './constants';
import { isValidStackName } from '../utils/validation';
import { getErrorMessage, isSqliteUniqueViolation } from '../utils/errors';
export interface LabelTemplate {
name: string;
color: string;
}
export interface LabelAssignResult {
stackName: string;
success: boolean;
error?: string;
}
export interface LabelAssignOutcome {
/** True when this node did not have the label and it was created here. */
created: boolean;
stackResults: LabelAssignResult[];
}
/**
* Wire shape of `POST /api/fleet-actions/labels/local-assign`. The in-process
* helper returns `stackResults`; the HTTP response names the same array
* `results` to match the assign fan-out's remote contract. Keep the rename in
* this one type so the producer and the control-side consumer cannot drift.
*/
export interface LabelLocalAssignResponse {
created: boolean;
results: LabelAssignResult[];
}
/**
* Per-node row in the fleet bulk-assign orchestrator response
* (`POST /api/fleet/labels/bulk-assign`). `reachable` is always set; `error`
* carries the node-level cause when a node could not be reached or resolved.
*/
export interface AssignNodeResult {
nodeId: number;
nodeName: string;
reachable: boolean;
created: boolean;
error?: string;
stackResults: LabelAssignResult[];
}
/** Attribute one node-level error to every stack a node was meant to receive. */
export function failAllAssign(stackNames: string[], error: string): LabelAssignResult[] {
return Array.from(new Set(stackNames)).map(stackName => ({ stackName, success: false, error }));
}
/**
* Validate a label template (the name/color a cross-node assign propagates).
* Mirrors the create-label rules in `routes/labels.ts` and is the single
* validator shared by the per-node receiver and the fleet orchestrator.
*/
export function validateLabelTemplate(
input: unknown,
): { ok: true; template: LabelTemplate } | { ok: false; error: string } {
if (!input || typeof input !== 'object') {
return { ok: false, error: 'label is required' };
}
const { name, color } = input as { name?: unknown; color?: unknown };
if (typeof name !== 'string' || name.trim().length === 0 || name.length > 30) {
return { ok: false, error: 'label.name is required and must be 1-30 characters' };
}
if (!/^[a-zA-Z0-9 -]+$/.test(name)) {
return { ok: false, error: 'label.name may only contain letters, numbers, spaces, and hyphens' };
}
if (typeof color !== 'string' || !(VALID_LABEL_COLORS as readonly string[]).includes(color)) {
return { ok: false, error: `label.color must be one of: ${VALID_LABEL_COLORS.join(', ')}` };
}
return { ok: true, template: { name: name.trim(), color } };
}
/**
* Resolve-or-create a label by name on one node, then assign it to the given
* stacks while preserving their existing labels (add semantics).
*
* Used by the gateway-orchestrated bulk-assign for the control node's own stacks
* and by the per-node `POST /api/fleet-actions/labels/local-assign` receiver that
* a control instance calls on each remote. Matching/creating by name (never by a
* shared id) keeps labels node-local: each node owns its own label id, so the
* control never reuses a local id on a remote.
*/
export async function runLocalLabelAssign(
nodeId: number,
label: LabelTemplate,
stackNames: string[],
): Promise<LabelAssignOutcome> {
const db = DatabaseService.getInstance();
// Resolve the label on this node by exact name; create it if missing.
let resolved = db.getLabels(nodeId).find(l => l.name === label.name);
let created = false;
if (!resolved) {
if (db.getLabelCount(nodeId) >= MAX_LABELS_PER_NODE) {
return { created: false, stackResults: failAllAssign(stackNames, `Maximum of ${MAX_LABELS_PER_NODE} labels per node reached`) };
}
try {
resolved = db.createLabel(nodeId, label.name, label.color);
created = true;
} catch (err) {
// A concurrent create can win the UNIQUE(node_id, name) race; re-fetch and
// reuse the now-existing label rather than failing the assignment.
if (isSqliteUniqueViolation(err)) {
resolved = db.getLabels(nodeId).find(l => l.name === label.name);
}
if (!resolved) {
return { created: false, stackResults: failAllAssign(stackNames, getErrorMessage(err, 'Failed to create label')) };
}
}
}
const labelId = resolved.id;
const fsStacks = new Set(await FileSystemService.getInstance(nodeId).getStacks());
const stackResults: LabelAssignResult[] = [];
for (const stackName of Array.from(new Set(stackNames))) {
if (!isValidStackName(stackName)) {
stackResults.push({ stackName, success: false, error: 'Invalid stack name' });
continue;
}
if (!fsStacks.has(stackName)) {
stackResults.push({ stackName, success: false, error: 'Stack not found' });
continue;
}
try {
db.addStackLabels(stackName, nodeId, [labelId]);
stackResults.push({ stackName, success: true });
} catch (err) {
stackResults.push({ stackName, success: false, error: getErrorMessage(err, 'Failed to assign label') });
}
}
return { created, stackResults };
}
+129
View File
@@ -39,6 +39,8 @@ import { invalidateNodeCaches, invalidateRemoteMetaCache } from '../helpers/cach
import { activeBulkActions } from './labels';
import { runLocalLabelStop, isLabelLocalStopResponse, type StackStopResult } from '../helpers/fleetLabelStop';
import { collectFleetLabelSummaries } from '../helpers/fleetLabelSummary';
import { runLocalLabelAssign, validateLabelTemplate, failAllAssign, type LabelLocalAssignResponse, type AssignNodeResult } from '../helpers/fleetLabelAssign';
import { MAX_ASSIGNMENTS } from '../helpers/constants';
import { buildLocalConfigurationStatus, type ConfigurationStatus } from './dashboard';
import { buildLocalGraph, mergeFleetGraph, isLocalDependencyGraph, type FleetNodeGraphResult } from '../services/DependencyGraphService';
import { PROXY_TIER_HEADER } from '../services/license-headers';
@@ -1351,6 +1353,133 @@ fleetRouter.post('/labels/fleet-stop', authMiddleware, async (req: Request, res:
}
});
// Fleet-wide bulk label assign. Propagates a label template (name + color) to
// stacks across one or more nodes: for each target node the label is resolved or
// created by name on that node, then assigned to the given stacks preserving
// their existing labels (add semantics). Labels are node-local, so the control
// never reuses a local label id on a remote; the local node runs in-process and
// each remote runs its own `/api/fleet-actions/labels/local-assign` receiver.
// Per-node failures (unknown node, no proxy target, unreachable, mixed-version
// remote, malformed response) degrade that node only and never discard the rest
// of the fan-out.
// Tier: requireAdmin (admin-only fleet plumbing; available on every license).
fleetRouter.post('/labels/bulk-assign', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
const body = req.body as { label?: unknown; targets?: unknown } | undefined;
if (!body || typeof body !== 'object') {
res.status(400).json({ error: 'Request body is required' });
return;
}
const validated = validateLabelTemplate(body.label);
if (!validated.ok) {
res.status(400).json({ error: validated.error });
return;
}
if (!Array.isArray(body.targets) || body.targets.length === 0) {
res.status(400).json({ error: 'targets must be a non-empty array' });
return;
}
// Normalize targets: each must name a node and carry a string array of stacks.
// Stack names are deduped per node and empty groups are dropped, so the cap
// measures real assignments rather than padded input.
const targets: { nodeId: number; stackNames: string[] }[] = [];
let totalStacks = 0;
for (const raw of body.targets as unknown[]) {
if (!raw || typeof raw !== 'object') {
res.status(400).json({ error: 'each target must be an object' });
return;
}
const { nodeId, stackNames } = raw as { nodeId?: unknown; stackNames?: unknown };
if (typeof nodeId !== 'number' || !Number.isInteger(nodeId)) {
res.status(400).json({ error: 'target.nodeId must be an integer' });
return;
}
if (!Array.isArray(stackNames) || !stackNames.every(s => typeof s === 'string')) {
res.status(400).json({ error: 'target.stackNames must be an array of strings' });
return;
}
const unique = Array.from(new Set(stackNames as string[]));
if (unique.length === 0) continue;
totalStacks += unique.length;
targets.push({ nodeId, stackNames: unique });
}
if (targets.length === 0) {
res.status(400).json({ error: 'no target stacks provided' });
return;
}
if (totalStacks > MAX_ASSIGNMENTS) {
res.status(400).json({ error: `targets may not exceed ${MAX_ASSIGNMENTS} stack assignments` });
return;
}
const { template } = validated;
try {
const db = DatabaseService.getInstance();
const nodesById = new Map(db.getNodes().map(n => [n.id, n]));
if (isDebugEnabled()) console.debug('[Fleet:debug] bulk-assign:', { label: template.name, targets: targets.length, totalStacks });
const results: AssignNodeResult[] = await Promise.all(targets.map(async (target): Promise<AssignNodeResult> => {
const node = nodesById.get(target.nodeId);
if (!node) {
return {
nodeId: target.nodeId, nodeName: `Node ${target.nodeId}`, reachable: false, created: false, error: 'Unknown node',
stackResults: failAllAssign(target.stackNames, 'Unknown node'),
};
}
if (node.type === 'local') {
try {
const outcome = await runLocalLabelAssign(node.id, template, target.stackNames);
return { nodeId: node.id, nodeName: node.name, reachable: true, created: outcome.created, stackResults: outcome.stackResults };
} catch (err) {
return {
nodeId: node.id, nodeName: node.name, reachable: true, created: false,
stackResults: failAllAssign(target.stackNames, getErrorMessage(err, 'Failed to assign labels')),
};
}
}
const proxyTarget = NodeRegistry.getInstance().getProxyTarget(node.id);
if (!proxyTarget) {
const error = formatNoTargetError(node);
return { nodeId: node.id, nodeName: node.name, reachable: false, created: false, error, stackResults: failAllAssign(target.stackNames, error) };
}
try {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (proxyTarget.apiToken) headers.Authorization = `Bearer ${proxyTarget.apiToken}`;
const response = await fetch(`${proxyTarget.apiUrl.replace(/\/$/, '')}/api/fleet-actions/labels/local-assign`, {
method: 'POST',
headers,
body: JSON.stringify({ label: template, stackNames: target.stackNames }),
signal: AbortSignal.timeout(60000),
});
if (!response.ok) {
const err = (await response.json().catch(() => ({}))) as { error?: string };
const message = err.error || `Remote returned ${response.status}`;
return { nodeId: node.id, nodeName: node.name, reachable: false, created: false, error: message, stackResults: failAllAssign(target.stackNames, message) };
}
// A 200 whose body is not the expected { created, results } shape is a
// degraded node, not a clean no-op: report it as a per-node failure so a
// malformed remote cannot read as a successful zero-stack assign.
const remote = (await response.json().catch(() => null)) as Partial<LabelLocalAssignResponse> | null;
if (!remote || typeof remote.created !== 'boolean' || !Array.isArray(remote.results)) {
const message = 'Remote returned a malformed response';
return { nodeId: node.id, nodeName: node.name, reachable: false, created: false, error: message, stackResults: failAllAssign(target.stackNames, message) };
}
return {
nodeId: node.id, nodeName: node.name, reachable: true,
created: remote.created,
stackResults: remote.results,
};
} catch (err) {
const errorMsg = getErrorMessage(err, 'Failed to reach remote node');
return { nodeId: node.id, nodeName: node.name, reachable: false, created: false, error: errorMsg, stackResults: failAllAssign(target.stackNames, errorMsg) };
}
}));
res.json({ results });
} catch (error) {
console.error('[Fleet] bulk-assign error:', error);
res.status(500).json({ error: getErrorMessage(error, 'Failed to run bulk label assign') });
}
});
// Fleet-wide Docker prune. Fans out to every node, running per-target prune
// (images/volumes/networks) under the chosen scope. Local nodes call
// DockerController directly under a per-node bulk-prune lock; remote nodes
+42 -57
View File
@@ -1,11 +1,11 @@
import { Router, type Request, type Response } from 'express';
import { DatabaseService } from '../services/DatabaseService';
import { authMiddleware } from '../middleware/auth';
import { requireAdmin, requireBody } from '../middleware/tierGates';
import { isValidStackName } from '../utils/validation';
import { getErrorMessage } from '../utils/errors';
import { isDebugEnabled } from '../utils/debug';
import { MAX_ASSIGNMENTS } from '../helpers/constants';
import { runLocalLabelStop, type LabelLocalStopResponse } from '../helpers/fleetLabelStop';
import { runLocalLabelAssign, validateLabelTemplate, type LabelLocalAssignResponse } from '../helpers/fleetLabelAssign';
// Per-node fleet-action endpoints. Mounted under `/api/fleet-actions/`, which
// is NOT in `PROXY_EXEMPT_PREFIXES`, so when `x-node-id` targets a remote node
@@ -14,61 +14,6 @@ import { runLocalLabelStop, type LabelLocalStopResponse } from '../helpers/fleet
// because their path must sit behind the `/api/fleet/` proxy-exempt prefix.
export const fleetActionsRouter = Router();
// Hard cap to bound a single bulk-assign request. A node typically has tens of
// stacks, not thousands; the cap protects against accidental or malicious
// payloads that would force thousands of DB writes in one handler.
const MAX_ASSIGNMENTS = 1000;
// Bulk label assignment for many stacks on a single node. The single-stack
// endpoint at `PUT /api/stacks/:stackName/labels` covers one stack at a time;
// this wrapper applies the same operation to many stacks atomically per HTTP
// request. Tier: requireAdmin (admin-only fleet plumbing). The per-stack
// endpoint is Community-tier organization metadata; this multi-stack wrapper
// matches the surrounding Fleet Actions surface, which is admin-only but
// available on every license.
fleetActionsRouter.post(
'/labels/bulk-assign',
authMiddleware,
async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
if (!requireBody(req, res)) return;
const { assignments } = req.body as { assignments?: unknown };
if (!Array.isArray(assignments)) {
res.status(400).json({ error: 'assignments must be an array' });
return;
}
if (assignments.length > MAX_ASSIGNMENTS) {
res.status(400).json({ error: `assignments may not exceed ${MAX_ASSIGNMENTS} entries` });
return;
}
const nodeId = req.nodeId ?? 0;
const db = DatabaseService.getInstance();
const results: { stackName: string; success: boolean; error?: string }[] = [];
for (const entry of assignments as unknown[]) {
if (!entry || typeof entry !== 'object') {
results.push({ stackName: '', success: false, error: 'Invalid assignment entry' });
continue;
}
const { stackName, labelIds } = entry as { stackName?: unknown; labelIds?: unknown };
if (typeof stackName !== 'string' || !isValidStackName(stackName)) {
results.push({ stackName: typeof stackName === 'string' ? stackName : '', success: false, error: 'Invalid stack name' });
continue;
}
if (!Array.isArray(labelIds) || !labelIds.every(id => typeof id === 'number')) {
results.push({ stackName, success: false, error: 'labelIds must be an array of numbers' });
continue;
}
try {
db.setStackLabels(stackName, nodeId, labelIds);
results.push({ stackName, success: true });
} catch (err) {
results.push({ stackName, success: false, error: getErrorMessage(err, 'Failed to set stack labels') });
}
}
res.json({ results });
},
);
// Per-node label-matched stop. A control instance calls this on each remote
// node during a fleet-wide stop-by-label so the destructive work runs under the
// remote's own admin auth and per-node bulk lock. Admin-only and available on
@@ -100,3 +45,43 @@ fleetActionsRouter.post(
}
},
);
// Per-node label assign. A control instance calls this on each target node
// during a fleet-wide bulk label assign so the label is resolved or created
// under the node's own database, by name, and assigned to the given stacks while
// preserving their existing labels (add semantics). Admin-only and available on
// every license, matching the rest of the Fleet Actions surface. Labels are
// node-local, so the control never reuses a local label id on a remote: the
// receiver owns label resolution for its own node.
fleetActionsRouter.post(
'/labels/local-assign',
authMiddleware,
async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
if (!requireBody(req, res)) return;
const { label, stackNames } = req.body as { label?: unknown; stackNames?: unknown };
const validated = validateLabelTemplate(label);
if (!validated.ok) {
res.status(400).json({ error: validated.error });
return;
}
if (!Array.isArray(stackNames) || !stackNames.every(s => typeof s === 'string')) {
res.status(400).json({ error: 'stackNames must be an array of strings' });
return;
}
if (stackNames.length > MAX_ASSIGNMENTS) {
res.status(400).json({ error: `stackNames may not exceed ${MAX_ASSIGNMENTS} entries` });
return;
}
const nodeId = req.nodeId ?? 0;
try {
const outcome = await runLocalLabelAssign(nodeId, validated.template, stackNames as string[]);
if (isDebugEnabled()) console.debug('[FleetActions:debug] local-assign:', { nodeId, label: validated.template.name, created: outcome.created, stacks: outcome.stackResults.length });
const body: LabelLocalAssignResponse = { created: outcome.created, results: outcome.stackResults };
res.json(body);
} catch (err) {
console.error('[FleetActions] local-assign error:', { nodeId, label: validated.template.name }, err);
res.status(500).json({ error: getErrorMessage(err, 'Failed to run local label assign') });
}
},
);
+27
View File
@@ -5343,6 +5343,33 @@ export class DatabaseService {
txn();
}
/**
* Add labels to a stack without disturbing its existing assignments. Unlike
* `setStackLabels` (replace), this is additive: validation and
* `INSERT OR IGNORE` run in one transaction and the
* `(label_id, stack_name, node_id)` primary key makes re-adds idempotent, so
* two additive writes to the same stack never drop each other's labels. A
* concurrent `setStackLabels` (replace) still wins last-writer, which is the
* intended replace semantics, not a lost update.
*/
public addStackLabels(stackName: string, nodeId: number, labelIds: number[]): void {
if (labelIds.length === 0) return;
const txn = this.db.transaction(() => {
const placeholders = labelIds.map(() => '?').join(',');
const validCount = this.db.prepare(
`SELECT COUNT(*) as cnt FROM stack_labels WHERE id IN (${placeholders}) AND node_id = ?`
).get(...labelIds, nodeId) as { cnt: number };
if (validCount.cnt !== labelIds.length) {
throw new Error('One or more label IDs are invalid for this node');
}
const insert = this.db.prepare('INSERT OR IGNORE INTO stack_label_assignments (label_id, stack_name, node_id) VALUES (?, ?, ?)');
for (const labelId of labelIds) {
insert.run(labelId, stackName, nodeId);
}
});
txn();
}
public getLabelsForStacks(nodeId: number): Record<string, Label[]> {
const rows = this.db.prepare(`
SELECT a.stack_name, l.id, l.node_id, l.name, l.color
+22 -22
View File
@@ -1,6 +1,6 @@
---
title: "Fleet Actions"
description: "Bulk operations across the fleet from one tab: stop stacks by label, replace labels on a batch of stacks, and reclaim Docker disk space on every node."
description: "Bulk operations across the fleet from one tab: stop stacks by label, assign a label to stacks across nodes, and reclaim Docker disk space on every node."
---
The **Actions** tab on the Fleet view groups bulk operations that touch more than a single stack on a single node. Each action lives in its own card, runs from the control instance, and reports per-node and per-stack results inline so you never have to click through a modal to learn what happened.
@@ -34,10 +34,10 @@ The cards share a tab and a role gate, but they don't share an execution path. K
| Card | Endpoint | Where it runs | Scope |
|---|---|---|---|
| Stop fleet by label | `POST /api/fleet/labels/fleet-stop` | Control instance orchestrates; fans out to each node | Every configured node |
| Bulk label assign | `POST /api/fleet-actions/labels/bulk-assign` | Target node (request is proxied) | The single node you pick |
| Bulk label assign | `POST /api/fleet/labels/bulk-assign` | Control instance orchestrates; fans out to each target node | The stacks you select across nodes |
| Prune Docker resources fleet-wide | `POST /api/fleet/labels/fleet-prune` | Control instance orchestrates; fans out to each node | Every reachable node |
The two fan-out cards (Stop and Prune) iterate every node in **Settings → Nodes**, including offline remotes; unreachable nodes show up in the results with a transport error rather than blocking the rest of the batch. The single-node card (Bulk label assign) proxies the request through the standard `x-node-id` header to the node you select, so the work happens locally on that node.
All three cards orchestrate from the control instance and report results grouped by node. Stop and Prune iterate every node in **Settings → Nodes**; Bulk label assign iterates only the nodes whose stacks you selected. Each card runs the authoritative work on the executing node (the local node in process, every remote over the node proxy), so unreachable nodes show up in the results with a transport error rather than blocking the rest of the batch.
## Stop fleet by label
@@ -77,30 +77,30 @@ A few quirks worth knowing:
## Bulk label assign
Replace the label set on a batch of stacks on a single node, in one round trip. Use this when you've decided on a taxonomy change (e.g. splitting `prod` into `prod-edge` and `prod-core`) and need to relabel many stacks without clicking through each stack's editor.
Add a stack label to stacks across the fleet in one round trip. Pick a label that exists anywhere in the fleet, select stacks on one or more nodes, and Sencho assigns that label on each target node, creating it there first if the node does not have it yet. Existing labels on the selected stacks are preserved. Use this to keep a label like `media` consistent across nodes without visiting each one to recreate and assign it by hand.
<Frame>
<img src="/images/fleet-actions/fleet-actions-bulk-assign.png" alt="Bulk label assign card with the Local node selected, three stacks checked (bazarr, plex; counter reads 'Stacks (3/14)'), two labels toggled on (Media and Network; counter reads 'Labels (2/3)'), and the primary button reading 'Apply to 3 stacks'." />
<img src="/images/fleet-actions/fleet-actions-bulk-assign.png" alt="Bulk label assign card. A label source row of pills with 'Media' selected, a target-stacks list grouped by node (Local and a remote) with several stacks checked, a preview showing 'create' on the remote and 'reuse' on Local, and a primary button reading 'Apply'." />
</Frame>
### Single-node scope (and why)
### Cross-node by label identity
Unlike the other two cards, Bulk label assign targets exactly one node. The request is proxied via `x-node-id` to the node you pick from the **Select a node** dropdown, so the label rewrites happen on that node's local database. There is no fleet-wide variant in v1; if you need to retag the same stacks on multiple nodes, run the card once per node.
Labels are node-local: each node owns its own copy of a label. Bulk label assign treats the label you pick as a name plus color, then resolves it per target node. If the node already has a label with that exact name, its own label is used; if not, Sencho creates one with the same name and color. The target node's own label is always used for the assignment, never the control node's.
### Step by step
1. Pick a node from the **Select a node** dropdown. The card loads that node's stacks and labels in parallel.
2. Check the stacks you want to update. The counter in the **STACKS** header reads `Stacks (selected/total)`; the **Select all** affordance flips to **Clear** once everything is selected.
3. Toggle the label pills you want as the new label set. The counter in the **LABELS** header tracks the selection.
4. Click **Apply to N stacks**. A confirmation appears titled `Apply N labels to M stacks?` (or the singular forms). Confirm to commit.
1. Pick a label under **Label · source**. The pills list every stack label defined across the reachable fleet. If the same name carries different colors on different nodes, the local node's color is used where the label is created.
2. Under **Target stacks**, check the stacks you want, grouped by node. Use the filter to narrow long lists and **Select all** to take a whole node. Unreachable nodes are shown and cannot be selected.
3. The **Preview** shows, per node, whether the label will be **created** or **reused** and how many stacks it will touch.
4. Click **Apply**. A confirmation summarizes the blast radius; confirm to commit. Results render grouped by node, each row noting whether the label was created or reused alongside the per-stack outcome.
### Replace, not append
### Add, preserving existing labels
The selected label set **replaces** each chosen stack's existing label set on this node. Selecting zero labels clears assignments on those stacks; the confirmation modal calls this out so the destructive case is hard to miss.
The selected label is **added** to each chosen stack; the stack keeps its other labels. The card only ever adds the one label you picked, so a fleet-wide propagation cannot accidentally wipe labels a remote node already carries.
### Batch ceiling
The endpoint accepts up to **1,000 stack assignments per call** and returns `400` over the limit. In practice this is well above any sensible UI selection. Per-entry validation is lenient: an invalid stack name returns a per-entry failure row in the **Per-stack results** card, and the rest of the batch still applies.
A single Apply accepts up to **1,000 stack assignments** summed across every target node and returns `400` over the limit. In practice this is well above any sensible UI selection. A stack that is missing on disk or has an invalid name returns a per-stack failure row, and the rest of the batch still applies.
## Prune Docker resources fleet-wide
@@ -145,8 +145,8 @@ Scope is a segmented control with two options:
Fleet Actions is intentionally narrow in v1. The following are deliberately out of scope:
- **No fleet-wide bulk label assign.** Bulk label assign targets one node at a time. Re-tagging the same stacks on multiple nodes is two clicks of the node selector and two confirmations.
- **No fleet-wide bulk start, restart, or update.** Stop is the only fleet-wide stack action today. Per-node multi-stack start, restart, and update live in the sidebar's Bulk mode.
- **No replace mode for bulk label assign.** The card only adds the label you pick; it never removes a stack's existing labels. To remove or swap labels on a stack, edit them from that stack's own view.
- **No fleet-wide bulk start, restart, or update.** Stop is the only fleet-wide stack lifecycle action today. Per-node multi-stack start, restart, and update live in the sidebar's Bulk mode.
- **No label-set selectors.** Stop fleet by label matches one label name. Combinations like "stacks labelled A AND B" are not supported.
- **No partial-stop ceiling.** The Stop card stops every stack the label matches; there is no "stop the first N" knob.
- **No undo.** A stopped stack stays stopped until you start it again; a pruned image is gone until it is pulled or rebuilt.
@@ -159,9 +159,9 @@ Fleet Actions is intentionally narrow in v1. The following are deliberately out
Tag the stacks you want to bring down with a dedicated label (for example `evening-shutdown`). Run **Stop fleet by label** with that label name. The per-node breakdown confirms each stack's stop result; restart from the sidebar when power is back.
### Migrate a label taxonomy on one node
### Propagate a label across the fleet
Split a coarse label like `prod` into `prod-edge` and `prod-core` by creating the new labels in **Settings → Labels** on the affected node, then opening **Bulk label assign**, picking that node, checking the stacks that should move, and toggling the new label set. The replace-not-append semantic guarantees the old label is removed in the same write.
Define a label like `media` on one node (for example the local node) under **Settings → Labels**, then open **Bulk label assign**, pick `media` under the label source, and check the matching stacks on every node. Sencho creates `media` on each node that does not have it and assigns it, leaving each stack's other labels intact. The label then matches fleet-wide in **Stop fleet by label** and in per-node stack filtering.
### Free disk before a heavy deploy
@@ -176,11 +176,11 @@ Run **Prune Docker resources fleet-wide** with **Images** selected and **Managed
<Accordion title="The autocomplete didn't suggest a stack label I know exists">
The picker queries each reachable node for its own stack labels, so a name is missing when no reachable node has a **stack label** by that name, or when the node that owns it could not be reached when the picker loaded (the picker flags this case). 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 node and asks each one authoritatively.
</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.
<Accordion title="Bulk label assign reports 'Invalid stack name' or 'Stack not found' for one row">
Stack names must match the standard validator: alphanumeric plus dash and underscore, no spaces, no path separators. A name that does not exist on its node returns *Stack not found*. Each stack is validated independently, so one bad row does not block the rest of the batch. Fix the offending entry and re-run; the rows that already succeeded are simply re-added with no change.
</Accordion>
<Accordion title="Apply to N stacks button is disabled">
The button requires at least one stack selected; the label selection can be empty (which clears assignments). Check that the node selector resolved (the **Loading…** spinner has cleared) and that the stack list is populated.
<Accordion title="The Apply button on Bulk label assign is disabled">
Apply needs both a label picked under the source and at least one stack checked. An empty label source means no stack labels are defined on any reachable node yet; create one under **Settings → Labels** first. Wait for the per-node lists to finish loading before selecting.
</Accordion>
<Accordion title="Fleet stop timed out on one remote node">
Each remote node call carries a 60-second timeout. A remote with many stacks or a slow Docker daemon can outlast that budget. Check the affected remote's logs; the stop usually completed on the remote even though the control instance stopped waiting. Re-running the same fleet-stop is safe: stacks that are already stopped return the per-stack error *No containers found for this stack* and do not toggle anything else.
+1 -1
View File
@@ -28,7 +28,7 @@ See [the pricing page](https://sencho.io/pricing) for current pricing.
- Multi-node management in both Proxy and Pilot Agent modes
- Fleet View with search, sort, filter, and node-card drill-down
- Manual and scheduled fleet snapshots (create, browse, restore, delete) and Remote OTA node updates (per-node and **Update all**)
- Actions tab (stop stacks fleet-wide by label, bulk-assign labels to many stacks on a node, prune Docker resources fleet-wide; admin role required), plus fleet-wide bulk Sencho restart
- Actions tab (stop stacks fleet-wide by label, assign a label to stacks across nodes, prune Docker resources fleet-wide; admin role required), plus fleet-wide bulk Sencho restart
- Bulk actions on a label (deploy, stop, or restart every stack tagged with it)
- Atomic deployments with automatic rollback, and one-click rollback to the previous deployment
- Auto-update policies for stack images and auto-heal policies for failed containers
+1 -1
View File
@@ -117,7 +117,7 @@ Operator-driven placement controls for fleets running Blueprints. Cordon nodes t
### Fleet Actions
Run fleet-wide bulk operations from one place: stop stacks across nodes by label selector, bulk-assign labels to many stacks on a single node, or prune Docker resources fleet-wide. Admin-only on every tier. [Learn more →](/features/fleet-actions)
Run fleet-wide bulk operations from one place: stop stacks across nodes by label selector, propagate a label to stacks across nodes (creating it where missing), or prune Docker resources fleet-wide. Admin-only on every tier. [Learn more →](/features/fleet-actions)
### Fleet Sync
+9 -11
View File
@@ -3,7 +3,7 @@ title: "Stack Labels"
description: "Per-node tags that group your stacks by purpose, surface them under collapsible headers in the sidebar, and unlock cross-stack bulk actions across the fleet."
---
A **Stack Label** is a per-node tag (name plus color) you can stick on any stack. Once a stack carries a label, the sidebar groups it under that label's header instead of dumping every stack into a flat list, and Fleet View can filter the overview by tag. Admins also get a pair of fleet-wide actions powered by labels: stop every stack labeled `prod` across every node, or replace the label set on a batch of stacks in one shot.
A **Stack Label** is a per-node tag (name plus color) you can stick on any stack. Once a stack carries a label, the sidebar groups it under that label's header instead of dumping every stack into a flat list, and Fleet View can filter the overview by tag. Admins also get a pair of fleet-wide actions powered by labels: stop every stack labeled `prod` across every node, or add a label to stacks across nodes in one shot.
<Frame>
<img src="/images/stack-labels/sidebar-grouping.png" alt="Sidebar showing stacks grouped under three uppercase label headers (MEDIA 6, UTILITIES 5, NETWORK 3) and an UNLABELED 1 group at the bottom. Each stack row carries a small colored dot on the trailing edge that matches the group's color." />
@@ -88,7 +88,7 @@ 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 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." />
<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 label source row with a highlighted Media pill, a target-stacks list grouped by node with several stacks ticked, a per-node create-or-reuse preview, and an Apply button." />
</Frame>
### Stop fleet by label
@@ -99,16 +99,14 @@ A node that carries no matching stack label is shown as such, and a node Sencho
### Bulk label assign
Pick a node from the dropdown; the card loads that node's stacks and labels in parallel. Tick the stacks you want to relabel and click the label pills you want to apply. The footer button reads `Apply to N stack(s)` and the confirmation modal title reads `Apply N label(s) to M stack(s)?` so the scope is unambiguous before you commit. A line under the controls makes the replacement semantics explicit:
Pick a label that exists anywhere in the fleet, then tick the stacks you want across one or more nodes (grouped by node, with a filter and per-node select-all). The preview shows, per node, whether the label will be created or reused; **Apply** adds the label to each chosen stack on its node, creating it there first with the same name and color if the node does not have it yet. Existing labels on the selected stacks are preserved. The confirmation modal summarizes the blast radius before you commit, and the result list breaks down per node, noting whether the label was created or reused.
> Selected labels replace each chosen stack's existing label set on this node. Selecting no labels clears assignments.
The **Bulk label assign** card is per-node only by design. To re-tag stacks on a different node, switch the picker; the stack and label list refreshes and your previous selection clears.
Because labels are node-local, each target node uses its own copy of the label rather than the control node's, so propagating a label keeps every node's label table self-consistent.
## Limits and rules
- **50 labels per node.** Settings hides the **New label** button at the cap; the inline `+ New label` entry in the stack menu hides itself too.
- **Names are unique per node**, case-sensitive. The same name on two nodes is two separate label rows. Cross-node fleet stop matches on name; bulk assign always operates on one node's labels at a time.
- **Names are unique per node**, case-sensitive. The same name on two nodes is two separate label rows. Cross-node fleet stop and bulk assign both match by name across nodes; each node resolves the name to its own label (and bulk assign creates it there if missing).
- **Allowed name characters**: letters, digits, spaces, and hyphens. Empty names and names beyond 30 characters are rejected at the API.
- **Bulk-action concurrency**: only one label-driven bulk action can run on a single node at a time. A second concurrent attempt against the same node returns HTTP 429 and the operator sees an error toast; the in-flight action keeps running.
- **Role visibility**: label authoring is open to every signed-in role. Sidebar grouping, trailing dots on stack rows, the **Settings · Organization · Labels** panel, the inline create form in the stack menu, and the Fleet View **Tags** filter all work for every user.
@@ -131,11 +129,11 @@ The **Bulk label assign** card is per-node only by design. To re-tag stacks on a
<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 queries each reachable node for its own stack labels; node labels never appear there. A label on a node Sencho cannot currently reach will not be suggested, and the picker flags that the list may be incomplete. 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.
<Accordion title="`Bulk label assign` did not remove the old labels on my stacks">
By design it never does: the card only adds the label you picked, leaving each stack's other labels intact. There is no clear or replace mode. To remove or swap a stack's labels, edit them from that stack's own **Labels** menu.
</Accordion>
<Accordion title="Two stacks with the same name on different nodes only got relabeled on one">
`Bulk label assign` is per-node by design. The node picker at the top is the source of truth and the stacks list only shows stacks on that node. Run the card a second time with the other node selected, or use **Stop fleet by label** instead if the goal is fleet-wide.
<Accordion title="A label did not appear on a remote node after bulk assign">
Confirm the node was reachable when you applied: an unreachable node is shown in the target list and reported in the per-node results rather than silently skipped. If the node was reachable, the label is created there by name with the chosen color and assigned; re-run to retry any node that failed.
</Accordion>
<Accordion title="The Fleet Actions cards return an error when I click Apply">
The cards run admin-only. Confirm the active user has the admin role under **Settings · Users**; operator and viewer roles see the cards rendered but cannot apply them. All other Stack Labels surfaces (sidebar grouping, trailing dots, the Settings panel, the inline create form, the Fleet View Tags filter) work for every role.
+3 -1
View File
@@ -226,7 +226,9 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
</TabsContent>
)}
<TabsContent value="actions">
<FleetActionsTab nodes={overview.allNodes} />
{/* Fleet Actions runs against the whole fleet, so it takes the
unfiltered node list rather than the overview-filtered view. */}
<FleetActionsTab nodes={overview.nodes} />
</TabsContent>
{isPaid && isAdmin && (
<TabsContent value="secrets">
@@ -1,103 +1,184 @@
/**
* Coverage for BulkLabelAssignCard.
* Coverage for the cross-node BulkLabelAssignCard.
*
* Loads the node's stacks + labels, gates Apply on a stack-and-label selection,
* confirms before applying, sends the assignment per-node, and surfaces a toast
* on every outcome (no silent failure).
* Loads each node's stacks + labels, derives label templates from the fleet,
* gates Apply on a template-and-stack selection, sends a name/color template plus
* per-node targets to the fleet orchestrator, and renders per-node results
* (created vs reused) with no silent failure.
*/
import { it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
vi.mock('@/lib/api', () => ({ fetchForNode: vi.fn() }));
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn(), fetchForNode: vi.fn() }));
const toastError = vi.fn();
const toastSuccess = vi.fn();
const toastWarning = vi.fn();
vi.mock('@/components/ui/toast-store', () => ({
toast: {
error: (...a: unknown[]) => toastError(...a),
success: (...a: unknown[]) => toastSuccess(...a),
warning: (...a: unknown[]) => toastWarning(...a),
info: vi.fn(),
warning: vi.fn(),
loading: vi.fn(() => 'toast-id'),
dismiss: vi.fn(),
},
}));
import { fetchForNode } from '@/lib/api';
import { apiFetch, fetchForNode } from '@/lib/api';
import { BulkLabelAssignCard } from './BulkLabelAssignCard';
import type { FleetNode } from '@/components/FleetView/types';
const mockedFetchForNode = fetchForNode as unknown as ReturnType<typeof vi.fn>;
const mockedApiFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
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', type: 'local', status: 'online' }] as unknown as FleetNode[];
const nodes = [
{ id: 1, name: 'central', type: 'local', status: 'online' },
{ id: 2, name: 'edge', type: 'remote', status: 'online' },
] as unknown as FleetNode[];
// Default fleet: node 1 (local) has stacks web/db and a `prod` label; node 2
// (remote) has stack api and no labels (so `prod` will be created there).
function defaultReads(path: string, nodeId: number): Promise<Response> {
if (path === `/fleet/node/${nodeId}/stacks`) {
if (nodeId === 1) return Promise.resolve(jsonResponse(200, ['web', 'db']));
if (nodeId === 2) return Promise.resolve(jsonResponse(200, ['api']));
return Promise.resolve(jsonResponse(503, { error: 'unreachable' }));
}
if (path === '/labels') {
if (nodeId === 1) return Promise.resolve(jsonResponse(200, [{ id: 10, name: 'prod', color: 'blue' }]));
if (nodeId === 2) return Promise.resolve(jsonResponse(200, []));
return Promise.resolve(jsonResponse(503, { error: 'unreachable' }));
}
return Promise.resolve(jsonResponse(200, {}));
}
beforeEach(() => {
vi.clearAllMocks();
mockedFetchForNode.mockImplementation((path: string) => {
if (path === '/fleet/node/1/stacks') return Promise.resolve(jsonResponse(200, ['web']));
if (path === '/labels') return Promise.resolve(jsonResponse(200, [{ id: 10, name: 'prod', color: '#3b82f6' }]));
return Promise.resolve(jsonResponse(200, { results: [] }));
});
mockedFetchForNode.mockImplementation((path: string, nodeId: number) => defaultReads(path, nodeId));
mockedApiFetch.mockResolvedValue(jsonResponse(200, { results: [] }));
});
it('keeps Apply disabled until both a stack and a label are selected', async () => {
it('loads stacks across nodes and gates Apply until a template and a stack are selected', async () => {
const user = userEvent.setup();
render(<BulkLabelAssignCard nodes={nodes} />);
await screen.findByText('web');
expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled();
await user.click(screen.getByRole('checkbox'));
// Stack selected but no label yet.
await screen.findByText('api');
expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled();
await user.click(screen.getByText('prod'));
// Template selected but no stack yet.
expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled();
await user.click(screen.getByText('web'));
expect(screen.getByRole('button', { name: 'Apply' })).toBeEnabled();
});
it('applies the assignment after confirmation and renders per-stack results', async () => {
it('sends the label template plus per-node targets and renders per-node results', async () => {
const user = userEvent.setup();
mockedFetchForNode.mockImplementation((path: string) => {
if (path === '/fleet/node/1/stacks') return Promise.resolve(jsonResponse(200, ['web']));
if (path === '/labels') return Promise.resolve(jsonResponse(200, [{ id: 10, name: 'prod', color: '#3b82f6' }]));
if (path === '/fleet-actions/labels/bulk-assign') return Promise.resolve(jsonResponse(200, { results: [{ stackName: 'web', success: true }] }));
return Promise.resolve(jsonResponse(200, {}));
});
mockedApiFetch.mockResolvedValue(jsonResponse(200, {
results: [
{ nodeId: 1, nodeName: 'central', reachable: true, created: false, stackResults: [{ stackName: 'web', success: true }] },
{ nodeId: 2, nodeName: 'edge', reachable: true, created: true, stackResults: [{ stackName: 'api', success: true }] },
],
}));
render(<BulkLabelAssignCard nodes={nodes} />);
await screen.findByText('web');
await user.click(screen.getByRole('checkbox'));
await screen.findByText('api');
await user.click(screen.getByText('prod'));
await user.click(screen.getByText('web'));
await user.click(screen.getByText('api'));
await user.click(screen.getByRole('button', { name: 'Apply' }));
const dialog = await screen.findByRole('alertdialog');
await user.click(within(dialog).getByRole('button', { name: 'Apply' }));
await waitFor(() => {
const call = mockedFetchForNode.mock.calls.find(c => c[0] === '/fleet-actions/labels/bulk-assign');
const call = mockedApiFetch.mock.calls.find(c => c[0] === '/fleet/labels/bulk-assign');
expect(call).toBeTruthy();
expect(JSON.parse(call![2].body)).toEqual({ assignments: [{ stackName: 'web', labelIds: [10] }] });
expect(JSON.parse(call![1].body)).toEqual({
label: { name: 'prod', color: 'blue' },
targets: [
{ nodeId: 1, stackNames: ['web'] },
{ nodeId: 2, stackNames: ['api'] },
],
});
});
await screen.findByText(/label reused/);
await screen.findByText(/label created/);
await waitFor(() => expect(toastSuccess).toHaveBeenCalled());
});
it('surfaces an error toast when the assignment returns non-ok', async () => {
it('prefers the local node color when the same label name has different colors across nodes', async () => {
const user = userEvent.setup();
mockedFetchForNode.mockImplementation((path: string) => {
if (path === '/fleet/node/1/stacks') return Promise.resolve(jsonResponse(200, ['web']));
if (path === '/labels') return Promise.resolve(jsonResponse(200, [{ id: 10, name: 'prod', color: '#3b82f6' }]));
if (path === '/fleet-actions/labels/bulk-assign') return Promise.resolve(jsonResponse(500, { error: 'assign failed' }));
return Promise.resolve(jsonResponse(200, {}));
mockedFetchForNode.mockImplementation((path: string, nodeId: number) => {
if (path === '/labels' && nodeId === 2) return Promise.resolve(jsonResponse(200, [{ id: 20, name: 'prod', color: 'teal' }]));
return defaultReads(path, nodeId);
});
render(<BulkLabelAssignCard nodes={nodes} />);
await screen.findByText('web');
await user.click(screen.getByRole('checkbox'));
await user.click(screen.getByText('prod'));
await user.click(screen.getByText('web'));
await user.click(screen.getByRole('button', { name: 'Apply' }));
const dialog = await screen.findByRole('alertdialog');
await user.click(within(dialog).getByRole('button', { name: 'Apply' }));
await waitFor(() => {
const call = mockedApiFetch.mock.calls.find(c => c[0] === '/fleet/labels/bulk-assign');
expect(call).toBeTruthy();
expect(JSON.parse(call![1].body).label.color).toBe('blue');
});
});
it('marks a node unreachable when its reads fail', async () => {
const threeNodes = [
...nodes,
{ id: 3, name: 'offline', type: 'remote', status: 'offline' },
] as unknown as FleetNode[];
render(<BulkLabelAssignCard nodes={threeNodes} />);
await screen.findByText('web');
await waitFor(() => expect(screen.getByText('unreachable')).toBeInTheDocument());
});
it('surfaces an error toast when the orchestrator returns non-ok', async () => {
const user = userEvent.setup();
mockedApiFetch.mockResolvedValue(jsonResponse(500, { error: 'assign failed' }));
render(<BulkLabelAssignCard nodes={nodes} />);
await screen.findByText('web');
await user.click(screen.getByText('prod'));
await user.click(screen.getByText('web'));
await user.click(screen.getByRole('button', { name: 'Apply' }));
const dialog = await screen.findByRole('alertdialog');
await user.click(within(dialog).getByRole('button', { name: 'Apply' }));
await waitFor(() => expect(toastError).toHaveBeenCalledWith('assign failed'));
});
it('warns and renders an unreachable row when one node fails', async () => {
const user = userEvent.setup();
mockedApiFetch.mockResolvedValue(jsonResponse(200, {
results: [
{ nodeId: 1, nodeName: 'central', reachable: true, created: true, stackResults: [{ stackName: 'web', success: true }] },
{ nodeId: 2, nodeName: 'edge', reachable: false, created: false, error: 'Node unreachable', stackResults: [{ stackName: 'api', success: false, error: 'Node unreachable' }] },
],
}));
render(<BulkLabelAssignCard nodes={nodes} />);
await screen.findByText('api');
await user.click(screen.getByText('prod'));
await user.click(screen.getByText('web'));
await user.click(screen.getByText('api'));
await user.click(screen.getByRole('button', { name: 'Apply' }));
const dialog = await screen.findByRole('alertdialog');
await user.click(within(dialog).getByRole('button', { name: 'Apply' }));
await screen.findByText(/\(unreachable\)/);
await waitFor(() => expect(toastWarning).toHaveBeenCalled());
});
@@ -1,125 +1,217 @@
import { useEffect, useMemo, useState } from 'react';
import { Button } from '@/components/ui/button';
import { useEffect, useMemo, useRef, useState } from 'react';
import { ConfirmModal } from '@/components/ui/modal';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { FleetActionCard } from '@/components/ui/fleet-action-card';
import { SheetSection } from '@/components/ui/system-sheet';
import { LabelPill } from '@/components/LabelPill';
import { fetchForNode } from '@/lib/api';
import { apiFetch, fetchForNode } 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 { type Label, type LabelColor, LABEL_COLORS } from '@/components/label-types';
import { ResultsList, type ResultRow } from '../ResultsList';
interface NodeStackResult { stackName: string; success: boolean; error?: string }
interface AssignNodeResult {
nodeId: number;
nodeName: string;
reachable?: boolean;
error?: string;
created: boolean;
stackResults: NodeStackResult[];
}
interface NodeData {
node: FleetNode;
reachable: boolean;
stacks: string[];
labels: Label[];
}
// A label name that exists somewhere in the fleet, with a deterministic color
// to propagate. `colorConflict` flags names whose stored color differs across
// nodes (the local node's color wins, then the most common, then the first).
interface LabelTemplate {
name: string;
color: LabelColor;
colorConflict: boolean;
}
interface Props {
nodes: FleetNode[];
}
const KICKER = 'font-mono text-[10px] uppercase tracking-[0.18em]';
export function BulkLabelAssignCard({ nodes }: Props) {
const [selectedNodeId, setSelectedNodeId] = useState<number>(() => {
const local = nodes.find(n => n.type === 'local');
return Number(local?.id ?? nodes[0]?.id ?? 0);
});
const [stacks, setStacks] = useState<string[]>([]);
const [labels, setLabels] = useState<Label[]>([]);
const [loadingLists, setLoadingLists] = useState(false);
const [selectedStacks, setSelectedStacks] = useState<Set<string>>(new Set());
const [selectedLabels, setSelectedLabels] = useState<Set<number>>(new Set());
const [nodeData, setNodeData] = useState<NodeData[]>([]);
const [loading, setLoading] = useState(false);
const [selectedTemplate, setSelectedTemplate] = useState<LabelTemplate | null>(null);
// nodeId -> selected stack names on that node.
const [selected, setSelected] = useState<Map<number, Set<string>>>(new Map());
const [search, setSearch] = useState('');
const [confirmOpen, setConfirmOpen] = useState(false);
const [running, setRunning] = useState(false);
const [results, setResults] = useState<ResultRow[]>([]);
const selectedNode = useMemo(() => nodes.find(n => n.id === selectedNodeId), [nodes, selectedNodeId]);
// Re-fetch only when the set of node ids changes, not on every parent render
// (the nodes array is a fresh reference each render, e.g. on each fleet poll).
// The latest nodes are read through a ref so the load effect can rebuild
// per-node state without taking the array as a reactive dependency.
const nodesRef = useRef(nodes);
useEffect(() => { nodesRef.current = nodes; });
const nodeIds = nodes.map(n => n.id).join(',');
useEffect(() => {
if (!selectedNodeId) return;
let cancelled = false;
async function load() {
setLoadingLists(true);
setSelectedStacks(new Set());
setSelectedLabels(new Set());
setLoading(true);
setSelected(new Map());
setSelectedTemplate(null);
setResults([]);
try {
const [stacksRes, labelsRes] = await Promise.all([
fetchForNode(`/fleet/node/${selectedNodeId}/stacks`, selectedNodeId),
fetchForNode('/labels', selectedNodeId),
]);
const stacksList = stacksRes.ok ? ((await stacksRes.json()) as string[]) : [];
const labelsList = labelsRes.ok ? ((await labelsRes.json()) as Label[]) : [];
if (!cancelled) {
setStacks(stacksList);
setLabels(labelsList);
const entries = await Promise.all(nodesRef.current.map(async (node): Promise<NodeData> => {
try {
const [stacksRes, labelsRes] = await Promise.all([
fetchForNode(`/fleet/node/${node.id}/stacks`, node.id),
fetchForNode('/labels', node.id),
]);
const stacks = stacksRes.ok ? ((await stacksRes.json()) as string[]) : [];
const labels = labelsRes.ok ? ((await labelsRes.json()) as Label[]) : [];
if (!stacksRes.ok || !labelsRes.ok) {
console.warn(`[BulkLabelAssign] node ${node.id} (${node.name}) load incomplete: stacks ${stacksRes.status}, labels ${labelsRes.status}`);
}
return { node, reachable: stacksRes.ok && labelsRes.ok, stacks, labels };
} catch (err) {
console.error(`[BulkLabelAssign] failed to load node ${node.id} (${node.name}):`, err);
return { node, reachable: false, stacks: [], labels: [] };
}
} catch {
if (!cancelled) {
setStacks([]);
setLabels([]);
}
} finally {
if (!cancelled) setLoadingLists(false);
}));
if (!cancelled) {
setNodeData(entries);
setLoading(false);
}
}
load();
return () => { cancelled = true; };
}, [selectedNodeId]);
}, [nodeIds]);
function toggleStack(stackName: string) {
setSelectedStacks(prev => {
const next = new Set(prev);
if (next.has(stackName)) next.delete(stackName);
else next.add(stackName);
// Distinct label templates across the fleet (name + deterministic color).
const templates = useMemo<LabelTemplate[]>(() => {
const byName = new Map<string, { colors: Map<string, number>; localColor?: string }>();
for (const entry of nodeData) {
const isLocal = entry.node.type === 'local';
for (const label of entry.labels) {
const t = byName.get(label.name) ?? { colors: new Map<string, number>() };
t.colors.set(label.color, (t.colors.get(label.color) ?? 0) + 1);
if (isLocal) t.localColor = label.color;
byName.set(label.name, t);
}
}
return Array.from(byName.entries())
.map(([name, t]) => {
let color = t.localColor;
if (!color) {
let best = -1;
for (const [c, count] of t.colors) {
if (count > best) { best = count; color = c; }
}
}
const safe: LabelColor = typeof color === 'string' && (LABEL_COLORS as string[]).includes(color) ? (color as LabelColor) : 'slate';
return { name, color: safe, colorConflict: t.colors.size > 1 };
})
.sort((a, b) => a.name.localeCompare(b.name));
}, [nodeData]);
const totalSelected = useMemo(() => {
let n = 0;
for (const set of selected.values()) n += set.size;
return n;
}, [selected]);
const nodesWithSelection = useMemo(
() => Array.from(selected.values()).filter(s => s.size > 0).length,
[selected],
);
function toggleStack(nodeId: number, stackName: string) {
setSelected(prev => {
const next = new Map(prev);
const set = new Set(next.get(nodeId) ?? []);
if (set.has(stackName)) set.delete(stackName);
else set.add(stackName);
next.set(nodeId, set);
return next;
});
}
function toggleLabel(labelId: number) {
setSelectedLabels(prev => {
const next = new Set(prev);
if (next.has(labelId)) next.delete(labelId);
else next.add(labelId);
function toggleAllForNode(nodeId: number, stacks: string[]) {
setSelected(prev => {
const next = new Map(prev);
const set = new Set(next.get(nodeId) ?? []);
const allSelected = stacks.length > 0 && stacks.every(s => set.has(s));
if (allSelected) stacks.forEach(s => set.delete(s));
else stacks.forEach(s => set.add(s));
next.set(nodeId, set);
return next;
});
}
function toggleAllStacks() {
if (selectedStacks.size === stacks.length) setSelectedStacks(new Set());
else setSelectedStacks(new Set(stacks));
}
function clearSelection() {
setSelectedStacks(new Set());
setSelectedLabels(new Set());
function reset() {
setSelected(new Map());
setSelectedTemplate(null);
setResults([]);
setSearch('');
}
const filterQuery = search.trim().toLowerCase();
function filteredStacks(stacks: string[]): string[] {
if (!filterQuery) return stacks;
return stacks.filter(s => s.toLowerCase().includes(filterQuery));
}
async function run() {
if (selectedStacks.size === 0) return;
const labelIds = Array.from(selectedLabels);
const assignments = Array.from(selectedStacks).map(stackName => ({ stackName, labelIds }));
const toastId = toast.loading(`Assigning labels to ${assignments.length} stack${assignments.length === 1 ? '' : 's'}`);
if (!selectedTemplate || totalSelected === 0) return;
const targets = Array.from(selected.entries())
.filter(([, set]) => set.size > 0)
.map(([nodeId, set]) => ({ nodeId, stackNames: Array.from(set) }));
const toastId = toast.loading(`Assigning "${selectedTemplate.name}" to ${totalSelected} stack${totalSelected === 1 ? '' : 's'} across ${targets.length} node${targets.length === 1 ? '' : 's'}`);
setRunning(true);
setResults([]);
try {
const res = await fetchForNode('/fleet-actions/labels/bulk-assign', selectedNodeId, {
const res = await apiFetch('/fleet/labels/bulk-assign', {
method: 'POST',
body: JSON.stringify({ assignments }),
body: JSON.stringify({ label: { name: selectedTemplate.name, color: selectedTemplate.color }, targets }),
});
const body = await res.json().catch(() => ({}));
toast.dismiss(toastId);
if (!res.ok) {
toast.error(body.error || 'Bulk label assignment failed');
toast.error(body.error || 'Bulk label assign failed');
return;
}
const rows: ResultRow[] = (body.results as NodeStackResult[] ?? []).map((r, i) => ({
key: `${r.stackName}-${i}`,
label: r.stackName || '(unnamed)',
success: r.success,
error: r.error,
}));
const apiResults = (body.results as AssignNodeResult[]) ?? [];
const rows: ResultRow[] = apiResults.map((node) => {
const unreachable = node.reachable === false;
const ok = node.stackResults.filter(s => s.success).length;
return {
key: `node-${node.nodeId}`,
label: unreachable
? `${node.nodeName} (unreachable)`
: `${node.nodeName} · ${node.created ? 'label created' : 'label reused'} · ${ok}/${node.stackResults.length} stack${node.stackResults.length === 1 ? '' : 's'}`,
success: !unreachable && node.stackResults.length > 0 && node.stackResults.every(s => s.success),
error: unreachable ? (node.error ?? 'Node unreachable') : undefined,
sub: node.stackResults.map((s, i) => ({
key: `${node.nodeId}-${s.stackName}-${i}`,
label: s.stackName,
success: s.success,
error: s.error,
})),
};
});
setResults(rows);
const ok = rows.filter(r => r.success).length;
const failed = rows.length - ok;
if (failed === 0) toast.success(`Updated labels on ${ok} stack${ok === 1 ? '' : 's'}.`);
else toast.warning(`${ok} updated, ${failed} failed. See results below.`);
const allStacks = apiResults.flatMap(n => n.stackResults);
const ok = allStacks.filter(s => s.success).length;
const failed = allStacks.length - ok;
const unreachableCount = apiResults.filter(n => n.reachable === false).length;
if (failed === 0 && unreachableCount === 0) toast.success(`Assigned "${selectedTemplate.name}" to ${ok} stack${ok === 1 ? '' : 's'} across ${apiResults.length} node${apiResults.length === 1 ? '' : 's'}.`);
else toast.warning(`${ok} assigned, ${failed} failed${unreachableCount > 0 ? `, ${unreachableCount} node${unreachableCount === 1 ? '' : 's'} unreachable` : ''}. See results below.`);
} catch (err) {
toast.dismiss(toastId);
toast.error(err instanceof Error ? err.message : 'Network error');
@@ -130,108 +222,167 @@ export function BulkLabelAssignCard({ nodes }: Props) {
}
const blastValue = useMemo(() => {
if (selectedStacks.size === 0 || selectedLabels.size === 0 || !selectedNode) return 'awaiting target';
const stackLabel = `${selectedStacks.size} ${selectedStacks.size === 1 ? 'stack' : 'stacks'}`;
// "local · " prefix triggers the primitive's cyan-dot path per §18.5.
if (selectedNode.type === 'local') return `local · ${stackLabel}`;
return `${selectedNode.name} · ${stackLabel}`;
}, [selectedNode, selectedStacks.size, selectedLabels.size]);
if (!selectedTemplate || totalSelected === 0) return 'awaiting target';
return `${totalSelected} stack${totalSelected === 1 ? '' : 's'} · ${nodesWithSelection} node${nodesWithSelection === 1 ? '' : 's'}`;
}, [selectedTemplate, totalSelected, nodesWithSelection]);
const previewNodes = useMemo(() => {
if (!selectedTemplate) return [];
return nodeData
.map(entry => {
const set = selected.get(entry.node.id);
const stacks = set ? Array.from(set) : [];
if (stacks.length === 0) return null;
const willCreate = !entry.labels.some(l => l.name === selectedTemplate.name);
return { nodeId: entry.node.id, nodeName: entry.node.name, willCreate, stacks };
})
.filter((n): n is { nodeId: number; nodeName: string; willCreate: boolean; stacks: string[] } => n !== null);
}, [nodeData, selected, selectedTemplate]);
return (
<>
<FleetActionCard
crumb={['Fleet', 'Actions', 'Bulk label assign']}
name="Bulk label assign."
meta="one node · multi-stack · replaces existing label set"
meta="cross-node · adds label · creates it where missing · preserves existing"
actionClass="transformative"
blastRadius={{ value: blastValue }}
secondaryAction={{
label: 'Reset',
onClick: clearSelection,
disabled: running || (selectedStacks.size === 0 && selectedLabels.size === 0),
onClick: reset,
disabled: running || (totalSelected === 0 && !selectedTemplate),
}}
primaryAction={{
label: 'Apply',
onClick: () => setConfirmOpen(true),
variant: 'primary',
disabled: running || selectedStacks.size === 0 || selectedLabels.size === 0,
disabled: running || !selectedTemplate || totalSelected === 0,
}}
footerContext="Reversible · yes · reassign anytime"
>
<SheetSection title="Node" meta={loadingLists ? 'loading…' : undefined}>
<NodeSegmented
nodes={nodes}
value={selectedNodeId}
onChange={setSelectedNodeId}
disabled={running}
/>
</SheetSection>
<SheetSection
title={`Stacks · ${selectedStacks.size} / ${stacks.length}`}
meta={stacks.length > 0
? (selectedStacks.size === stacks.length ? 'all selected' : 'multi-select')
: undefined}
title="Label · source"
meta={loading ? 'loading…' : `${templates.length} label${templates.length === 1 ? '' : 's'}`}
>
<div className="grid gap-0.5 max-h-44 overflow-auto pr-1 border border-card-border/40 rounded-md p-2">
{stacks.length === 0 && (
<p className={cn(KICKER, 'text-stat-subtitle mb-2 normal-case tracking-normal text-[11px]')}>
Pick a stack label from anywhere in the fleet. It is added to the selected stacks on each node, and created there with this color if the node does not have it yet.
</p>
<div className="flex flex-wrap gap-1.5 max-h-32 overflow-auto p-2 border border-card-border/40 rounded-md">
{templates.length === 0 && (
<span className="text-xs text-stat-subtitle">
{loadingLists ? 'Loading…' : selectedNode ? `No stacks on ${selectedNode.name}.` : 'Pick a node.'}
{loading ? 'Loading…' : 'No stack labels defined across the fleet.'}
</span>
)}
{stacks.map(stackName => (
<label
key={stackName}
className="flex items-center gap-2 py-1 px-1 rounded hover:bg-glass-highlight cursor-pointer"
>
<Checkbox
checked={selectedStacks.has(stackName)}
onCheckedChange={() => toggleStack(stackName)}
disabled={running}
{templates.map(t => {
const synthetic: Label = { id: -1, node_id: -1, name: t.name, color: t.color };
const active = selectedTemplate?.name === t.name;
return (
<LabelPill
key={t.name}
label={synthetic}
active={active}
onClick={() => !running && setSelectedTemplate(active ? null : t)}
/>
<span className="text-xs font-mono text-stat-value">{stackName}</span>
</label>
))}
);
})}
</div>
{stacks.length > 0 && (
<button
type="button"
disabled={running}
onClick={toggleAllStacks}
className="mt-2 font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle hover:text-stat-value disabled:opacity-50"
>
{selectedStacks.size === stacks.length ? 'Clear all' : 'Select all'}
</button>
{selectedTemplate?.colorConflict && (
<p className="mt-2 text-[11px] text-stat-subtitle">
This label uses different colors on different nodes. The shown color is applied where it is created.
</p>
)}
</SheetSection>
<SheetSection
title={`Labels · ${selectedLabels.size} / ${labels.length}`}
meta="replaces existing"
title={`Target stacks · ${totalSelected} selected`}
meta={nodesWithSelection > 0 ? `${nodesWithSelection} node${nodesWithSelection === 1 ? '' : 's'}` : undefined}
>
<div className="flex flex-wrap gap-1.5 max-h-32 overflow-auto p-2 border border-card-border/40 rounded-md">
{labels.length === 0 && (
<span className="text-xs text-stat-subtitle">
{loadingLists ? 'Loading…' : selectedNode ? `No labels defined on ${selectedNode.name}.` : ''}
</span>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Filter stacks…"
className="h-9 text-sm mb-2"
disabled={running}
autoComplete="off"
spellCheck={false}
/>
<div className="grid gap-2 max-h-64 overflow-auto pr-1">
{nodeData.length === 0 && (
<span className="text-xs text-stat-subtitle">{loading ? 'Loading…' : 'No nodes in the fleet.'}</span>
)}
{labels.map(label => (
<LabelPill
key={label.id}
label={label}
active={selectedLabels.has(label.id)}
onClick={() => !running && toggleLabel(label.id)}
/>
))}
{nodeData.map(entry => {
const stacks = filteredStacks(entry.stacks);
const set = selected.get(entry.node.id) ?? new Set<string>();
const allSelected = stacks.length > 0 && stacks.every(s => set.has(s));
return (
<div key={entry.node.id} className="border border-card-border/40 rounded-md p-2">
<div className="flex items-center justify-between mb-1">
<span className={cn(KICKER, 'text-stat-value')}>
{entry.node.name}{entry.node.type === 'local' ? ' · local' : ''}
</span>
{entry.reachable && stacks.length > 0 ? (
<button
type="button"
disabled={running}
onClick={() => toggleAllForNode(entry.node.id, stacks)}
className={cn(KICKER, 'text-stat-subtitle hover:text-stat-value disabled:opacity-50')}
>
{allSelected ? 'Clear' : 'Select all'}
</button>
) : (
<span className={cn(KICKER, entry.reachable ? 'text-stat-icon' : 'text-destructive')}>
{entry.reachable ? (filterQuery ? 'no matches' : 'no stacks') : 'unreachable'}
</span>
)}
</div>
{stacks.map(stackName => (
<label
key={stackName}
className="flex items-center gap-2 py-1 px-1 rounded hover:bg-glass-highlight cursor-pointer"
>
<Checkbox
checked={set.has(stackName)}
onCheckedChange={() => toggleStack(entry.node.id, stackName)}
disabled={running}
/>
<span className="text-xs font-mono text-stat-value">{stackName}</span>
</label>
))}
</div>
);
})}
</div>
<p className="mt-2 text-[11px] text-stat-subtitle">
Selected labels replace each chosen stack's existing label set on this node.
Selecting no labels clears assignments.
</p>
</SheetSection>
{selectedTemplate && previewNodes.length > 0 && (
<SheetSection
title={`Preview · ${totalSelected} stack${totalSelected === 1 ? '' : 's'}`}
meta={`across ${previewNodes.length} node${previewNodes.length === 1 ? '' : 's'}`}
>
<div className="rounded border border-card-border/60 bg-card/40 shadow-[inset_0_2px_4px_0_oklch(0_0_0_/_0.35)] p-2 space-y-1">
{previewNodes.map(n => (
<div key={n.nodeId} className="flex items-center gap-2">
<span className={cn(
KICKER,
'inline-flex items-center px-1 py-0.5 rounded-sm border shrink-0',
n.willCreate
? 'border-amber-400/40 bg-amber-400/10 text-amber-400'
: 'border-success/40 bg-success/10 text-success',
)}>
{n.willCreate ? 'create' : 'reuse'}
</span>
<span className="flex-1 min-w-0 truncate text-[11px] text-stat-value">{n.nodeName}</span>
<span className={cn(KICKER, 'shrink-0 text-stat-subtitle')}>
{n.stacks.length} stack{n.stacks.length === 1 ? '' : 's'}
</span>
</div>
))}
</div>
</SheetSection>
)}
{results.length > 0 && (
<SheetSection title="Per-stack results">
<SheetSection title="Per-node breakdown">
<ResultsList results={results} />
</SheetSection>
)}
@@ -242,12 +393,8 @@ export function BulkLabelAssignCard({ nodes }: Props) {
onOpenChange={(open) => { if (!open) setConfirmOpen(false); }}
variant="default"
kicker="Bulk label assign"
title={`Apply ${selectedLabels.size} label${selectedLabels.size === 1 ? '' : 's'} to ${selectedStacks.size} stack${selectedStacks.size === 1 ? '' : 's'}?`}
description={
selectedLabels.size === 0
? 'No labels selected, this will clear existing assignments on the selected stacks.'
: `Each selected stack's existing label set on ${selectedNode?.name ?? 'this node'} will be replaced with the chosen labels.`
}
title={`Assign "${selectedTemplate?.name ?? ''}" to ${totalSelected} stack${totalSelected === 1 ? '' : 's'} across ${nodesWithSelection} node${nodesWithSelection === 1 ? '' : 's'}?`}
description="The label is added to each selected stack, preserving its existing labels. On nodes that do not have this label yet, Sencho creates it with the chosen color."
confirmLabel="Apply"
confirming={running}
onConfirm={run}
@@ -255,33 +402,3 @@ export function BulkLabelAssignCard({ nodes }: Props) {
</>
);
}
interface NodeSegmentedProps {
nodes: FleetNode[];
value: number;
onChange: (id: number) => void;
disabled: boolean;
}
function NodeSegmented({ nodes, value, onChange, disabled }: NodeSegmentedProps) {
return (
<div className="inline-flex flex-wrap rounded-md border border-card-border/60 overflow-hidden">
{nodes.map(n => {
const active = n.id === value;
return (
<Button
key={n.id}
type="button"
variant={active ? 'default' : 'outline'}
size="sm"
disabled={disabled}
onClick={() => onChange(n.id)}
className={cn('rounded-none border-0 h-8 px-3 text-xs', active && 'pointer-events-none')}
>
{n.name}{n.type === 'local' ? ' (local)' : ''}
</Button>
);
})}
</div>
);
}