Files
sencho/frontend/src/components/blueprints/BlueprintDeploymentTable.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

177 lines
9.6 KiB
TypeScript

import { Lock, AlertTriangle, Pin, ShieldQuestion } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import {
type BlueprintDeployment,
type BlueprintClassification,
type BlueprintDeploymentStatus,
} from '@/lib/blueprintsApi';
import { useNodes } from '@/context/NodeContext';
import { formatTimeAgo } from '@/lib/relativeTime';
interface BlueprintDeploymentTableProps {
deployments: BlueprintDeployment[];
classification: BlueprintClassification;
canEdit: boolean;
busyNodeId: number | null;
onWithdraw: (nodeId: number) => void;
onAcceptStateReview: (nodeId: number) => void;
onRetry: (nodeId: number) => void;
pinnedNodeId?: number | null;
}
const STATUS_LABEL: Record<BlueprintDeploymentStatus, string> = {
pending: 'Pending',
pending_state_review: 'Awaiting confirmation',
deploying: 'Deploying',
active: 'Active',
drifted: 'Drifted',
correcting: 'Correcting',
failed: 'Failed',
withdrawing: 'Withdrawing',
withdrawn: 'Withdrawn',
evict_blocked: 'Evict blocked',
name_conflict: 'Name conflict',
};
function statusDotClass(status: BlueprintDeploymentStatus): string {
switch (status) {
case 'active': return 'bg-success';
case 'deploying':
case 'correcting': return 'bg-brand';
case 'failed':
case 'name_conflict': return 'bg-destructive';
case 'drifted':
case 'pending':
case 'pending_state_review':
case 'evict_blocked':
case 'withdrawing': return 'bg-warning';
default: return 'bg-muted-foreground';
}
}
export function BlueprintDeploymentTable({
deployments, classification, canEdit, busyNodeId, onWithdraw, onAcceptStateReview, onRetry, pinnedNodeId = null,
}: BlueprintDeploymentTableProps) {
const { nodes } = useNodes();
const nodesById = new Map(nodes.map(n => [n.id, n]));
if (deployments.length === 0) {
return (
<div className="rounded-lg border border-dashed border-border p-6 text-center text-xs text-stat-subtitle">
No matching nodes yet. Add a label or pick a node ID, then click Apply now.
</div>
);
}
return (
<div className="rounded-lg border border-card-border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-32 font-mono text-[10px] uppercase tracking-[0.18em]">Node</TableHead>
<TableHead className="font-mono text-[10px] uppercase tracking-[0.18em]">Status</TableHead>
<TableHead className="font-mono text-[10px] uppercase tracking-[0.18em]">Last activity</TableHead>
<TableHead className="font-mono text-[10px] uppercase tracking-[0.18em]">Notes</TableHead>
<TableHead className="text-right font-mono text-[10px] uppercase tracking-[0.18em]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{deployments.map(dep => {
const node = nodesById.get(dep.node_id);
const lastSeen = dep.last_drift_at ?? dep.last_deployed_at ?? dep.last_checked_at;
const isStateful = classification === 'stateful' || classification === 'unknown';
return (
<TableRow key={dep.id}>
<TableCell className="font-medium align-top">
<div className="flex items-center gap-2">
<span className="text-sm truncate">{node?.name ?? `node ${dep.node_id}`}</span>
{dep.status === 'active' && isStateful && (
<span title="Data pinned on this node" className="text-warning">
<Lock className="h-3 w-3" strokeWidth={1.5} />
</span>
)}
{pinnedNodeId === dep.node_id && (
<span
title="Blueprint is pinned to this node (Federation)"
className="inline-flex items-center gap-0.5 text-[9px] font-mono uppercase tracking-[0.18em] text-foreground/80"
>
<Pin className="h-3 w-3" strokeWidth={1.5} />
Pinned
</span>
)}
</div>
</TableCell>
<TableCell className="align-top">
<div className="flex items-center gap-2">
<span className={`inline-block w-2 h-2 rounded-full ${statusDotClass(dep.status)}`} aria-hidden />
<span className="text-xs">{STATUS_LABEL[dep.status]}</span>
</div>
</TableCell>
<TableCell className="text-xs text-stat-subtitle align-top">
{lastSeen ? formatTimeAgo(lastSeen) : '-'}
</TableCell>
<TableCell className="text-xs text-stat-subtitle align-top max-w-[280px]">
{dep.last_error ? (
<span className="text-destructive font-mono text-[10px] leading-relaxed">{dep.last_error}</span>
) : dep.drift_summary ? (
<span className="font-mono text-[10px] leading-relaxed">{dep.drift_summary}</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="align-top">
<div className="flex items-center justify-end gap-1">
{dep.status === 'pending_state_review' && canEdit && (
<Button
size="sm"
variant="default"
onClick={() => onAcceptStateReview(dep.node_id)}
disabled={busyNodeId === dep.node_id}
>
Confirm deploy
</Button>
)}
{dep.status === 'name_conflict' && (
<span className="inline-flex items-center gap-1 text-[10px] uppercase tracking-[0.18em] font-mono text-destructive">
<ShieldQuestion className="w-3 h-3" strokeWidth={1.5} />
Resolve manually
</span>
)}
{dep.status === 'failed' && canEdit && (
<Button
size="sm"
variant="outline"
onClick={() => onRetry(dep.node_id)}
disabled={busyNodeId === dep.node_id}
>
Retry
</Button>
)}
{(dep.status === 'active' || dep.status === 'drifted' || dep.status === 'evict_blocked' || dep.status === 'failed') && canEdit && (
<Button
size="sm"
variant="ghost"
onClick={() => onWithdraw(dep.node_id)}
disabled={busyNodeId === dep.node_id}
className="text-destructive hover:text-destructive"
>
{dep.status === 'evict_blocked' ? (
<span className="inline-flex items-center gap-1">
<AlertTriangle className="w-3 h-3" strokeWidth={1.5} />
Evict
</span>
) : 'Withdraw'}
</Button>
)}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
}