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
+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',
},
{