feat(FN-2976): detect and display external Tailscale funnel tunnels in remo

Merges external Tailscale funnel detection (FN-2976) — adds types, detection logic, status inclusion, kill flow, and a dedicated UI panel for funnel processes started outside Fusion — alongside custom AI providers API routes and a new settings UI section (FN-2965).

Fusion-Task-Id: FN-2976
This commit is contained in:
Fusion
2026-04-30 05:41:25 -07:00
committed by gsxdsm
parent 3218c05776
commit 3fcf5f4d82
16 changed files with 506 additions and 8 deletions

View File

@@ -251,6 +251,49 @@ describe("remote access provider/lifecycle contracts", () => {
}));
});
it("returns 200 for remote status when managed tunnel is stopped", async () => {
const store = createMockStore({
getSettings: vi.fn().mockResolvedValue({
remoteAccess: buildRemoteAccessSettings({ activeProvider: "tailscale" }),
}),
});
const { app } = createApp({ store });
const status = await REQUEST(app, "GET", "/api/remote/status");
expect(status.status).toBe(200);
expect(status.body).toEqual(expect.objectContaining({ state: "stopped" }));
});
it("omits externalTunnel detection when managed tunnel is running", async () => {
const engine = {
getRemoteTunnelManager: vi.fn().mockReturnValue({
getStatus: vi.fn().mockReturnValue({ state: "running", provider: "tailscale", url: "https://live.ts.net/", lastError: null }),
}),
getRemoteTunnelRestoreDiagnostics: vi.fn().mockReturnValue(null),
detectExternalTunnel: vi.fn(),
};
const { app } = createApp({ engine });
const status = await REQUEST(app, "GET", "/api/remote/status");
expect(status.status).toBe(200);
expect(status.body.externalTunnel).toBeNull();
expect(engine.detectExternalTunnel).not.toHaveBeenCalled();
});
it("calls engine killExternalTunnel via kill-external endpoint", async () => {
const engine = {
killExternalTunnel: vi.fn().mockResolvedValue(undefined),
};
const { app } = createApp({ engine });
const result = await REQUEST(app, "POST", "/api/remote/tunnel/kill-external", {});
expect(result.status).toBe(200);
expect(result.body).toEqual({ ok: true });
});
it("installs cloudflared via endpoint and returns install command metadata", async () => {
const { app } = createApp();

View File

@@ -380,8 +380,8 @@ describe("createServer health and headless mode", () => {
const dashStatusBody = dashStatus.body as Record<string, unknown>;
const headlessStatusBody = headlessStatus.body as Record<string, unknown>;
expect(Object.keys(dashStatusBody).sort()).toEqual(["cloudflaredAvailable", "lastError", "lastErrorCode", "provider", "restore", "state", "url"]);
expect(Object.keys(headlessStatusBody).sort()).toEqual(["cloudflaredAvailable", "lastError", "lastErrorCode", "provider", "restore", "state", "url"]);
expect(Object.keys(dashStatusBody).sort()).toEqual(["cloudflaredAvailable", "externalTunnel", "lastError", "lastErrorCode", "provider", "restore", "state", "url"]);
expect(Object.keys(headlessStatusBody).sort()).toEqual(["cloudflaredAvailable", "externalTunnel", "lastError", "lastErrorCode", "provider", "restore", "state", "url"]);
expect(headlessRoot.status).toBe(404);
});
});