feat(fleet): add Federation tab with cordon and pin policy (Admiral) (#964)

Ships the v1 MVP for the Federation tab as placement control, not
placement automation:

- Cordon a node: marks the node unschedulable so the BlueprintReconciler
  skips it for new placements only. Existing deployments continue to
  drift-check and redeploy on revision changes; cordon never triggers
  withdraw or eviction. Toggle on the NodeCard kebab (Admiral, admin
  role); Cordoned pill renders for all tiers.
- Pin a blueprint to a node: stores blueprints.pinned_node_id, replacing
  the desired set with the pinned node regardless of selector. Pin
  overrides cordon by design. Action lives only in the Federation tab;
  BlueprintDetail and the deployment table show read-only Pinned
  indicators.

Backend: idempotent migrations add nodes.cordoned/cordoned_at/cordoned_reason
and blueprints.pinned_node_id. New routes POST /api/nodes/:id/cordon,
POST /api/nodes/:id/uncordon, PUT /api/blueprints/:id/pin, all gated by
requireAdmiral plus requireAdmin. Audit summaries added so the existing
auditLog middleware records every operator action. deleteNode clears
dangling pins.

Reconciler: pin override evaluated before selector match; cordon filter
applied only to the new-placement branch (deploy/stateReview without an
existing deployment). 11 new Vitest cases cover cordon filter, pin
override, pin-overrides-cordon, missing pin target, pin shrinks
desired set (stateless withdraw + stateful evict_blocked), and pin
clearing on node delete.

Frontend: new FederationTab.tsx with cordoned-nodes summary and
pin-policy table. Federation moved out of the experimental flag into
{isAdmiral && (...)} + AdmiralGate, mirroring the Routing tab pattern.
Secrets stays under experimental.

Tests pass: backend tsc, full Vitest suite (1704 passed), frontend
tsc -b, ESLint (0 errors). Manual verification via the local dev
instance confirmed the tab is hidden at Community, the kebab and pill
render at Admiral, and cordon and pin endpoints round-trip end to end.

Refs cut-line-1.0.md Federation v1 MVP.
This commit is contained in:
Anso
2026-05-07 05:55:00 -04:00
committed by GitHub
parent 77d5ff58d3
commit 52b46753af
18 changed files with 897 additions and 35 deletions
+107 -2
View File
@@ -2,14 +2,24 @@ import { useState } from 'react';
import {
Server, Cpu, MemoryStick, HardDrive, ChevronDown, ChevronRight,
Layers, Wifi, WifiOff, AlertTriangle, Download, Loader2,
MoreVertical, Ban,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { ConfirmModal } from '@/components/ui/modal';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
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 { cordonNode, uncordonNode } from '@/lib/nodesApi';
import { UpdateStatusBadge } from './UpdateStatusBadge';
import { StackSection } from './NodeCardStackList';
import type { Label as StackLabel } from '../label-types';
@@ -27,6 +37,7 @@ export interface NodeCardProps {
updatingNodeId?: number | null;
onRetryUpdate?: (nodeId: number) => void;
onDismissUpdate?: (nodeId: number) => void;
onCordonChange?: () => void;
}
// --- Sub-Components ---
@@ -44,10 +55,16 @@ function UsageBar({ percent, color }: { percent: number; color: string }) {
// --- Main Export ---
export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, updatingNodeId, onRetryUpdate, onDismissUpdate }: NodeCardProps) {
export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, updatingNodeId, onRetryUpdate, onDismissUpdate, onCordonChange }: NodeCardProps) {
const [expanded, setExpanded] = useState(false);
const [stacks, setStacks] = useState<string[] | null>(node.stacks);
const [loadingStacks, setLoadingStacks] = useState(false);
const [cordonModalOpen, setCordonModalOpen] = useState(false);
const [cordonReason, setCordonReason] = useState('');
const [cordonSubmitting, setCordonSubmitting] = useState(false);
const { isPaid, license } = useLicense();
const isAdmiral = isPaid && license?.variant === 'admiral';
const isOnline = node.status === 'online';
const isLocal = node.type === 'local';
@@ -57,6 +74,31 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u
const memPercent = getNodeMem(node);
const diskPercent = getNodeDisk(node);
const openCordonModal = () => {
setCordonReason('');
setCordonModalOpen(true);
};
const handleCordonConfirm = async () => {
setCordonSubmitting(true);
try {
if (node.cordoned) {
await uncordonNode(node.id);
toast.success(`Uncordoned ${node.name}`);
} else {
await cordonNode(node.id, cordonReason.trim() || null);
toast.success(`Cordoned ${node.name}`);
}
setCordonModalOpen(false);
onCordonChange?.();
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to update cordon state';
toast.error(message);
} finally {
setCordonSubmitting(false);
}
};
const handleExpand = async () => {
const next = !expanded;
setExpanded(next);
@@ -89,10 +131,31 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u
{/* Card Header */}
<div className="relative p-4 pb-3">
{isLocal && (
<span className="absolute top-3 right-3 font-mono text-[9px] uppercase tracking-[0.22em] text-brand">
<span className={`absolute top-3 font-mono text-[9px] uppercase tracking-[0.22em] text-brand ${isAdmiral ? 'right-9' : 'right-3'}`}>
Local
</span>
)}
{isAdmiral && (
<div className="absolute top-2 right-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label="Node actions"
className="inline-flex items-center justify-center w-6 h-6 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
>
<MoreVertical className="w-4 h-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onSelect={openCordonModal}>
<Ban className="w-3.5 h-3.5 mr-2" />
{node.cordoned ? 'Uncordon node' : 'Cordon node'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2.5 min-w-0">
<div className={`flex items-center justify-center w-8 h-8 rounded-lg ${isOnline ? 'bg-success-muted' : 'bg-muted'}`}>
@@ -134,6 +197,15 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u
<AlertTriangle className="w-2.5 h-2.5 mr-0.5" /> Critical
</Badge>
)}
{node.cordoned && (
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0 h-4 shrink-0 bg-warning/15 text-warning border-warning/30"
title={node.cordoned_reason ?? 'Unschedulable: new blueprint deployments skip this node'}
>
<Ban className="w-2.5 h-2.5 mr-0.5" /> Cordoned
</Badge>
)}
</div>
</div>
</div>
@@ -219,6 +291,39 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u
)}
</div>
<ConfirmModal
open={cordonModalOpen}
onOpenChange={(open) => {
if (!cordonSubmitting) setCordonModalOpen(open);
}}
kicker="Federation"
title={node.cordoned ? `Uncordon ${node.name}` : `Cordon ${node.name}`}
description={node.cordoned
? 'Re-enable this node for new blueprint placements. Existing deployments are unchanged.'
: 'Mark this node as unschedulable. New blueprint deployments will skip it. Existing deployments remain in place.'}
confirmLabel={node.cordoned ? 'Uncordon node' : 'Cordon node'}
confirming={cordonSubmitting}
onConfirm={handleCordonConfirm}
>
{!node.cordoned && (
<div className="space-y-1.5">
<label htmlFor={`cordon-reason-${node.id}`} className="text-xs font-medium text-muted-foreground">
Reason (optional)
</label>
<input
id={`cordon-reason-${node.id}`}
type="text"
maxLength={256}
value={cordonReason}
onChange={(e) => setCordonReason(e.target.value)}
placeholder="e.g. draining for maintenance"
className="w-full h-8 px-2 text-sm rounded-md border border-input bg-background"
disabled={cordonSubmitting}
/>
</div>
)}
</ConfirmModal>
{/* Expandable Stack List with Container Drill-Down */}
{isOnline && (
<div className="border-t">
@@ -31,6 +31,7 @@ interface OverviewTabProps {
updatingNodeId: number | null;
onRetryUpdate?: (nodeId: number) => void;
onDismissUpdate?: (nodeId: number) => void;
onCordonChange?: () => void;
}
export function OverviewTab({
@@ -56,6 +57,7 @@ export function OverviewTab({
updatingNodeId,
onRetryUpdate,
onDismissUpdate,
onCordonChange,
}: OverviewTabProps) {
return (
<>
@@ -118,6 +120,7 @@ export function OverviewTab({
updatingNodeId={updatingNodeId}
onRetryUpdate={onRetryUpdate}
onDismissUpdate={onDismissUpdate}
onCordonChange={onCordonChange}
/>
))}
</div>
@@ -22,6 +22,9 @@ export interface FleetNode {
stats: FleetNodeStats | null;
systemStats: FleetNodeSystemStats | null;
stacks: string[] | null;
cordoned: boolean;
cordoned_at: number | null;
cordoned_reason: string | null;
}
export interface NodeUpdateStatus {