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:
Anso
2026-05-07 05:55:00 -04:00
committed by GitHub
parent 77d5ff58d3
commit 52b46753af
18 changed files with 897 additions and 35 deletions
+26 -25
View File
@@ -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>
+107 -2
View File
@@ -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>
);
}