mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
feat(fleet): open Fleet Actions tab to Community (admin-only) (#1153)
* feat(fleet): open Fleet Actions tab to Community (admin-only) Removes the requirePaid guard from the five Fleet Actions endpoints (fleet-stop, fleet-prune, match-preview, prune/estimate, bulk-assign) and drops the matching isPaid parent gate on FleetActionsTab so Community admins can run fleet-wide bulk operations. requireAdmin stays on every endpoint; operator and viewer roles still 403 on apply. Tests flipped from "403 PAID_REQUIRED on community" to positive "reachable on community + admin" assertions. Docs (fleet-actions, fleet-view, licensing, overview, stack-labels) rewritten to state the admin-role requirement once and drop the prior Skipper framing. * fix(fleet): apply audit findings from PR #1153 review - stack-labels.mdx: fix the page intro that still framed fleet label actions as "Operators on a Skipper or Admiral license". The cards are now Community + admin, so the intro reads "Admins also get a pair of fleet-wide actions". - Collapse redundant role-rule statements on the two affected pages. fleet-actions.mdx now states the admin gate once in the lead-in Note and again only in the troubleshooting accordion (the Prerequisites row was duplicative). stack-labels.mdx trims the "Limits and rules" bullet to the value-add half (label authoring is open to every role) and drops the Fleet Actions repetition. - Strip now-no-op mockTier('paid') calls from non-tier tests across the three fleet test files, plus the test-wide default in the fleet-action-card-endpoints beforeEach. Those mocks were misleading after the routes stopped consulting tier; if a future change re-adds requirePaid the tests will fail loudly instead of silently passing.
This commit is contained in:
@@ -75,7 +75,6 @@ beforeEach(() => {
|
||||
// not polluted by earlier tests.
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
mockFsStacks = ['alpha', 'beta'];
|
||||
pruneManagedOnly.mockResolvedValue({ success: true, reclaimedBytes: 0 });
|
||||
pruneSystem.mockResolvedValue({ success: true, reclaimedBytes: 0 });
|
||||
@@ -115,14 +114,15 @@ describe('POST /api/fleet/labels/match-preview', () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 403 PAID_REQUIRED on community tier', async () => {
|
||||
it('is reachable on community tier for admins (no PAID_REQUIRED)', async () => {
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/labels/match-preview')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ labelName: 'x' });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
.send({ labelName: 'does-not-exist' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.code).not.toBe('PAID_REQUIRED');
|
||||
expect(res.body.matchedNodes).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 400 when labelName is missing or empty', async () => {
|
||||
@@ -171,13 +171,15 @@ describe('POST /api/fleet/prune/estimate', () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 403 PAID_REQUIRED on community tier', async () => {
|
||||
it('is reachable on community tier for admins (no PAID_REQUIRED)', async () => {
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/prune/estimate')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ targets: ['images'], scope: 'managed' });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.code).not.toBe('PAID_REQUIRED');
|
||||
expect(res.body).toHaveProperty('totalBytes');
|
||||
});
|
||||
|
||||
it('returns 400 when targets is empty', async () => {
|
||||
|
||||
@@ -38,27 +38,29 @@ describe('Fleet Actions endpoints require authentication', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Fleet Actions tier gating', () => {
|
||||
describe('Fleet Actions tier gating (Community + admin)', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('POST /api/fleet/labels/fleet-stop returns 403 on community tier (Skipper+)', async () => {
|
||||
it('POST /api/fleet/labels/fleet-stop is reachable on community tier for admins', 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');
|
||||
.send({ labelName: 'this-label-does-not-exist' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.code).not.toBe('PAID_REQUIRED');
|
||||
expect(Array.isArray(res.body.results)).toBe(true);
|
||||
});
|
||||
|
||||
it('POST /api/fleet-actions/labels/bulk-assign returns 403 on community tier (Skipper+)', async () => {
|
||||
it('POST /api/fleet-actions/labels/bulk-assign is reachable on community tier for admins', 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');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.code).not.toBe('PAID_REQUIRED');
|
||||
expect(res.body.results).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,7 +68,6 @@ 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)
|
||||
@@ -76,7 +77,6 @@ describe('Fleet Actions input validation', () => {
|
||||
});
|
||||
|
||||
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)
|
||||
@@ -85,7 +85,6 @@ describe('Fleet Actions input validation', () => {
|
||||
});
|
||||
|
||||
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)
|
||||
@@ -95,7 +94,6 @@ describe('Fleet Actions input validation', () => {
|
||||
});
|
||||
|
||||
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')
|
||||
@@ -110,7 +108,6 @@ 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)
|
||||
@@ -124,7 +121,6 @@ describe('Fleet Actions orchestration shape', () => {
|
||||
});
|
||||
|
||||
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)
|
||||
@@ -134,7 +130,6 @@ describe('Fleet Actions orchestration shape', () => {
|
||||
});
|
||||
|
||||
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)
|
||||
|
||||
@@ -66,18 +66,19 @@ describe('POST /api/fleet/labels/fleet-prune', () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 403 PAID_REQUIRED on community tier', async () => {
|
||||
it('is reachable on community tier for admins (no PAID_REQUIRED)', async () => {
|
||||
mockTier('community');
|
||||
mockLocalPrune({ managedBytes: { images: 128 } });
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/labels/fleet-prune')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ targets: ['images'], scope: 'managed' });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.code).not.toBe('PAID_REQUIRED');
|
||||
expect(Array.isArray(res.body.results)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns 400 when body is missing', async () => {
|
||||
mockTier('paid');
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/labels/fleet-prune')
|
||||
.set('Authorization', authHeader)
|
||||
@@ -86,7 +87,6 @@ describe('POST /api/fleet/labels/fleet-prune', () => {
|
||||
});
|
||||
|
||||
it('returns 400 when targets is empty', async () => {
|
||||
mockTier('paid');
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/labels/fleet-prune')
|
||||
.set('Authorization', authHeader)
|
||||
@@ -96,7 +96,6 @@ describe('POST /api/fleet/labels/fleet-prune', () => {
|
||||
});
|
||||
|
||||
it('returns 400 when a target is unrecognized', async () => {
|
||||
mockTier('paid');
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/labels/fleet-prune')
|
||||
.set('Authorization', authHeader)
|
||||
@@ -106,7 +105,6 @@ describe('POST /api/fleet/labels/fleet-prune', () => {
|
||||
});
|
||||
|
||||
it('runs pruneManagedOnly per target on the local node and returns aggregated bytes', async () => {
|
||||
mockTier('paid');
|
||||
const fake = mockLocalPrune({ managedBytes: { images: 1500, volumes: 320 } });
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/labels/fleet-prune')
|
||||
@@ -125,7 +123,6 @@ describe('POST /api/fleet/labels/fleet-prune', () => {
|
||||
});
|
||||
|
||||
it('runs pruneSystem when scope is "all" and dedupes targets', async () => {
|
||||
mockTier('paid');
|
||||
const fake = mockLocalPrune({ allBytes: { networks: 0, images: 2048 } });
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/labels/fleet-prune')
|
||||
@@ -139,7 +136,6 @@ describe('POST /api/fleet/labels/fleet-prune', () => {
|
||||
});
|
||||
|
||||
it('records per-target failure when DockerController throws but continues remaining targets', async () => {
|
||||
mockTier('paid');
|
||||
mockLocalPrune({ managedBytes: { images: 100 }, throwOn: 'volumes' });
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/labels/fleet-prune')
|
||||
@@ -155,7 +151,6 @@ describe('POST /api/fleet/labels/fleet-prune', () => {
|
||||
});
|
||||
|
||||
it('reports lock contention when bulk-prune lock is already held', async () => {
|
||||
mockTier('paid');
|
||||
mockLocalPrune();
|
||||
const db = DatabaseService.getInstance();
|
||||
const localId = db.getNodes().find(n => n.type === 'local')!.id;
|
||||
@@ -171,7 +166,6 @@ describe('POST /api/fleet/labels/fleet-prune', () => {
|
||||
});
|
||||
|
||||
it('marks a remote node unreachable when fetch throws and short-circuits later targets', async () => {
|
||||
mockTier('paid');
|
||||
mockLocalPrune();
|
||||
const db = DatabaseService.getInstance();
|
||||
const remoteId = db.addNode({
|
||||
@@ -202,7 +196,6 @@ describe('POST /api/fleet/labels/fleet-prune', () => {
|
||||
});
|
||||
|
||||
it('parses remote node responses into per-target reclaimed bytes', async () => {
|
||||
mockTier('paid');
|
||||
mockLocalPrune();
|
||||
const db = DatabaseService.getInstance();
|
||||
const remoteId = db.addNode({
|
||||
|
||||
@@ -1028,9 +1028,8 @@ fleetRouter.delete('/update-status', authMiddleware, async (req: Request, res: R
|
||||
|
||||
// Fleet-wide stop by label name. Matches each node's labels by name and runs
|
||||
// container stops on each matching stack.
|
||||
// Tier: requirePaid + requireAdmin.
|
||||
// Tier: requireAdmin (admin-only fleet plumbing; available on every license).
|
||||
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; dryRun?: unknown } | undefined;
|
||||
if (!body || typeof body !== 'object') {
|
||||
@@ -1142,12 +1141,11 @@ fleetRouter.post('/labels/fleet-stop', authMiddleware, async (req: Request, res:
|
||||
// systemMaintenance.ts is safe because Docker's prune API is internally
|
||||
// serialized and idempotent (the worst case is a duplicate call returning 0
|
||||
// reclaimed bytes).
|
||||
// Tier: requirePaid + requireAdmin.
|
||||
// Tier: requireAdmin (admin-only fleet plumbing; available on every license).
|
||||
const FLEET_PRUNE_TARGETS = ['images', 'volumes', 'networks'] as const;
|
||||
type FleetPruneTarget = (typeof FLEET_PRUNE_TARGETS)[number];
|
||||
|
||||
fleetRouter.post('/labels/fleet-prune', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
|
||||
const body = req.body as { targets?: unknown; scope?: unknown; dryRun?: unknown } | undefined;
|
||||
@@ -1295,7 +1293,6 @@ fleetRouter.post('/labels/fleet-prune', authMiddleware, async (req: Request, res
|
||||
// assignments live in the central DB even for remote nodes, populated by the
|
||||
// nodes' own UIs and synced via Distributed API.
|
||||
fleetRouter.post('/labels/match-preview', 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') {
|
||||
@@ -1336,7 +1333,6 @@ fleetRouter.post('/labels/match-preview', authMiddleware, async (req: Request, r
|
||||
// fan-out shape as `/labels/fleet-prune` minus the locks (estimation is read
|
||||
// only).
|
||||
fleetRouter.post('/prune/estimate', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
|
||||
const body = req.body as { targets?: unknown; scope?: unknown } | undefined;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { requireAdmin, requireBody } from '../middleware/tierGates';
|
||||
import { isValidStackName } from '../utils/validation';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
|
||||
@@ -20,14 +20,14 @@ 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. The per-stack endpoint is
|
||||
// Community-tier organization metadata; this multi-stack wrapper is an
|
||||
// automation surface exposed only inside the Skipper+ Fleet Actions tab.
|
||||
// request. Tier: requireAdmin (admin-only fleet plumbing). The per-stack
|
||||
// endpoint is Community-tier organization metadata; this multi-stack wrapper
|
||||
// matches the surrounding Fleet Actions surface, which is admin-only but
|
||||
// available on every license.
|
||||
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 };
|
||||
|
||||
@@ -12,7 +12,7 @@ Three cards ship today: **Stop fleet by label**, **Bulk label assign**, and **Pr
|
||||
</Frame>
|
||||
|
||||
<Note>
|
||||
Fleet Actions is a Skipper feature. Every card requires an admin user role.
|
||||
Fleet Actions runs admin-only. Operator and viewer roles see the tab but cannot apply the cards.
|
||||
</Note>
|
||||
|
||||
## What Fleet Actions covers (and what it doesn't)
|
||||
@@ -29,7 +29,7 @@ Fleet Actions is the home for operations that span the fleet but don't fit anywh
|
||||
|
||||
## Three cards, three execution paths
|
||||
|
||||
The cards share a tab and a tier gate, but they don't share an execution path. Knowing which path runs explains the result panels and the failure modes.
|
||||
The cards share a tab and a role gate, but they don't share an execution path. Knowing which path runs explains the result panels and the failure modes.
|
||||
|
||||
| Card | Endpoint | Where it runs | Scope |
|
||||
|---|---|---|---|
|
||||
@@ -130,8 +130,6 @@ Scope is a segmented control with two options:
|
||||
|
||||
| Requirement | Why it matters |
|
||||
|---|---|
|
||||
| **Skipper or Admiral license on the control instance** | Every card gates on the control instance's tier. Remote nodes inherit the paid tier through the proxy's tier header, so a paid control plane covers the whole fleet. |
|
||||
| **Admin role for the active user** | Operator and viewer roles cannot reach any of the three endpoints. |
|
||||
| **Configured remote nodes in Settings → Nodes** | The two fan-out cards iterate the configured node list. A node missing its `api_url` or `api_token` shows up in the results with *Remote node not configured* per stack or per target. |
|
||||
| **Labels you intend to target** | Stop fleet by label and the autocomplete depend on labels existing on at least one node. See [Stack Labels](/features/stack-labels) for the authoring flow. |
|
||||
|
||||
@@ -189,8 +187,8 @@ Run **Prune Docker resources fleet-wide** with **Images** selected and **Managed
|
||||
<Accordion title="Prune across fleet reports 'A prune is already running on this node'">
|
||||
The per-node prune lock is held while the first prune is in flight. Wait for the first run to finish or click **Refresh** on the Fleet masthead to confirm it has cleared, then re-run. The lock is released automatically when the first run exits.
|
||||
</Accordion>
|
||||
<Accordion title="The Fleet Actions tab opens but the cards aren't there">
|
||||
Fleet Actions is a Skipper feature. On a Community license, the tab still opens and surfaces a calm explainer card pointing at the upgrade. Confirm the active license under **Settings → License**.
|
||||
<Accordion title="The Apply button returns an error toast">
|
||||
Fleet Actions runs admin-only. Confirm the active user has the admin role under **Settings → Users**. Operator and viewer roles see the cards but cannot apply them.
|
||||
</Accordion>
|
||||
<Accordion title="A node returns a transport error row for every stack or every target">
|
||||
The node is in **Settings → Nodes** but its `api_url` or `api_token` is missing, expired, or unreachable. Open **Settings → Nodes** on the control instance and click **Test connection** for the remote; fix the credential or the reachability, then re-run the action.
|
||||
@@ -210,4 +208,4 @@ Fleet Actions is one slice of the Fleet view. Each adjacent surface answers a di
|
||||
| [Fleet Sync](/features/fleet-sync) | Push-only replication of security rules from a control instance to its replicas. | Fleet Sync replicates state; Fleet Actions runs operations. |
|
||||
| [Multi-Node Management](/features/multi-node) | How nodes get added to the fleet (proxy or pilot mode) and how the license tier propagates. | Multi-Node Management is the prerequisite; Fleet Actions runs against whatever Multi-Node Management already configured. |
|
||||
| [Fleet View](/features/fleet-view) | The masthead, tab strip, and node grid that host the Fleet Actions tab. | Fleet Actions is one tab inside Fleet View. |
|
||||
| [Licensing](/features/licensing) | The full tier matrix and what each tier unlocks. | The single source of truth for the Skipper requirement called out at the top of this page. |
|
||||
| [Licensing](/features/licensing) | The full tier matrix and what each tier unlocks. | Licensing covers tier coverage across the product; Fleet Actions only cites the admin role check above. |
|
||||
|
||||
@@ -48,7 +48,7 @@ The Fleet view is a tab strip. Four tab triggers are visible to every tier; the
|
||||
| **Deployments** | Skipper | Blueprint deployments and reconciler state. See [Blueprints](/features/blueprint-model). |
|
||||
| **Routing** | Admiral | Cross-node service routing via Sencho Mesh. See [Sencho Mesh](/features/sencho-mesh). |
|
||||
| **Federation** | Admiral | Cordon nodes and pin blueprints to specific hosts. See [Fleet Federation](/features/fleet-federation). |
|
||||
| **Fleet Actions** | Community trigger / Skipper content | The tab is visible to every tier; opening it on Community surfaces a Skipper upgrade prompt. See [Fleet Actions](/features/fleet-actions). |
|
||||
| **Fleet Actions** | Community (admin role) | Fleet-wide bulk operations: stop stacks by label, bulk-assign labels, prune Docker resources. See [Fleet Actions](/features/fleet-actions). |
|
||||
| **Secrets** | Skipper | Encrypted env-var bundles you push to labeled nodes. See [Fleet Secrets](/features/fleet-secrets). |
|
||||
|
||||
### Action buttons
|
||||
|
||||
@@ -30,6 +30,7 @@ For larger deployments, an **Enterprise** tier is available with custom pricing,
|
||||
- Git sources for compose stacks
|
||||
- Multi-node management in both Proxy and Pilot Agent modes
|
||||
- Manual fleet snapshots (create, browse, restore, delete) and Remote OTA node updates (per-node and **Update all**)
|
||||
- Fleet Actions tab (stop stacks fleet-wide by label, bulk-assign labels to many stacks on a node, prune Docker resources fleet-wide; admin role required)
|
||||
- Custom S3-compatible backup target (bring your own AWS S3, Cloudflare R2, MinIO, Backblaze B2, or Wasabi bucket)
|
||||
- Vulnerability scanning: install, update, and uninstall Trivy, on-demand scans for vulnerabilities, secrets, and misconfigurations, plus scan comparison
|
||||
- CVE suppressions
|
||||
@@ -49,7 +50,7 @@ For larger deployments, an **Enterprise** tier is available with custom pricing,
|
||||
- Scan policies with `block_on_deploy` deploy enforcement, SBOM (SPDX, CycloneDX), and SARIF export
|
||||
- Auto-update of the managed Trivy binary
|
||||
- Bulk actions on a label (deploy, stop, or restart every stack tagged with it)
|
||||
- Fleet Actions (bulk restart Sencho)
|
||||
- Fleet-wide bulk Sencho restart
|
||||
- Blueprints and Fleet Secrets
|
||||
- Preset SSO for Google, GitHub, and Okta
|
||||
- Viewer accounts (1 admin plus 3 viewers)
|
||||
|
||||
@@ -117,7 +117,7 @@ Operator-driven placement controls for fleets running Blueprints. Cordon nodes t
|
||||
|
||||
### Fleet Actions
|
||||
|
||||
Run fleet-wide bulk operations from one place: stop stacks across nodes by label selector, or bulk-assign labels to many stacks at once on a single node. Skipper or Admiral. [Learn more →](/features/fleet-actions)
|
||||
Run fleet-wide bulk operations from one place: stop stacks across nodes by label selector, bulk-assign labels to many stacks on a single node, or prune Docker resources fleet-wide. Admin-only on every tier. [Learn more →](/features/fleet-actions)
|
||||
|
||||
### Fleet Sync
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ title: "Stack Labels"
|
||||
description: "Per-node tags that group your stacks by purpose, surface them under collapsible headers in the sidebar, and unlock cross-stack bulk actions across the fleet."
|
||||
---
|
||||
|
||||
A **Stack Label** is a per-node tag (name plus color) you can stick on any stack. Once a stack carries a label, the sidebar groups it under that label's header instead of dumping every stack into a flat list, and Fleet View can filter the overview by tag. Operators on a Skipper or Admiral license also get a pair of fleet-wide actions powered by labels: stop every stack labeled `prod` across every node, or replace the label set on a batch of stacks in one shot.
|
||||
A **Stack Label** is a per-node tag (name plus color) you can stick on any stack. Once a stack carries a label, the sidebar groups it under that label's header instead of dumping every stack into a flat list, and Fleet View can filter the overview by tag. Admins also get a pair of fleet-wide actions powered by labels: stop every stack labeled `prod` across every node, or replace the label set on a batch of stacks in one shot.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/stack-labels/sidebar-grouping.png" alt="Sidebar showing stacks grouped under three uppercase label headers (MEDIA 6, UTILITIES 5, NETWORK 3) and an UNLABELED 1 group at the bottom. Each stack row carries a small colored dot on the trailing edge that matches the group's color." />
|
||||
@@ -82,10 +82,10 @@ A stack can carry multiple labels and will then appear under each label's group
|
||||
## Fleet · Fleet Actions
|
||||
|
||||
<Note>
|
||||
Bulk actions on a label require a Sencho **Skipper** or **Admiral** license. Creating, assigning, and filtering by labels is available on every tier; only the cross-node bulk surface is paid.
|
||||
The Fleet Actions cards run admin-only on every tier. Operator and viewer roles see the cards but cannot apply them.
|
||||
</Note>
|
||||
|
||||
Two cards in the **Fleet · Fleet Actions** tab use labels to drive cross-node operations. Both endpoints are gated on a paid tier and an admin role: Community installs see the tab in the navigation but the body renders a single empty-state card explaining the gate, and a non-admin operator on a paid tier sees the cards rendered but receives a 403 from the backend on submit.
|
||||
Two cards in the **Fleet · Fleet Actions** tab use labels to drive cross-node operations. See [Fleet Actions](/features/fleet-actions) for the full reference.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/stack-labels/fleet-actions.png" alt="Fleet Actions tab with two cards side by side. Left card 'Stop fleet by label' (rose accent rail) has a Label name combobox containing 'Media' and a Stop matching stacks button beneath. Right card 'Bulk label assign' (purple accent rail) has a node selector reading Local (local), a stacks checklist showing plex and radarr ticked, a Labels row with a highlighted Media pill plus inactive Network and Utilities, and an Apply to 3 stacks button." />
|
||||
@@ -111,7 +111,7 @@ The **Bulk label assign** card is per-node only by design. To re-tag stacks on a
|
||||
- **Names are unique per node**, case-sensitive. The same name on two nodes is two separate label rows. Cross-node fleet stop matches on name; bulk assign always operates on one node's labels at a time.
|
||||
- **Allowed name characters**: letters, digits, spaces, and hyphens. Empty names and names beyond 30 characters are rejected at the API.
|
||||
- **Bulk-action concurrency**: only one label-driven bulk action can run on a single node at a time. A second concurrent attempt against the same node returns HTTP 429 and the operator sees an error toast; the in-flight action keeps running.
|
||||
- **Tier visibility**: organizing with labels is Community. Sidebar grouping, trailing dots on stack rows, the **Settings · Advanced · Labels** panel, the inline create form in the stack menu, and the Fleet View **Tags** filter all light up on every tier. Only the **Fleet · Fleet Actions** body content (the Stop-fleet-by-label and Bulk-label-assign cards) is paid; Community installs see the tab in the navigation but the body renders a single upgrade-context empty state.
|
||||
- **Role visibility**: label authoring is open to every signed-in role. Sidebar grouping, trailing dots on stack rows, the **Settings · Advanced · Labels** panel, the inline create form in the stack menu, and the Fleet View **Tags** filter all work for every user.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -137,8 +137,8 @@ The **Bulk label assign** card is per-node only by design. To re-tag stacks on a
|
||||
<Accordion title="Two stacks with the same name on different nodes only got relabeled on one">
|
||||
`Bulk label assign` is per-node by design. The node picker at the top is the source of truth and the stacks list only shows stacks on that node. Run the card a second time with the other node selected, or use **Stop fleet by label** instead if the goal is fleet-wide.
|
||||
</Accordion>
|
||||
<Accordion title="The Fleet Actions tab body shows an upgrade card on Community">
|
||||
Cross-node bulk actions stay on Skipper and Admiral. The tab itself is visible to every tier, but the body renders a single empty-state card on Community pointing at the upgrade. All other Stack Labels surfaces (sidebar grouping, trailing dots, the Settings panel, the inline create form, the Fleet View Tags filter) work the same on Community.
|
||||
<Accordion title="The Fleet Actions cards return an error when I click Apply">
|
||||
The cards run admin-only. Confirm the active user has the admin role under **Settings · Users**; operator and viewer roles see the cards rendered but cannot apply them. All other Stack Labels surfaces (sidebar grouping, trailing dots, the Settings panel, the inline create form, the Fleet View Tags filter) work for every role.
|
||||
</Accordion>
|
||||
<Accordion title="Deleting a label removed it from every stack">
|
||||
Working as designed. The destructive confirmation reads `Removes the label from every stack across the fleet.` The label row and every assignment row that pointed to it are dropped in a single transaction. There is no undo; recreate the label by name and color and reassign the affected stacks if you need to recover.
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { 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';
|
||||
@@ -10,18 +8,12 @@ interface Props {
|
||||
}
|
||||
|
||||
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) {
|
||||
return <EmptyState />;
|
||||
}
|
||||
|
||||
// Grid: 1 col under lg, 2 cols at lg and above. auto-rows-fr distributes
|
||||
// available height evenly across the auto-created rows, and each card uses
|
||||
// `flex flex-col h-full` (in <FleetActionCard>) with a `flex-1` body so it
|
||||
@@ -36,20 +28,3 @@ export function FleetActionsTab({ nodes }: Props) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Note: the §18.8 Community-tier "single full-width card with cyan rail + locked
|
||||
// footer (shell only, no upsell)" redesign is deferred to a follow-up PR.
|
||||
// Tracked in docs/internal/refactor/fleet-action-card-migration.md.
|
||||
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, assign labels to many stacks at once, and reclaim Docker disk space fleet-wide. Available on Skipper and Admiral.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user