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:
Anso
2026-05-14 10:21:08 -04:00
committed by GitHub
parent e7a3b544c0
commit 8dd0fce621
11 changed files with 663 additions and 66 deletions
+18 -2
View File
@@ -15,9 +15,16 @@ import { SchedulerService } from '../services/SchedulerService';
import { MfaService } from '../services/MfaService';
import { MeshService } from '../services/MeshService';
import { BlueprintReconciler } from '../services/BlueprintReconciler';
import { applyPilotModeCapabilityFilter } from '../services/CapabilityRegistry';
import { PilotTunnelManager } from '../services/PilotTunnelManager';
import { invalidateRemoteMetaCache } from '../helpers/cacheInvalidation';
import { sweepStaleTempDirs as sweepStaleGitTempDirs } from '../services/GitSourceService';
import { PORT } from '../helpers/constants';
function isPilotMode(): boolean {
return process.env.SENCHO_MODE === 'pilot';
}
/**
* Pilot-agent hosts never run the first-run setup wizard, so the wizard
* path that normally generates `auth_jwt_secret` (routes/auth.ts) never
@@ -32,7 +39,7 @@ import { PORT } from '../helpers/constants';
* Returns true when a fresh secret was written, false otherwise.
*/
export function ensurePilotJwtSecret(): boolean {
if (process.env.SENCHO_MODE !== 'pilot') return false;
if (!isPilotMode()) return false;
const dbSvc = DatabaseService.getInstance();
if (dbSvc.getGlobalSettings().auth_jwt_secret) return false;
const generated = crypto.randomBytes(64).toString('hex');
@@ -59,6 +66,10 @@ export async function startServer(server: Server): Promise<void> {
ensurePilotJwtSecret();
if (isPilotMode()) {
applyPilotModeCapabilityFilter();
}
// Initialize the license service before any tier-gated code can run.
LicenseService.getInstance().initialize();
@@ -76,6 +87,11 @@ export async function startServer(server: Server): Promise<void> {
});
BlueprintReconciler.getInstance().start();
// Drop the cached /api/meta entry on tunnel reconnect so the next
// /api/nodes/:id/meta refetches fresh capabilities and version through
// the live loopback bridge instead of waiting for the 3-minute TTL.
PilotTunnelManager.getInstance().on('tunnel-up', invalidateRemoteMetaCache);
// Async initializers are independent of each other; run in parallel
// so total boot time is the slowest one rather than the sum.
await Promise.all([
@@ -92,7 +108,7 @@ export async function startServer(server: Server): Promise<void> {
console.warn('[Trivy] Temp dir sweep failed:', (err as Error).message);
});
const isPilotAgent = process.env.SENCHO_MODE === 'pilot';
const isPilotAgent = isPilotMode();
const listenHost = isPilotAgent ? '127.0.0.1' : undefined;
server.listen(PORT, listenHost, () => {