mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
feat(fleet): add Fleet Actions tab for cross-node bulk operations (#963)
* feat(fleet): add Fleet Actions tab for cross-node bulk operations Introduces a new "Actions" sub-tab in Fleet view with two Skipper+ cards that fill gaps in the existing surface: - Stop fleet by label: matches a label name across every node and stops every stack assigned to it, reporting per-node and per-stack results. - Bulk label assign: applies the same label set to many stacks on one node in a single round trip. Other bulk operations stay in their existing homes (sidebar bulk mode, Schedules, NodeUpdatesSheet) to avoid duplicate surfaces. Backend: - POST /api/fleet/labels/fleet-stop (gateway-orchestrated, multi-node) - POST /api/fleet-actions/labels/bulk-assign (per-node, capped at 1000) - Tightens /api/fleet proxy-exempt prefix to /api/fleet/ so /api/fleet-actions/* is routed through the proxy for per-node calls. - Exports activeBulkActions from labels.ts so fleet-stop and label-action share the per-node lock and cannot double-stop the same containers. - Extracts containerActionForStack helper from stacks.ts for reuse. * chore(fleet): rename Actions tab to Fleet Actions and reorder Fleet sub-tabs - Tab label "Actions" -> "Fleet Actions" so the surface is unambiguous alongside Schedules and the sidebar bulk bar. - Reorder Fleet sub-tabs as Overview / Snapshots / Status | Deployments / Traffic / Fleet Actions, with the separator after Status. - Rename "Traffic · Routing" -> "Traffic" and update Sencho Mesh docs to match the shorter label. - Update Fleet Actions docs to the new tab name and placement.
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Tests for the Fleet Actions tab endpoints. Covers auth, tier gating, input
|
||||
* validation, and orchestration shape across the two routes.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let authHeader: string;
|
||||
let LicenseService: typeof import('../services/LicenseService').LicenseService;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ app } = await import('../index'));
|
||||
({ LicenseService } = await import('../services/LicenseService'));
|
||||
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
authHeader = `Bearer ${token}`;
|
||||
});
|
||||
|
||||
afterAll(() => cleanupTestDb(tmpDir));
|
||||
|
||||
function mockTier(tier: 'paid' | 'community') {
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue(tier);
|
||||
}
|
||||
|
||||
describe('Fleet Actions endpoints require authentication', () => {
|
||||
it('POST /api/fleet-actions/labels/bulk-assign returns 401 without auth', async () => {
|
||||
const res = await request(app).post('/api/fleet-actions/labels/bulk-assign').send({ assignments: [] });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('POST /api/fleet/labels/fleet-stop returns 401 without auth', async () => {
|
||||
const res = await request(app).post('/api/fleet/labels/fleet-stop').send({ labelName: 'prod' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Fleet Actions tier gating', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('POST /api/fleet/labels/fleet-stop returns 403 on community tier (Skipper+)', async () => {
|
||||
mockTier('community');
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/labels/fleet-stop')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ labelName: 'prod' });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('POST /api/fleet-actions/labels/bulk-assign returns 403 on community tier (Skipper+)', async () => {
|
||||
mockTier('community');
|
||||
const res = await request(app)
|
||||
.post('/api/fleet-actions/labels/bulk-assign')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ assignments: [] });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Fleet Actions input validation', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('POST /api/fleet/labels/fleet-stop rejects missing labelName', async () => {
|
||||
mockTier('paid');
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/labels/fleet-stop')
|
||||
.set('Authorization', authHeader)
|
||||
.send({});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/labelName/);
|
||||
});
|
||||
|
||||
it('POST /api/fleet/labels/fleet-stop rejects whitespace-only labelName', async () => {
|
||||
mockTier('paid');
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/labels/fleet-stop')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ labelName: ' ' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('POST /api/fleet-actions/labels/bulk-assign rejects non-array assignments', async () => {
|
||||
mockTier('paid');
|
||||
const res = await request(app)
|
||||
.post('/api/fleet-actions/labels/bulk-assign')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ assignments: 'oops' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/assignments must be an array/);
|
||||
});
|
||||
|
||||
it('POST /api/fleet-actions/labels/bulk-assign rejects oversized payload', async () => {
|
||||
mockTier('paid');
|
||||
const big = Array.from({ length: 1001 }, (_, i) => ({ stackName: `s${i}`, labelIds: [] }));
|
||||
const res = await request(app)
|
||||
.post('/api/fleet-actions/labels/bulk-assign')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ assignments: big });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/may not exceed/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Fleet Actions orchestration shape', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('POST /api/fleet/labels/fleet-stop with unknown label returns matched:false per node', async () => {
|
||||
mockTier('paid');
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/labels/fleet-stop')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ labelName: 'this-label-does-not-exist' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.results)).toBe(true);
|
||||
for (const row of res.body.results) {
|
||||
expect(row.matched).toBe(false);
|
||||
expect(row.stackResults).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('POST /api/fleet-actions/labels/bulk-assign accepts empty assignments and returns empty results', async () => {
|
||||
mockTier('paid');
|
||||
const res = await request(app)
|
||||
.post('/api/fleet-actions/labels/bulk-assign')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ assignments: [] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.results).toEqual([]);
|
||||
});
|
||||
|
||||
it('POST /api/fleet-actions/labels/bulk-assign rejects an entry with bad stack name in-line', async () => {
|
||||
mockTier('paid');
|
||||
const res = await request(app)
|
||||
.post('/api/fleet-actions/labels/bulk-assign')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ assignments: [{ stackName: 'has spaces!', labelIds: [1] }] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.results[0]).toMatchObject({ success: false, error: 'Invalid stack name' });
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@ export const PROXY_EXEMPT_PREFIXES: readonly string[] = [
|
||||
'/api/auth/',
|
||||
'/api/nodes',
|
||||
'/api/license',
|
||||
'/api/fleet',
|
||||
'/api/fleet/',
|
||||
'/api/webhooks',
|
||||
'/api/meta',
|
||||
];
|
||||
|
||||
@@ -20,6 +20,7 @@ import { webhooksRouter } from './routes/webhooks';
|
||||
import { usersRouter } from './routes/users';
|
||||
import { gitSourcesRouter, stackGitSourceRouter } from './routes/gitSources';
|
||||
import { fleetRouter } from './routes/fleet';
|
||||
import { fleetActionsRouter } from './routes/fleetActions';
|
||||
import { cloudBackupRouter } from './routes/cloudBackup';
|
||||
import { permissionsRouter } from './routes/permissions';
|
||||
import { convertRouter } from './routes/convert';
|
||||
@@ -98,6 +99,7 @@ app.use('/api/stacks', stackLabelsRouter);
|
||||
app.use('/api/api-tokens', apiTokensRouter);
|
||||
app.use('/api/audit-log', auditLogRouter);
|
||||
app.use('/api/fleet', fleetRouter);
|
||||
app.use('/api/fleet-actions', fleetActionsRouter);
|
||||
app.use('/api/cloud-backup', cloudBackupRouter);
|
||||
app.use('/api/webhooks', webhooksRouter);
|
||||
app.use('/api/users', usersRouter);
|
||||
|
||||
@@ -25,6 +25,9 @@ import { POLICY_SEVERITIES } from '../utils/severity';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
import { CloudBackupService } from '../services/CloudBackupService';
|
||||
import { NotificationService } from '../services/NotificationService';
|
||||
import { invalidateNodeCaches } from '../helpers/cacheInvalidation';
|
||||
import { containerActionForStack } from './stacks';
|
||||
import { activeBulkActions } from './labels';
|
||||
import { buildLocalConfigurationStatus, type ConfigurationStatus } from './dashboard';
|
||||
import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from '../services/license-headers';
|
||||
import { LicenseService } from '../services/LicenseService';
|
||||
@@ -844,6 +847,110 @@ fleetRouter.delete('/update-status', authMiddleware, async (req: Request, res: R
|
||||
res.status(204).send();
|
||||
});
|
||||
|
||||
// ─── Fleet Actions: gateway-orchestrated endpoints (multi-node) ───
|
||||
//
|
||||
// Per-node fleet-action endpoints (run on the target node via the proxy) live
|
||||
// in `routes/fleetActions.ts`. The endpoint below is gateway-orchestrated and
|
||||
// lives here so it sits behind the `/api/fleet/` proxy-exempt prefix.
|
||||
|
||||
// Fleet-wide stop by label name. Matches each node's labels by name and runs
|
||||
// container stops on each matching stack.
|
||||
// Tier: requirePaid + requireAdmin.
|
||||
fleetRouter.post('/labels/fleet-stop', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
const body = req.body as { labelName?: unknown } | undefined;
|
||||
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();
|
||||
const results = await Promise.all(nodes.map(async (node) => {
|
||||
const label = db.getLabels(node.id).find(l => l.name === trimmed);
|
||||
if (!label) {
|
||||
return { nodeId: node.id, nodeName: node.name, matched: false, stackResults: [] };
|
||||
}
|
||||
const stackNames = db.getStacksForLabel(label.id, node.id);
|
||||
if (stackNames.length === 0) {
|
||||
return { nodeId: node.id, nodeName: node.name, matched: true, stackResults: [] };
|
||||
}
|
||||
|
||||
if (node.type === 'local') {
|
||||
// Share the per-node bulk lock with `POST /api/labels/:id/action` so
|
||||
// a fleet-stop and a per-label action cannot double-stop the same
|
||||
// containers concurrently on the same local node.
|
||||
const lockKey = `bulk:${node.id}`;
|
||||
if (activeBulkActions.has(lockKey)) {
|
||||
return {
|
||||
nodeId: node.id, nodeName: node.name, matched: true,
|
||||
stackResults: stackNames.map(stackName => ({ stackName, success: false, error: 'A bulk action is already running on this node' })),
|
||||
};
|
||||
}
|
||||
activeBulkActions.add(lockKey);
|
||||
try {
|
||||
const fsStacks = await FileSystemService.getInstance(node.id).getStacks();
|
||||
const fsStackSet = new Set(fsStacks);
|
||||
const validStacks = stackNames.filter(name => fsStackSet.has(name));
|
||||
const stackResults: { stackName: string; success: boolean; error?: string }[] = [];
|
||||
for (const stackName of validStacks) {
|
||||
const outcome = await containerActionForStack(node.id, stackName, 'stop');
|
||||
if (outcome.kind === 'ok') stackResults.push({ stackName, success: true });
|
||||
else if (outcome.kind === 'no-containers') stackResults.push({ stackName, success: false, error: 'No containers found for this stack' });
|
||||
else stackResults.push({ stackName, success: false, error: outcome.message });
|
||||
}
|
||||
if (stackResults.some(r => r.success)) invalidateNodeCaches(node.id);
|
||||
return { nodeId: node.id, nodeName: node.name, matched: true, stackResults };
|
||||
} finally {
|
||||
activeBulkActions.delete(lockKey);
|
||||
}
|
||||
}
|
||||
|
||||
if (!node.api_url || !node.api_token) {
|
||||
return {
|
||||
nodeId: node.id, nodeName: node.name, matched: true,
|
||||
stackResults: stackNames.map(stackName => ({ stackName, success: false, error: 'Remote node not configured' })),
|
||||
};
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${node.api_url.replace(/\/$/, '')}/api/labels/${label.id}/action`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${node.api_token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'stop' }),
|
||||
signal: AbortSignal.timeout(60000),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const err = (await response.json().catch(() => ({}))) as { error?: string };
|
||||
const message = err.error || `Remote returned ${response.status}`;
|
||||
return {
|
||||
nodeId: node.id, nodeName: node.name, matched: true,
|
||||
stackResults: stackNames.map(stackName => ({ stackName, success: false, error: message })),
|
||||
};
|
||||
}
|
||||
const remote = (await response.json()) as { results?: { stackName: string; success: boolean; error?: string }[] };
|
||||
return { nodeId: node.id, nodeName: node.name, matched: true, stackResults: remote.results ?? [] };
|
||||
} catch (err) {
|
||||
const errorMsg = getErrorMessage(err, 'Failed to reach remote node');
|
||||
return {
|
||||
nodeId: node.id, nodeName: node.name, matched: true,
|
||||
stackResults: stackNames.map(stackName => ({ stackName, success: false, error: errorMsg })),
|
||||
};
|
||||
}
|
||||
}));
|
||||
res.json({ results });
|
||||
} catch (error) {
|
||||
console.error('[Fleet] fleet-stop error:', error);
|
||||
res.status(500).json({ error: getErrorMessage(error, 'Failed to run fleet stop') });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Fleet Snapshots (manual: Community; scheduled: Skipper+) ───
|
||||
|
||||
fleetRouter.post('/snapshots', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { authMiddleware } from '../middleware/auth';
|
||||
import { requirePaid, requireAdmin, requireBody } from '../middleware/tierGates';
|
||||
import { isValidStackName } from '../utils/validation';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
|
||||
// Per-node fleet-action endpoints. Mounted under `/api/fleet-actions/`, which
|
||||
// is NOT in `PROXY_EXEMPT_PREFIXES`, so when `x-node-id` targets a remote node
|
||||
// the gateway proxies the call and the remote Sencho instance runs its own
|
||||
// local handler. Multi-node orchestration endpoints live in `routes/fleet.ts`
|
||||
// because their path must sit behind the `/api/fleet/` proxy-exempt prefix.
|
||||
export const fleetActionsRouter = Router();
|
||||
|
||||
// Hard cap to bound a single bulk-assign request. A node typically has tens of
|
||||
// stacks, not thousands; the cap protects against accidental or malicious
|
||||
// payloads that would force thousands of DB writes in one handler.
|
||||
const MAX_ASSIGNMENTS = 1000;
|
||||
|
||||
// Bulk label assignment for many stacks on a single node. The single-stack
|
||||
// endpoint at `PUT /api/stacks/:stackName/labels` covers one stack at a time;
|
||||
// this wrapper applies the same operation to many stacks atomically per HTTP
|
||||
// request. Tier: requirePaid + requireAdmin (matches the per-stack endpoint).
|
||||
fleetActionsRouter.post(
|
||||
'/labels/bulk-assign',
|
||||
authMiddleware,
|
||||
async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireBody(req, res)) return;
|
||||
const { assignments } = req.body as { assignments?: unknown };
|
||||
if (!Array.isArray(assignments)) {
|
||||
res.status(400).json({ error: 'assignments must be an array' });
|
||||
return;
|
||||
}
|
||||
if (assignments.length > MAX_ASSIGNMENTS) {
|
||||
res.status(400).json({ error: `assignments may not exceed ${MAX_ASSIGNMENTS} entries` });
|
||||
return;
|
||||
}
|
||||
const nodeId = req.nodeId ?? 0;
|
||||
const db = DatabaseService.getInstance();
|
||||
const results: { stackName: string; success: boolean; error?: string }[] = [];
|
||||
for (const entry of assignments as unknown[]) {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
results.push({ stackName: '', success: false, error: 'Invalid assignment entry' });
|
||||
continue;
|
||||
}
|
||||
const { stackName, labelIds } = entry as { stackName?: unknown; labelIds?: unknown };
|
||||
if (typeof stackName !== 'string' || !isValidStackName(stackName)) {
|
||||
results.push({ stackName: typeof stackName === 'string' ? stackName : '', success: false, error: 'Invalid stack name' });
|
||||
continue;
|
||||
}
|
||||
if (!Array.isArray(labelIds) || !labelIds.every(id => typeof id === 'number')) {
|
||||
results.push({ stackName, success: false, error: 'labelIds must be an array of numbers' });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
db.setStackLabels(stackName, nodeId, labelIds);
|
||||
results.push({ stackName, success: true });
|
||||
} catch (err) {
|
||||
results.push({ stackName, success: false, error: getErrorMessage(err, 'Failed to set stack labels') });
|
||||
}
|
||||
}
|
||||
res.json({ results });
|
||||
},
|
||||
);
|
||||
@@ -15,7 +15,11 @@ import { getErrorMessage, isSqliteUniqueViolation } from '../utils/errors';
|
||||
import { parseIntParam } from '../utils/parseIntParam';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
|
||||
const activeBulkActions = new Set<string>();
|
||||
// Module-scope lock shared by `POST /api/labels/:id/action` and the fleet-wide
|
||||
// bulk endpoints in `routes/fleet.ts`. Keyed by `${nodeId}` so concurrent bulk
|
||||
// actions targeting the same node serialize and a fleet-stop cannot race a
|
||||
// per-label action on the same containers.
|
||||
export const activeBulkActions = new Set<string>();
|
||||
|
||||
export const labelsRouter = Router();
|
||||
|
||||
|
||||
@@ -624,7 +624,7 @@ stacksRouter.post('/:stackName/down', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
type StackContainerAction = 'restart' | 'stop' | 'start';
|
||||
export type StackContainerAction = 'restart' | 'stop' | 'start';
|
||||
|
||||
const CONTAINER_ACTION_META: Record<StackContainerAction, { category: NotificationCategory; pastTense: string }> = {
|
||||
restart: { category: 'stack_restarted', pastTense: 'restarted' },
|
||||
@@ -632,6 +632,31 @@ const CONTAINER_ACTION_META: Record<StackContainerAction, { category: Notificati
|
||||
start: { category: 'stack_started', pastTense: 'started' },
|
||||
};
|
||||
|
||||
export type ContainerActionOutcome =
|
||||
| { kind: 'ok'; count: number }
|
||||
| { kind: 'no-containers' }
|
||||
| { kind: 'error'; message: string };
|
||||
|
||||
export async function containerActionForStack(
|
||||
nodeId: number,
|
||||
stackName: string,
|
||||
action: StackContainerAction,
|
||||
): Promise<ContainerActionOutcome> {
|
||||
try {
|
||||
const dockerController = DockerController.getInstance(nodeId);
|
||||
const containers = await dockerController.getContainersByStack(stackName);
|
||||
if (!containers || containers.length === 0) return { kind: 'no-containers' };
|
||||
const op =
|
||||
action === 'restart' ? (id: string) => dockerController.restartContainer(id)
|
||||
: action === 'stop' ? (id: string) => dockerController.stopContainer(id)
|
||||
: (id: string) => dockerController.startContainer(id);
|
||||
await Promise.all(containers.map(c => op(c.Id)));
|
||||
return { kind: 'ok', count: containers.length };
|
||||
} catch (error: unknown) {
|
||||
return { kind: 'error', message: getErrorMessage(error, `Failed to ${action} containers`) };
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkContainerOp(
|
||||
req: Request,
|
||||
res: Response,
|
||||
@@ -640,34 +665,24 @@ async function bulkContainerOp(
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
|
||||
const titleCase = action.charAt(0).toUpperCase() + action.slice(1);
|
||||
try {
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
const containers = await dockerController.getContainersByStack(stackName);
|
||||
const outcome = await containerActionForStack(req.nodeId, stackName, action);
|
||||
|
||||
if (!containers || containers.length === 0) {
|
||||
res.status(404).json({ error: 'No containers found for this stack.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const op =
|
||||
action === 'restart' ? (id: string) => dockerController.restartContainer(id)
|
||||
: action === 'stop' ? (id: string) => dockerController.stopContainer(id)
|
||||
: (id: string) => dockerController.startContainer(id);
|
||||
|
||||
await Promise.all(containers.map(c => op(c.Id)));
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
console.log(`[Stacks] ${titleCase} completed: ${sanitizeForLog(stackName)} (${containers.length} containers)`);
|
||||
res.json({ success: true, message: `${titleCase} completed via Engine API.` });
|
||||
const { category, pastTense } = CONTAINER_ACTION_META[action];
|
||||
notifyActionSuccess(category, `${stackName} ${pastTense}`, stackName, req.user?.username ?? 'system');
|
||||
} catch (error: unknown) {
|
||||
console.error('[Stacks] %s failed: %s', sanitizeForLog(titleCase), sanitizeForLog(stackName), error);
|
||||
const message = getErrorMessage(error, `Failed to ${action} containers`);
|
||||
if (action !== 'start') {
|
||||
notifyActionFailure(action, stackName, error);
|
||||
}
|
||||
res.status(500).json({ error: message });
|
||||
if (outcome.kind === 'no-containers') {
|
||||
res.status(404).json({ error: 'No containers found for this stack.' });
|
||||
return;
|
||||
}
|
||||
if (outcome.kind === 'error') {
|
||||
console.error('[Stacks] %s failed: %s %s', sanitizeForLog(titleCase), sanitizeForLog(stackName), sanitizeForLog(outcome.message));
|
||||
if (action !== 'start') notifyActionFailure(action, stackName, new Error(outcome.message));
|
||||
res.status(500).json({ error: outcome.message });
|
||||
return;
|
||||
}
|
||||
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
console.log(`[Stacks] ${titleCase} completed: ${sanitizeForLog(stackName)} (${outcome.count} containers)`);
|
||||
res.json({ success: true, message: `${titleCase} completed via Engine API.` });
|
||||
const { category, pastTense } = CONTAINER_ACTION_META[action];
|
||||
notifyActionSuccess(category, `${stackName} ${pastTense}`, stackName, req.user?.username ?? 'system');
|
||||
}
|
||||
|
||||
stacksRouter.post('/:stackName/restart', (req, res) => bulkContainerOp(req, res, 'restart'));
|
||||
|
||||
@@ -129,6 +129,7 @@
|
||||
"features/pilot-agent",
|
||||
"features/sencho-mesh",
|
||||
"features/fleet-view",
|
||||
"features/fleet-actions",
|
||||
"features/fleet-sync",
|
||||
"features/fleet-backups",
|
||||
"features/remote-updates",
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
title: Fleet Actions
|
||||
description: Run fleet-wide bulk operations from one place — stop stacks across nodes by label, or apply labels to many stacks at once.
|
||||
---
|
||||
|
||||
<Note>
|
||||
Fleet Actions require a Sencho **Skipper** or **Admiral** license.
|
||||
</Note>
|
||||
|
||||
The **Fleet Actions** tab inside Fleet centralizes bulk operations that span more than a single stack and would otherwise need many manual clicks. It lives next to **Deployments** and **Traffic** in the Fleet view, after the separator that follows Status.
|
||||
|
||||
Fleet Actions covers the operations that aren't already exposed elsewhere in Sencho:
|
||||
|
||||
- **Stop fleet by label** dispatches a stop to every stack labeled with a given name across every node in the fleet.
|
||||
- **Bulk label assign** applies the same label set to many stacks on one node in a single round trip.
|
||||
|
||||
Other bulk actions you may be looking for live in their natural homes:
|
||||
|
||||
| Goal | Where to do it |
|
||||
|------|----------------|
|
||||
| Restart, stop, or update a subset of stacks | **Bulk mode** in the stack sidebar (press `B` to enable, then check the stacks you want) |
|
||||
| Schedule a recurring restart, update, or snapshot | **Schedules** in the primary navigation |
|
||||
| Trigger a Sencho self-update on remote nodes | **Check Updates** button on the Fleet tab |
|
||||
|
||||
## Stop fleet by label
|
||||
|
||||
This card sends a stop to every stack on every node that has a label matching the name you type. Labels are matched **by name** across the fleet, so a label called `production` on one node and a separate label also called `production` on another node both match.
|
||||
|
||||
1. Open **Fleet > Fleet Actions**.
|
||||
2. In the **Stop fleet by label** card, type a label name. The input suggests names that already exist on any node.
|
||||
3. Click **Stop matching stacks**.
|
||||
4. Confirm the destructive action. Sencho dispatches stops per node.
|
||||
5. Results appear inline below the card, grouped by node, with per-stack success or failure rows.
|
||||
|
||||
A node that has no label by that name appears in the results with **no matching label** instead of an error.
|
||||
|
||||
## Bulk label assign
|
||||
|
||||
This card replaces the label set on many stacks at once on a single node.
|
||||
|
||||
1. Pick the node you want to update from the dropdown.
|
||||
2. Check the stacks you want to update. **Select all** is available once stacks load.
|
||||
3. Click each label pill to toggle it. The chosen pills become the new label set for every selected stack.
|
||||
4. Click **Apply to N stacks** and confirm.
|
||||
|
||||
Selecting no labels and applying clears existing label assignments on the chosen stacks. The selected label set always **replaces** the existing one rather than appending to it.
|
||||
|
||||
## Permissions
|
||||
|
||||
Both cards require the **admin** role and a **Skipper** or **Admiral** license. Community-tier users see a calm explainer card in this tab instead of the action surface.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Nothing happens when I click "Stop matching stacks".**
|
||||
Verify a label by that name exists on at least one node. The autocomplete suggestions are aggregated from every reachable node; if your label only exists on a node that is currently offline, it will not appear in the suggestions but the request still tries every node.
|
||||
|
||||
**A node shows "Label not present" but I know I created the label there.**
|
||||
Labels are stored per node. The fleet stop matches by **name** only. If the label was renamed on one node, the new name is what gets matched. Check **Settings > Labels** on the affected node.
|
||||
|
||||
**Bulk label assign reports "Invalid stack name" for one row.**
|
||||
Stack names must be alphanumeric with dashes and underscores. The endpoint validates each entry independently, so a single bad name does not block the rest of the batch.
|
||||
|
||||
**The Fleet Actions tab is missing from Fleet.**
|
||||
Confirm the active license is **Skipper** or **Admiral** under **Settings > License**. The tab itself is always visible, but the action cards only render at paid tiers.
|
||||
@@ -27,7 +27,7 @@ For example, a Postgres `db` service in a stack named `api` on a node named `ops
|
||||
|
||||
## Enable the mesh
|
||||
|
||||
1. Open **Fleet → Traffic · Routing**.
|
||||
1. Open **Fleet → Traffic**.
|
||||
2. Toggle **mesh** on for each node you want to participate.
|
||||
3. Click **Add stack to mesh** on any node and tick the stacks whose services should be reachable cross-node.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useExperimental } from '@/hooks/useExperimental';
|
||||
import {
|
||||
RefreshCw, Search, Camera,
|
||||
Network, SlidersHorizontal,
|
||||
Send, KeyRound, ArrowLeftRight,
|
||||
Send, KeyRound, ArrowLeftRight, Wrench,
|
||||
} from 'lucide-react';
|
||||
import { FleetMasthead } from './fleet/FleetMasthead';
|
||||
import { ReconnectingOverlay } from './FleetView/ReconnectingOverlay';
|
||||
@@ -23,6 +23,7 @@ import { FleetConfiguration } from './fleet/FleetConfiguration';
|
||||
import { FleetSoonPlaceholder } from './fleet/FleetSoonPlaceholder';
|
||||
import { RoutingTab } from './fleet/RoutingTab';
|
||||
import { DeploymentsTab } from './blueprints/DeploymentsTab';
|
||||
import { FleetActionsTab } from './fleet/FleetActions/FleetActionsTab';
|
||||
|
||||
interface FleetViewProps {
|
||||
onNavigateToNode: (nodeId: number, stackName: string) => void;
|
||||
@@ -73,18 +74,12 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
<Camera className="w-4 h-4 mr-1.5" />Snapshots
|
||||
</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
{isAdmiral && (
|
||||
<TabsHighlightItem value="routing">
|
||||
<TabsTrigger value="routing">
|
||||
<ArrowLeftRight className="w-4 h-4 mr-1.5" />Traffic · Routing
|
||||
</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
)}
|
||||
<TabsHighlightItem value="configuration">
|
||||
<TabsTrigger value="configuration">
|
||||
<SlidersHorizontal className="w-4 h-4 mr-1.5" />Status
|
||||
</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
<span aria-hidden className="self-center mx-1 h-4 w-px bg-border" />
|
||||
{isPaid && (
|
||||
<TabsHighlightItem value="deployments">
|
||||
<TabsTrigger value="deployments">
|
||||
@@ -92,9 +87,20 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
)}
|
||||
{isAdmiral && (
|
||||
<TabsHighlightItem value="routing">
|
||||
<TabsTrigger value="routing">
|
||||
<ArrowLeftRight className="w-4 h-4 mr-1.5" />Traffic
|
||||
</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
)}
|
||||
<TabsHighlightItem value="actions">
|
||||
<TabsTrigger value="actions">
|
||||
<Wrench className="w-4 h-4 mr-1.5" />Fleet Actions
|
||||
</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
{experimental && (
|
||||
<>
|
||||
<span aria-hidden className="self-center mx-1 h-4 w-px bg-border" />
|
||||
<TabsHighlightItem value="federation">
|
||||
<TabsTrigger value="federation">
|
||||
<Network className="w-4 h-4 mr-1.5" />Federation
|
||||
@@ -162,13 +168,6 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
<TabsContent value="snapshots">
|
||||
<FleetSnapshots />
|
||||
</TabsContent>
|
||||
{isAdmiral && (
|
||||
<TabsContent value="routing">
|
||||
<AdmiralGate>
|
||||
<RoutingTab />
|
||||
</AdmiralGate>
|
||||
</TabsContent>
|
||||
)}
|
||||
<TabsContent value="configuration">
|
||||
<FleetConfiguration />
|
||||
</TabsContent>
|
||||
@@ -177,6 +176,16 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
<DeploymentsTab />
|
||||
</TabsContent>
|
||||
)}
|
||||
{isAdmiral && (
|
||||
<TabsContent value="routing">
|
||||
<AdmiralGate>
|
||||
<RoutingTab />
|
||||
</AdmiralGate>
|
||||
</TabsContent>
|
||||
)}
|
||||
<TabsContent value="actions">
|
||||
<FleetActionsTab nodes={overview.allNodes} />
|
||||
</TabsContent>
|
||||
{experimental && (
|
||||
<>
|
||||
<TabsContent value="federation">
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Square, Tags } from 'lucide-react';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import type { FleetNode } from '@/components/FleetView/types';
|
||||
import { LabelFleetStopCard } from './cards/LabelFleetStopCard';
|
||||
import { BulkLabelAssignCard } from './cards/BulkLabelAssignCard';
|
||||
|
||||
interface Props {
|
||||
nodes: FleetNode[];
|
||||
}
|
||||
|
||||
export function FleetActionsTab({ nodes }: Props) {
|
||||
const { isPaid } = useLicense();
|
||||
|
||||
if (nodes.length === 0) {
|
||||
return (
|
||||
<div className="text-sm text-stat-subtitle">Add a node to the fleet to use bulk actions.</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isPaid) {
|
||||
// Both actions are Skipper+. Community users see a calm empty state with
|
||||
// upgrade context rather than a stripped-down launcher.
|
||||
return (
|
||||
<EmptyState />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<LabelFleetStopCard nodes={nodes} accentTone="rose" icon={Square} />
|
||||
<BulkLabelAssignCard nodes={nodes} accentTone="purple" icon={Tags} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="rounded-lg border border-card-border/60 bg-card p-8 text-center">
|
||||
<div className="mx-auto mb-3 inline-flex h-10 w-10 items-center justify-center rounded-md bg-glass-highlight">
|
||||
<Tags className="h-5 w-5 text-stat-subtitle" strokeWidth={1.5} />
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-stat-value mb-1">Fleet-wide bulk actions</h3>
|
||||
<p className="text-xs text-stat-subtitle max-w-md mx-auto">
|
||||
Stop stacks across every node by label name, and assign labels to many
|
||||
stacks in one shot. Available on Skipper and Admiral.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { CheckCircle2, XCircle, MinusCircle } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
export interface ResultRow {
|
||||
key: string;
|
||||
label: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
/** Optional secondary rows nested under this row (e.g. per-stack results
|
||||
* underneath a per-node row in the fleet-stop view). */
|
||||
sub?: ResultRow[];
|
||||
}
|
||||
|
||||
interface ResultsListProps {
|
||||
title?: string;
|
||||
results: ResultRow[];
|
||||
/** Override the empty-state message when there is nothing to render yet. */
|
||||
emptyHint?: string;
|
||||
}
|
||||
|
||||
function Row({ row, indent = 0 }: { row: ResultRow; indent?: number }) {
|
||||
const Icon = row.success ? CheckCircle2 : XCircle;
|
||||
const tone = row.success ? 'text-success' : 'text-destructive';
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="flex items-start gap-2 py-1 text-sm"
|
||||
style={indent ? { paddingLeft: `${indent * 16}px` } : undefined}
|
||||
>
|
||||
<Icon className={`mt-0.5 h-3.5 w-3.5 shrink-0 ${tone}`} strokeWidth={1.5} />
|
||||
<span className="font-mono text-xs text-stat-value">{row.label}</span>
|
||||
{row.error && (
|
||||
<span className="text-xs text-stat-subtitle truncate">· {row.error}</span>
|
||||
)}
|
||||
</div>
|
||||
{row.sub?.map((child) => <Row key={child.key} row={child} indent={indent + 1} />)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ResultsList({ title, results, emptyHint }: ResultsListProps) {
|
||||
const succeeded = results.filter(r => r.success).length;
|
||||
const failed = results.length - succeeded;
|
||||
return (
|
||||
<Card className="bg-card shadow-card-bevel mt-4">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-stat-subtitle">
|
||||
{title ?? 'Results'}
|
||||
</span>
|
||||
{results.length > 0 ? (
|
||||
<>
|
||||
<Badge variant="outline" className="text-[10px] font-normal py-0 px-1.5 text-success">
|
||||
{succeeded} ok
|
||||
</Badge>
|
||||
{failed > 0 && (
|
||||
<Badge variant="outline" className="text-[10px] font-normal py-0 px-1.5 text-destructive">
|
||||
{failed} failed
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-stat-subtitle inline-flex items-center gap-1">
|
||||
<MinusCircle className="h-3 w-3" strokeWidth={1.5} />
|
||||
{emptyHint ?? 'No results yet.'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-0">
|
||||
{results.map((row) => <Row key={row.key} row={row} />)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { Loader2, Server } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ConfirmModal } from '@/components/ui/modal';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { LabelPill } from '@/components/LabelPill';
|
||||
import { fetchForNode } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { FleetNode } from '@/components/FleetView/types';
|
||||
import type { Label } from '@/components/label-types';
|
||||
import { ResultsList, type ResultRow } from '../ResultsList';
|
||||
import { TONE_RAIL, TONE_BG, type AccentTone } from './tone';
|
||||
|
||||
interface NodeStackResult { stackName: string; success: boolean; error?: string }
|
||||
|
||||
interface Props {
|
||||
nodes: FleetNode[];
|
||||
icon: LucideIcon;
|
||||
accentTone: AccentTone;
|
||||
}
|
||||
|
||||
export function BulkLabelAssignCard({ nodes, icon: Icon, accentTone }: Props) {
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string>(() => {
|
||||
const local = nodes.find(n => n.type === 'local');
|
||||
return String(local?.id ?? nodes[0]?.id ?? '');
|
||||
});
|
||||
const [stacks, setStacks] = useState<string[]>([]);
|
||||
const [labels, setLabels] = useState<Label[]>([]);
|
||||
const [loadingLists, setLoadingLists] = useState(false);
|
||||
const [selectedStacks, setSelectedStacks] = useState<Set<string>>(new Set());
|
||||
const [selectedLabels, setSelectedLabels] = useState<Set<number>>(new Set());
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [results, setResults] = useState<ResultRow[]>([]);
|
||||
|
||||
const nodeId = useMemo(() => Number(selectedNodeId) || 0, [selectedNodeId]);
|
||||
const selectedNode = useMemo(() => nodes.find(n => n.id === nodeId), [nodes, nodeId]);
|
||||
|
||||
// Load stacks + labels whenever the node changes.
|
||||
useEffect(() => {
|
||||
if (!nodeId) return;
|
||||
let cancelled = false;
|
||||
async function load() {
|
||||
setLoadingLists(true);
|
||||
setSelectedStacks(new Set());
|
||||
setSelectedLabels(new Set());
|
||||
setResults([]);
|
||||
try {
|
||||
const [stacksRes, labelsRes] = await Promise.all([
|
||||
fetchForNode(`/fleet/node/${nodeId}/stacks`, nodeId),
|
||||
fetchForNode('/labels', nodeId),
|
||||
]);
|
||||
const stacksList = stacksRes.ok ? ((await stacksRes.json()) as string[]) : [];
|
||||
const labelsList = labelsRes.ok ? ((await labelsRes.json()) as Label[]) : [];
|
||||
if (!cancelled) {
|
||||
setStacks(stacksList);
|
||||
setLabels(labelsList);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setStacks([]);
|
||||
setLabels([]);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoadingLists(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
return () => { cancelled = true; };
|
||||
}, [nodeId]);
|
||||
|
||||
function toggleStack(stackName: string) {
|
||||
setSelectedStacks(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(stackName)) next.delete(stackName);
|
||||
else next.add(stackName);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
function toggleLabel(labelId: number) {
|
||||
setSelectedLabels(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(labelId)) next.delete(labelId);
|
||||
else next.add(labelId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
function toggleAllStacks() {
|
||||
if (selectedStacks.size === stacks.length) setSelectedStacks(new Set());
|
||||
else setSelectedStacks(new Set(stacks));
|
||||
}
|
||||
|
||||
async function run() {
|
||||
if (selectedStacks.size === 0) return;
|
||||
const labelIds = Array.from(selectedLabels);
|
||||
const assignments = Array.from(selectedStacks).map(stackName => ({ stackName, labelIds }));
|
||||
const toastId = toast.loading(`Assigning labels to ${assignments.length} stack${assignments.length === 1 ? '' : 's'}…`);
|
||||
setRunning(true);
|
||||
try {
|
||||
const res = await fetchForNode('/fleet-actions/labels/bulk-assign', nodeId, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ assignments }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
toast.dismiss(toastId);
|
||||
if (!res.ok) {
|
||||
toast.error(body.error || 'Bulk label assignment failed');
|
||||
return;
|
||||
}
|
||||
const rows: ResultRow[] = (body.results as NodeStackResult[] ?? []).map((r, i) => ({
|
||||
key: `${r.stackName}-${i}`,
|
||||
label: r.stackName || '(unnamed)',
|
||||
success: r.success,
|
||||
error: r.error,
|
||||
}));
|
||||
setResults(rows);
|
||||
const ok = rows.filter(r => r.success).length;
|
||||
const failed = rows.length - ok;
|
||||
if (failed === 0) toast.success(`Updated labels on ${ok} stack${ok === 1 ? '' : 's'}.`);
|
||||
else toast.warning(`${ok} updated, ${failed} failed. See results below.`);
|
||||
} catch (err) {
|
||||
toast.dismiss(toastId);
|
||||
toast.error(err instanceof Error ? err.message : 'Network error');
|
||||
} finally {
|
||||
setRunning(false);
|
||||
setConfirmOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="relative overflow-hidden bg-card shadow-card-bevel">
|
||||
<span aria-hidden className={cn('absolute inset-y-0 left-0 w-[3px]', TONE_RAIL[accentTone])} />
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<span className={cn('inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-md', TONE_BG[accentTone])}>
|
||||
<Icon className="h-5 w-5" strokeWidth={1.5} />
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-base font-medium text-stat-value">Bulk label assign</h3>
|
||||
<p className="mt-1 text-xs text-stat-subtitle">
|
||||
Pick a node, multi-select stacks, and replace their labels in one shot.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-3.5 w-3.5 text-stat-subtitle" strokeWidth={1.5} />
|
||||
<Select value={selectedNodeId} onValueChange={setSelectedNodeId} disabled={running}>
|
||||
<SelectTrigger className="w-56 h-8 text-xs">
|
||||
<SelectValue placeholder="Select a node" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{nodes.map(n => (
|
||||
<SelectItem key={n.id} value={String(n.id)}>
|
||||
{n.name} {n.type === 'local' ? '(local)' : ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{loadingLists && <span className="text-xs text-stat-subtitle">Loading…</span>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-[10px] uppercase tracking-wide text-stat-subtitle">
|
||||
Stacks ({selectedStacks.size}/{stacks.length})
|
||||
</span>
|
||||
{stacks.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={running}
|
||||
onClick={toggleAllStacks}
|
||||
className="text-xs text-stat-subtitle hover:text-stat-value disabled:opacity-50"
|
||||
>
|
||||
{selectedStacks.size === stacks.length ? 'Clear' : 'Select all'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-0.5 max-h-44 overflow-auto pr-1 border border-card-border/40 rounded-md p-2">
|
||||
{stacks.length === 0 && (
|
||||
<span className="text-xs text-stat-subtitle">
|
||||
{loadingLists ? 'Loading…' : selectedNode ? `No stacks on ${selectedNode.name}.` : 'Pick a node.'}
|
||||
</span>
|
||||
)}
|
||||
{stacks.map(stackName => (
|
||||
<label
|
||||
key={stackName}
|
||||
className="flex items-center gap-2 py-1 px-1 rounded hover:bg-glass-highlight cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedStacks.has(stackName)}
|
||||
onCheckedChange={() => toggleStack(stackName)}
|
||||
disabled={running}
|
||||
/>
|
||||
<span className="text-xs font-mono text-stat-value">{stackName}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wide text-stat-subtitle mb-1.5">
|
||||
Labels ({selectedLabels.size}/{labels.length})
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 max-h-32 overflow-auto p-2 border border-card-border/40 rounded-md">
|
||||
{labels.length === 0 && (
|
||||
<span className="text-xs text-stat-subtitle">
|
||||
{loadingLists ? 'Loading…' : selectedNode ? `No labels defined on ${selectedNode.name}.` : ''}
|
||||
</span>
|
||||
)}
|
||||
{labels.map(label => (
|
||||
<LabelPill
|
||||
key={label.id}
|
||||
label={label}
|
||||
active={selectedLabels.has(label.id)}
|
||||
onClick={() => !running && toggleLabel(label.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-stat-subtitle">
|
||||
Selected labels replace each chosen stack's existing label set on this node.
|
||||
Selecting no labels clears assignments.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={running || selectedStacks.size === 0}
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
className="gap-2"
|
||||
>
|
||||
{running ? <Loader2 className="h-3.5 w-3.5 animate-spin" strokeWidth={1.5} /> : <Icon className="h-3.5 w-3.5" strokeWidth={1.5} />}
|
||||
Apply to {selectedStacks.size} stack{selectedStacks.size === 1 ? '' : 's'}
|
||||
</Button>
|
||||
{!running && results.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setResults([])}
|
||||
className="text-xs text-stat-subtitle hover:text-stat-value"
|
||||
>
|
||||
Clear results
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{results.length > 0 && (
|
||||
<ResultsList title="Per-stack results" results={results} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmModal
|
||||
open={confirmOpen}
|
||||
onOpenChange={(open) => { if (!open) setConfirmOpen(false); }}
|
||||
variant="default"
|
||||
kicker="Bulk label assign"
|
||||
title={`Apply ${selectedLabels.size} label${selectedLabels.size === 1 ? '' : 's'} to ${selectedStacks.size} stack${selectedStacks.size === 1 ? '' : 's'}?`}
|
||||
description={
|
||||
selectedLabels.size === 0
|
||||
? 'No labels selected, this will clear existing assignments on the selected stacks.'
|
||||
: `Each selected stack's existing label set on ${selectedNode?.name ?? 'this node'} will be replaced with the chosen labels.`
|
||||
}
|
||||
confirmLabel="Apply"
|
||||
confirming={running}
|
||||
onConfirm={run}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { Loader2, AlertTriangle } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ConfirmModal } from '@/components/ui/modal';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { apiFetch, fetchForNode } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { FleetNode } from '@/components/FleetView/types';
|
||||
import type { Label } from '@/components/label-types';
|
||||
import { ResultsList, type ResultRow } from '../ResultsList';
|
||||
import { TONE_RAIL, TONE_BG, type AccentTone } from './tone';
|
||||
|
||||
interface NodeStackResult { stackName: string; success: boolean; error?: string }
|
||||
interface FleetStopNodeResult {
|
||||
nodeId: number;
|
||||
nodeName: string;
|
||||
matched: boolean;
|
||||
stackResults: NodeStackResult[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
nodes: FleetNode[];
|
||||
icon: LucideIcon;
|
||||
accentTone: AccentTone;
|
||||
}
|
||||
|
||||
export function LabelFleetStopCard({ nodes, icon: Icon, accentTone }: Props) {
|
||||
const [labelName, setLabelName] = useState('');
|
||||
const [knownLabelNames, setKnownLabelNames] = useState<string[]>([]);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [results, setResults] = useState<ResultRow[]>([]);
|
||||
|
||||
// Aggregate label names across reachable nodes for autocomplete. Offline
|
||||
// nodes are skipped so the page-load fanout doesn't hang on dead remotes.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function loadSuggestions() {
|
||||
const names = new Set<string>();
|
||||
const reachable = nodes.filter(n => n.status === 'online');
|
||||
await Promise.all(reachable.map(async (node) => {
|
||||
try {
|
||||
const res = await fetchForNode('/labels', node.id);
|
||||
if (!res.ok) return;
|
||||
const list = (await res.json()) as Label[];
|
||||
for (const l of list) names.add(l.name);
|
||||
} catch {
|
||||
/* ignore — this node is unreachable, not user-facing */
|
||||
}
|
||||
}));
|
||||
if (!cancelled) setKnownLabelNames(Array.from(names).sort());
|
||||
}
|
||||
loadSuggestions();
|
||||
return () => { cancelled = true; };
|
||||
}, [nodes]);
|
||||
|
||||
async function run() {
|
||||
const trimmed = labelName.trim();
|
||||
if (!trimmed) return;
|
||||
const toastId = toast.loading(`Stopping stacks labeled "${trimmed}" across the fleet…`);
|
||||
setRunning(true);
|
||||
setResults([]);
|
||||
try {
|
||||
const res = await apiFetch('/fleet/labels/fleet-stop', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ labelName: trimmed }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
toast.dismiss(toastId);
|
||||
if (!res.ok) {
|
||||
toast.error(body.error || 'Fleet stop failed');
|
||||
return;
|
||||
}
|
||||
const apiResults = (body.results as FleetStopNodeResult[]) ?? [];
|
||||
const rows: ResultRow[] = apiResults.map((node) => ({
|
||||
key: `node-${node.nodeId}`,
|
||||
label: node.matched
|
||||
? `${node.nodeName} · ${node.stackResults.length} stack${node.stackResults.length === 1 ? '' : 's'}`
|
||||
: `${node.nodeName} (no matching label)`,
|
||||
success: node.matched && node.stackResults.every(s => s.success),
|
||||
error: node.matched ? undefined : 'Label not present',
|
||||
sub: node.stackResults.map((s, i) => ({
|
||||
key: `${node.nodeId}-${s.stackName}-${i}`,
|
||||
label: s.stackName,
|
||||
success: s.success,
|
||||
error: s.error,
|
||||
})),
|
||||
}));
|
||||
setResults(rows);
|
||||
const matchedNodes = apiResults.filter(n => n.matched).length;
|
||||
const stacksTouched = apiResults.flatMap(n => n.stackResults);
|
||||
const ok = stacksTouched.filter(s => s.success).length;
|
||||
const failed = stacksTouched.length - ok;
|
||||
if (matchedNodes === 0) toast.info('No nodes have a label by that name.');
|
||||
else if (failed === 0 && ok > 0) toast.success(`Stopped ${ok} stack${ok === 1 ? '' : 's'} across ${matchedNodes} node${matchedNodes === 1 ? '' : 's'}.`);
|
||||
else if (ok === 0 && failed === 0) toast.info('Label matched but no stacks were assigned to it.');
|
||||
else toast.warning(`${ok} stopped, ${failed} failed. See results below.`);
|
||||
} catch (err) {
|
||||
toast.dismiss(toastId);
|
||||
toast.error(err instanceof Error ? err.message : 'Network error');
|
||||
} finally {
|
||||
setRunning(false);
|
||||
setConfirmOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="relative overflow-hidden bg-card shadow-card-bevel">
|
||||
<span aria-hidden className={cn('absolute inset-y-0 left-0 w-[3px]', TONE_RAIL[accentTone])} />
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<span className={cn('inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-md', TONE_BG[accentTone])}>
|
||||
<Icon className="h-5 w-5" strokeWidth={1.5} />
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-base font-medium text-stat-value">Stop fleet by label</h3>
|
||||
<p className="mt-1 text-xs text-stat-subtitle">
|
||||
Stop every stack labeled with this name on every node. Labels are matched by name across the fleet.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label htmlFor="fleet-stop-label-input" className="block text-[10px] uppercase tracking-wide text-stat-subtitle mb-1.5">
|
||||
Label name
|
||||
</label>
|
||||
<Input
|
||||
id="fleet-stop-label-input"
|
||||
list="fleet-stop-label-suggestions"
|
||||
value={labelName}
|
||||
onChange={(e) => setLabelName(e.target.value)}
|
||||
placeholder="e.g. production"
|
||||
className="h-9 text-sm"
|
||||
disabled={running}
|
||||
/>
|
||||
<datalist id="fleet-stop-label-suggestions">
|
||||
{knownLabelNames.map(n => <option key={n} value={n} />)}
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={running || labelName.trim().length === 0}
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
className="gap-2"
|
||||
>
|
||||
{running ? <Loader2 className="h-3.5 w-3.5 animate-spin" strokeWidth={1.5} /> : <Icon className="h-3.5 w-3.5" strokeWidth={1.5} />}
|
||||
Stop matching stacks
|
||||
</Button>
|
||||
{!running && results.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setResults([])}
|
||||
className="text-xs text-stat-subtitle hover:text-stat-value"
|
||||
>
|
||||
Clear results
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{results.length === 0 && !running && (
|
||||
<div className="rounded-md border border-card-border/40 bg-glass-highlight/30 p-3 text-xs text-stat-subtitle">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" strokeWidth={1.5} />
|
||||
<span>
|
||||
Different nodes can have their own label rows. Stops are dispatched per node and report
|
||||
per-stack results below.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results.length > 0 && (
|
||||
<ResultsList title="Per-node breakdown" results={results} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmModal
|
||||
open={confirmOpen}
|
||||
onOpenChange={(open) => { if (!open) setConfirmOpen(false); }}
|
||||
variant="destructive"
|
||||
kicker="Fleet stop"
|
||||
title={`Stop all stacks labeled "${labelName.trim()}"?`}
|
||||
description="Sencho will stop every stack on every node that has a label with this name. Services will be unavailable until restarted."
|
||||
confirmLabel="Stop fleet"
|
||||
confirming={running}
|
||||
onConfirm={run}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Tone palette shared by the Fleet Actions cards. The two 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';
|
||||
|
||||
export const TONE_RAIL: Record<AccentTone, string> = {
|
||||
rose: 'bg-[var(--label-rose)]',
|
||||
purple: 'bg-[var(--label-purple)]',
|
||||
};
|
||||
|
||||
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)]',
|
||||
};
|
||||
Reference in New Issue
Block a user