diff --git a/backend/src/__tests__/fleet-actions.test.ts b/backend/src/__tests__/fleet-actions.test.ts new file mode 100644 index 00000000..c0848c92 --- /dev/null +++ b/backend/src/__tests__/fleet-actions.test.ts @@ -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' }); + }); +}); diff --git a/backend/src/helpers/proxyExemptPaths.ts b/backend/src/helpers/proxyExemptPaths.ts index 589aed86..53d9b335 100644 --- a/backend/src/helpers/proxyExemptPaths.ts +++ b/backend/src/helpers/proxyExemptPaths.ts @@ -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', ]; diff --git a/backend/src/index.ts b/backend/src/index.ts index 98d89b4c..ae7882b2 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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); diff --git a/backend/src/routes/fleet.ts b/backend/src/routes/fleet.ts index 95e43a5b..d608ca39 100644 --- a/backend/src/routes/fleet.ts +++ b/backend/src/routes/fleet.ts @@ -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 => { + 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 => { diff --git a/backend/src/routes/fleetActions.ts b/backend/src/routes/fleetActions.ts new file mode 100644 index 00000000..865cae9e --- /dev/null +++ b/backend/src/routes/fleetActions.ts @@ -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 => { + 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 }); + }, +); diff --git a/backend/src/routes/labels.ts b/backend/src/routes/labels.ts index 92a1ba24..382b5aba 100644 --- a/backend/src/routes/labels.ts +++ b/backend/src/routes/labels.ts @@ -15,7 +15,11 @@ import { getErrorMessage, isSqliteUniqueViolation } from '../utils/errors'; import { parseIntParam } from '../utils/parseIntParam'; import { sanitizeForLog } from '../utils/safeLog'; -const activeBulkActions = new Set(); +// 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(); export const labelsRouter = Router(); diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index 7a83beae..15f5113a 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -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 = { restart: { category: 'stack_restarted', pastTense: 'restarted' }, @@ -632,6 +632,31 @@ const CONTAINER_ACTION_META: Record { + 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')); diff --git a/docs/docs.json b/docs/docs.json index 00ce7a80..5336c7b1 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -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", diff --git a/docs/features/fleet-actions.mdx b/docs/features/fleet-actions.mdx new file mode 100644 index 00000000..e5702ca4 --- /dev/null +++ b/docs/features/fleet-actions.mdx @@ -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. +--- + + + Fleet Actions require a Sencho **Skipper** or **Admiral** license. + + +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. diff --git a/docs/features/sencho-mesh.mdx b/docs/features/sencho-mesh.mdx index e49f1952..9c4e581b 100644 --- a/docs/features/sencho-mesh.mdx +++ b/docs/features/sencho-mesh.mdx @@ -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. diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index 2fd72584..233d47e6 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -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) { Snapshots - {isAdmiral && ( - - - Traffic · Routing - - - )} Status + {isPaid && ( @@ -92,9 +87,20 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { )} + {isAdmiral && ( + + + Traffic + + + )} + + + Fleet Actions + + {experimental && ( <> - Federation @@ -162,13 +168,6 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { - {isAdmiral && ( - - - - - - )} @@ -177,6 +176,16 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { )} + {isAdmiral && ( + + + + + + )} + + + {experimental && ( <> diff --git a/frontend/src/components/fleet/FleetActions/FleetActionsTab.tsx b/frontend/src/components/fleet/FleetActions/FleetActionsTab.tsx new file mode 100644 index 00000000..33a5b746 --- /dev/null +++ b/frontend/src/components/fleet/FleetActions/FleetActionsTab.tsx @@ -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 ( +
Add a node to the fleet to use bulk actions.
+ ); + } + + if (!isPaid) { + // Both actions are Skipper+. Community users see a calm empty state with + // upgrade context rather than a stripped-down launcher. + return ( + + ); + } + + return ( +
+ + +
+ ); +} + +function EmptyState() { + return ( +
+
+ +
+

Fleet-wide bulk actions

+

+ Stop stacks across every node by label name, and assign labels to many + stacks in one shot. Available on Skipper and Admiral. +

+
+ ); +} diff --git a/frontend/src/components/fleet/FleetActions/ResultsList.tsx b/frontend/src/components/fleet/FleetActions/ResultsList.tsx new file mode 100644 index 00000000..04bf95bf --- /dev/null +++ b/frontend/src/components/fleet/FleetActions/ResultsList.tsx @@ -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 ( + <> +
+ + {row.label} + {row.error && ( + · {row.error} + )} +
+ {row.sub?.map((child) => )} + + ); +} + +export function ResultsList({ title, results, emptyHint }: ResultsListProps) { + const succeeded = results.filter(r => r.success).length; + const failed = results.length - succeeded; + return ( + + +
+ + {title ?? 'Results'} + + {results.length > 0 ? ( + <> + + {succeeded} ok + + {failed > 0 && ( + + {failed} failed + + )} + + ) : ( + + + {emptyHint ?? 'No results yet.'} + + )} +
+
+ {results.map((row) => )} +
+
+
+ ); +} diff --git a/frontend/src/components/fleet/FleetActions/cards/BulkLabelAssignCard.tsx b/frontend/src/components/fleet/FleetActions/cards/BulkLabelAssignCard.tsx new file mode 100644 index 00000000..ccc9cb25 --- /dev/null +++ b/frontend/src/components/fleet/FleetActions/cards/BulkLabelAssignCard.tsx @@ -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(() => { + const local = nodes.find(n => n.type === 'local'); + return String(local?.id ?? nodes[0]?.id ?? ''); + }); + const [stacks, setStacks] = useState([]); + const [labels, setLabels] = useState([]); + const [loadingLists, setLoadingLists] = useState(false); + const [selectedStacks, setSelectedStacks] = useState>(new Set()); + const [selectedLabels, setSelectedLabels] = useState>(new Set()); + const [confirmOpen, setConfirmOpen] = useState(false); + const [running, setRunning] = useState(false); + const [results, setResults] = useState([]); + + 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 ( + + + +
+ + + +
+

Bulk label assign

+

+ Pick a node, multi-select stacks, and replace their labels in one shot. +

+
+
+ +
+
+ + + {loadingLists && Loading…} +
+ +
+
+ + Stacks ({selectedStacks.size}/{stacks.length}) + + {stacks.length > 0 && ( + + )} +
+
+ {stacks.length === 0 && ( + + {loadingLists ? 'Loading…' : selectedNode ? `No stacks on ${selectedNode.name}.` : 'Pick a node.'} + + )} + {stacks.map(stackName => ( + + ))} +
+
+ +
+
+ Labels ({selectedLabels.size}/{labels.length}) +
+
+ {labels.length === 0 && ( + + {loadingLists ? 'Loading…' : selectedNode ? `No labels defined on ${selectedNode.name}.` : ''} + + )} + {labels.map(label => ( + !running && toggleLabel(label.id)} + /> + ))} +
+
+ +

+ Selected labels replace each chosen stack's existing label set on this node. + Selecting no labels clears assignments. +

+ +
+ + {!running && results.length > 0 && ( + + )} +
+ + {results.length > 0 && ( + + )} +
+ + { 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} + /> +
+
+ ); +} diff --git a/frontend/src/components/fleet/FleetActions/cards/LabelFleetStopCard.tsx b/frontend/src/components/fleet/FleetActions/cards/LabelFleetStopCard.tsx new file mode 100644 index 00000000..b2c0cc67 --- /dev/null +++ b/frontend/src/components/fleet/FleetActions/cards/LabelFleetStopCard.tsx @@ -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([]); + const [confirmOpen, setConfirmOpen] = useState(false); + const [running, setRunning] = useState(false); + const [results, setResults] = useState([]); + + // 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(); + 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 ( + + + +
+ + + +
+

Stop fleet by label

+

+ Stop every stack labeled with this name on every node. Labels are matched by name across the fleet. +

+
+
+ +
+
+ + setLabelName(e.target.value)} + placeholder="e.g. production" + className="h-9 text-sm" + disabled={running} + /> + + {knownLabelNames.map(n => +
+ +
+ + {!running && results.length > 0 && ( + + )} +
+ + {results.length === 0 && !running && ( +
+
+ + + Different nodes can have their own label rows. Stops are dispatched per node and report + per-stack results below. + +
+
+ )} + + {results.length > 0 && ( + + )} +
+ + { 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} + /> +
+
+ ); +} diff --git a/frontend/src/components/fleet/FleetActions/cards/tone.ts b/frontend/src/components/fleet/FleetActions/cards/tone.ts new file mode 100644 index 00000000..24246426 --- /dev/null +++ b/frontend/src/components/fleet/FleetActions/cards/tone.ts @@ -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 = { + rose: 'bg-[var(--label-rose)]', + purple: 'bg-[var(--label-purple)]', +}; + +export const TONE_BG: Record = { + rose: 'bg-[var(--label-rose-bg)] text-[var(--label-rose)]', + purple: 'bg-[var(--label-purple-bg)] text-[var(--label-purple)]', +};