mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-25 17:57:06 +00:00
8dd0fce621
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.
94 lines
3.1 KiB
TypeScript
94 lines
3.1 KiB
TypeScript
/**
|
|
* F9 regression guard for CapabilityRegistry:
|
|
*
|
|
* - fetchRemoteMeta omits the Authorization header when the apiToken is
|
|
* empty so the loopback bridge (used by pilot-agent proxy targets) is
|
|
* not handed a malformed `Bearer ` header.
|
|
* - applyPilotModeCapabilityFilter strips capabilities whose central->pilot
|
|
* path is not yet wired (host-console, self-update) so the frontend
|
|
* cannot offer them on a pilot-active session.
|
|
*/
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import axios from 'axios';
|
|
|
|
import {
|
|
CAPABILITIES,
|
|
applyPilotModeCapabilityFilter,
|
|
enableCapability,
|
|
fetchRemoteMeta,
|
|
getActiveCapabilities,
|
|
} from '../services/CapabilityRegistry';
|
|
|
|
describe('fetchRemoteMeta Authorization header', () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('sends Authorization: Bearer <token> when token is non-empty', async () => {
|
|
const getSpy = vi.spyOn(axios, 'get').mockResolvedValue({
|
|
data: { version: '0.76.7', capabilities: ['stacks'], startedAt: 1, updateError: null },
|
|
});
|
|
|
|
await fetchRemoteMeta('https://remote.example.com:1852', 'real-token');
|
|
|
|
expect(getSpy).toHaveBeenCalledTimes(1);
|
|
const init = getSpy.mock.calls[0][1] as { headers: Record<string, string> };
|
|
expect(init.headers).toEqual({ Authorization: 'Bearer real-token' });
|
|
});
|
|
|
|
it('omits Authorization entirely when token is empty (pilot-agent loopback)', async () => {
|
|
const getSpy = vi.spyOn(axios, 'get').mockResolvedValue({
|
|
data: { version: '0.76.7', capabilities: ['stacks'], startedAt: 1, updateError: null },
|
|
});
|
|
|
|
await fetchRemoteMeta('http://127.0.0.1:54321', '');
|
|
|
|
expect(getSpy).toHaveBeenCalledTimes(1);
|
|
const init = getSpy.mock.calls[0][1] as { headers: Record<string, string> };
|
|
expect(init.headers).toEqual({});
|
|
expect(init.headers).not.toHaveProperty('Authorization');
|
|
});
|
|
|
|
it('returns OFFLINE_META shape on transport failure', async () => {
|
|
vi.spyOn(axios, 'get').mockRejectedValue(new Error('connect ECONNREFUSED'));
|
|
|
|
const meta = await fetchRemoteMeta('http://127.0.0.1:54321', '');
|
|
|
|
expect(meta).toEqual({
|
|
version: null,
|
|
capabilities: [],
|
|
startedAt: null,
|
|
updateError: null,
|
|
online: false,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('applyPilotModeCapabilityFilter', () => {
|
|
afterEach(() => {
|
|
enableCapability('host-console');
|
|
enableCapability('self-update');
|
|
});
|
|
|
|
it('removes host-console and self-update from active capabilities', () => {
|
|
expect(CAPABILITIES).toContain('host-console');
|
|
expect(CAPABILITIES).toContain('self-update');
|
|
|
|
applyPilotModeCapabilityFilter();
|
|
const active = getActiveCapabilities();
|
|
|
|
expect(active).not.toContain('host-console');
|
|
expect(active).not.toContain('self-update');
|
|
expect(active).toContain('stacks');
|
|
});
|
|
|
|
it('is idempotent (safe to call multiple times)', () => {
|
|
applyPilotModeCapabilityFilter();
|
|
applyPilotModeCapabilityFilter();
|
|
const active = getActiveCapabilities();
|
|
|
|
expect(active).not.toContain('host-console');
|
|
expect(active.length).toBe(CAPABILITIES.length - 2);
|
|
});
|
|
});
|