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:
Anso
2026-05-07 05:41:53 -04:00
committed by GitHub
parent 907e7427e5
commit 77d5ff58d3
16 changed files with 1074 additions and 46 deletions
+107
View File
@@ -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> => {