mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-26 10:16:53 +00:00
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:
@@ -22,6 +22,7 @@ import FleetSnapshots from './FleetSnapshots';
|
||||
import { FleetConfiguration } from './fleet/FleetConfiguration';
|
||||
import { FleetSoonPlaceholder } from './fleet/FleetSoonPlaceholder';
|
||||
import { RoutingTab } from './fleet/RoutingTab';
|
||||
import { FederationTab } from './fleet/FederationTab';
|
||||
import { DeploymentsTab } from './blueprints/DeploymentsTab';
|
||||
import { FleetActionsTab } from './fleet/FleetActions/FleetActionsTab';
|
||||
|
||||
@@ -94,6 +95,13 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
)}
|
||||
{isAdmiral && (
|
||||
<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" />Fleet Actions
|
||||
@@ -101,11 +109,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
</TabsHighlightItem>
|
||||
{experimental && (
|
||||
<>
|
||||
<TabsHighlightItem value="federation">
|
||||
<TabsTrigger value="federation">
|
||||
<Network className="w-4 h-4 mr-1.5" />Federation
|
||||
</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
<span aria-hidden className="self-center mx-1 h-4 w-px bg-border" />
|
||||
<TabsHighlightItem value="secrets">
|
||||
<TabsTrigger value="secrets">
|
||||
<KeyRound className="w-4 h-4 mr-1.5" />Secrets
|
||||
@@ -162,6 +166,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
updatingNodeId={updateStatus.updatingNodeId}
|
||||
onRetryUpdate={updateStatus.retryNodeUpdate}
|
||||
onDismissUpdate={updateStatus.dismissNodeUpdate}
|
||||
onCordonChange={() => { void overview.fetchOverview(true); }}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
@@ -183,30 +188,26 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
</AdmiralGate>
|
||||
</TabsContent>
|
||||
)}
|
||||
{isAdmiral && (
|
||||
<TabsContent value="federation">
|
||||
<AdmiralGate>
|
||||
<FederationTab />
|
||||
</AdmiralGate>
|
||||
</TabsContent>
|
||||
)}
|
||||
<TabsContent value="actions">
|
||||
<FleetActionsTab nodes={overview.allNodes} />
|
||||
</TabsContent>
|
||||
{experimental && (
|
||||
<>
|
||||
<TabsContent value="federation">
|
||||
<FleetSoonPlaceholder
|
||||
icon={<Network className="h-4 w-4" />}
|
||||
kicker="Federation"
|
||||
title="The fleet as one logical surface"
|
||||
description="Pin policies, drain a node for maintenance, weight-aware scheduling. This stack runs on whichever node has capacity."
|
||||
plannedActions={['Pin policy', 'Drain node', 'Cordon', 'Capacity plan']}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="secrets">
|
||||
<FleetSoonPlaceholder
|
||||
icon={<KeyRound className="h-4 w-4" />}
|
||||
kicker="Secrets"
|
||||
title="One source of truth for env, creds and certs"
|
||||
description="Push to selected nodes, rotate centrally, audit who-saw-what. Solves silent drift across copies."
|
||||
plannedActions={['Sync env', 'Rotate', 'Audit', 'Pin to nodes']}
|
||||
/>
|
||||
</TabsContent>
|
||||
</>
|
||||
<TabsContent value="secrets">
|
||||
<FleetSoonPlaceholder
|
||||
icon={<KeyRound className="h-4 w-4" />}
|
||||
kicker="Secrets"
|
||||
title="One source of truth for env, creds and certs"
|
||||
description="Push to selected nodes, rotate centrally, audit who-saw-what. Solves silent drift across copies."
|
||||
plannedActions={['Sync env', 'Rotate', 'Audit', 'Pin to nodes']}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Lock, AlertTriangle, ShieldQuestion } from 'lucide-react';
|
||||
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 {
|
||||
@@ -17,6 +17,7 @@ interface BlueprintDeploymentTableProps {
|
||||
onWithdraw: (nodeId: number) => void;
|
||||
onAcceptStateReview: (nodeId: number) => void;
|
||||
onRetry: (nodeId: number) => void;
|
||||
pinnedNodeId?: number | null;
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<BlueprintDeploymentStatus, string> = {
|
||||
@@ -50,7 +51,7 @@ function statusDotClass(status: BlueprintDeploymentStatus): string {
|
||||
}
|
||||
|
||||
export function BlueprintDeploymentTable({
|
||||
deployments, classification, canEdit, busyNodeId, onWithdraw, onAcceptStateReview, onRetry,
|
||||
deployments, classification, canEdit, busyNodeId, onWithdraw, onAcceptStateReview, onRetry, pinnedNodeId = null,
|
||||
}: BlueprintDeploymentTableProps) {
|
||||
const { nodes } = useNodes();
|
||||
const nodesById = new Map(nodes.map(n => [n.id, n]));
|
||||
@@ -90,6 +91,15 @@ export function BlueprintDeploymentTable({
|
||||
<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">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Pencil, Play, Power, Trash2 } from 'lucide-react';
|
||||
import { Pencil, Pin, Play, Power, Trash2 } from 'lucide-react';
|
||||
import { SystemSheet, SheetSection } from '@/components/ui/system-sheet';
|
||||
import { Modal, ModalDestructiveHeader, ModalBody, ModalFooter } from '@/components/ui/modal';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -264,6 +264,18 @@ export function BlueprintDetail({ blueprintId, open, onOpenChange, onChanged, ca
|
||||
</SheetSection>
|
||||
) : (
|
||||
<>
|
||||
{blueprint.pinned_node_id !== null && (
|
||||
<SheetSection title="Pin" hideHeader>
|
||||
<div className="flex items-start gap-2 rounded-md border border-card-border bg-glass-highlight px-3 py-2">
|
||||
<Pin className="w-3.5 h-3.5 mt-0.5 text-foreground shrink-0" />
|
||||
<div className="text-xs text-stat-value">
|
||||
<span className="font-medium">Pinned to {nodes.find(n => n.id === blueprint.pinned_node_id)?.name ?? `node ${blueprint.pinned_node_id}`}.</span>{' '}
|
||||
<span className="text-stat-subtitle">Selector is overridden. Manage in the Federation tab.</span>
|
||||
</div>
|
||||
</div>
|
||||
</SheetSection>
|
||||
)}
|
||||
|
||||
{blueprint.description && (
|
||||
<SheetSection title="Description" hideHeader>
|
||||
<p className="text-xs text-stat-subtitle">{blueprint.description}</p>
|
||||
@@ -279,6 +291,7 @@ export function BlueprintDetail({ blueprintId, open, onOpenChange, onChanged, ca
|
||||
onWithdraw={openWithdraw}
|
||||
onAcceptStateReview={openAcceptStateReview}
|
||||
onRetry={handleRetryRow}
|
||||
pinnedNodeId={blueprint.pinned_node_id}
|
||||
/>
|
||||
</SheetSection>
|
||||
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user