Files
sencho/frontend/src/components/fleet/FederationTab.tsx
T
Anso 52b46753af 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.
2026-05-07 05:55:00 -04:00

225 lines
12 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from 'react';
import { Ban, Loader2, Network, Pin, Server } from 'lucide-react';
import { toast } from '@/components/ui/toast-store';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
listBlueprints,
pinBlueprint,
describeSelector,
type BlueprintListItem,
} from '@/lib/blueprintsApi';
import { listNodes, type NodeRecord } from '@/lib/nodesApi';
const UNPINNED = '__unpinned__';
function formatTimestamp(ms: number | null): string {
if (!ms) return '';
const date = new Date(ms);
return date.toLocaleString();
}
export function FederationTab() {
const [nodes, setNodes] = useState<NodeRecord[]>([]);
const [blueprints, setBlueprints] = useState<BlueprintListItem[]>([]);
const [loading, setLoading] = useState(true);
const [savingId, setSavingId] = useState<number | null>(null);
const refresh = useCallback(async () => {
try {
const [nodesResult, blueprintsResult] = await Promise.all([
listNodes(),
listBlueprints(),
]);
setNodes(nodesResult);
setBlueprints(blueprintsResult);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to load federation data';
toast.error(message);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { void refresh(); }, [refresh]);
const cordonedNodes = useMemo(() => nodes.filter(n => n.cordoned), [nodes]);
const nodeNameById = useMemo(() => {
const map = new Map<number, string>();
for (const node of nodes) map.set(node.id, node.name);
return map;
}, [nodes]);
const handlePinChange = useCallback(async (blueprintId: number, value: string) => {
const nodeId = value === UNPINNED ? null : Number.parseInt(value, 10);
setSavingId(blueprintId);
try {
const updated = await pinBlueprint(blueprintId, nodeId);
setBlueprints(prev => prev.map(b => b.id === blueprintId ? { ...b, pinned_node_id: updated.pinned_node_id } : b));
const blueprint = blueprints.find(b => b.id === blueprintId);
if (nodeId === null) {
toast.success(`${blueprint?.name ?? 'Blueprint'} unpinned`);
} else {
const targetName = nodeNameById.get(nodeId) ?? `node ${nodeId}`;
toast.success(`${blueprint?.name ?? 'Blueprint'} pinned to ${targetName}`);
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to update pin';
toast.error(message);
} finally {
setSavingId(null);
}
}, [blueprints, nodeNameById]);
if (loading) {
return (
<div className="flex items-center justify-center py-12 text-stat-subtitle">
<Loader2 className="w-5 h-5 animate-spin mr-2" />
Loading federation state
</div>
);
}
return (
<div className="space-y-6">
<header className="flex items-start gap-3">
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-glass-highlight border border-card-border">
<Network className="w-5 h-5 text-foreground" />
</div>
<div>
<div className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">Federation</div>
<h2 className="text-lg font-display italic">Placement control</h2>
<p className="text-sm text-muted-foreground max-w-2xl mt-0.5">
Mark nodes unschedulable and pin blueprints to specific nodes. Sencho proposes placements; you approve them. Existing deployments are never moved without an explicit operator action.
</p>
</div>
</header>
<section className="rounded-xl border border-card-border bg-card">
<div className="px-4 py-3 border-b border-card-border flex items-center justify-between">
<div className="flex items-center gap-2">
<Ban className="w-4 h-4 text-warning" />
<h3 className="text-sm font-medium">Cordoned nodes</h3>
<span className="text-xs text-muted-foreground">
{cordonedNodes.length} of {nodes.length}
</span>
</div>
<span className="text-[10px] font-mono uppercase tracking-[0.22em] text-stat-subtitle">
Toggle on each node card
</span>
</div>
<div className="p-4">
{cordonedNodes.length === 0 ? (
<p className="text-sm text-muted-foreground">
No nodes are cordoned. Use the kebab menu on any node card to mark it unschedulable.
</p>
) : (
<ul className="divide-y divide-card-border">
{cordonedNodes.map(node => (
<li key={node.id} className="py-2 first:pt-0 last:pb-0 flex items-start gap-3">
<Server className="w-4 h-4 mt-0.5 text-muted-foreground shrink-0" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">{node.name}</span>
<span className="text-[10px] font-mono uppercase tracking-[0.18em] text-stat-subtitle">{node.type}</span>
{node.cordoned_at && (
<span className="text-[10px] text-muted-foreground">
since {formatTimestamp(node.cordoned_at)}
</span>
)}
</div>
{node.cordoned_reason && (
<p className="text-xs text-muted-foreground mt-0.5">{node.cordoned_reason}</p>
)}
</div>
</li>
))}
</ul>
)}
</div>
</section>
<section className="rounded-xl border border-card-border bg-card">
<div className="px-4 py-3 border-b border-card-border flex items-center gap-2">
<Pin className="w-4 h-4 text-foreground" />
<h3 className="text-sm font-medium">Pin policy</h3>
<span className="text-xs text-muted-foreground">
Force a blueprint onto a specific node, overriding its selector.
</span>
</div>
<div className="p-4">
{blueprints.length === 0 ? (
<p className="text-sm text-muted-foreground">
No blueprints yet. Create one in the Deployments tab to manage placement here.
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-[10px] font-mono uppercase tracking-[0.18em] text-stat-subtitle border-b border-card-border">
<th className="py-2 pr-4 font-normal">Blueprint</th>
<th className="py-2 pr-4 font-normal">Selector</th>
<th className="py-2 pr-4 font-normal">Pinned to</th>
<th className="py-2 pr-4 font-normal">Effective</th>
</tr>
</thead>
<tbody className="divide-y divide-card-border">
{blueprints.map(bp => {
const pinnedName = bp.pinned_node_id !== null
? nodeNameById.get(bp.pinned_node_id) ?? `node ${bp.pinned_node_id}`
: null;
const effective = pinnedName
? `pin: ${pinnedName}`
: describeSelector(bp.selector);
return (
<tr key={bp.id}>
<td className="py-2 pr-4 align-top">
<div className="font-medium">{bp.name}</div>
{bp.description && (
<div className="text-xs text-muted-foreground line-clamp-1">{bp.description}</div>
)}
</td>
<td className="py-2 pr-4 align-top text-xs text-muted-foreground">
{describeSelector(bp.selector)}
</td>
<td className="py-2 pr-4 align-top">
<Select
value={bp.pinned_node_id !== null ? String(bp.pinned_node_id) : UNPINNED}
onValueChange={(value) => void handlePinChange(bp.id, value)}
disabled={savingId === bp.id}
>
<SelectTrigger className="h-8 w-56">
<SelectValue placeholder="(unpinned)" />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNPINNED}>(unpinned)</SelectItem>
{nodes.map(node => (
<SelectItem key={node.id} value={String(node.id)}>
{node.name}
{node.cordoned ? ' · cordoned' : ''}
</SelectItem>
))}
</SelectContent>
</Select>
</td>
<td className="py-2 pr-4 align-top text-xs text-muted-foreground">
{effective}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</section>
</div>
);
}