mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-24 17:36:42 +00:00
fix(fleet): show capabilities, version, metrics, and stacks for pilot-agent nodes (#1044)
When a pilot-agent node was the active node, the UI rendered "does not advertise this capability" across most tabs, a perpetual "Update available" badge, and a Fleet card body with blank CPU/RAM/Disk and "No stacks found". The cause was central-side aggregators in /api/fleet/* and /api/nodes/:id/meta only fanning out to proxy-mode remotes via node.api_url + node.api_token, which are null for pilot-agent. Route every affected aggregator through NodeRegistry.getProxyTarget so the loopback URL backed by the active pilot tunnel is used uniformly: - /api/nodes/:id/meta and /api/fleet/update-status fetch via the new NodeRegistry.fetchMetaForNode helper (resolves the target, delegates to fetchRemoteMeta, returns the shared OFFLINE_META on null). - fetchRemoteNodeOverview, /api/fleet/configuration, /api/fleet/node/:nodeId/stacks, and the stack-containers drilldown fetch through target.apiUrl with conditional Authorization. - fetchRemoteMeta omits the Authorization header when the token is empty (pilot-agent loopback) instead of sending a malformed Bearer string. - Pilot-agent rows preserve pilot_last_seen and mirror it into last_successful_contact so the Fleet "last seen" cell renders the recent tunnel timestamp during a brief reconnect. Pilot-mode capability filter excludes capabilities whose central-pilot path is not yet wired (host-console, self-update). Without this, the Console tab would surface for an Admiral pilot session and click through to central's host because the WS upgrade handler still gates on api_url + api_token. Filtered capabilities are removed at boot via applyPilotModeCapabilityFilter when SENCHO_MODE=pilot. Cache invalidation on tunnel-up: the meta cache for a reconnecting pilot is dropped so the next request rebuilds capabilities and version through the live bridge instead of waiting for the 3-minute TTL. The namespace constant moves to helpers/cacheInvalidation.ts alongside the new invalidateRemoteMetaCache helper. Husky commit-msg hook: add the missing shebang and a .gitattributes rule pinning .husky/* to LF line endings so commits do not fail with "Exec format error" on Windows shells where autocrlf=true converts the hook to CRLF. Tests cover Authorization-header behavior, pilot-mode filter idempotency, fetchMetaForNode dispatch (offline target, pilot-agent loopback, proxy-mode), and the four affected fleet routes for pilot-agent both when the tunnel is up and when it is down.
This commit is contained in:
@@ -79,7 +79,7 @@ export interface RemoteMeta {
|
||||
online: boolean;
|
||||
}
|
||||
|
||||
// Runtime capability overrides — services call disableCapability() during init
|
||||
// Runtime capability overrides; services call disableCapability() during init.
|
||||
const disabledCapabilities = new Set<Capability>();
|
||||
|
||||
export function disableCapability(c: Capability): void {
|
||||
@@ -96,11 +96,36 @@ export function getActiveCapabilities(): readonly string[] {
|
||||
return CAPABILITIES.filter(c => !disabledCapabilities.has(c));
|
||||
}
|
||||
|
||||
/**
|
||||
* Capabilities a pilot-agent process should hide from its own /api/meta because
|
||||
* the central->pilot path for them is not yet wired through the reverse tunnel.
|
||||
* Surfacing them would let the frontend offer a tab whose click silently falls
|
||||
* through to central's local handler.
|
||||
*/
|
||||
const PILOT_DISABLED_CAPABILITIES: readonly Capability[] = [
|
||||
'host-console',
|
||||
'self-update',
|
||||
];
|
||||
|
||||
/** Disable capabilities that require a central->pilot path that is not yet wired. */
|
||||
export function applyPilotModeCapabilityFilter(): void {
|
||||
for (const cap of PILOT_DISABLED_CAPABILITIES) disableCapability(cap);
|
||||
}
|
||||
|
||||
/** Shared offline shape returned when a remote node is unreachable. */
|
||||
export const OFFLINE_META: RemoteMeta = {
|
||||
version: null,
|
||||
capabilities: [],
|
||||
startedAt: null,
|
||||
updateError: null,
|
||||
online: false,
|
||||
};
|
||||
|
||||
/** Fetch /api/meta from a remote Sencho instance. Returns empty data on failure. */
|
||||
export async function fetchRemoteMeta(baseUrl: string, apiToken: string): Promise<RemoteMeta> {
|
||||
try {
|
||||
const res = await axios.get(`${baseUrl.replace(/\/$/, '')}/api/meta`, {
|
||||
headers: { Authorization: `Bearer ${apiToken}` },
|
||||
headers: apiToken ? { Authorization: `Bearer ${apiToken}` } : {},
|
||||
timeout: 5000,
|
||||
});
|
||||
const rawVersion: string | undefined = res.data.version;
|
||||
@@ -113,6 +138,6 @@ export async function fetchRemoteMeta(baseUrl: string, apiToken: string): Promis
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn(`[CapabilityRegistry] Failed to fetch meta from ${baseUrl}:`, (err as Error).message);
|
||||
return { version: null, capabilities: [], startedAt: null, updateError: null, online: false };
|
||||
return { ...OFFLINE_META };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import Docker from 'dockerode';
|
||||
import axios from 'axios';
|
||||
import { EventEmitter } from 'events';
|
||||
import { DatabaseService, Node } from './DatabaseService';
|
||||
import { fetchRemoteMeta } from './CapabilityRegistry';
|
||||
import { fetchRemoteMeta, OFFLINE_META, RemoteMeta } from './CapabilityRegistry';
|
||||
import { PilotTunnelManager } from './PilotTunnelManager';
|
||||
|
||||
/**
|
||||
@@ -119,6 +119,17 @@ export class NodeRegistry extends EventEmitter {
|
||||
return { apiUrl: node.api_url, apiToken: node.api_token };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch /api/meta from a remote node, dispatching through the proxy
|
||||
* target. Returns OFFLINE_META when no target is reachable (proxy-mode
|
||||
* missing api_url/api_token, or pilot-agent tunnel disconnected).
|
||||
*/
|
||||
public async fetchMetaForNode(nodeId: number): Promise<RemoteMeta> {
|
||||
const target = this.getProxyTarget(nodeId);
|
||||
if (!target) return { ...OFFLINE_META };
|
||||
return fetchRemoteMeta(target.apiUrl, target.apiToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connectivity to a specific node.
|
||||
* - Local: pings the Docker daemon directly
|
||||
|
||||
Reference in New Issue
Block a user