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
+145
View File
@@ -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' });
});
});
+1 -1
View File
@@ -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',
];
+2
View File
@@ -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);
+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> => {
+66
View File
@@ -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 });
},
);
+5 -1
View File
@@ -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();
+42 -27
View File
@@ -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'));
+1
View File
@@ -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",
+64
View File
@@ -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.
+1 -1
View File
@@ -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.
+25 -16
View File
@@ -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)]',
};