mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user