feat: move Blueprint orchestration and Federation placement to Community tier (#1555)

* feat: move core Blueprint orchestration to Community tier

Blueprints CRUD, reconciliation, and drift modes are now available on
Community. Pin remains Admiral-only via Federation placement controls.

* feat: move Federation placement controls to Community tier

Remove requirePaid from cordon, uncordon, and blueprint pin routes. Ungate the Federation tab and gate cordon UI on node:manage only. Update licensing and fleet docs for the new tier split.
This commit is contained in:
Anso
2026-07-04 21:20:20 -04:00
committed by GitHub
parent 1fac6f797f
commit fdbc1b1ebb
18 changed files with 264 additions and 117 deletions
+6 -16
View File
@@ -144,13 +144,11 @@ export function FleetView({ onNavigateToNode, onOpenSettingsSection, onOpenMuteR
</TabsHighlightItem>
)}
<span aria-hidden className="self-center mx-1 h-4 w-px bg-border" />
{isPaid && (
<TabsHighlightItem value="deployments">
<TabsHighlightItem value="deployments">
<TabsTrigger value="deployments">
<Send className="w-4 h-4 mr-1.5" />Deployments
</TabsTrigger>
</TabsHighlightItem>
)}
{isPaid && (
<TabsHighlightItem value="routing">
<TabsTrigger value="routing">
@@ -158,13 +156,11 @@ export function FleetView({ onNavigateToNode, onOpenSettingsSection, onOpenMuteR
</TabsTrigger>
</TabsHighlightItem>
)}
{isPaid && (
<TabsHighlightItem value="federation">
<TabsHighlightItem value="federation">
<TabsTrigger value="federation">
<Network className="w-4 h-4 mr-1.5" />Federation
</TabsTrigger>
</TabsHighlightItem>
)}
<TabsHighlightItem value="actions">
<TabsTrigger value="actions">
<Wrench className="w-4 h-4 mr-1.5" />Actions
@@ -261,11 +257,9 @@ export function FleetView({ onNavigateToNode, onOpenSettingsSection, onOpenMuteR
<ContainerLabelsTab onNavigateToNode={onNavigateToNode} />
</TabsContent>
)}
{isPaid && (
<TabsContent value="deployments">
<TabsContent value="deployments">
<DeploymentsTab />
</TabsContent>
)}
{isPaid && (
<TabsContent value="routing">
<PaidGate>
@@ -273,13 +267,9 @@ export function FleetView({ onNavigateToNode, onOpenSettingsSection, onOpenMuteR
</PaidGate>
</TabsContent>
)}
{isPaid && (
<TabsContent value="federation">
<PaidGate>
<FederationTab canManage={isAdmin} />
</PaidGate>
</TabsContent>
)}
<TabsContent value="federation">
<FederationTab canManage={isAdmin} />
</TabsContent>
<TabsContent value="actions">
{/* Fleet Actions runs against the whole fleet, so it takes the
unfiltered node list rather than the overview-filtered view. */}
@@ -21,7 +21,6 @@ import { formatBytes } from '@/lib/utils';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { formatVersion } from '@/lib/version';
import { useLicense } from '@/context/LicenseContext';
import { useAuth } from '@/context/AuthContext';
import { useNodes, type Node } from '@/context/NodeContext';
import { cordonNode, uncordonNode } from '@/lib/nodesApi';
@@ -71,16 +70,13 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u
const [cordonReason, setCordonReason] = useState('');
const [cordonSubmitting, setCordonSubmitting] = useState(false);
const { isPaid } = useLicense();
const { isAdmin, can } = useAuth();
const { nodes: registryNodes } = useNodes();
const registryNode = registryNodes.find(n => n.id === node.id);
const canEdit = Boolean(isAdmin && onEdit && registryNode);
const canDelete = Boolean(isAdmin && onDelete && registryNode && !registryNode.is_default);
// Cordon is a paid feature AND requires node:manage, matching the backend guard
// (requirePermission('node:manage','node',id) + requirePaid). Gating on tier
// alone would surface the control to deployer/viewer/auditor users whose calls 403.
const canCordon = isPaid && can('node:manage', 'node', String(node.id));
// Cordon requires node:manage, matching the backend guard.
const canCordon = can('node:manage', 'node', String(node.id));
const nodeMuteActions = useNodeMuteActions(
node.id,
node.name,
@@ -55,24 +55,23 @@ describe('NodeCard', () => {
expect(screen.queryByText('Running')).not.toBeInTheDocument();
});
it('hides the actions menu for a free-tier user', () => {
it('hides the actions menu when the user lacks node:manage and no edit/delete affordances', () => {
useAuthMock.mockReturnValue({ isAdmin: false, can: vi.fn(() => false) });
useLicenseMock.mockReturnValue({ isPaid: false });
render(<NodeCard {...baseProps(onlineNode())} />);
expect(screen.queryByRole('button', { name: 'Node actions' })).not.toBeInTheDocument();
});
it('exposes the actions menu (cordon entry point) for a paid admin', () => {
useLicenseMock.mockReturnValue({ isPaid: true });
it('exposes the actions menu (cordon entry point) for a Community admin with node:manage', () => {
useLicenseMock.mockReturnValue({ isPaid: false });
render(<NodeCard {...baseProps(onlineNode())} />);
// With no edit/delete affordances wired, the menu renders iff cordon is
// allowed: isPaid && can('node:manage'). The admin's can() returns true.
expect(screen.getByRole('button', { name: 'Node actions' })).toBeInTheDocument();
});
it('exposes the cordon control for a node-admin via the node:manage permission', async () => {
const can = vi.fn((action: string) => action === 'node:manage');
useAuthMock.mockReturnValue({ isAdmin: false, can });
useLicenseMock.mockReturnValue({ isPaid: true });
useLicenseMock.mockReturnValue({ isPaid: false });
render(<NodeCard {...baseProps(onlineNode())} />);
await userEvent.click(screen.getByRole('button', { name: 'Node actions' }));
@@ -80,18 +79,18 @@ describe('NodeCard', () => {
expect(can).toHaveBeenCalledWith('node:manage', 'node', '2');
});
it('hides the cordon control from a paid user lacking node:manage', () => {
it('hides the cordon control from a user lacking node:manage', () => {
useAuthMock.mockReturnValue({ isAdmin: false, can: vi.fn(() => false) });
useLicenseMock.mockReturnValue({ isPaid: true });
useLicenseMock.mockReturnValue({ isPaid: false });
render(<NodeCard {...baseProps(onlineNode())} />);
// The paid tier alone must not surface cordon to a deployer/viewer/auditor.
// node:manage alone must not surface cordon when false to a deployer/viewer/auditor.
expect(screen.queryByRole('button', { name: 'Node actions' })).not.toBeInTheDocument();
});
it('shows Uncordon when the node is already cordoned', async () => {
const can = vi.fn((action: string) => action === 'node:manage');
useAuthMock.mockReturnValue({ isAdmin: false, can });
useLicenseMock.mockReturnValue({ isPaid: true });
useLicenseMock.mockReturnValue({ isPaid: false });
render(<NodeCard {...baseProps({ ...onlineNode(), cordoned: true, cordoned_reason: 'patching' })} />);
await userEvent.click(screen.getByRole('button', { name: 'Node actions' }));
@@ -104,13 +103,13 @@ describe('NodeCard', () => {
};
it('renders the update button for an admin when an update is available', () => {
useAuthMock.mockReturnValue({ isAdmin: true });
useAuthMock.mockReturnValue({ isAdmin: true, can: vi.fn(() => true) });
render(<NodeCard {...baseProps(onlineNode())} updateStatus={updateAvailableStatus} onUpdate={vi.fn()} />);
expect(screen.getByRole('button', { name: /Update/ })).toBeInTheDocument();
});
it('hides the update button for a non-admin but still shows the read-only badge', () => {
useAuthMock.mockReturnValue({ isAdmin: false });
useAuthMock.mockReturnValue({ isAdmin: false, can: vi.fn(() => false) });
render(<NodeCard {...baseProps(onlineNode())} updateStatus={updateAvailableStatus} onUpdate={vi.fn()} />);
expect(screen.queryByRole('button', { name: /Update/ })).not.toBeInTheDocument();
expect(screen.getByText('Update available')).toBeInTheDocument();
@@ -16,13 +16,11 @@ import { BlueprintEmptyState } from './BlueprintEmptyState';
import { FleetTabHeading, FleetEmptyState } from '../fleet/FleetEmptyState';
import { BlueprintDetail } from './BlueprintDetail';
import { BlueprintEditor } from './BlueprintEditor';
import { useLicense } from '@/context/LicenseContext';
import { useAuth } from '@/context/AuthContext';
export function DeploymentsTab() {
const { isPaid } = useLicense();
const { isAdmin } = useAuth();
const canEdit = isPaid && isAdmin;
const canEdit = isAdmin;
const [blueprints, setBlueprints] = useState<BlueprintListItem[]>([]);
const [distinctLabels, setDistinctLabels] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
import { ChevronRight, Loader2 } from 'lucide-react';
import { apiFetch } from '@/lib/api';
import { useLicense } from '@/context/LicenseContext';
import { useAuth } from '@/context/AuthContext';
import { useNodes } from '@/context/NodeContext';
import { cordonNode, uncordonNode } from '@/lib/nodesApi';
@@ -159,9 +158,8 @@ function NodeDetail({
onInspectStack: (nodeId: number, stackName: string) => void;
onCordonChange: () => void;
}) {
const { isPaid } = useLicense();
const { can } = useAuth();
const canCordon = isPaid && can('node:manage', 'node', String(node.id));
const canCordon = can('node:manage', 'node', String(node.id));
const [confirmOpen, setConfirmOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);