mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-31 04:38:11 +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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user