mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 22:36:19 +00:00
52b46753af
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.
225 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|