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

@@ -42,6 +42,7 @@ const mockFetchRemoteStatus = vi.fn();
const mockInstallCloudflared = vi.fn();
const mockStartRemoteTunnel = vi.fn();
const mockStopRemoteTunnel = vi.fn();
const mockKillExternalTunnel = vi.fn();
const mockRegenerateRemotePersistentToken = vi.fn();
const mockGenerateShortLivedRemoteToken = vi.fn();
const mockFetchRemoteQr = vi.fn();
@@ -87,6 +88,7 @@ vi.mock("../../api", () => ({
installCloudflared: (...args: unknown[]) => mockInstallCloudflared(...args),
startRemoteTunnel: (...args: unknown[]) => mockStartRemoteTunnel(...args),
stopRemoteTunnel: (...args: unknown[]) => mockStopRemoteTunnel(...args),
killExternalTunnel: (...args: unknown[]) => mockKillExternalTunnel(...args),
regenerateRemotePersistentToken: (...args: unknown[]) => mockRegenerateRemotePersistentToken(...args),
generateShortLivedRemoteToken: (...args: unknown[]) => mockGenerateShortLivedRemoteToken(...args),
fetchRemoteQr: (...args: unknown[]) => mockFetchRemoteQr(...args),
@@ -302,6 +304,7 @@ describe("SettingsModal", () => {
mockInstallCloudflared.mockResolvedValue({ success: true, command: "brew install cloudflared" });
mockStartRemoteTunnel.mockResolvedValue({ state: "starting", provider: "tailscale" });
mockStopRemoteTunnel.mockResolvedValue({ state: "stopped", provider: null });
mockKillExternalTunnel.mockResolvedValue({ ok: true });
mockRegenerateRemotePersistentToken.mockResolvedValue({ token: "token", maskedToken: "****" });
mockGenerateShortLivedRemoteToken.mockResolvedValue({ token: "short", expiresAt: new Date(Date.now() + 60000).toISOString(), ttlMs: 60000 });
mockFetchRemoteQr.mockResolvedValue({ url: "https://remote.example.com", tokenType: "persistent", expiresAt: null, format: "image/svg", data: "<svg></svg>" });
@@ -1932,6 +1935,46 @@ describe("SettingsModal", () => {
});
});
it("shows external tunnel panel with actions when external tunnel is detected", async () => {
mockFetchRemoteStatus.mockResolvedValue({
provider: "tailscale",
state: "stopped",
url: null,
lastError: null,
externalTunnel: { provider: "tailscale", url: "https://machine.ts.net/" },
});
renderModal();
await waitForSettingsModalReady();
await openRemoteSection();
expect(await screen.findByText("External tailscale tunnel detected")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Start Fresh" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Use Existing" })).toBeInTheDocument();
});
it("Start Fresh kills external tunnel before starting", async () => {
mockFetchRemoteStatus.mockResolvedValue({
provider: "tailscale",
state: "stopped",
url: null,
lastError: null,
externalTunnel: { provider: "tailscale", url: "https://machine.ts.net/" },
});
renderModal();
await waitForSettingsModalReady();
await openRemoteSection();
await userEvent.click(screen.getByLabelText("Tailscale"));
await userEvent.click(await screen.findByRole("button", { name: "Start Fresh" }));
await waitFor(() => {
expect(mockKillExternalTunnel).toHaveBeenCalledWith(undefined);
expect(mockStartRemoteTunnel).toHaveBeenCalledWith(undefined);
});
});
it("regenerates persistent token and surfaces success feedback without exposing raw token text", async () => {
const addToast = vi.fn();
renderModal({ addToast });