mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 18:57:09 +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,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 });
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user