mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
+181
-13
@@ -1041,17 +1041,18 @@ fleetRouter.delete('/update-status', authMiddleware, async (req: Request, res: R
|
||||
fleetRouter.post('/labels/fleet-stop', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
const body = req.body as { labelName?: unknown } | undefined;
|
||||
const body = req.body as { labelName?: unknown; dryRun?: unknown } | undefined;
|
||||
if (!body || typeof body !== 'object') {
|
||||
res.status(400).json({ error: 'Request body is required' });
|
||||
return;
|
||||
}
|
||||
const { labelName } = body;
|
||||
const { labelName, dryRun } = body;
|
||||
if (typeof labelName !== 'string' || labelName.trim().length === 0) {
|
||||
res.status(400).json({ error: 'labelName is required' });
|
||||
return;
|
||||
}
|
||||
const trimmed = labelName.trim();
|
||||
const isDryRun = dryRun === true;
|
||||
try {
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodes = db.getNodes();
|
||||
@@ -1068,7 +1069,8 @@ fleetRouter.post('/labels/fleet-stop', authMiddleware, async (req: Request, res:
|
||||
if (node.type === 'local') {
|
||||
// Share the per-node bulk lock with `POST /api/labels/:id/action` so
|
||||
// a fleet-stop and a per-label action cannot double-stop the same
|
||||
// containers concurrently on the same local node.
|
||||
// containers concurrently on the same local node. Dry run acquires
|
||||
// the same lock so the rehearsal exercises the same contention path.
|
||||
const lockKey = `bulk:${node.id}`;
|
||||
if (activeBulkActions.has(lockKey)) {
|
||||
return {
|
||||
@@ -1081,14 +1083,18 @@ fleetRouter.post('/labels/fleet-stop', authMiddleware, async (req: Request, res:
|
||||
const fsStacks = await FileSystemService.getInstance(node.id).getStacks();
|
||||
const fsStackSet = new Set(fsStacks);
|
||||
const validStacks = stackNames.filter(name => fsStackSet.has(name));
|
||||
const stackResults: { stackName: string; success: boolean; error?: string }[] = [];
|
||||
const stackResults: { stackName: string; success: boolean; error?: string; dryRun?: boolean }[] = [];
|
||||
for (const stackName of validStacks) {
|
||||
if (isDryRun) {
|
||||
stackResults.push({ stackName, success: true, dryRun: true });
|
||||
continue;
|
||||
}
|
||||
const outcome = await containerActionForStack(node.id, stackName, 'stop');
|
||||
if (outcome.kind === 'ok') stackResults.push({ stackName, success: true });
|
||||
else if (outcome.kind === 'no-containers') stackResults.push({ stackName, success: false, error: 'No containers found for this stack' });
|
||||
else stackResults.push({ stackName, success: false, error: outcome.message });
|
||||
}
|
||||
if (stackResults.some(r => r.success)) invalidateNodeCaches(node.id);
|
||||
if (!isDryRun && stackResults.some(r => r.success)) invalidateNodeCaches(node.id);
|
||||
return { nodeId: node.id, nodeName: node.name, matched: true, stackResults };
|
||||
} finally {
|
||||
activeBulkActions.delete(lockKey);
|
||||
@@ -1105,7 +1111,7 @@ fleetRouter.post('/labels/fleet-stop', authMiddleware, async (req: Request, res:
|
||||
const response = await fetch(`${node.api_url.replace(/\/$/, '')}/api/labels/${label.id}/action`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${node.api_token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'stop' }),
|
||||
body: JSON.stringify({ action: 'stop', dryRun: isDryRun }),
|
||||
signal: AbortSignal.timeout(60000),
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -1116,7 +1122,7 @@ fleetRouter.post('/labels/fleet-stop', authMiddleware, async (req: Request, res:
|
||||
stackResults: stackNames.map(stackName => ({ stackName, success: false, error: message })),
|
||||
};
|
||||
}
|
||||
const remote = (await response.json()) as { results?: { stackName: string; success: boolean; error?: string }[] };
|
||||
const remote = (await response.json()) as { results?: { stackName: string; success: boolean; error?: string; dryRun?: boolean }[] };
|
||||
return { nodeId: node.id, nodeName: node.name, matched: true, stackResults: remote.results ?? [] };
|
||||
} catch (err) {
|
||||
const errorMsg = getErrorMessage(err, 'Failed to reach remote node');
|
||||
@@ -1149,7 +1155,7 @@ fleetRouter.post('/labels/fleet-prune', authMiddleware, async (req: Request, res
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
|
||||
const body = req.body as { targets?: unknown; scope?: unknown } | undefined;
|
||||
const body = req.body as { targets?: unknown; scope?: unknown; dryRun?: unknown } | undefined;
|
||||
if (!body || typeof body !== 'object') {
|
||||
res.status(400).json({ error: 'Request body is required' });
|
||||
return;
|
||||
@@ -1169,8 +1175,9 @@ fleetRouter.post('/labels/fleet-prune', authMiddleware, async (req: Request, res
|
||||
}
|
||||
const targets: FleetPruneTarget[] = Array.from(dedup);
|
||||
const scope: 'managed' | 'all' = body.scope === 'all' ? 'all' : 'managed';
|
||||
const isDryRun = body.dryRun === true;
|
||||
|
||||
type TargetResult = { target: FleetPruneTarget; success: boolean; reclaimedBytes: number; error?: string };
|
||||
type TargetResult = { target: FleetPruneTarget; success: boolean; reclaimedBytes: number; error?: string; dryRun?: boolean };
|
||||
type NodeResult = {
|
||||
nodeId: number; nodeName: string; reachable: boolean; error?: string; targets: TargetResult[];
|
||||
};
|
||||
@@ -1196,6 +1203,13 @@ fleetRouter.post('/labels/fleet-prune', authMiddleware, async (req: Request, res
|
||||
let anySuccess = false;
|
||||
for (const target of targets) {
|
||||
try {
|
||||
if (isDryRun) {
|
||||
const estimate = scope === 'managed'
|
||||
? await dockerController.estimateManagedReclaim(target, knownStacks)
|
||||
: await dockerController.estimateSystemReclaim(target, knownStacks);
|
||||
targetResults.push({ target, success: true, reclaimedBytes: estimate.reclaimableBytes, dryRun: true });
|
||||
continue;
|
||||
}
|
||||
const result = scope === 'managed'
|
||||
? await dockerController.pruneManagedOnly(target, knownStacks)
|
||||
: await dockerController.pruneSystem(target);
|
||||
@@ -1205,7 +1219,7 @@ fleetRouter.post('/labels/fleet-prune', authMiddleware, async (req: Request, res
|
||||
targetResults.push({ target, success: false, reclaimedBytes: 0, error: getErrorMessage(err, 'Prune failed') });
|
||||
}
|
||||
}
|
||||
if (anySuccess) invalidateNodeCaches(node.id);
|
||||
if (anySuccess && !isDryRun) invalidateNodeCaches(node.id);
|
||||
return { nodeId: node.id, nodeName: node.name, reachable: true, targets: targetResults };
|
||||
} finally {
|
||||
activeBulkActions.delete(lockKey);
|
||||
@@ -1232,7 +1246,7 @@ fleetRouter.post('/labels/fleet-prune', authMiddleware, async (req: Request, res
|
||||
const response = await fetch(`${baseUrl}/api/system/prune/system`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${node.api_token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ target, scope }),
|
||||
body: JSON.stringify({ target, scope, dryRun: isDryRun }),
|
||||
signal: AbortSignal.timeout(120000),
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -1242,12 +1256,14 @@ fleetRouter.post('/labels/fleet-prune', authMiddleware, async (req: Request, res
|
||||
targetResults.push({ target, success: false, reclaimedBytes: 0, error: message });
|
||||
continue;
|
||||
}
|
||||
const remote = (await response.json().catch(() => null)) as { success?: boolean; reclaimedBytes?: number } | null;
|
||||
const remote = (await response.json().catch(() => null)) as { success?: boolean; reclaimedBytes?: number; dryRun?: boolean } | null;
|
||||
if (!remote || typeof remote.reclaimedBytes !== 'number') {
|
||||
targetResults.push({ target, success: false, reclaimedBytes: 0, error: 'Invalid response from remote node' });
|
||||
continue;
|
||||
}
|
||||
targetResults.push({ target, success: remote.success !== false, reclaimedBytes: remote.reclaimedBytes });
|
||||
const entry: TargetResult = { target, success: remote.success !== false, reclaimedBytes: remote.reclaimedBytes };
|
||||
if (remote.dryRun) entry.dryRun = true;
|
||||
targetResults.push(entry);
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err, 'Failed to reach remote node');
|
||||
nodeUnreachable = message;
|
||||
@@ -1269,6 +1285,158 @@ fleetRouter.post('/labels/fleet-prune', authMiddleware, async (req: Request, res
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Fleet Actions: blast-radius preview endpoints (non-destructive) ───
|
||||
//
|
||||
// Power the live readouts in the Fleet Action cards. Same auth gates as the
|
||||
// destructive endpoints above so the surface stays uniform: an operator who
|
||||
// can fire `fleet-stop` is also the operator who can ask how big it would be.
|
||||
|
||||
// Per-label fleet preview. Walks the central node list and looks up the label
|
||||
// + assignments table for each node. No remote fan-out: stack-to-label
|
||||
// assignments live in the central DB even for remote nodes, populated by the
|
||||
// nodes' own UIs and synced via Distributed API.
|
||||
fleetRouter.post('/labels/match-preview', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
const body = req.body as { labelName?: unknown } | undefined;
|
||||
if (!body || typeof body !== 'object') {
|
||||
res.status(400).json({ error: 'Request body is required' });
|
||||
return;
|
||||
}
|
||||
const { labelName } = body;
|
||||
if (typeof labelName !== 'string' || labelName.trim().length === 0) {
|
||||
res.status(400).json({ error: 'labelName is required' });
|
||||
return;
|
||||
}
|
||||
const trimmed = labelName.trim();
|
||||
try {
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodes = db.getNodes();
|
||||
let matchedStacks = 0;
|
||||
const perNode = nodes.map((node) => {
|
||||
const label = db.getLabels(node.id).find(l => l.name === trimmed);
|
||||
const stackNames = label ? db.getStacksForLabel(label.id, node.id) : [];
|
||||
matchedStacks += stackNames.length;
|
||||
return {
|
||||
nodeId: node.id,
|
||||
nodeName: node.name,
|
||||
stackCount: stackNames.length,
|
||||
stackNames,
|
||||
};
|
||||
});
|
||||
const matchedNodes = perNode.filter(n => n.stackCount > 0).length;
|
||||
res.json({ matchedNodes, matchedStacks, perNode });
|
||||
} catch (error) {
|
||||
console.error('[Fleet] match-preview error:', error);
|
||||
res.status(500).json({ error: getErrorMessage(error, 'Failed to compute match preview') });
|
||||
}
|
||||
});
|
||||
|
||||
// Fleet-wide prune size estimate. Local node uses the controller estimate
|
||||
// helper; remote nodes hit `/api/system/prune/estimate` per target. Same
|
||||
// fan-out shape as `/labels/fleet-prune` minus the locks (estimation is read
|
||||
// only).
|
||||
fleetRouter.post('/prune/estimate', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
|
||||
const body = req.body as { targets?: unknown; scope?: unknown } | undefined;
|
||||
if (!body || typeof body !== 'object') {
|
||||
res.status(400).json({ error: 'Request body is required' });
|
||||
return;
|
||||
}
|
||||
const rawTargets = Array.isArray(body.targets) ? body.targets : null;
|
||||
if (!rawTargets || rawTargets.length === 0) {
|
||||
res.status(400).json({ error: 'targets must be a non-empty array' });
|
||||
return;
|
||||
}
|
||||
const dedup = new Set<FleetPruneTarget>();
|
||||
for (const t of rawTargets) {
|
||||
if (typeof t !== 'string' || !(FLEET_PRUNE_TARGETS as readonly string[]).includes(t)) {
|
||||
res.status(400).json({ error: `Invalid target: ${typeof t === 'string' ? t : typeof t}` });
|
||||
return;
|
||||
}
|
||||
dedup.add(t as FleetPruneTarget);
|
||||
}
|
||||
const targets: FleetPruneTarget[] = Array.from(dedup);
|
||||
const scope: 'managed' | 'all' = body.scope === 'all' ? 'all' : 'managed';
|
||||
|
||||
type NodeEstimate = {
|
||||
nodeId: number; nodeName: string; reclaimableBytes: number; reachable: boolean; error?: string;
|
||||
};
|
||||
|
||||
try {
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodes = db.getNodes();
|
||||
const perNode: NodeEstimate[] = await Promise.all(nodes.map(async (node): Promise<NodeEstimate> => {
|
||||
if (node.type === 'local') {
|
||||
try {
|
||||
const knownStacks = scope === 'managed' ? await FileSystemService.getInstance(node.id).getStacks() : [];
|
||||
const dockerController = DockerController.getInstance(node.id);
|
||||
let nodeBytes = 0;
|
||||
for (const target of targets) {
|
||||
const result = scope === 'managed'
|
||||
? await dockerController.estimateManagedReclaim(target, knownStacks)
|
||||
: await dockerController.estimateSystemReclaim(target, knownStacks);
|
||||
nodeBytes += result.reclaimableBytes;
|
||||
}
|
||||
return { nodeId: node.id, nodeName: node.name, reclaimableBytes: nodeBytes, reachable: true };
|
||||
} catch (err) {
|
||||
return {
|
||||
nodeId: node.id, nodeName: node.name, reclaimableBytes: 0, reachable: false,
|
||||
error: getErrorMessage(err, 'Failed to estimate locally'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!node.api_url || !node.api_token) {
|
||||
return {
|
||||
nodeId: node.id, nodeName: node.name, reclaimableBytes: 0, reachable: false,
|
||||
error: 'Remote node not configured',
|
||||
};
|
||||
}
|
||||
const baseUrl = node.api_url.replace(/\/$/, '');
|
||||
// Estimate is a live readout; fan out the per-target fetches in parallel
|
||||
// so wall time matches the slowest single call rather than the sum.
|
||||
// (The destructive sibling stays serial because Docker prune is internally
|
||||
// serialized and one failure should short-circuit later targets there.)
|
||||
const perTarget = await Promise.all(targets.map(async (target): Promise<{ bytes: number; error?: string }> => {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/system/prune/estimate`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${node.api_token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ target, scope }),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errBody = (await response.json().catch(() => ({}))) as { error?: string };
|
||||
return { bytes: 0, error: errBody.error || `Remote returned ${response.status}` };
|
||||
}
|
||||
const remote = (await response.json().catch(() => null)) as { reclaimableBytes?: number } | null;
|
||||
if (!remote || typeof remote.reclaimableBytes !== 'number') {
|
||||
return { bytes: 0, error: 'Invalid response from remote node' };
|
||||
}
|
||||
return { bytes: remote.reclaimableBytes };
|
||||
} catch (err) {
|
||||
return { bytes: 0, error: getErrorMessage(err, 'Failed to reach remote node') };
|
||||
}
|
||||
}));
|
||||
const firstError = perTarget.find(t => t.error)?.error;
|
||||
if (firstError) {
|
||||
return { nodeId: node.id, nodeName: node.name, reclaimableBytes: 0, reachable: false, error: firstError };
|
||||
}
|
||||
const nodeBytes = perTarget.reduce((sum, t) => sum + t.bytes, 0);
|
||||
return { nodeId: node.id, nodeName: node.name, reclaimableBytes: nodeBytes, reachable: true };
|
||||
}));
|
||||
|
||||
const totalBytes = perNode.reduce((acc, n) => acc + (n.reachable ? n.reclaimableBytes : 0), 0);
|
||||
res.json({ totalBytes, perNode });
|
||||
} catch (error) {
|
||||
console.error('[Fleet] prune-estimate error:', error);
|
||||
res.status(500).json({ error: getErrorMessage(error, 'Failed to compute prune estimate') });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Fleet Snapshots (manual: Community; scheduled: Skipper+) ───
|
||||
|
||||
fleetRouter.post('/snapshots', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
|
||||
@@ -172,12 +172,13 @@ labelsRouter.post('/:id/action', authMiddleware, async (req: Request, res: Respo
|
||||
try {
|
||||
const id = parseIntParam(req, res, 'id', 'label ID');
|
||||
if (id === null) return;
|
||||
const { action } = req.body;
|
||||
const { action, dryRun } = req.body;
|
||||
const validActions = ['deploy', 'stop', 'restart'];
|
||||
if (!action || !validActions.includes(action)) {
|
||||
res.status(400).json({ error: `action must be one of: ${validActions.join(', ')}` });
|
||||
return;
|
||||
}
|
||||
const isDryRun = dryRun === true;
|
||||
|
||||
const nodeId = req.nodeId ?? 0;
|
||||
|
||||
@@ -200,11 +201,17 @@ labelsRouter.post('/:id/action', authMiddleware, async (req: Request, res: Respo
|
||||
const fsStackNames = new Set(fsStacks);
|
||||
const validStacks = stackNames.filter(name => fsStackNames.has(name));
|
||||
|
||||
if (isDebugEnabled()) console.debug('[Labels:debug] Bulk action start:', { id, action, nodeId, totalLabeled: stackNames.length, validStacks: validStacks.length });
|
||||
if (isDebugEnabled()) console.debug('[Labels:debug] Bulk action start:', { id, action, nodeId, totalLabeled: stackNames.length, validStacks: validStacks.length, dryRun: isDryRun });
|
||||
|
||||
const results: { stackName: string; success: boolean; error?: string }[] = [];
|
||||
const results: { stackName: string; success: boolean; error?: string; dryRun?: boolean }[] = [];
|
||||
|
||||
for (const stackName of validStacks) {
|
||||
if (isDryRun) {
|
||||
// Rehearse the action under the same lock + label resolution + fs
|
||||
// intersection. Skip the destructive leaf call.
|
||||
results.push({ stackName, success: true, dryRun: true });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (action === 'deploy') {
|
||||
const gate = await enforcePolicyPreDeploy(
|
||||
@@ -235,10 +242,10 @@ labelsRouter.post('/:id/action', authMiddleware, async (req: Request, res: Respo
|
||||
|
||||
const succeeded = results.filter(r => r.success).length;
|
||||
const failed = results.length - succeeded;
|
||||
console.log(`[Labels] Bulk ${sanitizeForLog(action)} on label ${id}: ${validStacks.length} stacks (${succeeded} succeeded, ${failed} failed)`);
|
||||
if (isDebugEnabled()) console.debug('[Labels:debug] Bulk action complete:', { id, action, total: results.length, succeeded, failed });
|
||||
console.log(`[Labels] Bulk ${sanitizeForLog(action)}${isDryRun ? ' (dry run)' : ''} on label ${id}: ${validStacks.length} stacks (${succeeded} succeeded, ${failed} failed)`);
|
||||
if (isDebugEnabled()) console.debug('[Labels:debug] Bulk action complete:', { id, action, total: results.length, succeeded, failed, dryRun: isDryRun });
|
||||
|
||||
if (succeeded > 0) {
|
||||
if (succeeded > 0 && !isDryRun) {
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
}
|
||||
res.json({ results });
|
||||
|
||||
@@ -49,15 +49,37 @@ systemMaintenanceRouter.post('/prune/orphans', async (req: Request, res: Respons
|
||||
systemMaintenanceRouter.post('/prune/system', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const { target, scope } = req.body as { target: string; scope?: string };
|
||||
const { target, scope, dryRun } = req.body as { target: string; scope?: string; dryRun?: boolean };
|
||||
if (!['containers', 'images', 'networks', 'volumes'].includes(target)) {
|
||||
return res.status(400).json({ error: 'Invalid prune target' });
|
||||
}
|
||||
|
||||
const pruneScope = scope === 'managed' ? 'managed' : 'all';
|
||||
console.log(`[Resources] System prune: ${target} (scope: ${pruneScope})`);
|
||||
const isDryRun = dryRun === true;
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
|
||||
if (isDryRun) {
|
||||
// Rehearse the destructive path: same scope resolution, same Docker
|
||||
// enumeration, no remove calls. Containers have no managed estimate
|
||||
// helper because pruneManagedOnly does not handle them.
|
||||
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
|
||||
let estimate: { reclaimableBytes: number };
|
||||
if (pruneScope === 'managed' && target !== 'containers') {
|
||||
estimate = await dockerController.estimateManagedReclaim(
|
||||
target as 'images' | 'volumes' | 'networks',
|
||||
knownStacks,
|
||||
);
|
||||
} else {
|
||||
estimate = await dockerController.estimateSystemReclaim(
|
||||
target as 'containers' | 'images' | 'networks' | 'volumes',
|
||||
knownStacks,
|
||||
);
|
||||
}
|
||||
res.json({ message: 'Dry run', success: true, dryRun: true, reclaimedBytes: estimate.reclaimableBytes });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[Resources] System prune: ${target} (scope: ${pruneScope})`);
|
||||
let result: { success: boolean; reclaimedBytes: number };
|
||||
if (pruneScope === 'managed' && target !== 'containers') {
|
||||
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
|
||||
@@ -80,6 +102,41 @@ systemMaintenanceRouter.post('/prune/system', async (req: Request, res: Response
|
||||
}
|
||||
});
|
||||
|
||||
// Non-destructive size estimate for a prune target/scope. The Fleet Actions
|
||||
// "Prune fleet-wide" card calls this on each remote node to populate its live
|
||||
// blast-radius readout before the operator confirms. Reuses the same Docker
|
||||
// enumeration as `/prune/system` so the estimate matches what the destructive
|
||||
// path would reclaim.
|
||||
systemMaintenanceRouter.post('/prune/estimate', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const { target, scope } = req.body as { target: string; scope?: string };
|
||||
if (!['containers', 'images', 'networks', 'volumes'].includes(target)) {
|
||||
return res.status(400).json({ error: 'Invalid prune target' });
|
||||
}
|
||||
const pruneScope = scope === 'managed' ? 'managed' : 'all';
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
|
||||
|
||||
let result: { reclaimableBytes: number };
|
||||
if (pruneScope === 'managed' && target !== 'containers') {
|
||||
result = await dockerController.estimateManagedReclaim(
|
||||
target as 'images' | 'volumes' | 'networks',
|
||||
knownStacks,
|
||||
);
|
||||
} else {
|
||||
result = await dockerController.estimateSystemReclaim(
|
||||
target as 'containers' | 'images' | 'networks' | 'volumes',
|
||||
knownStacks,
|
||||
);
|
||||
}
|
||||
res.json({ reclaimableBytes: result.reclaimableBytes });
|
||||
} catch (error: unknown) {
|
||||
console.error('Prune estimate error:', error);
|
||||
res.status(500).json({ error: 'Failed to estimate reclaimable bytes' });
|
||||
}
|
||||
});
|
||||
|
||||
systemMaintenanceRouter.get('/docker-df', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
|
||||
|
||||
@@ -386,6 +386,69 @@ class DockerController {
|
||||
return { success: true, reclaimedBytes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-destructive sibling of `pruneManagedOnly` used by the dry-run path and
|
||||
* the `/api/system/prune/estimate` route. Walks the same filter rules but
|
||||
* does not call `.remove()`. Kept structurally parallel to the destructive
|
||||
* method so the two stay in lockstep when the enumeration logic changes.
|
||||
*/
|
||||
public async estimateManagedReclaim(
|
||||
target: 'images' | 'volumes' | 'networks',
|
||||
knownStackNames: string[],
|
||||
): Promise<{ reclaimableBytes: number }> {
|
||||
const knownSet = new Set(knownStackNames);
|
||||
const projectToStack = await DockerController.resolveProjectNameMap(knownStackNames);
|
||||
let reclaimableBytes = 0;
|
||||
|
||||
if (target === 'volumes') {
|
||||
const rawVolumeData = await this.docker.listVolumes();
|
||||
const rawVolumes: any[] = (this.validateApiData<any>(rawVolumeData)).Volumes || [];
|
||||
const prunable = rawVolumes.filter((v: any) => {
|
||||
return !!DockerController.resolveProjectLabel(v.Labels?.['com.docker.compose.project'], knownSet, projectToStack)
|
||||
&& (v.UsageData?.RefCount ?? 1) === 0;
|
||||
});
|
||||
for (const vol of prunable) reclaimableBytes += vol.UsageData?.Size ?? 0;
|
||||
} else if (target === 'networks') {
|
||||
// Networks have no on-disk size; the dry-run still reports 0 so the
|
||||
// shape matches the destructive path.
|
||||
} else if (target === 'images') {
|
||||
const allContainers = await this.docker.listContainers({ all: true });
|
||||
const resolvedBase = path.resolve(COMPOSE_DIR);
|
||||
const absDirToStack = DockerController.buildAbsDirMap(knownStackNames);
|
||||
const unmanagedImageIds = new Set<string>();
|
||||
for (const c of allContainers as any[]) {
|
||||
const stack = DockerController.resolveContainerStack(
|
||||
c.Labels, projectToStack, knownSet, absDirToStack, resolvedBase,
|
||||
);
|
||||
if (!stack) unmanagedImageIds.add(c.ImageID);
|
||||
}
|
||||
const rawImages = await this.docker.listImages({ all: false });
|
||||
const prunable = (rawImages as any[]).filter((img: any) =>
|
||||
img.Containers === 0 && !unmanagedImageIds.has(img.Id),
|
||||
);
|
||||
for (const img of prunable) reclaimableBytes += img.Size ?? 0;
|
||||
}
|
||||
|
||||
return { reclaimableBytes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-destructive estimate for the `all` (system) prune scope. Reuses the
|
||||
* same disk-usage source as the resources page so the readout matches what
|
||||
* the operator already sees there.
|
||||
*/
|
||||
public async estimateSystemReclaim(
|
||||
target: 'containers' | 'images' | 'networks' | 'volumes',
|
||||
knownStackNames: string[],
|
||||
): Promise<{ reclaimableBytes: number }> {
|
||||
const df = await this.getDiskUsageClassified(knownStackNames);
|
||||
if (target === 'images') return { reclaimableBytes: df.reclaimableImages };
|
||||
if (target === 'containers') return { reclaimableBytes: df.reclaimableContainers };
|
||||
if (target === 'volumes') return { reclaimableBytes: df.reclaimableVolumes };
|
||||
// Networks have no on-disk size.
|
||||
return { reclaimableBytes: 0 };
|
||||
}
|
||||
|
||||
public async getDiskUsageClassified(knownStackNames: string[]): Promise<{
|
||||
reclaimableImages: number;
|
||||
reclaimableContainers: number;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Square, Tags, Eraser } from 'lucide-react';
|
||||
import { Tags } from 'lucide-react';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import type { FleetNode } from '@/components/FleetView/types';
|
||||
import { LabelFleetStopCard } from './cards/LabelFleetStopCard';
|
||||
@@ -19,22 +19,24 @@ export function FleetActionsTab({ nodes }: Props) {
|
||||
}
|
||||
|
||||
if (!isPaid) {
|
||||
// Every action is Skipper+. Community users see a calm empty state with
|
||||
// upgrade context rather than a stripped-down launcher.
|
||||
return (
|
||||
<EmptyState />
|
||||
);
|
||||
return <EmptyState />;
|
||||
}
|
||||
|
||||
// Grid breakpoints per audit §18.7: 1 / 2 / 3 columns at 760 / 1280. The
|
||||
// 4-column breakpoint is reserved for when a 4th card lands; v1 has three
|
||||
// so it is intentionally omitted to avoid a hanging gap on wide displays.
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
<LabelFleetStopCard nodes={nodes} accentTone="rose" icon={Square} />
|
||||
<BulkLabelAssignCard nodes={nodes} accentTone="purple" icon={Tags} />
|
||||
<FleetPruneCard nodes={nodes} accentTone="amber" icon={Eraser} />
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-[18px] items-start auto-rows-min">
|
||||
<LabelFleetStopCard nodes={nodes} />
|
||||
<BulkLabelAssignCard nodes={nodes} />
|
||||
<FleetPruneCard nodes={nodes} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Note: the §18.8 Community-tier "single full-width card with cyan rail + locked
|
||||
// footer (shell only, no upsell)" redesign is deferred to a follow-up PR.
|
||||
// Tracked in docs/internal/refactor/fleet-action-card-migration.md.
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="rounded-lg border border-card-border/60 bg-card p-8 text-center">
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { Loader2, Server } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ConfirmModal } from '@/components/ui/modal';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
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 { toast } from '@/components/ui/toast-store';
|
||||
@@ -13,20 +11,17 @@ import { cn } from '@/lib/utils';
|
||||
import type { FleetNode } from '@/components/FleetView/types';
|
||||
import type { Label } from '@/components/label-types';
|
||||
import { ResultsList, type ResultRow } from '../ResultsList';
|
||||
import { TONE_RAIL, TONE_BG, type AccentTone } from './tone';
|
||||
|
||||
interface NodeStackResult { stackName: string; success: boolean; error?: string }
|
||||
|
||||
interface Props {
|
||||
nodes: FleetNode[];
|
||||
icon: LucideIcon;
|
||||
accentTone: AccentTone;
|
||||
}
|
||||
|
||||
export function BulkLabelAssignCard({ nodes, icon: Icon, accentTone }: Props) {
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string>(() => {
|
||||
export function BulkLabelAssignCard({ nodes }: Props) {
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<number>(() => {
|
||||
const local = nodes.find(n => n.type === 'local');
|
||||
return String(local?.id ?? nodes[0]?.id ?? '');
|
||||
return Number(local?.id ?? nodes[0]?.id ?? 0);
|
||||
});
|
||||
const [stacks, setStacks] = useState<string[]>([]);
|
||||
const [labels, setLabels] = useState<Label[]>([]);
|
||||
@@ -37,12 +32,10 @@ export function BulkLabelAssignCard({ nodes, icon: Icon, accentTone }: Props) {
|
||||
const [running, setRunning] = useState(false);
|
||||
const [results, setResults] = useState<ResultRow[]>([]);
|
||||
|
||||
const nodeId = useMemo(() => Number(selectedNodeId) || 0, [selectedNodeId]);
|
||||
const selectedNode = useMemo(() => nodes.find(n => n.id === nodeId), [nodes, nodeId]);
|
||||
const selectedNode = useMemo(() => nodes.find(n => n.id === selectedNodeId), [nodes, selectedNodeId]);
|
||||
|
||||
// Load stacks + labels whenever the node changes.
|
||||
useEffect(() => {
|
||||
if (!nodeId) return;
|
||||
if (!selectedNodeId) return;
|
||||
let cancelled = false;
|
||||
async function load() {
|
||||
setLoadingLists(true);
|
||||
@@ -51,8 +44,8 @@ export function BulkLabelAssignCard({ nodes, icon: Icon, accentTone }: Props) {
|
||||
setResults([]);
|
||||
try {
|
||||
const [stacksRes, labelsRes] = await Promise.all([
|
||||
fetchForNode(`/fleet/node/${nodeId}/stacks`, nodeId),
|
||||
fetchForNode('/labels', nodeId),
|
||||
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[]) : [];
|
||||
@@ -71,7 +64,7 @@ export function BulkLabelAssignCard({ nodes, icon: Icon, accentTone }: Props) {
|
||||
}
|
||||
load();
|
||||
return () => { cancelled = true; };
|
||||
}, [nodeId]);
|
||||
}, [selectedNodeId]);
|
||||
|
||||
function toggleStack(stackName: string) {
|
||||
setSelectedStacks(prev => {
|
||||
@@ -93,6 +86,11 @@ export function BulkLabelAssignCard({ nodes, icon: Icon, accentTone }: Props) {
|
||||
if (selectedStacks.size === stacks.length) setSelectedStacks(new Set());
|
||||
else setSelectedStacks(new Set(stacks));
|
||||
}
|
||||
function clearSelection() {
|
||||
setSelectedStacks(new Set());
|
||||
setSelectedLabels(new Set());
|
||||
setResults([]);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
if (selectedStacks.size === 0) return;
|
||||
@@ -101,7 +99,7 @@ export function BulkLabelAssignCard({ nodes, icon: Icon, accentTone }: Props) {
|
||||
const toastId = toast.loading(`Assigning labels to ${assignments.length} stack${assignments.length === 1 ? '' : 's'}…`);
|
||||
setRunning(true);
|
||||
try {
|
||||
const res = await fetchForNode('/fleet-actions/labels/bulk-assign', nodeId, {
|
||||
const res = await fetchForNode('/fleet-actions/labels/bulk-assign', selectedNodeId, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ assignments }),
|
||||
});
|
||||
@@ -131,147 +129,159 @@ export function BulkLabelAssignCard({ nodes, icon: Icon, accentTone }: 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]);
|
||||
|
||||
return (
|
||||
<Card className="relative overflow-hidden bg-card shadow-card-bevel">
|
||||
<span aria-hidden className={cn('absolute inset-y-0 left-0 w-[3px]', TONE_RAIL[accentTone])} />
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<span className={cn('inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-md', TONE_BG[accentTone])}>
|
||||
<Icon className="h-5 w-5" strokeWidth={1.5} />
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-base font-medium text-stat-value">Bulk label assign</h3>
|
||||
<p className="mt-1 text-xs text-stat-subtitle">
|
||||
Pick a node, multi-select stacks, and replace their labels in one shot.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
<FleetActionCard
|
||||
crumb={['Fleet', 'Actions', 'Bulk label assign']}
|
||||
name="Bulk label assign."
|
||||
meta="one node · multi-stack · replaces existing label set"
|
||||
actionClass="transformative"
|
||||
blastRadius={{ value: blastValue }}
|
||||
secondaryAction={{
|
||||
label: 'Reset',
|
||||
onClick: clearSelection,
|
||||
disabled: running || (selectedStacks.size === 0 && selectedLabels.size === 0),
|
||||
}}
|
||||
primaryAction={{
|
||||
label: 'Apply',
|
||||
onClick: () => setConfirmOpen(true),
|
||||
variant: 'primary',
|
||||
disabled: running || selectedStacks.size === 0 || selectedLabels.size === 0,
|
||||
}}
|
||||
footerContext="Reversible · yes · reassign anytime"
|
||||
>
|
||||
<SheetSection title="Node" meta={loadingLists ? 'loading…' : undefined}>
|
||||
<NodeSegmented
|
||||
nodes={nodes}
|
||||
value={selectedNodeId}
|
||||
onChange={setSelectedNodeId}
|
||||
disabled={running}
|
||||
/>
|
||||
</SheetSection>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-3.5 w-3.5 text-stat-subtitle" strokeWidth={1.5} />
|
||||
<Select value={selectedNodeId} onValueChange={setSelectedNodeId} disabled={running}>
|
||||
<SelectTrigger className="w-56 h-8 text-xs">
|
||||
<SelectValue placeholder="Select a node" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{nodes.map(n => (
|
||||
<SelectItem key={n.id} value={String(n.id)}>
|
||||
{n.name} {n.type === 'local' ? '(local)' : ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{loadingLists && <span className="text-xs text-stat-subtitle">Loading…</span>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-[10px] uppercase tracking-wide text-stat-subtitle">
|
||||
Stacks ({selectedStacks.size}/{stacks.length})
|
||||
<SheetSection
|
||||
title={`Stacks · ${selectedStacks.size} / ${stacks.length}`}
|
||||
meta={stacks.length > 0
|
||||
? (selectedStacks.size === stacks.length ? 'all selected' : 'multi-select')
|
||||
: undefined}
|
||||
>
|
||||
<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 && (
|
||||
<span className="text-xs text-stat-subtitle">
|
||||
{loadingLists ? 'Loading…' : selectedNode ? `No stacks on ${selectedNode.name}.` : 'Pick a node.'}
|
||||
</span>
|
||||
{stacks.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
)}
|
||||
{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}
|
||||
onClick={toggleAllStacks}
|
||||
className="text-xs text-stat-subtitle hover:text-stat-value disabled:opacity-50"
|
||||
>
|
||||
{selectedStacks.size === stacks.length ? 'Clear' : 'Select all'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<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 && (
|
||||
<span className="text-xs text-stat-subtitle">
|
||||
{loadingLists ? 'Loading…' : selectedNode ? `No stacks on ${selectedNode.name}.` : 'Pick a node.'}
|
||||
</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}
|
||||
/>
|
||||
<span className="text-xs font-mono text-stat-value">{stackName}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wide text-stat-subtitle mb-1.5">
|
||||
Labels ({selectedLabels.size}/{labels.length})
|
||||
</div>
|
||||
<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>
|
||||
)}
|
||||
{labels.map(label => (
|
||||
<LabelPill
|
||||
key={label.id}
|
||||
label={label}
|
||||
active={selectedLabels.has(label.id)}
|
||||
onClick={() => !running && toggleLabel(label.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<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>
|
||||
)}
|
||||
</SheetSection>
|
||||
|
||||
<p className="text-[11px] text-stat-subtitle">
|
||||
<SheetSection
|
||||
title={`Labels · ${selectedLabels.size} / ${labels.length}`}
|
||||
meta="replaces existing"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
{labels.map(label => (
|
||||
<LabelPill
|
||||
key={label.id}
|
||||
label={label}
|
||||
active={selectedLabels.has(label.id)}
|
||||
onClick={() => !running && toggleLabel(label.id)}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={running || selectedStacks.size === 0}
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
className="gap-2"
|
||||
>
|
||||
{running ? <Loader2 className="h-3.5 w-3.5 animate-spin" strokeWidth={1.5} /> : <Icon className="h-3.5 w-3.5" strokeWidth={1.5} />}
|
||||
Apply to {selectedStacks.size} stack{selectedStacks.size === 1 ? '' : 's'}
|
||||
</Button>
|
||||
{!running && results.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setResults([])}
|
||||
className="text-xs text-stat-subtitle hover:text-stat-value"
|
||||
>
|
||||
Clear results
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{results.length > 0 && (
|
||||
<SheetSection title="Per-stack results">
|
||||
<ResultsList results={results} />
|
||||
</SheetSection>
|
||||
)}
|
||||
</FleetActionCard>
|
||||
|
||||
{results.length > 0 && (
|
||||
<ResultsList title="Per-stack results" results={results} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmModal
|
||||
open={confirmOpen}
|
||||
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.`
|
||||
}
|
||||
confirmLabel="Apply"
|
||||
confirming={running}
|
||||
onConfirm={run}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ConfirmModal
|
||||
open={confirmOpen}
|
||||
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.`
|
||||
}
|
||||
confirmLabel="Apply"
|
||||
confirming={running}
|
||||
onConfirm={run}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { useState } from 'react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { Loader2, AlertTriangle } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { ConfirmModal } from '@/components/ui/modal';
|
||||
import { FleetActionCard } from '@/components/ui/fleet-action-card';
|
||||
import { SheetSection } from '@/components/ui/system-sheet';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { cn, formatBytes } from '@/lib/utils';
|
||||
import type { FleetNode } from '@/components/FleetView/types';
|
||||
import { ResultsList, type ResultRow } from '../ResultsList';
|
||||
import { TONE_RAIL, TONE_BG, type AccentTone } from './tone';
|
||||
|
||||
type PruneTarget = 'images' | 'volumes' | 'networks';
|
||||
type PruneScope = 'managed' | 'all';
|
||||
@@ -21,24 +19,35 @@ const ALL_TARGETS: ReadonlyArray<{ id: PruneTarget; label: string }> = [
|
||||
{ id: 'networks', label: 'Networks' },
|
||||
];
|
||||
|
||||
interface TargetResult { target: PruneTarget; success: boolean; reclaimedBytes: number; error?: string }
|
||||
interface TargetResult { target: PruneTarget; success: boolean; reclaimedBytes: number; error?: string; dryRun?: boolean }
|
||||
interface FleetPruneNodeResult {
|
||||
nodeId: number; nodeName: string; reachable: boolean; error?: string; targets: TargetResult[];
|
||||
}
|
||||
|
||||
interface PruneEstimateNode { nodeId: number; nodeName: string; reclaimableBytes: number; reachable: boolean; error?: string }
|
||||
interface PruneEstimateResponse { totalBytes: number; perNode: PruneEstimateNode[] }
|
||||
|
||||
type EstimateState =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'loading' }
|
||||
| { kind: 'unavailable' }
|
||||
| { kind: 'ready'; data: PruneEstimateResponse };
|
||||
|
||||
interface Props {
|
||||
nodes: FleetNode[];
|
||||
icon: LucideIcon;
|
||||
accentTone: AccentTone;
|
||||
}
|
||||
|
||||
export function FleetPruneCard({ nodes, icon: Icon, accentTone }: Props) {
|
||||
const KICKER = 'font-mono text-[10px] uppercase tracking-[0.18em]';
|
||||
const ESTIMATE_ROW_LIMIT = 6;
|
||||
|
||||
export function FleetPruneCard({ nodes }: Props) {
|
||||
const nodeCount = nodes.length;
|
||||
const [targets, setTargets] = useState<Set<PruneTarget>>(new Set(['images']));
|
||||
const [scope, setScope] = useState<PruneScope>('managed');
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [results, setResults] = useState<ResultRow[]>([]);
|
||||
const [estimate, setEstimate] = useState<EstimateState>({ kind: 'idle' });
|
||||
|
||||
const toggleTarget = (target: PruneTarget) => {
|
||||
setTargets(prev => {
|
||||
@@ -49,16 +58,53 @@ export function FleetPruneCard({ nodes, icon: Icon, accentTone }: Props) {
|
||||
});
|
||||
};
|
||||
|
||||
async function run() {
|
||||
// Re-estimate when the operator's choices change. Debounced because each
|
||||
// tick fans out per-target HTTP to every remote node; back-to-back clicks
|
||||
// on the target checkboxes would otherwise pile concurrent fleet-wide fans
|
||||
// onto the backend.
|
||||
const estimateDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => {
|
||||
if (estimateDebounceRef.current) clearTimeout(estimateDebounceRef.current);
|
||||
if (targets.size === 0) {
|
||||
setEstimate({ kind: 'idle' });
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setEstimate({ kind: 'loading' });
|
||||
estimateDebounceRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const res = await apiFetch('/fleet/prune/estimate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ targets: Array.from(targets), scope }),
|
||||
});
|
||||
if (cancelled) return;
|
||||
if (res.status === 404 || !res.ok) {
|
||||
setEstimate({ kind: 'unavailable' });
|
||||
return;
|
||||
}
|
||||
const data = (await res.json()) as PruneEstimateResponse;
|
||||
if (!cancelled) setEstimate({ kind: 'ready', data });
|
||||
} catch {
|
||||
if (!cancelled) setEstimate({ kind: 'unavailable' });
|
||||
}
|
||||
}, 350);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (estimateDebounceRef.current) clearTimeout(estimateDebounceRef.current);
|
||||
};
|
||||
}, [targets, scope]);
|
||||
|
||||
async function run(opts: { dryRun: boolean }) {
|
||||
if (targets.size === 0) return;
|
||||
const selected = Array.from(targets);
|
||||
const toastId = toast.loading(`Pruning ${selected.join(', ')} across the fleet…`);
|
||||
const verb = opts.dryRun ? 'Dry-running prune of' : 'Pruning';
|
||||
const toastId = toast.loading(`${verb} ${selected.join(', ')} across the fleet…`);
|
||||
setRunning(true);
|
||||
setResults([]);
|
||||
try {
|
||||
const res = await apiFetch('/fleet/labels/fleet-prune', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ targets: selected, scope }),
|
||||
body: JSON.stringify({ targets: selected, scope, dryRun: opts.dryRun }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
toast.dismiss(toastId);
|
||||
@@ -73,7 +119,7 @@ export function FleetPruneCard({ nodes, icon: Icon, accentTone }: Props) {
|
||||
return {
|
||||
key: `node-${node.nodeId}`,
|
||||
label: node.reachable
|
||||
? `${node.nodeName} · ${formatBytes(totalBytes)}`
|
||||
? `${node.nodeName} · ${formatBytes(totalBytes)}${opts.dryRun ? ' (dry run)' : ''}`
|
||||
: `${node.nodeName} (unreachable)`,
|
||||
success: allOk,
|
||||
error: node.reachable ? undefined : node.error,
|
||||
@@ -92,7 +138,9 @@ export function FleetPruneCard({ nodes, icon: Icon, accentTone }: Props) {
|
||||
(sum, n) => sum + n.targets.reduce((s, t) => s + (t.reclaimedBytes ?? 0), 0),
|
||||
0,
|
||||
);
|
||||
if (okNodes === totalNodes && totalNodes > 0) {
|
||||
if (opts.dryRun) {
|
||||
toast.success(`Dry run: ${formatBytes(totalReclaimed)} would be reclaimed across ${totalNodes} node${totalNodes === 1 ? '' : 's'}.`);
|
||||
} else if (okNodes === totalNodes && totalNodes > 0) {
|
||||
toast.success(`Reclaimed ${formatBytes(totalReclaimed)} across ${totalNodes} node${totalNodes === 1 ? '' : 's'}.`);
|
||||
} else if (okNodes === 0) {
|
||||
toast.error('Prune failed on every node. See results below.');
|
||||
@@ -108,130 +156,171 @@ export function FleetPruneCard({ nodes, icon: Icon, accentTone }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
const targetCount = targets.size;
|
||||
const isAllScope = scope === 'all';
|
||||
|
||||
const blastValue = useMemo(() => {
|
||||
if (targets.size === 0) return 'awaiting target';
|
||||
if (estimate.kind === 'loading') return '~ estimating…';
|
||||
if (estimate.kind === 'unavailable') return '~ estimate unavailable';
|
||||
if (estimate.kind === 'ready') {
|
||||
const { totalBytes } = estimate.data;
|
||||
if (totalBytes === 0) return '0 reclaimable';
|
||||
return `~ ${formatBytes(totalBytes)} reclaimable`;
|
||||
}
|
||||
return 'awaiting target';
|
||||
}, [targets.size, estimate]);
|
||||
|
||||
const blastTone = estimate.kind === 'loading' || estimate.kind === 'unavailable' ? 'muted' as const : undefined;
|
||||
|
||||
return (
|
||||
<Card className="relative overflow-hidden bg-card shadow-card-bevel">
|
||||
<span aria-hidden className={cn('absolute inset-y-0 left-0 w-[3px]', TONE_RAIL[accentTone])} />
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<span className={cn('inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-md', TONE_BG[accentTone])}>
|
||||
<Icon className="h-5 w-5" strokeWidth={1.5} />
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-base font-medium text-stat-value">Prune Docker resources fleet-wide</h3>
|
||||
<p className="mt-1 text-xs text-stat-subtitle">
|
||||
Reclaim space on {nodeCount} node{nodeCount === 1 ? '' : 's'} by removing unused images, volumes, and networks. Reclaimed bytes are approximate.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wide text-stat-subtitle mb-1.5">Targets</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{ALL_TARGETS.map(t => (
|
||||
<label
|
||||
key={t.id}
|
||||
className="flex items-center gap-2 py-1 px-2 rounded hover:bg-glass-highlight cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={targets.has(t.id)}
|
||||
onCheckedChange={() => toggleTarget(t.id)}
|
||||
disabled={running}
|
||||
/>
|
||||
<span className="text-xs text-stat-value">{t.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wide text-stat-subtitle mb-1.5">Scope</div>
|
||||
<div className="inline-flex rounded-md border border-card-border/60 overflow-hidden">
|
||||
<Button
|
||||
type="button"
|
||||
variant={scope === 'managed' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
disabled={running}
|
||||
onClick={() => setScope('managed')}
|
||||
className="rounded-none border-0 h-8 px-3 text-xs"
|
||||
<>
|
||||
<FleetActionCard
|
||||
crumb={['Fleet', 'Actions', 'Prune resources']}
|
||||
name="Prune fleet-wide."
|
||||
meta="images · volumes · networks · serial per node"
|
||||
actionClass="maintenance"
|
||||
blastRadius={{ value: blastValue, tone: blastTone }}
|
||||
secondaryAction={{
|
||||
label: running ? 'Running…' : 'Dry run',
|
||||
onClick: () => run({ dryRun: true }),
|
||||
disabled: running || targets.size === 0,
|
||||
}}
|
||||
primaryAction={{
|
||||
label: 'Prune fleet',
|
||||
onClick: () => setConfirmOpen(true),
|
||||
variant: 'destructive',
|
||||
// Block the destructive confirm until the operator has actually
|
||||
// seen what the readout says will be reclaimed. Falling back to a
|
||||
// confirm modal with no estimate context is the audit §F20.3 problem.
|
||||
disabled: running || targets.size === 0 || estimate.kind !== 'ready',
|
||||
}}
|
||||
footerContext={`Reversible · no · serial across ${nodeCount} node${nodeCount === 1 ? '' : 's'}`}
|
||||
>
|
||||
<SheetSection
|
||||
title={`Targets · ${targets.size} / ${ALL_TARGETS.length}`}
|
||||
meta={targets.size === 0 ? 'pick at least one' : undefined}
|
||||
>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{ALL_TARGETS.map(t => (
|
||||
<label
|
||||
key={t.id}
|
||||
className="flex items-center gap-2 py-1 px-2 rounded hover:bg-glass-highlight cursor-pointer"
|
||||
>
|
||||
Managed only
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={scope === 'all' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
disabled={running}
|
||||
onClick={() => setScope('all')}
|
||||
className="rounded-none border-0 h-8 px-3 text-xs"
|
||||
>
|
||||
All unused
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-1.5 text-[11px] text-stat-subtitle">
|
||||
{scope === 'managed'
|
||||
? 'Restricts to resources owned by stacks Sencho manages.'
|
||||
: 'Removes every unused resource, including workloads Sencho does not manage.'}
|
||||
</p>
|
||||
<Checkbox
|
||||
checked={targets.has(t.id)}
|
||||
onCheckedChange={() => toggleTarget(t.id)}
|
||||
disabled={running}
|
||||
/>
|
||||
<span className="text-xs text-stat-value">{t.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</SheetSection>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<SheetSection title="Scope" meta={scope === 'managed' ? 'sencho-owned only' : 'all unused'}>
|
||||
<div className="inline-flex rounded-md border border-card-border/60 overflow-hidden">
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
variant={scope === 'managed' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
disabled={running || targetCount === 0}
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
className="gap-2"
|
||||
disabled={running}
|
||||
onClick={() => setScope('managed')}
|
||||
className="rounded-none border-0 h-8 px-3 text-xs"
|
||||
>
|
||||
{running ? <Loader2 className="h-3.5 w-3.5 animate-spin" strokeWidth={1.5} /> : <Icon className="h-3.5 w-3.5" strokeWidth={1.5} />}
|
||||
Prune across fleet
|
||||
Managed only
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={scope === 'all' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
disabled={running}
|
||||
onClick={() => setScope('all')}
|
||||
className="rounded-none border-0 h-8 px-3 text-xs"
|
||||
>
|
||||
All unused
|
||||
</Button>
|
||||
{!running && results.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setResults([])}
|
||||
className="text-xs text-stat-subtitle hover:text-stat-value"
|
||||
>
|
||||
Clear results
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 text-[11px] text-stat-subtitle">
|
||||
{scope === 'managed'
|
||||
? 'Restricts to resources owned by stacks Sencho manages.'
|
||||
: 'Removes every unused resource, including workloads Sencho does not manage.'}
|
||||
</p>
|
||||
</SheetSection>
|
||||
|
||||
{results.length === 0 && !running && (
|
||||
<div className="rounded-md border border-card-border/40 bg-glass-highlight/30 p-3 text-xs text-stat-subtitle">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" strokeWidth={1.5} />
|
||||
<span>
|
||||
Prune is destructive and cannot be undone. Each target is run serially per node; reclaimed bytes appear per node and per target below.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{targets.size > 0 && <EstimateSection estimate={estimate} />}
|
||||
|
||||
{results.length > 0 && (
|
||||
<ResultsList title="Per-node breakdown" results={results} />
|
||||
)}
|
||||
</div>
|
||||
{results.length > 0 && (
|
||||
<SheetSection title="Per-node breakdown">
|
||||
<ResultsList results={results} />
|
||||
</SheetSection>
|
||||
)}
|
||||
</FleetActionCard>
|
||||
|
||||
<ConfirmModal
|
||||
open={confirmOpen}
|
||||
onOpenChange={(open) => { if (!open) setConfirmOpen(false); }}
|
||||
variant="destructive"
|
||||
kicker="Fleet prune"
|
||||
title={isAllScope ? 'Prune ALL unused resources across the fleet?' : 'Prune managed resources across the fleet?'}
|
||||
description={
|
||||
isAllScope
|
||||
? 'This runs docker prune --all on every reachable node. Any image, volume, or network not currently in use will be deleted, including resources from workloads Sencho does not manage. This cannot be undone.'
|
||||
: 'Sencho will remove unused Docker resources owned by stacks known to this fleet on every reachable node. Active resources are not touched.'
|
||||
}
|
||||
confirmLabel={isAllScope ? 'Prune everything unused' : 'Prune managed'}
|
||||
confirming={running}
|
||||
onConfirm={run}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ConfirmModal
|
||||
open={confirmOpen}
|
||||
onOpenChange={(open) => { if (!open) setConfirmOpen(false); }}
|
||||
variant="destructive"
|
||||
kicker="Fleet prune"
|
||||
title={isAllScope ? 'Prune ALL unused resources across the fleet?' : 'Prune managed resources across the fleet?'}
|
||||
description={
|
||||
isAllScope
|
||||
? 'This runs docker prune --all on every reachable node. Any image, volume, or network not currently in use will be deleted, including resources from workloads Sencho does not manage. This cannot be undone.'
|
||||
: 'Sencho will remove unused Docker resources owned by stacks known to this fleet on every reachable node. Active resources are not touched.'
|
||||
}
|
||||
confirmLabel={isAllScope ? 'Prune everything unused' : 'Prune managed'}
|
||||
confirming={running}
|
||||
onConfirm={() => run({ dryRun: false })}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EstimateSection({ estimate }: { estimate: EstimateState }) {
|
||||
if (estimate.kind === 'idle' || estimate.kind === 'loading') {
|
||||
return (
|
||||
<SheetSection title="Estimate · per node" meta={estimate.kind === 'loading' ? 'computing…' : undefined}>
|
||||
<div className={cn(KICKER, 'text-stat-icon')}>walking each node's docker daemon</div>
|
||||
</SheetSection>
|
||||
);
|
||||
}
|
||||
if (estimate.kind === 'unavailable') {
|
||||
return (
|
||||
<SheetSection title="Estimate · per node" meta="unavailable">
|
||||
<div className={cn(KICKER, 'text-stat-icon')}>estimate endpoint did not respond</div>
|
||||
</SheetSection>
|
||||
);
|
||||
}
|
||||
const { perNode } = estimate.data;
|
||||
const visible = perNode.slice(0, ESTIMATE_ROW_LIMIT);
|
||||
const remaining = perNode.length - visible.length;
|
||||
return (
|
||||
<SheetSection title="Estimate · per node" meta={`${perNode.length} node${perNode.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">
|
||||
<ul className="space-y-1">
|
||||
{visible.map((n) => (
|
||||
<li 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.reachable
|
||||
? 'border-success/40 bg-success/10 text-success'
|
||||
: 'border-stat-subtitle/40 bg-card text-stat-subtitle',
|
||||
)}>
|
||||
{n.reachable ? 'OK' : '--'}
|
||||
</span>
|
||||
<span className="flex-1 min-w-0 truncate font-mono text-[11px] text-stat-value">{n.nodeName}</span>
|
||||
<span className={cn(KICKER, 'shrink-0 tabular-nums', n.reachable ? 'text-stat-subtitle' : 'text-stat-icon')}>
|
||||
{n.reachable ? formatBytes(n.reclaimableBytes) : (n.error ?? 'unreachable')}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{remaining > 0 && (
|
||||
<li className={cn(KICKER, 'text-stat-icon pt-1')}>
|
||||
+ {remaining} more node{remaining === 1 ? '' : 's'}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</SheetSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { Loader2, AlertTriangle } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ConfirmModal } from '@/components/ui/modal';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { FleetActionCard } from '@/components/ui/fleet-action-card';
|
||||
import { SheetSection } from '@/components/ui/system-sheet';
|
||||
import { apiFetch, fetchForNode } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { FleetNode } from '@/components/FleetView/types';
|
||||
import type { Label } from '@/components/label-types';
|
||||
import { ResultsList, type ResultRow } from '../ResultsList';
|
||||
import { TONE_RAIL, TONE_BG, type AccentTone } from './tone';
|
||||
|
||||
interface NodeStackResult { stackName: string; success: boolean; error?: string }
|
||||
interface NodeStackResult { stackName: string; success: boolean; error?: string; dryRun?: boolean }
|
||||
interface FleetStopNodeResult {
|
||||
nodeId: number;
|
||||
nodeName: string;
|
||||
@@ -21,21 +18,31 @@ interface FleetStopNodeResult {
|
||||
stackResults: NodeStackResult[];
|
||||
}
|
||||
|
||||
interface MatchPreviewNode { nodeId: number; nodeName: string; stackCount: number; stackNames: string[] }
|
||||
interface MatchPreviewResponse { matchedNodes: number; matchedStacks: number; perNode: MatchPreviewNode[] }
|
||||
|
||||
type PreviewState =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'loading' }
|
||||
| { kind: 'unavailable' }
|
||||
| { kind: 'ready'; data: MatchPreviewResponse };
|
||||
|
||||
interface Props {
|
||||
nodes: FleetNode[];
|
||||
icon: LucideIcon;
|
||||
accentTone: AccentTone;
|
||||
}
|
||||
|
||||
export function LabelFleetStopCard({ nodes, icon: Icon, accentTone }: Props) {
|
||||
const KICKER = 'font-mono text-[10px] uppercase tracking-[0.18em]';
|
||||
const PREVIEW_ROW_LIMIT = 6;
|
||||
|
||||
export function LabelFleetStopCard({ nodes }: Props) {
|
||||
const [labelName, setLabelName] = useState('');
|
||||
const [knownLabelNames, setKnownLabelNames] = useState<string[]>([]);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [results, setResults] = useState<ResultRow[]>([]);
|
||||
const [preview, setPreview] = useState<PreviewState>({ kind: 'idle' });
|
||||
|
||||
// Aggregate label names across reachable nodes for autocomplete. Offline
|
||||
// nodes are skipped so the page-load fanout doesn't hang on dead remotes.
|
||||
// Aggregate label names across reachable nodes for autocomplete.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function loadSuggestions() {
|
||||
@@ -48,7 +55,7 @@ export function LabelFleetStopCard({ nodes, icon: Icon, accentTone }: Props) {
|
||||
const list = (await res.json()) as Label[];
|
||||
for (const l of list) names.add(l.name);
|
||||
} catch {
|
||||
/* ignore — this node is unreachable, not user-facing */
|
||||
/* unreachable node, not user-facing */
|
||||
}
|
||||
}));
|
||||
if (!cancelled) setKnownLabelNames(Array.from(names).sort());
|
||||
@@ -57,16 +64,68 @@ export function LabelFleetStopCard({ nodes, icon: Icon, accentTone }: Props) {
|
||||
return () => { cancelled = true; };
|
||||
}, [nodes]);
|
||||
|
||||
async function run() {
|
||||
// Debounced live preview. The blast-radius readout and the preview section
|
||||
// both read from the same state.
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => {
|
||||
const trimmed = labelName.trim();
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
if (trimmed.length === 0) {
|
||||
setPreview({ kind: 'idle' });
|
||||
return;
|
||||
}
|
||||
setPreview({ kind: 'loading' });
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const res = await apiFetch('/fleet/labels/match-preview', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ labelName: trimmed }),
|
||||
});
|
||||
if (res.status === 404) {
|
||||
setPreview({ kind: 'unavailable' });
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
setPreview({ kind: 'unavailable' });
|
||||
return;
|
||||
}
|
||||
const data = (await res.json()) as MatchPreviewResponse;
|
||||
setPreview({ kind: 'ready', data });
|
||||
} catch {
|
||||
setPreview({ kind: 'unavailable' });
|
||||
}
|
||||
}, 500);
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [labelName]);
|
||||
|
||||
const blastValue = useMemo(() => {
|
||||
const trimmed = labelName.trim();
|
||||
if (trimmed.length === 0) return 'awaiting target';
|
||||
if (preview.kind === 'loading') return 'resolving…';
|
||||
if (preview.kind === 'unavailable') return 'preview unavailable';
|
||||
if (preview.kind === 'ready') {
|
||||
const { matchedNodes, matchedStacks } = preview.data;
|
||||
if (matchedStacks === 0 || matchedNodes === 0) return '0 nodes match';
|
||||
return `${matchedStacks} stacks · ${matchedNodes} nodes`;
|
||||
}
|
||||
return 'awaiting target';
|
||||
}, [labelName, preview]);
|
||||
|
||||
const blastTone = preview.kind === 'loading' || preview.kind === 'unavailable' ? 'muted' as const : undefined;
|
||||
|
||||
async function run(opts: { dryRun: boolean }) {
|
||||
const trimmed = labelName.trim();
|
||||
if (!trimmed) return;
|
||||
const toastId = toast.loading(`Stopping stacks labeled "${trimmed}" across the fleet…`);
|
||||
const verb = opts.dryRun ? 'Dry-running' : 'Stopping';
|
||||
const toastId = toast.loading(`${verb} stacks labeled "${trimmed}" across the fleet…`);
|
||||
setRunning(true);
|
||||
setResults([]);
|
||||
try {
|
||||
const res = await apiFetch('/fleet/labels/fleet-stop', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ labelName: trimmed }),
|
||||
body: JSON.stringify({ labelName: trimmed, dryRun: opts.dryRun }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
toast.dismiss(toastId);
|
||||
@@ -78,7 +137,7 @@ export function LabelFleetStopCard({ nodes, icon: Icon, accentTone }: Props) {
|
||||
const rows: ResultRow[] = apiResults.map((node) => ({
|
||||
key: `node-${node.nodeId}`,
|
||||
label: node.matched
|
||||
? `${node.nodeName} · ${node.stackResults.length} stack${node.stackResults.length === 1 ? '' : 's'}`
|
||||
? `${node.nodeName} · ${node.stackResults.length} stack${node.stackResults.length === 1 ? '' : 's'}${opts.dryRun ? ' (dry run)' : ''}`
|
||||
: `${node.nodeName} (no matching label)`,
|
||||
success: node.matched && node.stackResults.every(s => s.success),
|
||||
error: node.matched ? undefined : 'Label not present',
|
||||
@@ -95,6 +154,7 @@ export function LabelFleetStopCard({ nodes, icon: Icon, accentTone }: Props) {
|
||||
const ok = stacksTouched.filter(s => s.success).length;
|
||||
const failed = stacksTouched.length - ok;
|
||||
if (matchedNodes === 0) toast.info('No nodes have a label by that name.');
|
||||
else if (opts.dryRun) toast.success(`Dry run: would stop ${ok} stack${ok === 1 ? '' : 's'} across ${matchedNodes} node${matchedNodes === 1 ? '' : 's'}.`);
|
||||
else if (failed === 0 && ok > 0) toast.success(`Stopped ${ok} stack${ok === 1 ? '' : 's'} across ${matchedNodes} node${matchedNodes === 1 ? '' : 's'}.`);
|
||||
else if (ok === 0 && failed === 0) toast.info('Label matched but no stacks were assigned to it.');
|
||||
else toast.warning(`${ok} stopped, ${failed} failed. See results below.`);
|
||||
@@ -107,92 +167,144 @@ export function LabelFleetStopCard({ nodes, icon: Icon, accentTone }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
const trimmed = labelName.trim();
|
||||
const previewSection = renderPreviewSection(preview, trimmed);
|
||||
|
||||
// Freshness placeholder: until a run-record store exists, the footer carries
|
||||
// reversibility alone. Add freshness once a /fleet/labels/last-fleet-stop
|
||||
// endpoint or equivalent ships.
|
||||
// TODO(freshness): emit "last fleet-stop {age}" once the run-record source lands.
|
||||
const footerContext = 'Reversible · no';
|
||||
|
||||
return (
|
||||
<Card className="relative overflow-hidden bg-card shadow-card-bevel">
|
||||
<span aria-hidden className={cn('absolute inset-y-0 left-0 w-[3px]', TONE_RAIL[accentTone])} />
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<span className={cn('inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-md', TONE_BG[accentTone])}>
|
||||
<Icon className="h-5 w-5" strokeWidth={1.5} />
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-base font-medium text-stat-value">Stop fleet by label</h3>
|
||||
<p className="mt-1 text-xs text-stat-subtitle">
|
||||
Stop every stack labeled with this name on every node. Labels are matched by name across the fleet.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
<FleetActionCard
|
||||
crumb={['Fleet', 'Actions', 'Stop by label']}
|
||||
name="Stop by label."
|
||||
meta="label-match · per-node fan-out · reports per-stack results"
|
||||
actionClass="destructive"
|
||||
blastRadius={{ value: blastValue, tone: blastTone }}
|
||||
secondaryAction={{
|
||||
label: running ? 'Running…' : 'Dry run',
|
||||
onClick: () => run({ dryRun: true }),
|
||||
disabled: running || trimmed.length === 0,
|
||||
}}
|
||||
primaryAction={{
|
||||
label: 'Stop fleet',
|
||||
onClick: () => setConfirmOpen(true),
|
||||
variant: 'destructive',
|
||||
disabled: running || trimmed.length === 0,
|
||||
}}
|
||||
footerContext={footerContext}
|
||||
>
|
||||
<SheetSection
|
||||
title="Label · target"
|
||||
meta={`auto-suggested · ${knownLabelNames.length} known`}
|
||||
>
|
||||
<Input
|
||||
id="fleet-stop-label-input"
|
||||
list="fleet-stop-label-suggestions"
|
||||
value={labelName}
|
||||
onChange={(e) => setLabelName(e.target.value)}
|
||||
placeholder="e.g. production"
|
||||
className="h-9 text-sm"
|
||||
disabled={running}
|
||||
/>
|
||||
<datalist id="fleet-stop-label-suggestions">
|
||||
{knownLabelNames.map(n => <option key={n} value={n} />)}
|
||||
</datalist>
|
||||
</SheetSection>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label htmlFor="fleet-stop-label-input" className="block text-[10px] uppercase tracking-wide text-stat-subtitle mb-1.5">
|
||||
Label name
|
||||
</label>
|
||||
<Input
|
||||
id="fleet-stop-label-input"
|
||||
list="fleet-stop-label-suggestions"
|
||||
value={labelName}
|
||||
onChange={(e) => setLabelName(e.target.value)}
|
||||
placeholder="e.g. production"
|
||||
className="h-9 text-sm"
|
||||
disabled={running}
|
||||
/>
|
||||
<datalist id="fleet-stop-label-suggestions">
|
||||
{knownLabelNames.map(n => <option key={n} value={n} />)}
|
||||
</datalist>
|
||||
</div>
|
||||
{previewSection}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={running || labelName.trim().length === 0}
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
className="gap-2"
|
||||
>
|
||||
{running ? <Loader2 className="h-3.5 w-3.5 animate-spin" strokeWidth={1.5} /> : <Icon className="h-3.5 w-3.5" strokeWidth={1.5} />}
|
||||
Stop matching stacks
|
||||
</Button>
|
||||
{!running && results.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setResults([])}
|
||||
className="text-xs text-stat-subtitle hover:text-stat-value"
|
||||
>
|
||||
Clear results
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{results.length > 0 && (
|
||||
<SheetSection title="Per-node breakdown">
|
||||
<ResultsList results={results} />
|
||||
</SheetSection>
|
||||
)}
|
||||
</FleetActionCard>
|
||||
|
||||
{results.length === 0 && !running && (
|
||||
<div className="rounded-md border border-card-border/40 bg-glass-highlight/30 p-3 text-xs text-stat-subtitle">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" strokeWidth={1.5} />
|
||||
<span>
|
||||
Different nodes can have their own label rows. Stops are dispatched per node and report
|
||||
per-stack results below.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results.length > 0 && (
|
||||
<ResultsList title="Per-node breakdown" results={results} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmModal
|
||||
open={confirmOpen}
|
||||
onOpenChange={(open) => { if (!open) setConfirmOpen(false); }}
|
||||
variant="destructive"
|
||||
kicker="Fleet stop"
|
||||
title={`Stop all stacks labeled "${labelName.trim()}"?`}
|
||||
description="Sencho will stop every stack on every node that has a label with this name. Services will be unavailable until restarted."
|
||||
confirmLabel="Stop fleet"
|
||||
confirming={running}
|
||||
onConfirm={run}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ConfirmModal
|
||||
open={confirmOpen}
|
||||
onOpenChange={(open) => { if (!open) setConfirmOpen(false); }}
|
||||
variant="destructive"
|
||||
kicker="Fleet stop"
|
||||
title={`Stop all stacks labeled "${trimmed}"?`}
|
||||
description="Sencho will stop every stack on every node that has a label with this name. Services will be unavailable until restarted."
|
||||
confirmLabel="Stop fleet"
|
||||
confirming={running}
|
||||
onConfirm={() => run({ dryRun: false })}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderPreviewSection(preview: PreviewState, trimmed: string) {
|
||||
if (trimmed.length === 0) return null;
|
||||
if (preview.kind === 'loading') {
|
||||
return (
|
||||
<SheetSection title="Preview" meta="resolving…">
|
||||
<div className={cn(KICKER, 'text-stat-icon')}>looking up label across the fleet</div>
|
||||
</SheetSection>
|
||||
);
|
||||
}
|
||||
if (preview.kind === 'unavailable') {
|
||||
return (
|
||||
<SheetSection title="Preview" meta="unavailable">
|
||||
<div className={cn(KICKER, 'text-stat-icon')}>preview endpoint did not respond</div>
|
||||
</SheetSection>
|
||||
);
|
||||
}
|
||||
if (preview.kind === 'ready') {
|
||||
const { matchedStacks, matchedNodes, perNode } = preview.data;
|
||||
if (matchedStacks === 0) {
|
||||
return (
|
||||
<SheetSection title="Preview" meta="0 stacks">
|
||||
<div className={cn(KICKER, 'text-stat-icon')}>no node has a label by that name</div>
|
||||
</SheetSection>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<SheetSection
|
||||
title={`Preview · ${matchedStacks} stack${matchedStacks === 1 ? '' : 's'}`}
|
||||
meta={`across ${matchedNodes} node${matchedNodes === 1 ? '' : 's'}`}
|
||||
>
|
||||
<PreviewWell perNode={perNode} />
|
||||
</SheetSection>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface PreviewWellProps {
|
||||
perNode: MatchPreviewNode[];
|
||||
}
|
||||
|
||||
function PreviewWell({ perNode }: PreviewWellProps) {
|
||||
const flat = perNode.flatMap(n => n.stackNames.map(s => ({ stack: s, node: n.nodeName })));
|
||||
const visible = flat.slice(0, PREVIEW_ROW_LIMIT);
|
||||
const remaining = flat.length - visible.length;
|
||||
const remainingNodes = remaining > 0
|
||||
? new Set(flat.slice(PREVIEW_ROW_LIMIT).map(r => r.node)).size
|
||||
: 0;
|
||||
return (
|
||||
<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">
|
||||
<ul className="space-y-1">
|
||||
{visible.map((row, i) => (
|
||||
<li key={`${row.node}-${row.stack}-${i}`} className="flex items-center gap-2">
|
||||
<span className={cn(KICKER, 'inline-flex items-center px-1 py-0.5 rounded-sm border border-success/40 bg-success/10 text-success shrink-0')}>
|
||||
UP
|
||||
</span>
|
||||
<span className="flex-1 min-w-0 truncate font-mono text-[11px] text-stat-value">{row.stack}</span>
|
||||
<span className={cn(KICKER, 'shrink-0 text-stat-subtitle')}>{row.node}</span>
|
||||
</li>
|
||||
))}
|
||||
{remaining > 0 && (
|
||||
<li className={cn(KICKER, 'text-stat-icon pt-1')}>
|
||||
+ {remaining} more across {remainingNodes} node{remainingNodes === 1 ? '' : 's'}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
// Tone palette shared by the Fleet Actions cards. The cards live as siblings
|
||||
// under `cards/`, so the lookup tables sit next to them rather than hoisting
|
||||
// to a global tokens module.
|
||||
|
||||
export type AccentTone = 'rose' | 'purple' | 'amber';
|
||||
|
||||
export const TONE_RAIL: Record<AccentTone, string> = {
|
||||
rose: 'bg-[var(--label-rose)]',
|
||||
purple: 'bg-[var(--label-purple)]',
|
||||
amber: 'bg-[var(--label-amber)]',
|
||||
};
|
||||
|
||||
export const TONE_BG: Record<AccentTone, string> = {
|
||||
rose: 'bg-[var(--label-rose-bg)] text-[var(--label-rose)]',
|
||||
purple: 'bg-[var(--label-purple-bg)] text-[var(--label-purple)]',
|
||||
amber: 'bg-[var(--label-amber-bg)] text-[var(--label-amber)]',
|
||||
};
|
||||
@@ -0,0 +1,209 @@
|
||||
import * as React from 'react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Tracked-mono kicker. The toolbar/footer band and the blast-radius readout
|
||||
// share `0.18em`; SheetSection headers inside the body use `0.22em` (set in
|
||||
// system-sheet.tsx).
|
||||
const KICKER = 'font-mono text-[10px] uppercase tracking-[0.18em]';
|
||||
|
||||
export type FleetActionClass = 'destructive' | 'transformative' | 'maintenance';
|
||||
export type BlastRadiusTone = 'warning' | 'success' | 'muted';
|
||||
export type PrimaryActionVariant = 'primary' | 'destructive';
|
||||
|
||||
export interface FleetActionCardProps {
|
||||
/** Crumb segments. Last segment renders in --stat-title; the rest in --stat-subtitle with separators. */
|
||||
crumb: string[];
|
||||
/** Italic serif noun for the action (e.g. "Stop by label."). Section rung. */
|
||||
name: string;
|
||||
/** One-line mono summary of the action's shape (e.g. "label-match · per-node fan-out"). */
|
||||
meta: string;
|
||||
/** Closed set; drives the chip color, the blast dot color, and the primary-button variant invariant. */
|
||||
actionClass: FleetActionClass;
|
||||
/**
|
||||
* Live blast-radius readout shown in the toolbar slot. Literal-prefix rules:
|
||||
* - "awaiting target" → dot muted, text muted.
|
||||
* - "0 ..." → dot muted, primary + secondary forced-disabled.
|
||||
* - "local · ..." → dot in --brand (cyan) instead of the class color.
|
||||
* - Otherwise → dot in the class color; tone overrides text color only.
|
||||
*/
|
||||
blastRadius: { value: string; tone?: BlastRadiusTone };
|
||||
/** Optional outline secondary action (Dry run / Reset). Disabled automatically on "awaiting"/"0 ..." readouts. */
|
||||
secondaryAction?: { label: string; onClick: () => void; disabled?: boolean };
|
||||
/** Required primary action. The variant must follow the action-class invariants documented below. */
|
||||
primaryAction: { label: string; onClick: () => void; variant: PrimaryActionVariant; disabled?: boolean };
|
||||
/** Tracked-mono footer line. Omits the band entirely when undefined. */
|
||||
footerContext?: string;
|
||||
/** `<SheetSection>` blocks. */
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const CLASS_LABEL: Record<FleetActionClass, string> = {
|
||||
destructive: 'Destructive',
|
||||
transformative: 'Transformative',
|
||||
maintenance: 'Maintenance',
|
||||
};
|
||||
|
||||
const CHIP_CLASS: Record<FleetActionClass, string> = {
|
||||
destructive: 'border-destructive/40 bg-destructive/10 text-destructive',
|
||||
transformative: 'border-action-transformative/40 bg-action-transformative/10 text-action-transformative',
|
||||
maintenance: 'border-warning/40 bg-warning/10 text-warning',
|
||||
};
|
||||
|
||||
const DOT_CLASS: Record<FleetActionClass, string> = {
|
||||
destructive: 'bg-destructive',
|
||||
transformative: 'bg-action-transformative',
|
||||
maintenance: 'bg-warning',
|
||||
};
|
||||
|
||||
const TONE_TEXT_CLASS: Record<BlastRadiusTone, string> = {
|
||||
warning: 'text-warning',
|
||||
success: 'text-success',
|
||||
muted: 'text-stat-icon',
|
||||
};
|
||||
|
||||
export function FleetActionCard(props: FleetActionCardProps) {
|
||||
const {
|
||||
crumb, name, meta, actionClass, blastRadius,
|
||||
secondaryAction, primaryAction, footerContext, children,
|
||||
} = props;
|
||||
|
||||
// Literal-prefix detection per audit §18.5. These strings are the source of
|
||||
// truth callers pass in; do NOT layer additional state props onto the
|
||||
// primitive for what the prefix already expresses.
|
||||
const isAwaitingTarget = blastRadius.value === 'awaiting target';
|
||||
const isZeroMatch = blastRadius.value.startsWith('0 ');
|
||||
const isLocalScope = blastRadius.value.startsWith('local · ');
|
||||
const forceDisable = isAwaitingTarget || isZeroMatch;
|
||||
|
||||
const dotClass = (isAwaitingTarget || isZeroMatch)
|
||||
? 'bg-stat-subtitle/50'
|
||||
: isLocalScope
|
||||
? 'bg-brand'
|
||||
: DOT_CLASS[actionClass];
|
||||
|
||||
const blastTextClass = (isAwaitingTarget || isZeroMatch)
|
||||
? 'text-stat-icon'
|
||||
: blastRadius.tone
|
||||
? TONE_TEXT_CLASS[blastRadius.tone]
|
||||
: 'text-stat-value';
|
||||
|
||||
// Dev-mode invariant: a maintenance card with a destructive primary should
|
||||
// also signal irreversibility in its footer, otherwise the visual class
|
||||
// (amber) and the action variant (rose) disagree about safety.
|
||||
if (import.meta.env?.MODE !== 'production') {
|
||||
if (actionClass === 'maintenance' && primaryAction.variant === 'destructive') {
|
||||
if (!footerContext || !footerContext.includes('Reversible · no')) {
|
||||
console.warn(
|
||||
'[FleetActionCard] maintenance + destructive primary should carry a "Reversible · no" footer.',
|
||||
{ name, footerContext },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const lastCrumb = crumb[crumb.length - 1] ?? '';
|
||||
const headCrumbs = crumb.slice(0, -1);
|
||||
|
||||
return (
|
||||
<Card className="relative overflow-hidden p-0">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-y-0 left-0 w-[3px] bg-brand"
|
||||
/>
|
||||
|
||||
<header className="px-6 pt-4 pb-3">
|
||||
<div className={cn(KICKER, 'flex flex-wrap items-baseline gap-1 leading-none')}>
|
||||
{headCrumbs.map((segment, idx) => (
|
||||
<React.Fragment key={`${segment}-${idx}`}>
|
||||
<span className="text-stat-subtitle">{segment}</span>
|
||||
<span className="text-stat-icon" aria-hidden="true">{'›'}</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
<span className="text-stat-title">{lastCrumb}</span>
|
||||
</div>
|
||||
<h3 className="mt-2 font-display italic text-[22px] leading-[28px] text-stat-value">
|
||||
{name}
|
||||
</h3>
|
||||
<div className={cn('mt-1.5 font-mono text-[11px] leading-none text-stat-subtitle tracking-[0.04em]')}>
|
||||
{meta}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex items-center gap-2 px-6 py-2 border-y border-card-border/40 bg-card/60">
|
||||
<span
|
||||
className={cn(
|
||||
KICKER,
|
||||
'inline-flex items-center px-1.5 py-0.5 rounded-sm border shrink-0',
|
||||
CHIP_CLASS[actionClass],
|
||||
)}
|
||||
>
|
||||
{CLASS_LABEL[actionClass]}
|
||||
</span>
|
||||
<div className={cn(KICKER, 'flex items-center gap-1.5 leading-none min-w-0', blastTextClass)}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn('inline-block w-1.5 h-1.5 rounded-full shrink-0', dotClass)}
|
||||
/>
|
||||
<span className="truncate">{blastRadius.value}</span>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
{secondaryAction && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={secondaryAction.onClick}
|
||||
disabled={secondaryAction.disabled || forceDisable}
|
||||
className={cn(KICKER, 'h-7 px-3')}
|
||||
>
|
||||
{secondaryAction.label}
|
||||
</Button>
|
||||
)}
|
||||
<PrimaryButton
|
||||
action={primaryAction}
|
||||
disabled={primaryAction.disabled || forceDisable}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="px-6">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{footerContext && (
|
||||
<div className="px-6 py-2 border-t border-card-border/40 bg-card/60">
|
||||
<div className={cn(KICKER, 'text-stat-subtitle leading-none truncate')}>
|
||||
{footerContext}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface PrimaryButtonProps {
|
||||
action: { label: string; onClick: () => void; variant: PrimaryActionVariant };
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
// Primary button kept inline to encode the §18.3 invariant: cyan-filled for
|
||||
// transformative/maintenance (when reversible), ghost-rose for destructive.
|
||||
// Never solid rose on the card; solid rose is reserved for AlertDialog (§10).
|
||||
function PrimaryButton({ action, disabled }: PrimaryButtonProps) {
|
||||
const cyanFilled = 'bg-brand text-brand-foreground hover:bg-brand/90 border border-transparent';
|
||||
const ghostRose = 'border border-destructive/40 text-destructive bg-transparent hover:bg-destructive/15 hover:text-destructive hover:border-destructive/60';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={action.onClick}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
KICKER,
|
||||
'h-7 rounded-md px-3 whitespace-nowrap transition-colors disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
|
||||
action.variant === 'primary' ? cyanFilled : ghostRose,
|
||||
)}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -299,15 +299,22 @@ export interface SheetSectionProps {
|
||||
title: string;
|
||||
/** Hide the title rule + label (useful when a section is the only one in a tab). */
|
||||
hideHeader?: boolean;
|
||||
/** Optional right-aligned mono meta on the section header row (count, scope, freshness). */
|
||||
meta?: string;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SheetSection({ title, hideHeader, className, children }: SheetSectionProps) {
|
||||
export function SheetSection({ title, hideHeader, meta, className, children }: SheetSectionProps) {
|
||||
return (
|
||||
<section className={cn('first:pt-0 first:border-t-0 -mx-6 px-6 py-4 border-t border-card-border/40', className)}>
|
||||
{!hideHeader && (
|
||||
<h3 className={cn(KICKER_CLASS, 'text-stat-subtitle mb-3 leading-none')}>{title}</h3>
|
||||
<div className="mb-3 flex items-baseline justify-between gap-3 leading-none">
|
||||
<h3 className={cn(KICKER_CLASS, 'text-stat-subtitle')}>{title}</h3>
|
||||
{meta && (
|
||||
<span className={cn(KICKER_CLASS, 'text-stat-subtitle/80 shrink-0')}>{meta}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</section>
|
||||
|
||||
@@ -122,6 +122,12 @@
|
||||
--label-custom: var(--label-amber);
|
||||
--label-custom-bg: var(--label-amber-bg);
|
||||
|
||||
/* Fleet Action card action-class chip / blast-dot colors.
|
||||
Closed set per audit §18.3: destructive (rose) / transformative (purple) / maintenance (amber).
|
||||
Two of the three reuse existing semantic tokens; transformative gets its own
|
||||
alias so component code does not reach for --label-purple directly. */
|
||||
--action-transformative: var(--label-purple);
|
||||
|
||||
/* Chart grid & axis */
|
||||
--chart-grid: oklch(0 0 0 / 0.06);
|
||||
--chart-tick: oklch(0 0 0 / 0.45);
|
||||
@@ -299,6 +305,9 @@
|
||||
--label-custom: var(--label-amber);
|
||||
--label-custom-bg: var(--label-amber-bg);
|
||||
|
||||
/* Fleet Action card action-class chip / blast-dot colors (dark theme). */
|
||||
--action-transformative: var(--label-purple);
|
||||
|
||||
/* Chart grid & axis */
|
||||
--chart-grid: oklch(1 0 0 / 0.05);
|
||||
--chart-tick: oklch(1 0 0 / 0.40);
|
||||
@@ -407,6 +416,7 @@
|
||||
--color-update-foreground: var(--update-foreground);
|
||||
--color-update-muted: var(--update-muted);
|
||||
--color-destructive-muted: var(--destructive-muted);
|
||||
--color-action-transformative: var(--action-transformative);
|
||||
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-mono);
|
||||
|
||||
Reference in New Issue
Block a user