From 52b46753afde31c094f238dc27f1d80ba5e34c18 Mon Sep 17 00:00:00 2001 From: Anso Date: Thu, 7 May 2026 05:55:00 -0400 Subject: [PATCH] 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. --- backend/src/__tests__/blueprints.test.ts | 152 ++++++++++++ backend/src/routes/blueprints.ts | 42 +++- backend/src/routes/fleet.ts | 24 ++ backend/src/routes/nodes.ts | 63 +++++ backend/src/services/BlueprintReconciler.ts | 27 ++- backend/src/services/DatabaseService.ts | 44 +++- backend/src/utils/audit-summaries.ts | 5 + docs/docs.json | 1 + docs/features/fleet-federation.mdx | 92 +++++++ frontend/src/components/FleetView.tsx | 51 ++-- .../src/components/FleetView/NodeCard.tsx | 109 ++++++++- .../src/components/FleetView/OverviewTab.tsx | 3 + frontend/src/components/FleetView/types.ts | 3 + .../blueprints/BlueprintDeploymentTable.tsx | 14 +- .../components/blueprints/BlueprintDetail.tsx | 15 +- .../src/components/fleet/FederationTab.tsx | 224 ++++++++++++++++++ frontend/src/lib/blueprintsApi.ts | 10 + frontend/src/lib/nodesApi.ts | 53 +++++ 18 files changed, 897 insertions(+), 35 deletions(-) create mode 100644 docs/features/fleet-federation.mdx create mode 100644 frontend/src/components/fleet/FederationTab.tsx create mode 100644 frontend/src/lib/nodesApi.ts diff --git a/backend/src/__tests__/blueprints.test.ts b/backend/src/__tests__/blueprints.test.ts index 46c1613d..ecf6181a 100644 --- a/backend/src/__tests__/blueprints.test.ts +++ b/backend/src/__tests__/blueprints.test.ts @@ -176,6 +176,158 @@ describe('BlueprintReconciler.computeDecision', () => { expect(decision.withdraw).toEqual([]); }); + it('skips new placements onto cordoned nodes (cordon filter)', () => { + const nodeId = seedNode(); + DatabaseService.getInstance().setNodeCordoned(nodeId, true, 'maintenance'); + const bp = seedBlueprint({ classification: 'stateless', nodeIds: [nodeId] }); + const reconciler = BlueprintReconciler.getInstance() as unknown as ReconcilerWithCompute; + const allNodes = DatabaseService.getInstance().getNodes(); + const decision = reconciler.computeDecision(bp, allNodes); + expect(decision.deploy).toEqual([]); + expect(decision.stateReview).toEqual([]); + }); + + it('skips state-review for stateful blueprints landing on cordoned nodes', () => { + const nodeId = seedNode(); + DatabaseService.getInstance().setNodeCordoned(nodeId, true, null); + const bp = seedBlueprint({ classification: 'stateful', nodeIds: [nodeId] }); + const reconciler = BlueprintReconciler.getInstance() as unknown as ReconcilerWithCompute; + const allNodes = DatabaseService.getInstance().getNodes(); + const decision = reconciler.computeDecision(bp, allNodes); + expect(decision.stateReview).toEqual([]); + expect(decision.deploy).toEqual([]); + }); + + it('still redeploys for revision drift on a cordoned node (existing deployment, not a new placement)', () => { + const nodeId = seedNode(); + DatabaseService.getInstance().setNodeCordoned(nodeId, true, null); + const bp = seedBlueprint({ classification: 'stateless', nodeIds: [nodeId] }); + DatabaseService.getInstance().upsertDeployment({ + blueprint_id: bp.id, + node_id: nodeId, + status: 'active', + applied_revision: bp.revision - 1, + }); + const reconciler = BlueprintReconciler.getInstance() as unknown as ReconcilerWithCompute; + const allNodes = DatabaseService.getInstance().getNodes(); + const decision = reconciler.computeDecision(bp, allNodes); + expect(decision.deploy.map((n: { id: number }) => n.id)).toContain(nodeId); + }); + + it('still drift-checks active deployments on a cordoned node', () => { + const nodeId = seedNode(); + DatabaseService.getInstance().setNodeCordoned(nodeId, true, null); + const bp = seedBlueprint({ classification: 'stateless', nodeIds: [nodeId] }); + DatabaseService.getInstance().upsertDeployment({ + blueprint_id: bp.id, + node_id: nodeId, + status: 'active', + applied_revision: bp.revision, + }); + const reconciler = BlueprintReconciler.getInstance() as unknown as ReconcilerWithCompute; + const allNodes = DatabaseService.getInstance().getNodes(); + const decision = reconciler.computeDecision(bp, allNodes); + expect(decision.check.map((n: { id: number }) => n.id)).toContain(nodeId); + }); + + it('honors pin override: desired set is exactly the pinned node, regardless of selector', () => { + const nodeA = seedNode(); + const nodeB = seedNode(); + const bp = seedBlueprint({ classification: 'stateless', nodeIds: [nodeA] }); + DatabaseService.getInstance().setBlueprintPinnedNode(bp.id, nodeB); + const refreshed = DatabaseService.getInstance().getBlueprint(bp.id)!; + const reconciler = BlueprintReconciler.getInstance() as unknown as ReconcilerWithCompute; + const allNodes = DatabaseService.getInstance().getNodes(); + const decision = reconciler.computeDecision(refreshed, allNodes); + expect(decision.deploy.map((n: { id: number }) => n.id)).toContain(nodeB); + expect(decision.deploy.map((n: { id: number }) => n.id)).not.toContain(nodeA); + }); + + it('pin overrides cordon: pinned blueprint deploys onto a cordoned node', () => { + const nodeId = seedNode(); + DatabaseService.getInstance().setNodeCordoned(nodeId, true, null); + const bp = seedBlueprint({ classification: 'stateless', nodeIds: [] }); + DatabaseService.getInstance().setBlueprintPinnedNode(bp.id, nodeId); + const refreshed = DatabaseService.getInstance().getBlueprint(bp.id)!; + const reconciler = BlueprintReconciler.getInstance() as unknown as ReconcilerWithCompute; + const allNodes = DatabaseService.getInstance().getNodes(); + const decision = reconciler.computeDecision(refreshed, allNodes); + expect(decision.deploy.map((n: { id: number }) => n.id)).toContain(nodeId); + }); + + it('pin to a non-existent node yields an empty desired set', () => { + const bp = seedBlueprint({ classification: 'stateless', nodeIds: [] }); + DatabaseService.getInstance().setBlueprintPinnedNode(bp.id, 999_999); + const refreshed = DatabaseService.getInstance().getBlueprint(bp.id)!; + const reconciler = BlueprintReconciler.getInstance() as unknown as ReconcilerWithCompute; + const allNodes = DatabaseService.getInstance().getNodes(); + const decision = reconciler.computeDecision(refreshed, allNodes); + expect(decision.deploy).toEqual([]); + expect(decision.stateReview).toEqual([]); + }); + + it('pin shrinks the desired set: stateless deployments on non-pinned nodes are queued for withdraw', () => { + const nodeA = seedNode(); + const nodeB = seedNode(); + const bp = seedBlueprint({ classification: 'stateless', nodeIds: [nodeA, nodeB] }); + DatabaseService.getInstance().upsertDeployment({ + blueprint_id: bp.id, node_id: nodeA, status: 'active', applied_revision: bp.revision, + }); + DatabaseService.getInstance().upsertDeployment({ + blueprint_id: bp.id, node_id: nodeB, status: 'active', applied_revision: bp.revision, + }); + DatabaseService.getInstance().setBlueprintPinnedNode(bp.id, nodeA); + const refreshed = DatabaseService.getInstance().getBlueprint(bp.id)!; + const reconciler = BlueprintReconciler.getInstance() as unknown as ReconcilerWithCompute; + const allNodes = DatabaseService.getInstance().getNodes(); + const decision = reconciler.computeDecision(refreshed, allNodes); + expect(decision.check.map((n: { id: number }) => n.id)).toContain(nodeA); + expect(decision.withdraw.map((n: { id: number }) => n.id)).toContain(nodeB); + expect(decision.evictBlocked).toEqual([]); + }); + + it('pin shrinks the desired set: stateful deployments on non-pinned nodes are queued for evict_blocked', () => { + const nodeA = seedNode(); + const nodeB = seedNode(); + const bp = seedBlueprint({ classification: 'stateful', nodeIds: [nodeA, nodeB] }); + DatabaseService.getInstance().upsertDeployment({ + blueprint_id: bp.id, node_id: nodeA, status: 'active', applied_revision: bp.revision, + }); + DatabaseService.getInstance().upsertDeployment({ + blueprint_id: bp.id, node_id: nodeB, status: 'active', applied_revision: bp.revision, + }); + DatabaseService.getInstance().setBlueprintPinnedNode(bp.id, nodeA); + const refreshed = DatabaseService.getInstance().getBlueprint(bp.id)!; + const reconciler = BlueprintReconciler.getInstance() as unknown as ReconcilerWithCompute; + const allNodes = DatabaseService.getInstance().getNodes(); + const decision = reconciler.computeDecision(refreshed, allNodes); + expect(decision.evictBlocked.map((n: { id: number }) => n.id)).toContain(nodeB); + expect(decision.withdraw).toEqual([]); + }); + + it('deleting the pinned node clears the pin from the blueprint', () => { + const nodeId = seedNode(); + const bp = seedBlueprint({ classification: 'stateless', nodeIds: [] }); + DatabaseService.getInstance().setBlueprintPinnedNode(bp.id, nodeId); + expect(DatabaseService.getInstance().getBlueprint(bp.id)!.pinned_node_id).toBe(nodeId); + DatabaseService.getInstance().deleteNode(nodeId); + expect(DatabaseService.getInstance().getBlueprint(bp.id)!.pinned_node_id).toBeNull(); + }); + + it('clearing the pin restores selector behavior on the next tick', () => { + const nodeA = seedNode(); + const nodeB = seedNode(); + const bp = seedBlueprint({ classification: 'stateless', nodeIds: [nodeA] }); + DatabaseService.getInstance().setBlueprintPinnedNode(bp.id, nodeB); + DatabaseService.getInstance().setBlueprintPinnedNode(bp.id, null); + const refreshed = DatabaseService.getInstance().getBlueprint(bp.id)!; + const reconciler = BlueprintReconciler.getInstance() as unknown as ReconcilerWithCompute; + const allNodes = DatabaseService.getInstance().getNodes(); + const decision = reconciler.computeDecision(refreshed, allNodes); + expect(decision.deploy.map((n: { id: number }) => n.id)).toContain(nodeA); + expect(decision.deploy.map((n: { id: number }) => n.id)).not.toContain(nodeB); + }); + it('matches via labels selector and respects label changes', () => { const nodeA = seedNode(); const nodeB = seedNode(); diff --git a/backend/src/routes/blueprints.ts b/backend/src/routes/blueprints.ts index d3c63322..a68dd1f0 100644 --- a/backend/src/routes/blueprints.ts +++ b/backend/src/routes/blueprints.ts @@ -1,6 +1,6 @@ import { Router, type Request, type Response } from 'express'; import { authMiddleware } from '../middleware/auth'; -import { requirePaid, requireAdmin, requireBody } from '../middleware/tierGates'; +import { requirePaid, requireAdmiral, requireAdmin, requireBody } from '../middleware/tierGates'; import { DatabaseService, type BlueprintSelector, @@ -441,6 +441,46 @@ blueprintsRouter.get('/:id/preview', (req: Request, res: Response): void => { } }); +blueprintsRouter.put('/:id/pin', async (req: Request, res: Response): Promise => { + if (!requireAdmiral(req, res)) return; + if (!requireAdmin(req, res)) return; + if (!requireBody(req, res)) return; + const id = parseIntParam(req, res, 'id'); + if (id === null) return; + const rawNodeId = (req.body as { nodeId?: unknown }).nodeId; + let nodeId: number | null; + if (rawNodeId === null) { + nodeId = null; + } else if (typeof rawNodeId === 'number' && Number.isInteger(rawNodeId) && rawNodeId > 0) { + nodeId = rawNodeId; + } else { + res.status(400).json({ error: 'nodeId must be a positive integer or null' }); + return; + } + try { + const blueprint = DatabaseService.getInstance().getBlueprint(id); + if (!blueprint) { res.status(404).json({ error: 'Blueprint not found' }); return; } + if (nodeId !== null) { + const node = DatabaseService.getInstance().getNode(nodeId); + if (!node) { res.status(404).json({ error: 'Node not found' }); return; } + } + const updated = DatabaseService.getInstance().setBlueprintPinnedNode(id, nodeId); + if (!updated) { res.status(404).json({ error: 'Blueprint not found' }); return; } + // Trigger immediate reconciliation so the pin takes effect without + // waiting for the next 60s tick. Errors here are logged but do not + // fail the request: the pin is already persisted. + if (updated.enabled) { + BlueprintReconciler.getInstance().reconcileOne(id).catch(err => { + console.warn('[Blueprints] post-pin reconcileOne failed:', err); + }); + } + res.json(updated); + } catch (error) { + console.error('[Blueprints] Pin error:', error); + res.status(500).json({ error: 'Failed to update blueprint pin' }); + } +}); + blueprintsRouter.post('/analyze', (req: Request, res: Response): void => { if (!requirePaid(req, res)) return; if (!requireBody(req, res)) return; diff --git a/backend/src/routes/fleet.ts b/backend/src/routes/fleet.ts index d608ca39..65e2f336 100644 --- a/backend/src/routes/fleet.ts +++ b/backend/src/routes/fleet.ts @@ -94,6 +94,9 @@ interface FleetNodeOverview { latency_ms?: number; last_successful_contact?: number | null; pilot_last_seen?: number | null; + cordoned: boolean; + cordoned_at: number | null; + cordoned_reason: string | null; } /** Resolve the version to compare nodes against (latest from GitHub, or gateway fallback). */ @@ -162,6 +165,9 @@ async function fetchLocalNodeOverview(node: Node): Promise { }, stacks, last_successful_contact: node.last_successful_contact ?? null, + cordoned: node.cordoned, + cordoned_at: node.cordoned_at, + cordoned_reason: node.cordoned_reason, }; } catch (error) { console.error(`[Fleet] Local node ${node.name} error:`, error); @@ -169,6 +175,9 @@ async function fetchLocalNodeOverview(node: Node): Promise { id: node.id, name: node.name, type: node.type, status: 'offline', stats: null, systemStats: null, stacks: null, last_successful_contact: node.last_successful_contact ?? null, + cordoned: node.cordoned, + cordoned_at: node.cordoned_at, + cordoned_reason: node.cordoned_reason, }; } } @@ -187,6 +196,9 @@ async function fetchRemoteNodeOverview(node: Node, db: DatabaseService): Promise stacks: null, last_successful_contact: node.pilot_last_seen ? Math.floor(node.pilot_last_seen / 1000) : null, pilot_last_seen: node.pilot_last_seen ? Math.floor(node.pilot_last_seen / 1000) : null, + cordoned: node.cordoned, + cordoned_at: node.cordoned_at, + cordoned_reason: node.cordoned_reason, }; } @@ -195,6 +207,9 @@ async function fetchRemoteNodeOverview(node: Node, db: DatabaseService): Promise id: node.id, name: node.name, type: node.type, status: 'offline', stats: null, systemStats: null, stacks: null, last_successful_contact: node.last_successful_contact ?? null, + cordoned: node.cordoned, + cordoned_at: node.cordoned_at, + cordoned_reason: node.cordoned_reason, }; } @@ -254,6 +269,9 @@ async function fetchRemoteNodeOverview(node: Node, db: DatabaseService): Promise last_successful_contact: isOnline ? Math.floor(completedAt / 1000) : node.last_successful_contact ?? null, + cordoned: node.cordoned, + cordoned_at: node.cordoned_at, + cordoned_reason: node.cordoned_reason, }; } catch (error) { console.error(`[Fleet] Remote node ${node.name} error:`, error); @@ -261,6 +279,9 @@ async function fetchRemoteNodeOverview(node: Node, db: DatabaseService): Promise id: node.id, name: node.name, type: node.type, mode: node.mode, status: 'offline', stats: null, systemStats: null, stacks: null, last_successful_contact: node.last_successful_contact ?? null, + cordoned: node.cordoned, + cordoned_at: node.cordoned_at, + cordoned_reason: node.cordoned_reason, }; } } @@ -345,6 +366,9 @@ fleetRouter.get('/overview', authMiddleware, async (_req: Request, res: Response stats: null, systemStats: null, stacks: null, + cordoned: nodes[i].cordoned, + cordoned_at: nodes[i].cordoned_at, + cordoned_reason: nodes[i].cordoned_reason, }; }); diff --git a/backend/src/routes/nodes.ts b/backend/src/routes/nodes.ts index f63d9ac6..8a1863ef 100644 --- a/backend/src/routes/nodes.ts +++ b/backend/src/routes/nodes.ts @@ -4,6 +4,7 @@ import crypto from 'crypto'; import { authMiddleware } from '../middleware/auth'; import { requirePermission } from '../middleware/permissions'; import { rejectApiTokenScope } from '../middleware/apiTokenScope'; +import { requireAdmiral } from '../middleware/tierGates'; import { DatabaseService } from '../services/DatabaseService'; import { NodeRegistry } from '../services/NodeRegistry'; import { CacheService } from '../services/CacheService'; @@ -255,6 +256,68 @@ nodesRouter.delete('/:id', async (req: Request, res: Response) => { } }); +nodesRouter.post('/:id/cordon', (req: Request, res: Response) => { + if (rejectApiTokenScope(req, res, NODE_SCOPE_MESSAGE)) return; + const nodeIdParam = req.params.id as string; + if (!requirePermission(req, res, 'node:manage', 'node', nodeIdParam)) return; + if (!requireAdmiral(req, res)) return; + const id = parseInt(nodeIdParam, 10); + if (!Number.isInteger(id) || id <= 0) { + res.status(400).json({ error: 'Invalid node id' }); + return; + } + const rawReason = (req.body && typeof req.body === 'object') ? (req.body as { reason?: unknown }).reason : undefined; + let reason: string | null = null; + if (rawReason !== undefined && rawReason !== null) { + if (typeof rawReason !== 'string') { + res.status(400).json({ error: 'reason must be a string' }); + return; + } + const trimmed = rawReason.trim(); + if (trimmed.length > 256) { + res.status(400).json({ error: 'reason must be 256 characters or fewer' }); + return; + } + reason = trimmed.length > 0 ? trimmed : null; + } + try { + const existing = DatabaseService.getInstance().getNode(id); + if (!existing) { + res.status(404).json({ error: 'Node not found' }); + return; + } + const updated = DatabaseService.getInstance().setNodeCordoned(id, true, reason); + res.set('cache-control', 'no-store').json(updated); + } catch (error: unknown) { + console.error('Failed to cordon node:', error); + res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to cordon node' }); + } +}); + +nodesRouter.post('/:id/uncordon', (req: Request, res: Response) => { + if (rejectApiTokenScope(req, res, NODE_SCOPE_MESSAGE)) return; + const nodeIdParam = req.params.id as string; + if (!requirePermission(req, res, 'node:manage', 'node', nodeIdParam)) return; + if (!requireAdmiral(req, res)) return; + const id = parseInt(nodeIdParam, 10); + if (!Number.isInteger(id) || id <= 0) { + res.status(400).json({ error: 'Invalid node id' }); + return; + } + try { + const existing = DatabaseService.getInstance().getNode(id); + if (!existing) { + res.status(404).json({ error: 'Node not found' }); + return; + } + const updated = DatabaseService.getInstance().setNodeCordoned(id, false, null); + res.set('cache-control', 'no-store').json(updated); + } catch (error: unknown) { + console.error('Failed to uncordon node:', error); + res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to uncordon node' }); + } +}); + nodesRouter.post('/:id/test', async (req: Request, res: Response) => { try { const id = parseInt(req.params.id as string); diff --git a/backend/src/services/BlueprintReconciler.ts b/backend/src/services/BlueprintReconciler.ts index 13f4f759..ab05afec 100644 --- a/backend/src/services/BlueprintReconciler.ts +++ b/backend/src/services/BlueprintReconciler.ts @@ -154,8 +154,25 @@ export class BlueprintReconciler { } private computeDecision(blueprint: Blueprint, allNodes: Node[]): ReconcileDecision { - const labelSvc = NodeLabelService.getInstance(); - const desiredNodes = labelSvc.matchSelector(blueprint.selector, allNodes); + // Pin override: a pinned blueprint deploys only on its pinned node, + // regardless of the selector. The pinned node also wins over a + // cordon flag (pin is an explicit operator decision; cordon governs + // automatic placement only). + let desiredNodes: Node[]; + if (blueprint.pinned_node_id !== null) { + const pinned = allNodes.find(n => n.id === blueprint.pinned_node_id); + if (!pinned) { + console.warn( + `[BlueprintReconciler] blueprint "${blueprint.name}" pinned to node ${blueprint.pinned_node_id} which no longer exists; treating desired set as empty`, + ); + desiredNodes = []; + } else { + desiredNodes = [pinned]; + } + } else { + const labelSvc = NodeLabelService.getInstance(); + desiredNodes = labelSvc.matchSelector(blueprint.selector, allNodes); + } const desiredIds = new Set(desiredNodes.map(n => n.id)); const existingDeployments = DatabaseService.getInstance().listDeployments(blueprint.id); @@ -174,6 +191,12 @@ export class BlueprintReconciler { for (const node of desiredNodes) { const dep = deploymentByNode.get(node.id); if (!dep) { + // Cordon filter: skip new placements onto cordoned nodes. + // Pin always wins, so the pinned node is exempt. Existing + // deployments below are untouched: cordon does not evict. + if (node.cordoned && blueprint.pinned_node_id !== node.id) { + continue; + } if (blueprint.classification === 'stateful' || blueprint.classification === 'unknown') { decision.stateReview.push(node); } else { diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 8f41cf20..fd87810f 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -72,6 +72,9 @@ export interface Node { pilot_last_seen?: number | null; pilot_agent_version?: string | null; last_successful_contact?: number | null; + cordoned: boolean; + cordoned_at: number | null; + cordoned_reason: string | null; } export interface StackRestartSummary { @@ -275,6 +278,7 @@ export interface Blueprint { created_at: number; updated_at: number; created_by: string | null; + pinned_node_id: number | null; } export interface BlueprintDeployment { @@ -587,6 +591,8 @@ export class DatabaseService { this.migrateNodeLabels(); this.migrateBlueprints(); this.migrateAddNodeLastContact(); + this.migrateAddNodeCordonFields(); + this.migrateAddBlueprintPinnedNode(); // Reset the cache once at end of constructor in case any migration // populated it via getGlobalSettings() and a subsequent migration @@ -1411,6 +1417,16 @@ export class DatabaseService { this.tryAddColumn('nodes', 'last_successful_contact', 'INTEGER'); } + private migrateAddNodeCordonFields(): void { + this.tryAddColumn('nodes', 'cordoned', 'INTEGER NOT NULL DEFAULT 0'); + this.tryAddColumn('nodes', 'cordoned_at', 'INTEGER'); + this.tryAddColumn('nodes', 'cordoned_reason', 'TEXT'); + } + + private migrateAddBlueprintPinnedNode(): void { + this.tryAddColumn('blueprints', 'pinned_node_id', 'INTEGER'); + } + // --- Sencho Mesh --- public listMeshStacks(nodeId?: number): Array<{ id: number; node_id: number; stack_name: string; created_at: number; created_by: string | null }> { @@ -1888,11 +1904,14 @@ export class DatabaseService { pilot_last_seen: row.pilot_last_seen ?? null, pilot_agent_version: row.pilot_agent_version ?? null, last_successful_contact: row.last_successful_contact ?? null, + cordoned: row.cordoned === 1, + cordoned_at: row.cordoned_at ?? null, + cordoned_reason: row.cordoned_reason ?? null, }; } private static readonly NODE_COLUMNS = - 'id, name, type, compose_dir, is_default, status, created_at, api_url, api_token, mode, pilot_last_seen, pilot_agent_version, last_successful_contact'; + 'id, name, type, compose_dir, is_default, status, created_at, api_url, api_token, mode, pilot_last_seen, pilot_agent_version, last_successful_contact, cordoned, cordoned_at, cordoned_reason'; public getNodes(): Node[] { const stmt = this.db.prepare(`SELECT ${DatabaseService.NODE_COLUMNS} FROM nodes ORDER BY is_default DESC, name ASC`); @@ -1913,7 +1932,7 @@ export class DatabaseService { return this.decryptNodeRow(row); } - public addNode(node: Omit & { mode?: NodeMode }): number { + public addNode(node: Omit & { mode?: NodeMode }): number { if (node.is_default) { this.db.prepare('UPDATE nodes SET is_default = 0').run(); } @@ -1977,6 +1996,7 @@ export class DatabaseService { this.db.prepare('DELETE FROM stack_update_status WHERE node_id = ?').run(id); this.db.prepare('DELETE FROM stack_label_assignments WHERE node_id = ?').run(id); this.db.prepare('DELETE FROM stack_labels WHERE node_id = ?').run(id); + this.db.prepare('UPDATE blueprints SET pinned_node_id = NULL WHERE pinned_node_id = ?').run(id); this.deleteRoleAssignmentsByResource('node', String(id)); this.db.prepare('DELETE FROM nodes WHERE id = ?').run(id); })(); @@ -1986,6 +2006,25 @@ export class DatabaseService { this.db.prepare('UPDATE nodes SET status = ? WHERE id = ?').run(status, id); } + public setNodeCordoned(id: number, cordoned: boolean, reason: string | null): Node | undefined { + if (cordoned) { + this.db.prepare( + 'UPDATE nodes SET cordoned = 1, cordoned_at = ?, cordoned_reason = ? WHERE id = ?' + ).run(Date.now(), reason, id); + } else { + this.db.prepare( + 'UPDATE nodes SET cordoned = 0, cordoned_at = NULL, cordoned_reason = NULL WHERE id = ?' + ).run(id); + } + return this.getNode(id); + } + + public setBlueprintPinnedNode(blueprintId: number, nodeId: number | null): Blueprint | undefined { + this.db.prepare('UPDATE blueprints SET pinned_node_id = ?, updated_at = ? WHERE id = ?') + .run(nodeId, Date.now(), blueprintId); + return this.getBlueprint(blueprintId); + } + public updateNodeLastContact(nodeId: number): void { this.db.prepare('UPDATE nodes SET last_successful_contact = ? WHERE id = ?') .run(Math.floor(Date.now() / 1000), nodeId); @@ -3862,6 +3901,7 @@ export class DatabaseService { created_at: row.created_at as number, updated_at: row.updated_at as number, created_by: (row.created_by as string | null) ?? null, + pinned_node_id: (row.pinned_node_id as number | null) ?? null, }; } diff --git a/backend/src/utils/audit-summaries.ts b/backend/src/utils/audit-summaries.ts index 4602d997..3c0930f1 100644 --- a/backend/src/utils/audit-summaries.ts +++ b/backend/src/utils/audit-summaries.ts @@ -46,6 +46,8 @@ export const AUDIT_ROUTE_SUMMARIES: Record = { 'POST /nodes': 'Added node', 'PUT /nodes': 'Updated node', 'DELETE /nodes': 'Deleted node', + 'POST /nodes/*/cordon': 'Cordoned node', + 'POST /nodes/*/uncordon': 'Uncordoned node', // User management 'POST /users': 'Created user', @@ -126,6 +128,9 @@ export const AUDIT_ROUTE_SUMMARIES: Record = { // Auto-update 'POST /auto-update/execute': 'Executed auto-update', + + // Blueprints (Federation pin) + 'PUT /blueprints/*/pin': 'Updated blueprint pin', }; // Pre-sorted at module load: most specific patterns (by segment count) first. diff --git a/docs/docs.json b/docs/docs.json index 5336c7b1..e82288c0 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -129,6 +129,7 @@ "features/pilot-agent", "features/sencho-mesh", "features/fleet-view", + "features/fleet-federation", "features/fleet-actions", "features/fleet-sync", "features/fleet-backups", diff --git a/docs/features/fleet-federation.mdx b/docs/features/fleet-federation.mdx new file mode 100644 index 00000000..f392cc8b --- /dev/null +++ b/docs/features/fleet-federation.mdx @@ -0,0 +1,92 @@ +--- +title: "Fleet Federation" +description: "Operator-driven placement controls: cordon nodes and pin blueprints to specific nodes." +--- + +The **Federation** tab is a placement-control surface for fleets running [Blueprints](/features/blueprint-model). It lets you steer where new deployments land without rewriting selectors or labels: mark a node unschedulable for new work, or force a specific blueprint to remain on a specific node regardless of selector matches. + +Federation lives under **Fleet → Federation**. + + +Federation is an Admiral feature. The tab is hidden at the Community and Skipper tiers. Cordon and pin actions require an admin user role. + + +## Placement control, not placement automation + +Sencho's blueprint reconciler resolves selectors automatically: when a node grows a matching label, the blueprint deploys; when the label is removed, the blueprint is withdrawn (or `evict_blocked` for stateful workloads). That works well until you need to override the automatic decision: take a node out of rotation for maintenance, keep a stack pinned to one host, or hold ground while you migrate. Federation gives you those overrides as explicit operator actions. + +The model is deliberate: Sencho proposes placements; you confirm or override them. The reconciler never moves an existing deployment in response to cordon or pin. Cordon affects only *new* placements, and pin only changes which node the reconciler considers desired. Eviction from non-pinned nodes still flows through the existing state-review and confirmation prompts. + +## Cordon a node + +Cordon marks a node as unschedulable. From the moment a node is cordoned: + +- **New blueprint deployments skip it.** A blueprint whose selector matches the cordoned node will not deploy a fresh stack there. +- **Existing deployments on the node are unchanged.** Active stacks keep running. Drift checks keep running. Revision bumps still redeploy in place. The reconciler does not initiate a withdraw or evict because of a cordon. +- **The cordon is visible to everyone.** Lower-tier viewers see a "Cordoned" pill on the node card so they understand why the node is not picking up new work, even though they cannot toggle the state. + +To cordon a node, open the node's card on **Fleet → Overview**, click the kebab menu (`⋯`) in the top-right corner, and choose **Cordon node**. You can attach an optional one-line reason (up to 256 characters); it surfaces in the Federation tab summary and in the audit log. Use **Uncordon node** from the same menu to lift the restriction. + +| Without cordon | With cordon | +|---|---| +| Selector match → new stack deploys | Selector match → reconciler skips this node | +| Stack already deployed → drift-check + redeploy on revision | Same: existing deployment is unaffected | +| Stack leaves selector → withdraw / evict_blocked | Same: cordon does not change the selector | + +The Federation tab shows a read-only summary of currently cordoned nodes (name, type, when cordoned, optional reason). The action lives on the node card; the summary is for awareness. + +## Pin a blueprint to a node + +Pinning a blueprint forces the reconciler to treat that blueprint as desired only on a single specific node, regardless of what its selector says. Use a pin when: + +- You want one blueprint to stay on one host (a workload that depends on local state, a service that must run on the gateway node, a host-specific integration). +- You are migrating a blueprint between nodes and want to hold it on the destination while you tear down the source. +- You need to override an unintended selector match without rewriting the selector. + +Set or clear pins from **Fleet → Federation**, in the **Pin policy** table: + +| Column | What it shows | +|---|---| +| **Blueprint** | Name and short description. | +| **Selector** | The selector you would otherwise match against (kept for context). | +| **Pinned to** | A dropdown listing every node in the fleet plus an "(unpinned)" option. Changing it saves immediately and triggers a reconciliation. | +| **Effective** | The desired set the reconciler will actually use: the pinned node when set, the selector summary otherwise. | + +When a pin is set, the blueprint's [drift mode](/features/blueprint-model#drift-policy) and stateful classification still apply on the pinned node. Pin only changes *where* the blueprint is desired, not *how* it is reconciled there. + +The blueprint detail sheet renders a small read-only banner (`Pinned to . Selector is overridden.`) and the deployment table marks the pinned row with a "Pinned" indicator so the override is obvious anywhere a blueprint surfaces. + +### Pin overrides cordon + +Cordon governs automatic placement; pin is an explicit operator decision. When the two collide (a blueprint pinned to a cordoned node), pin wins. The blueprint stays on (or deploys onto) the pinned node even though the node is otherwise unschedulable. This keeps cordon's cost predictable: cordoning a node cannot silently break a workload you previously chose to anchor there. If you want to remove the workload too, unpin the blueprint or withdraw it explicitly. + +### Pinning a stateful blueprint + +Pinning a stateful blueprint that is currently deployed on multiple nodes shrinks the desired set to one node. On the next reconciliation tick, the non-pinned deployments enter `evict_blocked` and wait for an explicit eviction confirmation. This is the same flow that protects stateful workloads from automatic withdraw when a selector changes. Confirm each eviction from the deployment table or unpin the blueprint to restore the original desired set. + +## Out of scope + +Federation v1 ships the two controls above and nothing more. Items deliberately deferred: + +- **Drain node.** Evacuating all blueprints from a node depends on volume migration, which is operator-driven for stateful workloads in the current release. +- **Capacity planning.** Predictive resource utilisation belongs to a later iteration once there is real-world fleet usage data to calibrate against. + +## Troubleshooting + + + + Check whether the node is cordoned. Cordoned nodes are excluded from new placements; the Federation tab summary lists every cordoned node and the reason. Uncordon the node to re-enable automatic placement, or pin the blueprint explicitly to deploy onto a cordoned node. + + + The pin is the source of truth. Open Federation and confirm the pinned node matches your intent. The Effective column shows what the reconciler will use. Selector matches are ignored while a pin is set. + + + That is the stateful guard working as intended. The reconciler does not auto-evict stateful workloads; each leftover deployment must be confirmed from the deployment table, just like a selector change would require. Unpinning the blueprint restores the original desired set if you want to keep all of them. + + + Federation requires an Admiral license. On Skipper, Federation is hidden by design and the rest of the blueprint surface (catalog, deployments, drift) remains available. The cordoned pill on a node card is visible to all tiers; only the toggle is gated. + + + The pin clears automatically when its target node is removed from the fleet. The blueprint reverts to its selector behaviour on the next reconciliation tick. + + diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index 233d47e6..d72ec7ae 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -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) { )} + {isAdmiral && ( + + + Federation + + + )} Fleet Actions @@ -101,11 +109,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { {experimental && ( <> - - - Federation - - + Secrets @@ -162,6 +166,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { updatingNodeId={updateStatus.updatingNodeId} onRetryUpdate={updateStatus.retryNodeUpdate} onDismissUpdate={updateStatus.dismissNodeUpdate} + onCordonChange={() => { void overview.fetchOverview(true); }} /> @@ -183,30 +188,26 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { )} + {isAdmiral && ( + + + + + + )} {experimental && ( - <> - - } - 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']} - /> - - - } - 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']} - /> - - + + } + 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']} + /> + )} diff --git a/frontend/src/components/FleetView/NodeCard.tsx b/frontend/src/components/FleetView/NodeCard.tsx index 29b7b9b5..3563fc50 100644 --- a/frontend/src/components/FleetView/NodeCard.tsx +++ b/frontend/src/components/FleetView/NodeCard.tsx @@ -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(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 */}
{isLocal && ( - + ★ Local )} + {isAdmiral && ( +
+ + + + + + + + {node.cordoned ? 'Uncordon node' : 'Cordon node'} + + + +
+ )}
@@ -134,6 +197,15 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u Critical )} + {node.cordoned && ( + + Cordoned + + )}
@@ -219,6 +291,39 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u )}
+ { + 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 && ( +
+ + 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} + /> +
+ )} +
+ {/* Expandable Stack List with Container Drill-Down */} {isOnline && (
diff --git a/frontend/src/components/FleetView/OverviewTab.tsx b/frontend/src/components/FleetView/OverviewTab.tsx index 3c907540..b4d0d2ba 100644 --- a/frontend/src/components/FleetView/OverviewTab.tsx +++ b/frontend/src/components/FleetView/OverviewTab.tsx @@ -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} /> ))}
diff --git a/frontend/src/components/FleetView/types.ts b/frontend/src/components/FleetView/types.ts index 042a032e..f68f13d2 100644 --- a/frontend/src/components/FleetView/types.ts +++ b/frontend/src/components/FleetView/types.ts @@ -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 { diff --git a/frontend/src/components/blueprints/BlueprintDeploymentTable.tsx b/frontend/src/components/blueprints/BlueprintDeploymentTable.tsx index 79b8e12b..7814e028 100644 --- a/frontend/src/components/blueprints/BlueprintDeploymentTable.tsx +++ b/frontend/src/components/blueprints/BlueprintDeploymentTable.tsx @@ -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 = { @@ -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({
)} + {pinnedNodeId === dep.node_id && ( + + + Pinned + + )} diff --git a/frontend/src/components/blueprints/BlueprintDetail.tsx b/frontend/src/components/blueprints/BlueprintDetail.tsx index c8403039..403080ef 100644 --- a/frontend/src/components/blueprints/BlueprintDetail.tsx +++ b/frontend/src/components/blueprints/BlueprintDetail.tsx @@ -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 ) : ( <> + {blueprint.pinned_node_id !== null && ( + +
+ +
+ Pinned to {nodes.find(n => n.id === blueprint.pinned_node_id)?.name ?? `node ${blueprint.pinned_node_id}`}.{' '} + Selector is overridden. Manage in the Federation tab. +
+
+
+ )} + {blueprint.description && (

{blueprint.description}

@@ -279,6 +291,7 @@ export function BlueprintDetail({ blueprintId, open, onOpenChange, onChanged, ca onWithdraw={openWithdraw} onAcceptStateReview={openAcceptStateReview} onRetry={handleRetryRow} + pinnedNodeId={blueprint.pinned_node_id} />
diff --git a/frontend/src/components/fleet/FederationTab.tsx b/frontend/src/components/fleet/FederationTab.tsx new file mode 100644 index 00000000..2e618dff --- /dev/null +++ b/frontend/src/components/fleet/FederationTab.tsx @@ -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([]); + const [blueprints, setBlueprints] = useState([]); + const [loading, setLoading] = useState(true); + const [savingId, setSavingId] = useState(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(); + 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 ( +
+ + Loading federation state… +
+ ); + } + + return ( +
+
+
+ +
+
+
Federation
+

Placement control

+

+ 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. +

+
+
+ +
+
+
+ +

Cordoned nodes

+ + {cordonedNodes.length} of {nodes.length} + +
+ + Toggle on each node card + +
+
+ {cordonedNodes.length === 0 ? ( +

+ No nodes are cordoned. Use the kebab menu on any node card to mark it unschedulable. +

+ ) : ( +
    + {cordonedNodes.map(node => ( +
  • + +
    +
    + {node.name} + {node.type} + {node.cordoned_at && ( + + since {formatTimestamp(node.cordoned_at)} + + )} +
    + {node.cordoned_reason && ( +

    {node.cordoned_reason}

    + )} +
    +
  • + ))} +
+ )} +
+
+ +
+
+ +

Pin policy

+ + Force a blueprint onto a specific node, overriding its selector. + +
+
+ {blueprints.length === 0 ? ( +

+ No blueprints yet. Create one in the Deployments tab to manage placement here. +

+ ) : ( +
+ + + + + + + + + + + {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 ( + + + + + + + ); + })} + +
BlueprintSelectorPinned toEffective
+
{bp.name}
+ {bp.description && ( +
{bp.description}
+ )} +
+ {describeSelector(bp.selector)} + + + + {effective} +
+
+ )} +
+
+
+ ); +} diff --git a/frontend/src/lib/blueprintsApi.ts b/frontend/src/lib/blueprintsApi.ts index 7d0f6ed9..410053ff 100644 --- a/frontend/src/lib/blueprintsApi.ts +++ b/frontend/src/lib/blueprintsApi.ts @@ -33,6 +33,7 @@ export interface Blueprint { created_at: number; updated_at: number; created_by: string | null; + pinned_node_id: number | null; } export interface BlueprintListItem extends Blueprint { @@ -172,6 +173,15 @@ export async function applyBlueprint(id: number): Promise<{ message: string }> { return expectJson(res, 'Failed to apply blueprint'); } +export async function pinBlueprint(id: number, nodeId: number | null): Promise { + const res = await apiFetch(`/blueprints/${id}/pin`, { + method: 'PUT', + body: JSON.stringify({ nodeId }), + localOnly: true, + }); + return expectJson(res, 'Failed to update blueprint pin'); +} + export async function withdrawDeployment( blueprintId: number, nodeId: number, diff --git a/frontend/src/lib/nodesApi.ts b/frontend/src/lib/nodesApi.ts new file mode 100644 index 00000000..5f443c37 --- /dev/null +++ b/frontend/src/lib/nodesApi.ts @@ -0,0 +1,53 @@ +import { apiFetch } from './api'; + +export interface NodeRecord { + id: number; + name: string; + type: 'local' | 'remote'; + mode?: string; + compose_dir?: string; + is_default?: boolean; + status: 'online' | 'offline' | 'unknown'; + api_url?: string; + cordoned: boolean; + cordoned_at: number | null; + cordoned_reason: string | null; +} + +async function expectJson(res: Response, fallback: string): Promise { + if (!res.ok) { + let detail = fallback; + try { + const body = await res.json(); + if (body && typeof body === 'object' && typeof body.error === 'string') detail = body.error; + } catch { + // body not JSON + } + const err = new Error(detail) as Error & { status: number }; + err.status = res.status; + throw err; + } + return res.json() as Promise; +} + +export async function listNodes(): Promise { + const res = await apiFetch('/nodes', { localOnly: true }); + return expectJson(res, 'Failed to load nodes'); +} + +export async function cordonNode(id: number, reason: string | null): Promise { + const res = await apiFetch(`/nodes/${id}/cordon`, { + method: 'POST', + body: JSON.stringify(reason ? { reason } : {}), + localOnly: true, + }); + return expectJson(res, 'Failed to cordon node'); +} + +export async function uncordonNode(id: number): Promise { + const res = await apiFetch(`/nodes/${id}/uncordon`, { + method: 'POST', + localOnly: true, + }); + return expectJson(res, 'Failed to uncordon node'); +}