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:
@@ -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'));
|
||||
|
||||
Reference in New Issue
Block a user