feat: drop stack labels and network topology to Community tier (#995)

* feat(labels): drop tier gate to Community for organization endpoints

Stack Labels CRUD and per-stack assignment are now Community-tier features:
list, create, update, delete labels, and assign labels to a single stack.

The two automation surfaces stay Skipper+: per-label bulk deploy / stop /
restart (POST /api/labels/:id/action) and the Fleet Actions tab's
bulk-assign card (POST /api/fleet-actions/labels/bulk-assign). Tier story
is now organize free, automate paid.

Add a route-level test that proves the CRUD endpoints succeed on a Community
license while the bulk-action endpoint still returns 403. Update overview,
licensing, and stack-labels docs to reflect the new tier placement and to
note that bulk actions on a label still require Skipper or Admiral.

* feat(topology): drop tier gate to Community for network topology view

The Resources tab's Networks > Topology view is now available on every tier.
Drops requirePaid from GET /api/system/networks/topology, removes the
isPaid wrapper around the List | Topology toggle in ResourcesView, and
removes the PaidGate around the topology graph.

CapabilityGate stays in place so a node running on a build without the
network-topology capability still renders its lock card instead of the
graph. Add a route-level test that proves the endpoint returns 200 on a
Community license. Update licensing and resources docs to reflect the new
tier placement.

* fix(labels): expose Settings > Labels tab on Community tier

The settings registry entry for the Labels tab still carried tier:
'skipper', which kept the tab hidden in the Settings sidebar even though
the underlying CRUD endpoints now serve Community. Drop the tier flag so
Community users can discover and reach the section that backs the
already-Community-tier label endpoints.
This commit is contained in:
Anso
2026-05-08 15:24:25 -04:00
committed by GitHub
parent 0f3380897a
commit 1803512f70
12 changed files with 213 additions and 70 deletions
@@ -0,0 +1,113 @@
/**
* Confirms Stack Labels CRUD + per-stack assignment is reachable on the
* Community tier. The per-label bulk-action endpoint stays Skipper+ and is
* exercised here too to guard against an accidental gate removal in the
* future.
*/
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('Stack Labels on Community tier', () => {
afterEach(() => vi.restoreAllMocks());
it('GET /api/labels returns 200 (empty array) on community', async () => {
mockTier('community');
const res = await request(app).get('/api/labels').set('Authorization', authHeader);
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
});
it('POST /api/labels creates a label on community', async () => {
mockTier('community');
const res = await request(app)
.post('/api/labels')
.set('Authorization', authHeader)
.send({ name: 'production', color: 'teal' });
expect(res.status).toBe(201);
expect(res.body).toMatchObject({ name: 'production', color: 'teal' });
expect(typeof res.body.id).toBe('number');
});
it('GET /api/labels/assignments returns 200 on community', async () => {
mockTier('community');
const res = await request(app).get('/api/labels/assignments').set('Authorization', authHeader);
expect(res.status).toBe(200);
expect(typeof res.body).toBe('object');
});
it('PUT /api/labels/:id updates a label on community', async () => {
mockTier('community');
const created = await request(app)
.post('/api/labels')
.set('Authorization', authHeader)
.send({ name: 'staging', color: 'blue' });
expect(created.status).toBe(201);
const res = await request(app)
.put(`/api/labels/${created.body.id}`)
.set('Authorization', authHeader)
.send({ color: 'rose' });
expect(res.status).toBe(200);
expect(res.body.color).toBe('rose');
});
it('DELETE /api/labels/:id removes a label on community', async () => {
mockTier('community');
const created = await request(app)
.post('/api/labels')
.set('Authorization', authHeader)
.send({ name: 'temp', color: 'amber' });
expect(created.status).toBe(201);
const res = await request(app)
.delete(`/api/labels/${created.body.id}`)
.set('Authorization', authHeader);
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
it('PUT /api/stacks/:stackName/labels accepts an empty assignment on community', async () => {
mockTier('community');
const res = await request(app)
.put('/api/stacks/some-stack/labels')
.set('Authorization', authHeader)
.send({ labelIds: [] });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
});
describe('Stack Labels bulk-action endpoint stays Skipper+', () => {
afterEach(() => vi.restoreAllMocks());
it('POST /api/labels/:id/action returns 403 on community', async () => {
mockTier('community');
const res = await request(app)
.post('/api/labels/1/action')
.set('Authorization', authHeader)
.send({ action: 'deploy' });
expect(res.status).toBe(403);
expect(res.body.code).toBe('PAID_REQUIRED');
});
});
@@ -0,0 +1,44 @@
/**
* Confirms GET /api/system/networks/topology is reachable on the Community
* tier. Mocks DockerController.getTopologyData so the route can return data
* without a real Docker daemon.
*/
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;
let DockerController: typeof import('../services/DockerController').default;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
({ LicenseService } = await import('../services/LicenseService'));
DockerController = (await import('../services/DockerController')).default;
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('Network topology on Community tier', () => {
afterEach(() => vi.restoreAllMocks());
it('GET /api/system/networks/topology returns 200 on community', async () => {
mockTier('community');
vi.spyOn(DockerController.prototype, 'getTopologyData').mockResolvedValue([]);
const res = await request(app)
.get('/api/system/networks/topology')
.set('Authorization', authHeader);
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
});
});
+3 -1
View File
@@ -20,7 +20,9 @@ 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).
// 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.
fleetActionsRouter.post(
'/labels/bulk-assign',
authMiddleware,
-6
View File
@@ -24,7 +24,6 @@ export const activeBulkActions = new Set<string>();
export const labelsRouter = Router();
labelsRouter.get('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
try {
const nodeId = req.nodeId ?? 0;
const labels = DatabaseService.getInstance().getLabels(nodeId);
@@ -37,7 +36,6 @@ labelsRouter.get('/', authMiddleware, async (req: Request, res: Response): Promi
});
labelsRouter.post('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
if (!requireBody(req, res)) return;
try {
const nodeId = req.nodeId ?? 0;
@@ -77,7 +75,6 @@ labelsRouter.post('/', authMiddleware, async (req: Request, res: Response): Prom
});
labelsRouter.get('/assignments', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
try {
const nodeId = req.nodeId ?? 0;
const db = DatabaseService.getInstance();
@@ -108,7 +105,6 @@ labelsRouter.get('/assignments', authMiddleware, async (req: Request, res: Respo
});
labelsRouter.put('/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
if (!requireBody(req, res)) return;
try {
const id = parseIntParam(req, res, 'id', 'label ID');
@@ -152,7 +148,6 @@ labelsRouter.put('/:id', authMiddleware, async (req: Request, res: Response): Pr
});
labelsRouter.delete('/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
try {
const id = parseIntParam(req, res, 'id', 'label ID');
if (id === null) return;
@@ -260,7 +255,6 @@ labelsRouter.post('/:id/action', authMiddleware, async (req: Request, res: Respo
export const stackLabelsRouter = Router();
stackLabelsRouter.put('/:stackName/labels', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
if (!requireBody(req, res)) return;
try {
const stackName = req.params.stackName as string;
+1 -2
View File
@@ -1,7 +1,7 @@
import { Router, type Request, type Response } from 'express';
import DockerController, { type CreateNetworkOptions, type NetworkDriver } from '../services/DockerController';
import { FileSystemService } from '../services/FileSystemService';
import { requireAdmin, requirePaid } from '../middleware/tierGates';
import { requireAdmin } from '../middleware/tierGates';
import { invalidateNodeCaches } from '../helpers/cacheInvalidation';
import { isValidDockerResourceId, isValidCidr, isValidIPv4 } from '../utils/validation';
import { isDebugEnabled } from '../utils/debug';
@@ -209,7 +209,6 @@ systemMaintenanceRouter.post('/networks/delete', async (req: Request, res: Respo
});
systemMaintenanceRouter.get('/networks/topology', async (req: Request, res: Response) => {
if (!requirePaid(req, res)) return;
try {
const includeSystem = req.query.includeSystem === 'true';
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
+4 -1
View File
@@ -24,6 +24,8 @@ Lifetime pricing is an early-adopter offer available for a limited time only.
**Community** includes:
- Unlimited nodes, compose editor, global logs, app store, alerts, and more
- Fleet View with search, sort, filters, node-card expand, and topology
- Stack labels
- Network topology graph
- Manual fleet snapshots (create, browse, restore, delete)
- Per-node Sencho updates and the Check Updates view
- Vulnerability scanning: install/update/uninstall Trivy, on-demand scans (vulnerabilities, secrets, misconfigurations), scan comparison, and CVE suppressions
@@ -31,7 +33,8 @@ Lifetime pricing is an early-adopter offer available for a limited time only.
- Custom OIDC single sign-on (works with Authelia, Keycloak, Authentik, Zitadel, Pocket ID, or any spec-compliant OIDC identity provider)
**Skipper** includes everything in Community, plus:
- Webhooks and stack labels
- Webhooks
- Bulk actions on a label (deploy, stop, or restart every stack tagged with it)
- Atomic deployments
- Bulk **Update All** across the fleet, scheduled scans, scheduled updates, and scheduled fleet snapshots
- Scan policies with `block_on_deploy` enforcement, SBOM (SPDX, CycloneDX), and SARIF export
+1 -1
View File
@@ -61,7 +61,7 @@ Link a stack to a Git repository and keep `compose.yaml` in sync via manual pull
### Stack labels
Tag your stacks with custom colored labels like `production`, `staging`, or `media-server`. Filter the sidebar by label, identify stacks at a glance, and organize your infrastructure visually. Skipper or Admiral. [Learn more →](/features/stack-labels)
Tag your stacks with custom colored labels like `production`, `staging`, or `media-server`. Filter the sidebar by label, identify stacks at a glance, and organize your infrastructure visually. Bulk-action a label (deploy, stop, or restart every stack tagged with it) on Skipper or Admiral. [Learn more →](/features/stack-labels)
## Observability
+2 -6
View File
@@ -126,7 +126,7 @@ Lists all Docker networks with ID, name, driver, scope (`local`, `global`, `swar
**Filter chips:** `All`, `Managed`, `External`, each with its count.
A **List | Topology** view-mode toggle sits on the right of the toolbar. **List** is the default tabular view; **Topology** is a paid-tier graph view of how containers connect to networks (covered below).
A **List | Topology** view-mode toggle sits on the right of the toolbar. **List** is the default tabular view; **Topology** is a graph view of how containers connect to networks (covered below).
#### Create Network
@@ -163,7 +163,7 @@ The sheet is grouped into sections:
- **Connected** shows every container attached to the network, with its name, IPv4 address, and MAC address. The section header counts the containers.
- **Labels** lists all Docker labels on the network (rendered only when present).
#### Network topology <span style={{fontSize: '0.75em', color: 'var(--brand)'}}>Skipper</span>
#### Network topology
Switch to the **Topology** view to see an interactive graph of your Docker networks and the containers attached to them. The graph uses an automatic hierarchical layout (networks on top, containers below) that scales cleanly regardless of how many networks and containers you have.
@@ -187,10 +187,6 @@ By default, system networks (`bridge`, `host`, `none`) are hidden so the graph s
<img src="/images/networks/network-topology-toggle.png" alt="Topology with Show system networks toggled on; arr-net plus the bridge, none, and host system networks all visible at the top" />
</Frame>
<Note>
Network Topology requires a Skipper or Admiral license. On Community, the Topology toggle shows an upgrade prompt instead of the graph.
</Note>
### Unmanaged
<Frame>
+4 -4
View File
@@ -3,10 +3,6 @@ title: Stack Labels
description: Organize your stacks with colored labels for quick filtering, grouping, and bulk actions.
---
<Note>
Stack Labels require a Sencho **Skipper** or **Admiral** license.
</Note>
Stack Labels let you tag your Docker Compose stacks with custom colored labels like `production`, `staging`, or `media-server`. Once labeled, you can filter your stack list, identify stacks at a glance, and run bulk actions on entire groups.
<Frame>
@@ -76,6 +72,10 @@ The **Tags** filter in the [Fleet View](/features/fleet-view) toolbar lets you f
## Bulk 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.
</Note>
Right-click a label pill in the sidebar to access bulk actions:
| Action | Effect |
+38 -43
View File
@@ -24,7 +24,6 @@ import type { ScanSummary, VulnSeverity } from '@/types/security';
import { useNodes } from '@/context/NodeContext';
import { useAuth } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
import { PaidGate } from './PaidGate';
import { CapabilityGate } from './CapabilityGate';
import LazyBoundary from './LazyBoundary';
import { formatBytes } from '@/lib/utils';
@@ -935,28 +934,26 @@ export default function ResourcesView() {
/>
)}
<div className="flex items-center gap-2 pr-3">
{isPaid && (
<div className="flex items-center gap-0.5 bg-muted/50 rounded-lg p-0.5">
<button
onClick={() => setNetworkViewMode('list')}
className={cn(
'px-2.5 py-1 rounded-md text-xs font-medium transition-all duration-200',
networkViewMode === 'list' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
)}
>
List
</button>
<button
onClick={() => setNetworkViewMode('topology')}
className={cn(
'px-2.5 py-1 rounded-md text-xs font-medium transition-all duration-200 flex items-center gap-1',
networkViewMode === 'topology' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
)}
>
Topology
</button>
</div>
)}
<div className="flex items-center gap-0.5 bg-muted/50 rounded-lg p-0.5">
<button
onClick={() => setNetworkViewMode('list')}
className={cn(
'px-2.5 py-1 rounded-md text-xs font-medium transition-all duration-200',
networkViewMode === 'list' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
)}
>
List
</button>
<button
onClick={() => setNetworkViewMode('topology')}
className={cn(
'px-2.5 py-1 rounded-md text-xs font-medium transition-all duration-200 flex items-center gap-1',
networkViewMode === 'topology' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
)}
>
Topology
</button>
</div>
{isAdmin && networkViewMode === 'list' && (
<Button
variant="outline"
@@ -973,26 +970,24 @@ export default function ResourcesView() {
{networkViewMode === 'topology' ? (
<div className="p-4">
<PaidGate>
<CapabilityGate capability="network-topology" featureName="Network Topology">
<LazyBoundary>
<Suspense fallback={
<div className="flex items-center justify-center h-[400px] text-muted-foreground gap-2">
<span className="text-sm">Loading topology...</span>
</div>
}>
<NetworkTopologyView
key={activeNode?.id}
onContainerClick={(id, name) => {
window.dispatchEvent(new CustomEvent<SenchoOpenLogsDetail>(SENCHO_OPEN_LOGS_EVENT, {
detail: { containerId: id, containerName: name },
}));
}}
/>
</Suspense>
</LazyBoundary>
</CapabilityGate>
</PaidGate>
<CapabilityGate capability="network-topology" featureName="Network Topology">
<LazyBoundary>
<Suspense fallback={
<div className="flex items-center justify-center h-[400px] text-muted-foreground gap-2">
<span className="text-sm">Loading topology...</span>
</div>
}>
<NetworkTopologyView
key={activeNode?.id}
onContainerClick={(id, name) => {
window.dispatchEvent(new CustomEvent<SenchoOpenLogsDetail>(SENCHO_OPEN_LOGS_EVENT, {
detail: { containerId: id, containerName: name },
}));
}}
/>
</Suspense>
</LazyBoundary>
</CapabilityGate>
</div>
) : (
<Table>
@@ -6,7 +6,6 @@ import { Modal, ModalHeader, ModalBody, ModalFooter, ConfirmModal } from '@/comp
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { SENCHO_LABELS_CHANGED } from '@/lib/events';
import { PaidGate } from '../PaidGate';
import { CapabilityGate } from '../CapabilityGate';
import { LabelDot } from '../LabelPill';
import { LABEL_COLORS, MAX_LABELS_PER_NODE, type Label, type LabelColor } from '../label-types';
@@ -127,8 +126,7 @@ export function LabelsSection({ onLabelsChanged }: LabelsSectionProps = {}) {
};
return (
<PaidGate>
<CapabilityGate capability="labels" featureName="Stack Labels">
<CapabilityGate capability="labels" featureName="Stack Labels">
<div className="space-y-4">
<div className="flex justify-end">
<SettingsPrimaryButton size="sm" onClick={openCreate} disabled={labels.length >= MAX_LABELS_PER_NODE}>
@@ -238,7 +236,6 @@ export function LabelsSection({ onLabelsChanged }: LabelsSectionProps = {}) {
Removes the label from every stack across the fleet.
</p>
</ConfirmModal>
</CapabilityGate>
</PaidGate>
</CapabilityGate>
);
}
+1 -1
View File
@@ -171,7 +171,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
label: 'Labels',
description: 'Per-node labels for stacks and containers.',
keywords: ['labels', 'tags', 'palette', 'organisation'],
tier: 'skipper',
tier: null,
scope: 'node',
},
{