mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-27 04:11:01 +00:00
cb58cc423f
* fix(fleet): resolve Stop-by-label stack labels across all nodes The Fleet Actions "Stop by label" card only ever saw the control node's own stack labels. The stack-label routes are proxied, so each node stores its labels in its own database and the control holds no mirror for remote nodes. The suggestions, match-preview, and fleet-stop endpoints all read that nonexistent mirror, so remote-only labels were invisible and remote stacks were skipped before the remote was ever asked. Make all three authoritative across the fleet with a new collectFleetLabelSummaries helper: the local node reads its own database, and each remote is queried live through its labels and label-assignments endpoints over the proxy, with fail-closed parsing and per-node reachability. fleet-stop drops the mirror pre-check and always calls each reachable remote's local-stop receiver, reporting unreachable nodes at the node level so they never block the reachable ones. The picker now surfaces remote-only labels, aggregates shared names once with combined counts and the carrying node names, and flags incomplete coverage when a node is unreachable. The preview groups matches per node, lists unreachable nodes separately, and distinguishes no matching stacks from "label exists but no stacks" from "remote unavailable". * fix(fleet): harden Stop-by-label card against malformed responses Guard the match-preview and fleet-stop response bodies so a malformed but 200 reply degrades instead of crashing or misreporting. match-preview now validates the per-node shape before rendering (the preview reads it outside any try/catch) and logs a malformed body; fleet-stop distinguishes a non-array results body (a server bug, now logged and surfaced as an unexpected-response error) from a genuine empty fleet, and guards per-node stackResults. Docs: an unreachable or errored remote is reported once per node, not as a per-stack error row.
141 lines
6.5 KiB
TypeScript
141 lines
6.5 KiB
TypeScript
import { DatabaseService, type Node } from '../services/DatabaseService';
|
|
import { FileSystemService } from '../services/FileSystemService';
|
|
import { NodeRegistry } from '../services/NodeRegistry';
|
|
import { formatNoTargetError } from '../utils/remoteTarget';
|
|
import { getErrorMessage } from '../utils/errors';
|
|
|
|
export interface NodeLabelSummaryEntry {
|
|
name: string;
|
|
color: string;
|
|
stackNames: string[];
|
|
}
|
|
|
|
export interface NodeLabelSummary {
|
|
nodeId: number;
|
|
nodeName: string;
|
|
reachable: boolean;
|
|
labels: NodeLabelSummaryEntry[];
|
|
error?: string;
|
|
}
|
|
|
|
// Per-node fetch budget. Suggestions load on card mount and match-preview runs
|
|
// on a 500ms debounce, so keep this short: a wedged or slow remote should fall
|
|
// to "unreachable" fast rather than stall the whole fleet readout.
|
|
const SUMMARY_FETCH_TIMEOUT_MS = 5000;
|
|
|
|
/**
|
|
* Authoritative stack-label summary for one node's own labels.
|
|
*
|
|
* Stack names are filtered to those still present on disk, matching the
|
|
* `/api/labels/assignments` cleanup (routes/labels.ts) and runLocalLabelStop's
|
|
* validStacks filter (helpers/fleetLabelStop.ts), so the preview/suggestion
|
|
* counts equal the set a stop would actually act on.
|
|
*/
|
|
export async function readLocalLabelSummary(nodeId: number): Promise<NodeLabelSummaryEntry[]> {
|
|
const db = DatabaseService.getInstance();
|
|
const labels = db.getLabels(nodeId);
|
|
if (labels.length === 0) return [];
|
|
const fsStacks = new Set(await FileSystemService.getInstance(nodeId).getStacks());
|
|
return labels.map(label => ({
|
|
name: label.name,
|
|
color: label.color,
|
|
stackNames: db.getStacksForLabel(label.id, nodeId).filter(name => fsStacks.has(name)),
|
|
}));
|
|
}
|
|
|
|
// Fail-closed parser for a remote `/api/labels` body (Label[]). A malformed
|
|
// shape returns null so the caller marks the node unreachable rather than
|
|
// flowing partial data into the aggregate counts.
|
|
function parseRemoteLabels(value: unknown): { name: string; color: string }[] | null {
|
|
if (!Array.isArray(value)) return null;
|
|
const out: { name: string; color: string }[] = [];
|
|
for (const item of value) {
|
|
if (!item || typeof item !== 'object') return null;
|
|
const label = item as Record<string, unknown>;
|
|
if (typeof label.name !== 'string' || typeof label.color !== 'string') return null;
|
|
out.push({ name: label.name, color: label.color });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Fail-closed parser for a remote `/api/labels/assignments` body
|
|
// (Record<stackName, Label[]>). Collapses to label-name -> stack-name lists.
|
|
function parseRemoteAssignments(value: unknown): Map<string, string[]> | null {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
const stacksByLabel = new Map<string, string[]>();
|
|
for (const [stackName, labels] of Object.entries(value as Record<string, unknown>)) {
|
|
if (!Array.isArray(labels)) return null;
|
|
for (const label of labels) {
|
|
if (!label || typeof label !== 'object') return null;
|
|
const name = (label as Record<string, unknown>).name;
|
|
if (typeof name !== 'string') return null;
|
|
const list = stacksByLabel.get(name) ?? [];
|
|
list.push(stackName);
|
|
stacksByLabel.set(name, list);
|
|
}
|
|
}
|
|
return stacksByLabel;
|
|
}
|
|
|
|
async function summarizeRemoteNode(node: Node): Promise<NodeLabelSummary> {
|
|
const target = NodeRegistry.getInstance().getProxyTarget(node.id);
|
|
if (!target) {
|
|
return { nodeId: node.id, nodeName: node.name, reachable: false, labels: [], error: formatNoTargetError(node) };
|
|
}
|
|
const base = target.apiUrl.replace(/\/$/, '');
|
|
// Conditional Bearer only: pilot-agent targets carry an empty token and reach
|
|
// the remote over the loopback tunnel without an Authorization header.
|
|
const headers: Record<string, string> = {};
|
|
if (target.apiToken) headers.Authorization = `Bearer ${target.apiToken}`;
|
|
try {
|
|
const [labelsRes, assignmentsRes] = await Promise.all([
|
|
fetch(`${base}/api/labels`, { headers, signal: AbortSignal.timeout(SUMMARY_FETCH_TIMEOUT_MS) }),
|
|
fetch(`${base}/api/labels/assignments`, { headers, signal: AbortSignal.timeout(SUMMARY_FETCH_TIMEOUT_MS) }),
|
|
]);
|
|
if (!labelsRes.ok || !assignmentsRes.ok) {
|
|
// Surface the remote's own error body (e.g. a token/tier message) the same
|
|
// way the fleet-stop leg does, so a reached-but-erroring node reports its
|
|
// real cause rather than a bare status number.
|
|
const failed = labelsRes.ok ? assignmentsRes : labelsRes;
|
|
const errBody = (await failed.json().catch(() => ({}))) as { error?: string };
|
|
return { nodeId: node.id, nodeName: node.name, reachable: false, labels: [], error: errBody.error || `Remote returned ${failed.status}` };
|
|
}
|
|
const labels = parseRemoteLabels(await labelsRes.json().catch(() => null));
|
|
const stacksByLabel = parseRemoteAssignments(await assignmentsRes.json().catch(() => null));
|
|
if (!labels || !stacksByLabel) {
|
|
return { nodeId: node.id, nodeName: node.name, reachable: false, labels: [], error: 'Invalid label response from remote node' };
|
|
}
|
|
const summary = labels.map(label => ({
|
|
name: label.name,
|
|
color: label.color,
|
|
stackNames: stacksByLabel.get(label.name) ?? [],
|
|
}));
|
|
return { nodeId: node.id, nodeName: node.name, reachable: true, labels: summary };
|
|
} catch (err) {
|
|
return { nodeId: node.id, nodeName: node.name, reachable: false, labels: [], error: getErrorMessage(err, 'Failed to reach remote node') };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Authoritative fleet-wide stack-label state: the local node from its own DB,
|
|
* each remote queried live through its `/api/labels` + `/api/labels/assignments`
|
|
* endpoints via the proxy target. Replaces the old central-DB read, which only
|
|
* ever held the local node's labels (remote label rows are never mirrored to the
|
|
* control). A node that cannot be reached, or that answers with a non-ok or
|
|
* unusable response, is `reachable: false` with an error (its real cause) and
|
|
* never blocks the reachable ones; reachable nodes carry complete `labels`.
|
|
*/
|
|
export async function collectFleetLabelSummaries(): Promise<NodeLabelSummary[]> {
|
|
const nodes = DatabaseService.getInstance().getNodes();
|
|
return Promise.all(nodes.map(async (node): Promise<NodeLabelSummary> => {
|
|
if (node.type === 'local') {
|
|
try {
|
|
return { nodeId: node.id, nodeName: node.name, reachable: true, labels: await readLocalLabelSummary(node.id) };
|
|
} catch (err) {
|
|
return { nodeId: node.id, nodeName: node.name, reachable: false, labels: [], error: getErrorMessage(err, 'Failed to read local labels') };
|
|
}
|
|
}
|
|
return summarizeRemoteNode(node);
|
|
}));
|
|
}
|