mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-01 05:07:59 +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:
@@ -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