mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-28 19:27:41 +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> => {
|
fleetRouter.post('/labels/fleet-stop', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||||
if (!requirePaid(req, res)) return;
|
if (!requirePaid(req, res)) return;
|
||||||
if (!requireAdmin(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') {
|
if (!body || typeof body !== 'object') {
|
||||||
res.status(400).json({ error: 'Request body is required' });
|
res.status(400).json({ error: 'Request body is required' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { labelName } = body;
|
const { labelName, dryRun } = body;
|
||||||
if (typeof labelName !== 'string' || labelName.trim().length === 0) {
|
if (typeof labelName !== 'string' || labelName.trim().length === 0) {
|
||||||
res.status(400).json({ error: 'labelName is required' });
|
res.status(400).json({ error: 'labelName is required' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const trimmed = labelName.trim();
|
const trimmed = labelName.trim();
|
||||||
|
const isDryRun = dryRun === true;
|
||||||
try {
|
try {
|
||||||
const db = DatabaseService.getInstance();
|
const db = DatabaseService.getInstance();
|
||||||
const nodes = db.getNodes();
|
const nodes = db.getNodes();
|
||||||
@@ -1068,7 +1069,8 @@ fleetRouter.post('/labels/fleet-stop', authMiddleware, async (req: Request, res:
|
|||||||
if (node.type === 'local') {
|
if (node.type === 'local') {
|
||||||
// Share the per-node bulk lock with `POST /api/labels/:id/action` so
|
// 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
|
// 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}`;
|
const lockKey = `bulk:${node.id}`;
|
||||||
if (activeBulkActions.has(lockKey)) {
|
if (activeBulkActions.has(lockKey)) {
|
||||||
return {
|
return {
|
||||||
@@ -1081,14 +1083,18 @@ fleetRouter.post('/labels/fleet-stop', authMiddleware, async (req: Request, res:
|
|||||||
const fsStacks = await FileSystemService.getInstance(node.id).getStacks();
|
const fsStacks = await FileSystemService.getInstance(node.id).getStacks();
|
||||||
const fsStackSet = new Set(fsStacks);
|
const fsStackSet = new Set(fsStacks);
|
||||||
const validStacks = stackNames.filter(name => fsStackSet.has(name));
|
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) {
|
for (const stackName of validStacks) {
|
||||||
|
if (isDryRun) {
|
||||||
|
stackResults.push({ stackName, success: true, dryRun: true });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const outcome = await containerActionForStack(node.id, stackName, 'stop');
|
const outcome = await containerActionForStack(node.id, stackName, 'stop');
|
||||||
if (outcome.kind === 'ok') stackResults.push({ stackName, success: true });
|
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 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 });
|
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 };
|
return { nodeId: node.id, nodeName: node.name, matched: true, stackResults };
|
||||||
} finally {
|
} finally {
|
||||||
activeBulkActions.delete(lockKey);
|
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`, {
|
const response = await fetch(`${node.api_url.replace(/\/$/, '')}/api/labels/${label.id}/action`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${node.api_token}`, 'Content-Type': 'application/json' },
|
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),
|
signal: AbortSignal.timeout(60000),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
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 })),
|
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 ?? [] };
|
return { nodeId: node.id, nodeName: node.name, matched: true, stackResults: remote.results ?? [] };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMsg = getErrorMessage(err, 'Failed to reach remote node');
|
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 (!requirePaid(req, res)) return;
|
||||||
if (!requireAdmin(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') {
|
if (!body || typeof body !== 'object') {
|
||||||
res.status(400).json({ error: 'Request body is required' });
|
res.status(400).json({ error: 'Request body is required' });
|
||||||
return;
|
return;
|
||||||
@@ -1169,8 +1175,9 @@ fleetRouter.post('/labels/fleet-prune', authMiddleware, async (req: Request, res
|
|||||||
}
|
}
|
||||||
const targets: FleetPruneTarget[] = Array.from(dedup);
|
const targets: FleetPruneTarget[] = Array.from(dedup);
|
||||||
const scope: 'managed' | 'all' = body.scope === 'all' ? 'all' : 'managed';
|
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 = {
|
type NodeResult = {
|
||||||
nodeId: number; nodeName: string; reachable: boolean; error?: string; targets: TargetResult[];
|
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;
|
let anySuccess = false;
|
||||||
for (const target of targets) {
|
for (const target of targets) {
|
||||||
try {
|
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'
|
const result = scope === 'managed'
|
||||||
? await dockerController.pruneManagedOnly(target, knownStacks)
|
? await dockerController.pruneManagedOnly(target, knownStacks)
|
||||||
: await dockerController.pruneSystem(target);
|
: 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') });
|
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 };
|
return { nodeId: node.id, nodeName: node.name, reachable: true, targets: targetResults };
|
||||||
} finally {
|
} finally {
|
||||||
activeBulkActions.delete(lockKey);
|
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`, {
|
const response = await fetch(`${baseUrl}/api/system/prune/system`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${node.api_token}`, 'Content-Type': 'application/json' },
|
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),
|
signal: AbortSignal.timeout(120000),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
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 });
|
targetResults.push({ target, success: false, reclaimedBytes: 0, error: message });
|
||||||
continue;
|
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') {
|
if (!remote || typeof remote.reclaimedBytes !== 'number') {
|
||||||
targetResults.push({ target, success: false, reclaimedBytes: 0, error: 'Invalid response from remote node' });
|
targetResults.push({ target, success: false, reclaimedBytes: 0, error: 'Invalid response from remote node' });
|
||||||
continue;
|
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) {
|
} catch (err) {
|
||||||
const message = getErrorMessage(err, 'Failed to reach remote node');
|
const message = getErrorMessage(err, 'Failed to reach remote node');
|
||||||
nodeUnreachable = message;
|
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+) ───
|
// ─── Fleet Snapshots (manual: Community; scheduled: Skipper+) ───
|
||||||
|
|
||||||
fleetRouter.post('/snapshots', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
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 {
|
try {
|
||||||
const id = parseIntParam(req, res, 'id', 'label ID');
|
const id = parseIntParam(req, res, 'id', 'label ID');
|
||||||
if (id === null) return;
|
if (id === null) return;
|
||||||
const { action } = req.body;
|
const { action, dryRun } = req.body;
|
||||||
const validActions = ['deploy', 'stop', 'restart'];
|
const validActions = ['deploy', 'stop', 'restart'];
|
||||||
if (!action || !validActions.includes(action)) {
|
if (!action || !validActions.includes(action)) {
|
||||||
res.status(400).json({ error: `action must be one of: ${validActions.join(', ')}` });
|
res.status(400).json({ error: `action must be one of: ${validActions.join(', ')}` });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const isDryRun = dryRun === true;
|
||||||
|
|
||||||
const nodeId = req.nodeId ?? 0;
|
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 fsStackNames = new Set(fsStacks);
|
||||||
const validStacks = stackNames.filter(name => fsStackNames.has(name));
|
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) {
|
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 {
|
try {
|
||||||
if (action === 'deploy') {
|
if (action === 'deploy') {
|
||||||
const gate = await enforcePolicyPreDeploy(
|
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 succeeded = results.filter(r => r.success).length;
|
||||||
const failed = results.length - succeeded;
|
const failed = results.length - succeeded;
|
||||||
console.log(`[Labels] Bulk ${sanitizeForLog(action)} on label ${id}: ${validStacks.length} stacks (${succeeded} succeeded, ${failed} 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 });
|
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);
|
invalidateNodeCaches(req.nodeId);
|
||||||
}
|
}
|
||||||
res.json({ results });
|
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) => {
|
systemMaintenanceRouter.post('/prune/system', async (req: Request, res: Response) => {
|
||||||
if (!requireAdmin(req, res)) return;
|
if (!requireAdmin(req, res)) return;
|
||||||
try {
|
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)) {
|
if (!['containers', 'images', 'networks', 'volumes'].includes(target)) {
|
||||||
return res.status(400).json({ error: 'Invalid prune target' });
|
return res.status(400).json({ error: 'Invalid prune target' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const pruneScope = scope === 'managed' ? 'managed' : 'all';
|
const pruneScope = scope === 'managed' ? 'managed' : 'all';
|
||||||
console.log(`[Resources] System prune: ${target} (scope: ${pruneScope})`);
|
const isDryRun = dryRun === true;
|
||||||
const dockerController = DockerController.getInstance(req.nodeId);
|
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 };
|
let result: { success: boolean; reclaimedBytes: number };
|
||||||
if (pruneScope === 'managed' && target !== 'containers') {
|
if (pruneScope === 'managed' && target !== 'containers') {
|
||||||
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
|
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) => {
|
systemMaintenanceRouter.get('/docker-df', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
|
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
|
||||||
|
|||||||
@@ -386,6 +386,69 @@ class DockerController {
|
|||||||
return { success: true, reclaimedBytes };
|
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<{
|
public async getDiskUsageClassified(knownStackNames: string[]): Promise<{
|
||||||
reclaimableImages: number;
|
reclaimableImages: number;
|
||||||
reclaimableContainers: 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 { useLicense } from '@/context/LicenseContext';
|
||||||
import type { FleetNode } from '@/components/FleetView/types';
|
import type { FleetNode } from '@/components/FleetView/types';
|
||||||
import { LabelFleetStopCard } from './cards/LabelFleetStopCard';
|
import { LabelFleetStopCard } from './cards/LabelFleetStopCard';
|
||||||
@@ -19,22 +19,24 @@ export function FleetActionsTab({ nodes }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!isPaid) {
|
if (!isPaid) {
|
||||||
// Every action is Skipper+. Community users see a calm empty state with
|
return <EmptyState />;
|
||||||
// upgrade context rather than a stripped-down launcher.
|
|
||||||
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 (
|
return (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-[18px] items-start auto-rows-min">
|
||||||
<LabelFleetStopCard nodes={nodes} accentTone="rose" icon={Square} />
|
<LabelFleetStopCard nodes={nodes} />
|
||||||
<BulkLabelAssignCard nodes={nodes} accentTone="purple" icon={Tags} />
|
<BulkLabelAssignCard nodes={nodes} />
|
||||||
<FleetPruneCard nodes={nodes} accentTone="amber" icon={Eraser} />
|
<FleetPruneCard nodes={nodes} />
|
||||||
</div>
|
</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() {
|
function EmptyState() {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-card-border/60 bg-card p-8 text-center">
|
<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 { 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 { Button } from '@/components/ui/button';
|
||||||
import { ConfirmModal } from '@/components/ui/modal';
|
import { ConfirmModal } from '@/components/ui/modal';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
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 { LabelPill } from '@/components/LabelPill';
|
||||||
import { fetchForNode } from '@/lib/api';
|
import { fetchForNode } from '@/lib/api';
|
||||||
import { toast } from '@/components/ui/toast-store';
|
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 { FleetNode } from '@/components/FleetView/types';
|
||||||
import type { Label } from '@/components/label-types';
|
import type { Label } from '@/components/label-types';
|
||||||
import { ResultsList, type ResultRow } from '../ResultsList';
|
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 }
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
nodes: FleetNode[];
|
nodes: FleetNode[];
|
||||||
icon: LucideIcon;
|
|
||||||
accentTone: AccentTone;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BulkLabelAssignCard({ nodes, icon: Icon, accentTone }: Props) {
|
export function BulkLabelAssignCard({ nodes }: Props) {
|
||||||
const [selectedNodeId, setSelectedNodeId] = useState<string>(() => {
|
const [selectedNodeId, setSelectedNodeId] = useState<number>(() => {
|
||||||
const local = nodes.find(n => n.type === 'local');
|
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 [stacks, setStacks] = useState<string[]>([]);
|
||||||
const [labels, setLabels] = useState<Label[]>([]);
|
const [labels, setLabels] = useState<Label[]>([]);
|
||||||
@@ -37,12 +32,10 @@ export function BulkLabelAssignCard({ nodes, icon: Icon, accentTone }: Props) {
|
|||||||
const [running, setRunning] = useState(false);
|
const [running, setRunning] = useState(false);
|
||||||
const [results, setResults] = useState<ResultRow[]>([]);
|
const [results, setResults] = useState<ResultRow[]>([]);
|
||||||
|
|
||||||
const nodeId = useMemo(() => Number(selectedNodeId) || 0, [selectedNodeId]);
|
const selectedNode = useMemo(() => nodes.find(n => n.id === selectedNodeId), [nodes, selectedNodeId]);
|
||||||
const selectedNode = useMemo(() => nodes.find(n => n.id === nodeId), [nodes, nodeId]);
|
|
||||||
|
|
||||||
// Load stacks + labels whenever the node changes.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!nodeId) return;
|
if (!selectedNodeId) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
async function load() {
|
async function load() {
|
||||||
setLoadingLists(true);
|
setLoadingLists(true);
|
||||||
@@ -51,8 +44,8 @@ export function BulkLabelAssignCard({ nodes, icon: Icon, accentTone }: Props) {
|
|||||||
setResults([]);
|
setResults([]);
|
||||||
try {
|
try {
|
||||||
const [stacksRes, labelsRes] = await Promise.all([
|
const [stacksRes, labelsRes] = await Promise.all([
|
||||||
fetchForNode(`/fleet/node/${nodeId}/stacks`, nodeId),
|
fetchForNode(`/fleet/node/${selectedNodeId}/stacks`, selectedNodeId),
|
||||||
fetchForNode('/labels', nodeId),
|
fetchForNode('/labels', selectedNodeId),
|
||||||
]);
|
]);
|
||||||
const stacksList = stacksRes.ok ? ((await stacksRes.json()) as string[]) : [];
|
const stacksList = stacksRes.ok ? ((await stacksRes.json()) as string[]) : [];
|
||||||
const labelsList = labelsRes.ok ? ((await labelsRes.json()) as Label[]) : [];
|
const labelsList = labelsRes.ok ? ((await labelsRes.json()) as Label[]) : [];
|
||||||
@@ -71,7 +64,7 @@ export function BulkLabelAssignCard({ nodes, icon: Icon, accentTone }: Props) {
|
|||||||
}
|
}
|
||||||
load();
|
load();
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [nodeId]);
|
}, [selectedNodeId]);
|
||||||
|
|
||||||
function toggleStack(stackName: string) {
|
function toggleStack(stackName: string) {
|
||||||
setSelectedStacks(prev => {
|
setSelectedStacks(prev => {
|
||||||
@@ -93,6 +86,11 @@ export function BulkLabelAssignCard({ nodes, icon: Icon, accentTone }: Props) {
|
|||||||
if (selectedStacks.size === stacks.length) setSelectedStacks(new Set());
|
if (selectedStacks.size === stacks.length) setSelectedStacks(new Set());
|
||||||
else setSelectedStacks(new Set(stacks));
|
else setSelectedStacks(new Set(stacks));
|
||||||
}
|
}
|
||||||
|
function clearSelection() {
|
||||||
|
setSelectedStacks(new Set());
|
||||||
|
setSelectedLabels(new Set());
|
||||||
|
setResults([]);
|
||||||
|
}
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
if (selectedStacks.size === 0) return;
|
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'}…`);
|
const toastId = toast.loading(`Assigning labels to ${assignments.length} stack${assignments.length === 1 ? '' : 's'}…`);
|
||||||
setRunning(true);
|
setRunning(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetchForNode('/fleet-actions/labels/bulk-assign', nodeId, {
|
const res = await fetchForNode('/fleet-actions/labels/bulk-assign', selectedNodeId, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ assignments }),
|
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 (
|
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])} />
|
<FleetActionCard
|
||||||
<CardContent className="p-6">
|
crumb={['Fleet', 'Actions', 'Bulk label assign']}
|
||||||
<div className="flex items-start gap-3 mb-4">
|
name="Bulk label assign."
|
||||||
<span className={cn('inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-md', TONE_BG[accentTone])}>
|
meta="one node · multi-stack · replaces existing label set"
|
||||||
<Icon className="h-5 w-5" strokeWidth={1.5} />
|
actionClass="transformative"
|
||||||
</span>
|
blastRadius={{ value: blastValue }}
|
||||||
<div className="flex-1">
|
secondaryAction={{
|
||||||
<h3 className="text-base font-medium text-stat-value">Bulk label assign</h3>
|
label: 'Reset',
|
||||||
<p className="mt-1 text-xs text-stat-subtitle">
|
onClick: clearSelection,
|
||||||
Pick a node, multi-select stacks, and replace their labels in one shot.
|
disabled: running || (selectedStacks.size === 0 && selectedLabels.size === 0),
|
||||||
</p>
|
}}
|
||||||
</div>
|
primaryAction={{
|
||||||
</div>
|
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">
|
<SheetSection
|
||||||
<div className="flex items-center gap-2">
|
title={`Stacks · ${selectedStacks.size} / ${stacks.length}`}
|
||||||
<Server className="h-3.5 w-3.5 text-stat-subtitle" strokeWidth={1.5} />
|
meta={stacks.length > 0
|
||||||
<Select value={selectedNodeId} onValueChange={setSelectedNodeId} disabled={running}>
|
? (selectedStacks.size === stacks.length ? 'all selected' : 'multi-select')
|
||||||
<SelectTrigger className="w-56 h-8 text-xs">
|
: undefined}
|
||||||
<SelectValue placeholder="Select a node" />
|
>
|
||||||
</SelectTrigger>
|
<div className="grid gap-0.5 max-h-44 overflow-auto pr-1 border border-card-border/40 rounded-md p-2">
|
||||||
<SelectContent>
|
{stacks.length === 0 && (
|
||||||
{nodes.map(n => (
|
<span className="text-xs text-stat-subtitle">
|
||||||
<SelectItem key={n.id} value={String(n.id)}>
|
{loadingLists ? 'Loading…' : selectedNode ? `No stacks on ${selectedNode.name}.` : 'Pick a node.'}
|
||||||
{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})
|
|
||||||
</span>
|
</span>
|
||||||
{stacks.length > 0 && (
|
)}
|
||||||
<button
|
{stacks.map(stackName => (
|
||||||
type="button"
|
<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}
|
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)}
|
|
||||||
/>
|
/>
|
||||||
))}
|
<span className="text-xs font-mono text-stat-value">{stackName}</span>
|
||||||
</div>
|
</label>
|
||||||
|
))}
|
||||||
</div>
|
</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.
|
Selected labels replace each chosen stack's existing label set on this node.
|
||||||
Selecting no labels clears assignments.
|
Selecting no labels clears assignments.
|
||||||
</p>
|
</p>
|
||||||
|
</SheetSection>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
{results.length > 0 && (
|
||||||
<Button
|
<SheetSection title="Per-stack results">
|
||||||
variant="outline"
|
<ResultsList results={results} />
|
||||||
size="sm"
|
</SheetSection>
|
||||||
disabled={running || selectedStacks.size === 0}
|
)}
|
||||||
onClick={() => setConfirmOpen(true)}
|
</FleetActionCard>
|
||||||
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 && (
|
<ConfirmModal
|
||||||
<ResultsList title="Per-stack results" results={results} />
|
open={confirmOpen}
|
||||||
)}
|
onOpenChange={(open) => { if (!open) setConfirmOpen(false); }}
|
||||||
</div>
|
variant="default"
|
||||||
|
kicker="Bulk label assign"
|
||||||
<ConfirmModal
|
title={`Apply ${selectedLabels.size} label${selectedLabels.size === 1 ? '' : 's'} to ${selectedStacks.size} stack${selectedStacks.size === 1 ? '' : 's'}?`}
|
||||||
open={confirmOpen}
|
description={
|
||||||
onOpenChange={(open) => { if (!open) setConfirmOpen(false); }}
|
selectedLabels.size === 0
|
||||||
variant="default"
|
? 'No labels selected, this will clear existing assignments on the selected stacks.'
|
||||||
kicker="Bulk label assign"
|
: `Each selected stack's existing label set on ${selectedNode?.name ?? 'this node'} will be replaced with the chosen labels.`
|
||||||
title={`Apply ${selectedLabels.size} label${selectedLabels.size === 1 ? '' : 's'} to ${selectedStacks.size} stack${selectedStacks.size === 1 ? '' : 's'}?`}
|
}
|
||||||
description={
|
confirmLabel="Apply"
|
||||||
selectedLabels.size === 0
|
confirming={running}
|
||||||
? 'No labels selected, this will clear existing assignments on the selected stacks.'
|
onConfirm={run}
|
||||||
: `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 {
|
||||||
</CardContent>
|
nodes: FleetNode[];
|
||||||
</Card>
|
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 { useEffect, useMemo, useRef, 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 { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { ConfirmModal } from '@/components/ui/modal';
|
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 { apiFetch } from '@/lib/api';
|
||||||
import { toast } from '@/components/ui/toast-store';
|
import { toast } from '@/components/ui/toast-store';
|
||||||
import { cn, formatBytes } from '@/lib/utils';
|
import { cn, formatBytes } from '@/lib/utils';
|
||||||
import type { FleetNode } from '@/components/FleetView/types';
|
import type { FleetNode } from '@/components/FleetView/types';
|
||||||
import { ResultsList, type ResultRow } from '../ResultsList';
|
import { ResultsList, type ResultRow } from '../ResultsList';
|
||||||
import { TONE_RAIL, TONE_BG, type AccentTone } from './tone';
|
|
||||||
|
|
||||||
type PruneTarget = 'images' | 'volumes' | 'networks';
|
type PruneTarget = 'images' | 'volumes' | 'networks';
|
||||||
type PruneScope = 'managed' | 'all';
|
type PruneScope = 'managed' | 'all';
|
||||||
@@ -21,24 +19,35 @@ const ALL_TARGETS: ReadonlyArray<{ id: PruneTarget; label: string }> = [
|
|||||||
{ id: 'networks', label: 'Networks' },
|
{ 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 {
|
interface FleetPruneNodeResult {
|
||||||
nodeId: number; nodeName: string; reachable: boolean; error?: string; targets: TargetResult[];
|
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 {
|
interface Props {
|
||||||
nodes: FleetNode[];
|
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 nodeCount = nodes.length;
|
||||||
const [targets, setTargets] = useState<Set<PruneTarget>>(new Set(['images']));
|
const [targets, setTargets] = useState<Set<PruneTarget>>(new Set(['images']));
|
||||||
const [scope, setScope] = useState<PruneScope>('managed');
|
const [scope, setScope] = useState<PruneScope>('managed');
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
const [running, setRunning] = useState(false);
|
const [running, setRunning] = useState(false);
|
||||||
const [results, setResults] = useState<ResultRow[]>([]);
|
const [results, setResults] = useState<ResultRow[]>([]);
|
||||||
|
const [estimate, setEstimate] = useState<EstimateState>({ kind: 'idle' });
|
||||||
|
|
||||||
const toggleTarget = (target: PruneTarget) => {
|
const toggleTarget = (target: PruneTarget) => {
|
||||||
setTargets(prev => {
|
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;
|
if (targets.size === 0) return;
|
||||||
const selected = Array.from(targets);
|
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);
|
setRunning(true);
|
||||||
setResults([]);
|
setResults([]);
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch('/fleet/labels/fleet-prune', {
|
const res = await apiFetch('/fleet/labels/fleet-prune', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ targets: selected, scope }),
|
body: JSON.stringify({ targets: selected, scope, dryRun: opts.dryRun }),
|
||||||
});
|
});
|
||||||
const body = await res.json().catch(() => ({}));
|
const body = await res.json().catch(() => ({}));
|
||||||
toast.dismiss(toastId);
|
toast.dismiss(toastId);
|
||||||
@@ -73,7 +119,7 @@ export function FleetPruneCard({ nodes, icon: Icon, accentTone }: Props) {
|
|||||||
return {
|
return {
|
||||||
key: `node-${node.nodeId}`,
|
key: `node-${node.nodeId}`,
|
||||||
label: node.reachable
|
label: node.reachable
|
||||||
? `${node.nodeName} · ${formatBytes(totalBytes)}`
|
? `${node.nodeName} · ${formatBytes(totalBytes)}${opts.dryRun ? ' (dry run)' : ''}`
|
||||||
: `${node.nodeName} (unreachable)`,
|
: `${node.nodeName} (unreachable)`,
|
||||||
success: allOk,
|
success: allOk,
|
||||||
error: node.reachable ? undefined : node.error,
|
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),
|
(sum, n) => sum + n.targets.reduce((s, t) => s + (t.reclaimedBytes ?? 0), 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'}.`);
|
toast.success(`Reclaimed ${formatBytes(totalReclaimed)} across ${totalNodes} node${totalNodes === 1 ? '' : 's'}.`);
|
||||||
} else if (okNodes === 0) {
|
} else if (okNodes === 0) {
|
||||||
toast.error('Prune failed on every node. See results below.');
|
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 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 (
|
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])} />
|
<FleetActionCard
|
||||||
<CardContent className="p-6">
|
crumb={['Fleet', 'Actions', 'Prune resources']}
|
||||||
<div className="flex items-start gap-3 mb-4">
|
name="Prune fleet-wide."
|
||||||
<span className={cn('inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-md', TONE_BG[accentTone])}>
|
meta="images · volumes · networks · serial per node"
|
||||||
<Icon className="h-5 w-5" strokeWidth={1.5} />
|
actionClass="maintenance"
|
||||||
</span>
|
blastRadius={{ value: blastValue, tone: blastTone }}
|
||||||
<div className="flex-1">
|
secondaryAction={{
|
||||||
<h3 className="text-base font-medium text-stat-value">Prune Docker resources fleet-wide</h3>
|
label: running ? 'Running…' : 'Dry run',
|
||||||
<p className="mt-1 text-xs text-stat-subtitle">
|
onClick: () => run({ dryRun: true }),
|
||||||
Reclaim space on {nodeCount} node{nodeCount === 1 ? '' : 's'} by removing unused images, volumes, and networks. Reclaimed bytes are approximate.
|
disabled: running || targets.size === 0,
|
||||||
</p>
|
}}
|
||||||
</div>
|
primaryAction={{
|
||||||
</div>
|
label: 'Prune fleet',
|
||||||
|
onClick: () => setConfirmOpen(true),
|
||||||
<div className="space-y-3">
|
variant: 'destructive',
|
||||||
<div>
|
// Block the destructive confirm until the operator has actually
|
||||||
<div className="text-[10px] uppercase tracking-wide text-stat-subtitle mb-1.5">Targets</div>
|
// seen what the readout says will be reclaimed. Falling back to a
|
||||||
<div className="flex flex-wrap gap-3">
|
// confirm modal with no estimate context is the audit §F20.3 problem.
|
||||||
{ALL_TARGETS.map(t => (
|
disabled: running || targets.size === 0 || estimate.kind !== 'ready',
|
||||||
<label
|
}}
|
||||||
key={t.id}
|
footerContext={`Reversible · no · serial across ${nodeCount} node${nodeCount === 1 ? '' : 's'}`}
|
||||||
className="flex items-center gap-2 py-1 px-2 rounded hover:bg-glass-highlight cursor-pointer"
|
>
|
||||||
>
|
<SheetSection
|
||||||
<Checkbox
|
title={`Targets · ${targets.size} / ${ALL_TARGETS.length}`}
|
||||||
checked={targets.has(t.id)}
|
meta={targets.size === 0 ? 'pick at least one' : undefined}
|
||||||
onCheckedChange={() => toggleTarget(t.id)}
|
>
|
||||||
disabled={running}
|
<div className="flex flex-wrap gap-3">
|
||||||
/>
|
{ALL_TARGETS.map(t => (
|
||||||
<span className="text-xs text-stat-value">{t.label}</span>
|
<label
|
||||||
</label>
|
key={t.id}
|
||||||
))}
|
className="flex items-center gap-2 py-1 px-2 rounded hover:bg-glass-highlight cursor-pointer"
|
||||||
</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"
|
|
||||||
>
|
>
|
||||||
Managed only
|
<Checkbox
|
||||||
</Button>
|
checked={targets.has(t.id)}
|
||||||
<Button
|
onCheckedChange={() => toggleTarget(t.id)}
|
||||||
type="button"
|
disabled={running}
|
||||||
variant={scope === 'all' ? 'default' : 'outline'}
|
/>
|
||||||
size="sm"
|
<span className="text-xs text-stat-value">{t.label}</span>
|
||||||
disabled={running}
|
</label>
|
||||||
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>
|
|
||||||
</div>
|
</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
|
<Button
|
||||||
variant="outline"
|
type="button"
|
||||||
|
variant={scope === 'managed' ? 'default' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={running || targetCount === 0}
|
disabled={running}
|
||||||
onClick={() => setConfirmOpen(true)}
|
onClick={() => setScope('managed')}
|
||||||
className="gap-2"
|
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} />}
|
Managed only
|
||||||
Prune across fleet
|
</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>
|
</Button>
|
||||||
{!running && results.length > 0 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setResults([])}
|
|
||||||
className="text-xs text-stat-subtitle hover:text-stat-value"
|
|
||||||
>
|
|
||||||
Clear results
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</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 && (
|
{targets.size > 0 && <EstimateSection estimate={estimate} />}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{results.length > 0 && (
|
{results.length > 0 && (
|
||||||
<ResultsList title="Per-node breakdown" results={results} />
|
<SheetSection title="Per-node breakdown">
|
||||||
)}
|
<ResultsList results={results} />
|
||||||
</div>
|
</SheetSection>
|
||||||
|
)}
|
||||||
|
</FleetActionCard>
|
||||||
|
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
open={confirmOpen}
|
open={confirmOpen}
|
||||||
onOpenChange={(open) => { if (!open) setConfirmOpen(false); }}
|
onOpenChange={(open) => { if (!open) setConfirmOpen(false); }}
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
kicker="Fleet prune"
|
kicker="Fleet prune"
|
||||||
title={isAllScope ? 'Prune ALL unused resources across the fleet?' : 'Prune managed resources across the fleet?'}
|
title={isAllScope ? 'Prune ALL unused resources across the fleet?' : 'Prune managed resources across the fleet?'}
|
||||||
description={
|
description={
|
||||||
isAllScope
|
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.'
|
? '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.'
|
: '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'}
|
confirmLabel={isAllScope ? 'Prune everything unused' : 'Prune managed'}
|
||||||
confirming={running}
|
confirming={running}
|
||||||
onConfirm={run}
|
onConfirm={() => run({ dryRun: false })}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</>
|
||||||
</Card>
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 { useEffect, useMemo, useRef, 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 { ConfirmModal } from '@/components/ui/modal';
|
import { ConfirmModal } from '@/components/ui/modal';
|
||||||
import { Input } from '@/components/ui/input';
|
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 { apiFetch, fetchForNode } from '@/lib/api';
|
||||||
import { toast } from '@/components/ui/toast-store';
|
import { toast } from '@/components/ui/toast-store';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { FleetNode } from '@/components/FleetView/types';
|
import type { FleetNode } from '@/components/FleetView/types';
|
||||||
import type { Label } from '@/components/label-types';
|
import type { Label } from '@/components/label-types';
|
||||||
import { ResultsList, type ResultRow } from '../ResultsList';
|
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 {
|
interface FleetStopNodeResult {
|
||||||
nodeId: number;
|
nodeId: number;
|
||||||
nodeName: string;
|
nodeName: string;
|
||||||
@@ -21,21 +18,31 @@ interface FleetStopNodeResult {
|
|||||||
stackResults: NodeStackResult[];
|
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 {
|
interface Props {
|
||||||
nodes: FleetNode[];
|
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 [labelName, setLabelName] = useState('');
|
||||||
const [knownLabelNames, setKnownLabelNames] = useState<string[]>([]);
|
const [knownLabelNames, setKnownLabelNames] = useState<string[]>([]);
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
const [running, setRunning] = useState(false);
|
const [running, setRunning] = useState(false);
|
||||||
const [results, setResults] = useState<ResultRow[]>([]);
|
const [results, setResults] = useState<ResultRow[]>([]);
|
||||||
|
const [preview, setPreview] = useState<PreviewState>({ kind: 'idle' });
|
||||||
|
|
||||||
// Aggregate label names across reachable nodes for autocomplete. Offline
|
// Aggregate label names across reachable nodes for autocomplete.
|
||||||
// nodes are skipped so the page-load fanout doesn't hang on dead remotes.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
async function loadSuggestions() {
|
async function loadSuggestions() {
|
||||||
@@ -48,7 +55,7 @@ export function LabelFleetStopCard({ nodes, icon: Icon, accentTone }: Props) {
|
|||||||
const list = (await res.json()) as Label[];
|
const list = (await res.json()) as Label[];
|
||||||
for (const l of list) names.add(l.name);
|
for (const l of list) names.add(l.name);
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore — this node is unreachable, not user-facing */
|
/* unreachable node, not user-facing */
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
if (!cancelled) setKnownLabelNames(Array.from(names).sort());
|
if (!cancelled) setKnownLabelNames(Array.from(names).sort());
|
||||||
@@ -57,16 +64,68 @@ export function LabelFleetStopCard({ nodes, icon: Icon, accentTone }: Props) {
|
|||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [nodes]);
|
}, [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();
|
const trimmed = labelName.trim();
|
||||||
if (!trimmed) return;
|
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);
|
setRunning(true);
|
||||||
setResults([]);
|
setResults([]);
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch('/fleet/labels/fleet-stop', {
|
const res = await apiFetch('/fleet/labels/fleet-stop', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ labelName: trimmed }),
|
body: JSON.stringify({ labelName: trimmed, dryRun: opts.dryRun }),
|
||||||
});
|
});
|
||||||
const body = await res.json().catch(() => ({}));
|
const body = await res.json().catch(() => ({}));
|
||||||
toast.dismiss(toastId);
|
toast.dismiss(toastId);
|
||||||
@@ -78,7 +137,7 @@ export function LabelFleetStopCard({ nodes, icon: Icon, accentTone }: Props) {
|
|||||||
const rows: ResultRow[] = apiResults.map((node) => ({
|
const rows: ResultRow[] = apiResults.map((node) => ({
|
||||||
key: `node-${node.nodeId}`,
|
key: `node-${node.nodeId}`,
|
||||||
label: node.matched
|
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)`,
|
: `${node.nodeName} (no matching label)`,
|
||||||
success: node.matched && node.stackResults.every(s => s.success),
|
success: node.matched && node.stackResults.every(s => s.success),
|
||||||
error: node.matched ? undefined : 'Label not present',
|
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 ok = stacksTouched.filter(s => s.success).length;
|
||||||
const failed = stacksTouched.length - ok;
|
const failed = stacksTouched.length - ok;
|
||||||
if (matchedNodes === 0) toast.info('No nodes have a label by that name.');
|
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 (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 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.`);
|
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 (
|
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])} />
|
<FleetActionCard
|
||||||
<CardContent className="p-6">
|
crumb={['Fleet', 'Actions', 'Stop by label']}
|
||||||
<div className="flex items-start gap-3 mb-4">
|
name="Stop by label."
|
||||||
<span className={cn('inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-md', TONE_BG[accentTone])}>
|
meta="label-match · per-node fan-out · reports per-stack results"
|
||||||
<Icon className="h-5 w-5" strokeWidth={1.5} />
|
actionClass="destructive"
|
||||||
</span>
|
blastRadius={{ value: blastValue, tone: blastTone }}
|
||||||
<div className="flex-1">
|
secondaryAction={{
|
||||||
<h3 className="text-base font-medium text-stat-value">Stop fleet by label</h3>
|
label: running ? 'Running…' : 'Dry run',
|
||||||
<p className="mt-1 text-xs text-stat-subtitle">
|
onClick: () => run({ dryRun: true }),
|
||||||
Stop every stack labeled with this name on every node. Labels are matched by name across the fleet.
|
disabled: running || trimmed.length === 0,
|
||||||
</p>
|
}}
|
||||||
</div>
|
primaryAction={{
|
||||||
</div>
|
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">
|
{previewSection}
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
{results.length > 0 && (
|
||||||
<Button
|
<SheetSection title="Per-node breakdown">
|
||||||
variant="outline"
|
<ResultsList results={results} />
|
||||||
size="sm"
|
</SheetSection>
|
||||||
disabled={running || labelName.trim().length === 0}
|
)}
|
||||||
onClick={() => setConfirmOpen(true)}
|
</FleetActionCard>
|
||||||
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 && !running && (
|
<ConfirmModal
|
||||||
<div className="rounded-md border border-card-border/40 bg-glass-highlight/30 p-3 text-xs text-stat-subtitle">
|
open={confirmOpen}
|
||||||
<div className="flex items-start gap-2">
|
onOpenChange={(open) => { if (!open) setConfirmOpen(false); }}
|
||||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" strokeWidth={1.5} />
|
variant="destructive"
|
||||||
<span>
|
kicker="Fleet stop"
|
||||||
Different nodes can have their own label rows. Stops are dispatched per node and report
|
title={`Stop all stacks labeled "${trimmed}"?`}
|
||||||
per-stack results below.
|
description="Sencho will stop every stack on every node that has a label with this name. Services will be unavailable until restarted."
|
||||||
</span>
|
confirmLabel="Stop fleet"
|
||||||
</div>
|
confirming={running}
|
||||||
</div>
|
onConfirm={() => run({ dryRun: false })}
|
||||||
)}
|
/>
|
||||||
|
</>
|
||||||
{results.length > 0 && (
|
);
|
||||||
<ResultsList title="Per-node breakdown" results={results} />
|
}
|
||||||
)}
|
|
||||||
</div>
|
function renderPreviewSection(preview: PreviewState, trimmed: string) {
|
||||||
|
if (trimmed.length === 0) return null;
|
||||||
<ConfirmModal
|
if (preview.kind === 'loading') {
|
||||||
open={confirmOpen}
|
return (
|
||||||
onOpenChange={(open) => { if (!open) setConfirmOpen(false); }}
|
<SheetSection title="Preview" meta="resolving…">
|
||||||
variant="destructive"
|
<div className={cn(KICKER, 'text-stat-icon')}>looking up label across the fleet</div>
|
||||||
kicker="Fleet stop"
|
</SheetSection>
|
||||||
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"
|
if (preview.kind === 'unavailable') {
|
||||||
confirming={running}
|
return (
|
||||||
onConfirm={run}
|
<SheetSection title="Preview" meta="unavailable">
|
||||||
/>
|
<div className={cn(KICKER, 'text-stat-icon')}>preview endpoint did not respond</div>
|
||||||
</CardContent>
|
</SheetSection>
|
||||||
</Card>
|
);
|
||||||
|
}
|
||||||
|
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;
|
title: string;
|
||||||
/** Hide the title rule + label (useful when a section is the only one in a tab). */
|
/** Hide the title rule + label (useful when a section is the only one in a tab). */
|
||||||
hideHeader?: boolean;
|
hideHeader?: boolean;
|
||||||
|
/** Optional right-aligned mono meta on the section header row (count, scope, freshness). */
|
||||||
|
meta?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SheetSection({ title, hideHeader, className, children }: SheetSectionProps) {
|
export function SheetSection({ title, hideHeader, meta, className, children }: SheetSectionProps) {
|
||||||
return (
|
return (
|
||||||
<section className={cn('first:pt-0 first:border-t-0 -mx-6 px-6 py-4 border-t border-card-border/40', className)}>
|
<section className={cn('first:pt-0 first:border-t-0 -mx-6 px-6 py-4 border-t border-card-border/40', className)}>
|
||||||
{!hideHeader && (
|
{!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}
|
{children}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -122,6 +122,12 @@
|
|||||||
--label-custom: var(--label-amber);
|
--label-custom: var(--label-amber);
|
||||||
--label-custom-bg: var(--label-amber-bg);
|
--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 & axis */
|
||||||
--chart-grid: oklch(0 0 0 / 0.06);
|
--chart-grid: oklch(0 0 0 / 0.06);
|
||||||
--chart-tick: oklch(0 0 0 / 0.45);
|
--chart-tick: oklch(0 0 0 / 0.45);
|
||||||
@@ -299,6 +305,9 @@
|
|||||||
--label-custom: var(--label-amber);
|
--label-custom: var(--label-amber);
|
||||||
--label-custom-bg: var(--label-amber-bg);
|
--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 & axis */
|
||||||
--chart-grid: oklch(1 0 0 / 0.05);
|
--chart-grid: oklch(1 0 0 / 0.05);
|
||||||
--chart-tick: oklch(1 0 0 / 0.40);
|
--chart-tick: oklch(1 0 0 / 0.40);
|
||||||
@@ -407,6 +416,7 @@
|
|||||||
--color-update-foreground: var(--update-foreground);
|
--color-update-foreground: var(--update-foreground);
|
||||||
--color-update-muted: var(--update-muted);
|
--color-update-muted: var(--update-muted);
|
||||||
--color-destructive-muted: var(--destructive-muted);
|
--color-destructive-muted: var(--destructive-muted);
|
||||||
|
--color-action-transformative: var(--action-transformative);
|
||||||
|
|
||||||
--font-sans: var(--font-sans);
|
--font-sans: var(--font-sans);
|
||||||
--font-mono: var(--font-mono);
|
--font-mono: var(--font-mono);
|
||||||
|
|||||||
Reference in New Issue
Block a user