mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 10:21:03 +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:
@@ -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();
|
||||
|
||||
@@ -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<void> => {
|
||||
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;
|
||||
|
||||
@@ -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<FleetNodeOverview> {
|
||||
},
|
||||
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<FleetNodeOverview> {
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Node, 'id' | 'status' | 'created_at' | 'mode'> & { mode?: NodeMode }): number {
|
||||
public addNode(node: Omit<Node, 'id' | 'status' | 'created_at' | 'mode' | 'cordoned' | 'cordoned_at' | 'cordoned_reason'> & { 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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,8 @@ export const AUDIT_ROUTE_SUMMARIES: Record<string, string> = {
|
||||
'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<string, string> = {
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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**.
|
||||
|
||||
<Note>
|
||||
Federation is an Admiral feature. The tab is hidden at the Community and Skipper tiers. Cordon and pin actions require an admin user role.
|
||||
</Note>
|
||||
|
||||
## 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 <node>. 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
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="A blueprint refuses to deploy on a node that matches its selector">
|
||||
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.
|
||||
</Accordion>
|
||||
<Accordion title="A pinned blueprint deployed somewhere unexpected">
|
||||
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.
|
||||
</Accordion>
|
||||
<Accordion title="Pinning a blueprint left rows in evict_blocked on the other nodes">
|
||||
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.
|
||||
</Accordion>
|
||||
<Accordion title="The Federation tab is not visible">
|
||||
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.
|
||||
</Accordion>
|
||||
<Accordion title="A pinned node was deleted">
|
||||
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.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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<Blueprint> {
|
||||
const res = await apiFetch(`/blueprints/${id}/pin`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ nodeId }),
|
||||
localOnly: true,
|
||||
});
|
||||
return expectJson<Blueprint>(res, 'Failed to update blueprint pin');
|
||||
}
|
||||
|
||||
export async function withdrawDeployment(
|
||||
blueprintId: number,
|
||||
nodeId: number,
|
||||
|
||||
@@ -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<T>(res: Response, fallback: string): Promise<T> {
|
||||
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<T>;
|
||||
}
|
||||
|
||||
export async function listNodes(): Promise<NodeRecord[]> {
|
||||
const res = await apiFetch('/nodes', { localOnly: true });
|
||||
return expectJson<NodeRecord[]>(res, 'Failed to load nodes');
|
||||
}
|
||||
|
||||
export async function cordonNode(id: number, reason: string | null): Promise<NodeRecord> {
|
||||
const res = await apiFetch(`/nodes/${id}/cordon`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(reason ? { reason } : {}),
|
||||
localOnly: true,
|
||||
});
|
||||
return expectJson<NodeRecord>(res, 'Failed to cordon node');
|
||||
}
|
||||
|
||||
export async function uncordonNode(id: number): Promise<NodeRecord> {
|
||||
const res = await apiFetch(`/nodes/${id}/uncordon`, {
|
||||
method: 'POST',
|
||||
localOnly: true,
|
||||
});
|
||||
return expectJson<NodeRecord>(res, 'Failed to uncordon node');
|
||||
}
|
||||
Reference in New Issue
Block a user