feat(fleet): redesign Fleet Action cards to the System Sheet recipe (#1137)

Lift <FleetActionCard> primitive from audit §20 / DESIGN §9.12. Migrate
the three v1 cards (stop-by-label, bulk-label-assign, prune-fleet-wide)
to consume it: cyan rail on every card, action class as a chip, blast
radius as a live readout in the toolbar, preview section replaces the
warning banner, footer carries reversibility plus freshness. Drop the
per-card tone rail, the icon prop, and cards/tone.ts.

Add /fleet/labels/match-preview and /fleet/prune/estimate for the live
blast readouts (chrome falls back to "preview unavailable" if either
404s). Add dryRun: true to the existing fleet-stop and fleet-prune
endpoints so the Dry run button rehearses the full code path (locks,
per-node fan-out, remote propagation) without firing the destructive
leaf call. Result flows into the existing ResultsList.

Extend <SheetSection> with optional meta. Add --action-transformative
semantic token.
This commit is contained in:
Anso
2026-05-21 11:45:52 -04:00
committed by GitHub
parent d0e140444a
commit 6f301e005a
13 changed files with 1471 additions and 423 deletions
@@ -0,0 +1,331 @@
/**
* Tests for the Fleet Action card preview / estimate endpoints and the dry-run
* flag added to the existing fleet-stop / fleet-prune routes.
*
* Covers:
* - POST /api/fleet/labels/match-preview (new): auth, tier, validation, real return shape.
* - POST /api/fleet/prune/estimate (new): auth, tier, validation, local + remote fan-out.
* - POST /api/fleet/labels/fleet-stop with dryRun: true: rehearses without invoking the destructive leaf.
* - POST /api/fleet/labels/fleet-prune with dryRun: true: rehearses without invoking pruneManagedOnly / pruneSystem.
*/
import { beforeAll, beforeEach, afterAll, describe, expect, it, vi } from 'vitest';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
let mockFsStacks: string[] = [];
const pruneManagedOnly = vi.fn();
const pruneSystem = vi.fn();
const estimateManagedReclaim = vi.fn();
const estimateSystemReclaim = vi.fn();
const getContainersByStack = vi.fn();
const stopContainer = vi.fn();
const restartContainer = vi.fn();
const invalidateNodeCaches = vi.fn();
vi.mock('../services/FileSystemService', () => ({
FileSystemService: {
getInstance: vi.fn(() => ({
getStacks: vi.fn(async () => mockFsStacks),
})),
},
}));
vi.mock('../services/DockerController', () => ({
default: {
getInstance: vi.fn(() => ({
pruneManagedOnly,
pruneSystem,
estimateManagedReclaim,
estimateSystemReclaim,
getContainersByStack,
stopContainer,
restartContainer,
})),
},
}));
vi.mock('../helpers/cacheInvalidation', () => ({
invalidateNodeCaches,
}));
let tmpDir: string;
let app: import('express').Express;
let authHeader: string;
let db: import('../services/DatabaseService').DatabaseService;
let LicenseService: typeof import('../services/LicenseService').LicenseService;
let activeBulkActions: typeof import('../routes/labels').activeBulkActions;
let labelCounter = 0;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
({ LicenseService } = await import('../services/LicenseService'));
({ activeBulkActions } = await import('../routes/labels'));
const { DatabaseService } = await import('../services/DatabaseService');
db = DatabaseService.getInstance();
authHeader = `Bearer ${jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' })}`;
});
afterAll(() => cleanupTestDb(tmpDir));
beforeEach(() => {
// restoreAllMocks resets spies but leaves call history on module-top vi.fn()
// mocks intact; clearAllMocks zeroes that history so per-test call counts are
// not polluted by earlier tests.
vi.restoreAllMocks();
vi.clearAllMocks();
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
mockFsStacks = ['alpha', 'beta'];
pruneManagedOnly.mockResolvedValue({ success: true, reclaimedBytes: 0 });
pruneSystem.mockResolvedValue({ success: true, reclaimedBytes: 0 });
estimateManagedReclaim.mockResolvedValue({ reclaimableBytes: 0 });
estimateSystemReclaim.mockResolvedValue({ reclaimableBytes: 0 });
getContainersByStack.mockResolvedValue([{ Id: 'container-1' }]);
stopContainer.mockResolvedValue(undefined);
restartContainer.mockResolvedValue(undefined);
activeBulkActions.clear();
db.getDb().prepare('DELETE FROM stack_label_assignments').run();
db.getDb().prepare('DELETE FROM stack_labels').run();
});
async function createAssignedLabel(name: string, stacks: string[]) {
const created = await request(app)
.post('/api/labels')
.set('Authorization', authHeader)
.send({ name: `${name}-${++labelCounter}`, color: 'teal' });
expect(created.status).toBe(201);
for (const stack of stacks) {
const assigned = await request(app)
.put(`/api/stacks/${stack}/labels`)
.set('Authorization', authHeader)
.send({ labelIds: [created.body.id] });
expect(assigned.status).toBe(200);
}
return created.body as { id: number; node_id: number; name: string };
}
describe('POST /api/fleet/labels/match-preview', () => {
it('returns 401 without auth', async () => {
const res = await request(app)
.post('/api/fleet/labels/match-preview')
.send({ labelName: 'x' });
expect(res.status).toBe(401);
});
it('returns 403 PAID_REQUIRED on community tier', async () => {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
const res = await request(app)
.post('/api/fleet/labels/match-preview')
.set('Authorization', authHeader)
.send({ labelName: 'x' });
expect(res.status).toBe(403);
expect(res.body.code).toBe('PAID_REQUIRED');
});
it('returns 400 when labelName is missing or empty', async () => {
const a = await request(app)
.post('/api/fleet/labels/match-preview')
.set('Authorization', authHeader)
.send({});
expect(a.status).toBe(400);
const b = await request(app)
.post('/api/fleet/labels/match-preview')
.set('Authorization', authHeader)
.send({ labelName: ' ' });
expect(b.status).toBe(400);
});
it('returns matched counts and per-node stack lists for a real label', async () => {
const label = await createAssignedLabel('preview', ['alpha', 'beta']);
const res = await request(app)
.post('/api/fleet/labels/match-preview')
.set('Authorization', authHeader)
.send({ labelName: label.name });
expect(res.status).toBe(200);
expect(res.body.matchedNodes).toBe(1);
expect(res.body.matchedStacks).toBe(2);
expect(res.body.perNode).toHaveLength(1);
expect(res.body.perNode[0].stackCount).toBe(2);
expect(res.body.perNode[0].stackNames.sort()).toEqual(['alpha', 'beta']);
});
it('returns zero counts for an unknown label without erroring', async () => {
const res = await request(app)
.post('/api/fleet/labels/match-preview')
.set('Authorization', authHeader)
.send({ labelName: 'does-not-exist' });
expect(res.status).toBe(200);
expect(res.body.matchedNodes).toBe(0);
expect(res.body.matchedStacks).toBe(0);
});
});
describe('POST /api/fleet/prune/estimate', () => {
it('returns 401 without auth', async () => {
const res = await request(app)
.post('/api/fleet/prune/estimate')
.send({ targets: ['images'], scope: 'managed' });
expect(res.status).toBe(401);
});
it('returns 403 PAID_REQUIRED on community tier', async () => {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
const res = await request(app)
.post('/api/fleet/prune/estimate')
.set('Authorization', authHeader)
.send({ targets: ['images'], scope: 'managed' });
expect(res.status).toBe(403);
});
it('returns 400 when targets is empty', async () => {
const res = await request(app)
.post('/api/fleet/prune/estimate')
.set('Authorization', authHeader)
.send({ targets: [], scope: 'managed' });
expect(res.status).toBe(400);
});
it('aggregates per-node reclaimable bytes from estimateManagedReclaim on the local node', async () => {
estimateManagedReclaim.mockImplementation(async (target: string) =>
({ reclaimableBytes: target === 'images' ? 4096 : 256 }));
const res = await request(app)
.post('/api/fleet/prune/estimate')
.set('Authorization', authHeader)
.send({ targets: ['images', 'volumes'], scope: 'managed' });
expect(res.status).toBe(200);
expect(res.body.totalBytes).toBe(4096 + 256);
expect(res.body.perNode).toHaveLength(1);
expect(res.body.perNode[0].reachable).toBe(true);
expect(res.body.perNode[0].reclaimableBytes).toBe(4096 + 256);
expect(pruneManagedOnly).not.toHaveBeenCalled();
expect(pruneSystem).not.toHaveBeenCalled();
});
it('uses estimateSystemReclaim when scope is "all"', async () => {
estimateSystemReclaim.mockResolvedValue({ reclaimableBytes: 1024 });
const res = await request(app)
.post('/api/fleet/prune/estimate')
.set('Authorization', authHeader)
.send({ targets: ['images'], scope: 'all' });
expect(res.status).toBe(200);
expect(estimateManagedReclaim).not.toHaveBeenCalled();
expect(estimateSystemReclaim).toHaveBeenCalled();
expect(res.body.perNode[0].reclaimableBytes).toBe(1024);
});
it('marks a remote node unreachable when its estimate endpoint is down', async () => {
const remoteId = db.addNode({
name: 'remote-est',
type: 'remote',
api_url: 'http://remote-est.example:1852',
api_token: 'tok',
compose_dir: '/app/compose',
is_default: false,
});
try {
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('connect ECONNREFUSED'));
const res = await request(app)
.post('/api/fleet/prune/estimate')
.set('Authorization', authHeader)
.send({ targets: ['images'], scope: 'managed' });
expect(res.status).toBe(200);
const remote = res.body.perNode.find((n: { nodeId: number }) => n.nodeId === remoteId);
expect(remote.reachable).toBe(false);
expect(remote.error).toMatch(/ECONNREFUSED/);
expect(remote.reclaimableBytes).toBe(0);
} finally {
db.deleteNode(remoteId);
}
});
});
describe('POST /api/fleet/labels/fleet-stop with dryRun: true', () => {
it('marks each stack dryRun: true and does not invoke containerActionForStack', async () => {
const label = await createAssignedLabel('dry-stop', ['alpha', 'beta']);
const res = await request(app)
.post('/api/fleet/labels/fleet-stop')
.set('Authorization', authHeader)
.send({ labelName: label.name, dryRun: true });
expect(res.status).toBe(200);
expect(res.body.results).toHaveLength(1);
const node = res.body.results[0];
expect(node.matched).toBe(true);
expect(node.stackResults).toHaveLength(2);
for (const stack of node.stackResults) {
expect(stack.success).toBe(true);
expect(stack.dryRun).toBe(true);
}
// containerActionForStack walks DockerController.stopContainer / restartContainer
// internally; if dry-run incorrectly invoked it, those mocks would record calls.
expect(stopContainer).not.toHaveBeenCalled();
expect(restartContainer).not.toHaveBeenCalled();
// Dry run must not bust caches.
expect(invalidateNodeCaches).not.toHaveBeenCalled();
});
it('still runs the real action when dryRun is omitted', async () => {
const label = await createAssignedLabel('real-stop', ['alpha']);
const res = await request(app)
.post('/api/fleet/labels/fleet-stop')
.set('Authorization', authHeader)
.send({ labelName: label.name });
expect(res.status).toBe(200);
// The destructive path invokes containerActionForStack, which in turn calls
// DockerController.stopContainer for each container on the matched stack.
expect(stopContainer).toHaveBeenCalled();
// The destructive path should also have invalidated the local node's cache.
expect(invalidateNodeCaches).toHaveBeenCalled();
});
});
describe('POST /api/fleet/labels/fleet-prune with dryRun: true', () => {
it('routes to estimateManagedReclaim and marks each target dryRun: true', async () => {
estimateManagedReclaim.mockResolvedValue({ reclaimableBytes: 2048 });
const res = await request(app)
.post('/api/fleet/labels/fleet-prune')
.set('Authorization', authHeader)
.send({ targets: ['images', 'volumes'], scope: 'managed', dryRun: true });
expect(res.status).toBe(200);
const node = res.body.results[0];
expect(node.reachable).toBe(true);
expect(node.targets).toHaveLength(2);
for (const t of node.targets) {
expect(t.success).toBe(true);
expect(t.reclaimedBytes).toBe(2048);
expect(t.dryRun).toBe(true);
}
expect(pruneManagedOnly).not.toHaveBeenCalled();
expect(pruneSystem).not.toHaveBeenCalled();
expect(estimateManagedReclaim).toHaveBeenCalledTimes(2);
expect(invalidateNodeCaches).not.toHaveBeenCalled();
});
it('routes to estimateSystemReclaim when scope is "all"', async () => {
estimateSystemReclaim.mockResolvedValue({ reclaimableBytes: 8192 });
const res = await request(app)
.post('/api/fleet/labels/fleet-prune')
.set('Authorization', authHeader)
.send({ targets: ['images'], scope: 'all', dryRun: true });
expect(res.status).toBe(200);
expect(estimateManagedReclaim).not.toHaveBeenCalled();
expect(estimateSystemReclaim).toHaveBeenCalled();
expect(pruneManagedOnly).not.toHaveBeenCalled();
expect(pruneSystem).not.toHaveBeenCalled();
expect(res.body.results[0].targets[0].reclaimedBytes).toBe(8192);
});
it('still invokes pruneManagedOnly when dryRun is omitted', async () => {
pruneManagedOnly.mockResolvedValue({ success: true, reclaimedBytes: 512 });
const res = await request(app)
.post('/api/fleet/labels/fleet-prune')
.set('Authorization', authHeader)
.send({ targets: ['images'], scope: 'managed' });
expect(res.status).toBe(200);
expect(pruneManagedOnly).toHaveBeenCalled();
expect(estimateManagedReclaim).not.toHaveBeenCalled();
expect(res.body.results[0].targets[0].dryRun).toBeUndefined();
});
});