diff --git a/backend/src/__tests__/labels-community-tier.test.ts b/backend/src/__tests__/labels-community-tier.test.ts new file mode 100644 index 00000000..989d8228 --- /dev/null +++ b/backend/src/__tests__/labels-community-tier.test.ts @@ -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'); + }); +}); diff --git a/backend/src/__tests__/topology-community-tier.test.ts b/backend/src/__tests__/topology-community-tier.test.ts new file mode 100644 index 00000000..d39e54e8 --- /dev/null +++ b/backend/src/__tests__/topology-community-tier.test.ts @@ -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); + }); +}); diff --git a/backend/src/routes/fleetActions.ts b/backend/src/routes/fleetActions.ts index 865cae9e..128d32d2 100644 --- a/backend/src/routes/fleetActions.ts +++ b/backend/src/routes/fleetActions.ts @@ -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, diff --git a/backend/src/routes/labels.ts b/backend/src/routes/labels.ts index 382b5aba..3788bb22 100644 --- a/backend/src/routes/labels.ts +++ b/backend/src/routes/labels.ts @@ -24,7 +24,6 @@ export const activeBulkActions = new Set(); export const labelsRouter = Router(); labelsRouter.get('/', authMiddleware, async (req: Request, res: Response): Promise => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - if (!requirePaid(req, res)) return; if (!requireBody(req, res)) return; try { const stackName = req.params.stackName as string; diff --git a/backend/src/routes/systemMaintenance.ts b/backend/src/routes/systemMaintenance.ts index c522e794..32f2575b 100644 --- a/backend/src/routes/systemMaintenance.ts +++ b/backend/src/routes/systemMaintenance.ts @@ -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(); diff --git a/docs/features/licensing.mdx b/docs/features/licensing.mdx index ebef393c..36ef3ce4 100644 --- a/docs/features/licensing.mdx +++ b/docs/features/licensing.mdx @@ -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 diff --git a/docs/features/overview.mdx b/docs/features/overview.mdx index 91e6f873..d3b0af03 100644 --- a/docs/features/overview.mdx +++ b/docs/features/overview.mdx @@ -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 diff --git a/docs/features/resources.mdx b/docs/features/resources.mdx index 94eedb8a..a97bef1f 100644 --- a/docs/features/resources.mdx +++ b/docs/features/resources.mdx @@ -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 Skipper +#### 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 Topology with Show system networks toggled on; arr-net plus the bridge, none, and host system networks all visible at the top - - Network Topology requires a Skipper or Admiral license. On Community, the Topology toggle shows an upgrade prompt instead of the graph. - - ### Unmanaged diff --git a/docs/features/stack-labels.mdx b/docs/features/stack-labels.mdx index 4404832b..21778e97 100644 --- a/docs/features/stack-labels.mdx +++ b/docs/features/stack-labels.mdx @@ -3,10 +3,6 @@ title: Stack Labels description: Organize your stacks with colored labels for quick filtering, grouping, and bulk actions. --- - - Stack Labels require a Sencho **Skipper** or **Admiral** license. - - 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. @@ -76,6 +72,10 @@ The **Tags** filter in the [Fleet View](/features/fleet-view) toolbar lets you f ## Bulk actions + + Bulk actions on a label require a Sencho **Skipper** or **Admiral** license. Creating, assigning, and filtering by labels is available on every tier. + + Right-click a label pill in the sidebar to access bulk actions: | Action | Effect | diff --git a/frontend/src/components/ResourcesView.tsx b/frontend/src/components/ResourcesView.tsx index 5ece7c21..73936095 100644 --- a/frontend/src/components/ResourcesView.tsx +++ b/frontend/src/components/ResourcesView.tsx @@ -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() { /> )}
- {isPaid && ( -
- - -
- )} +
+ + +
{isAdmin && networkViewMode === 'list' && (
- }> - { - window.dispatchEvent(new CustomEvent(SENCHO_OPEN_LOGS_EVENT, { - detail: { containerId: id, containerName: name }, - })); - }} - /> - - - - + + + + Loading topology... + + }> + { + window.dispatchEvent(new CustomEvent(SENCHO_OPEN_LOGS_EVENT, { + detail: { containerId: id, containerName: name }, + })); + }} + /> + + + ) : ( diff --git a/frontend/src/components/settings/LabelsSection.tsx b/frontend/src/components/settings/LabelsSection.tsx index 1e8ad5b1..e6148c5b 100644 --- a/frontend/src/components/settings/LabelsSection.tsx +++ b/frontend/src/components/settings/LabelsSection.tsx @@ -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 ( - - +
= MAX_LABELS_PER_NODE}> @@ -238,7 +236,6 @@ export function LabelsSection({ onLabelsChanged }: LabelsSectionProps = {}) { Removes the label from every stack across the fleet.

- - + ); } diff --git a/frontend/src/components/settings/registry.ts b/frontend/src/components/settings/registry.ts index 1e162704..351ec42d 100644 --- a/frontend/src/components/settings/registry.ts +++ b/frontend/src/components/settings/registry.ts @@ -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', }, {