diff --git a/.changeset/polite-remote-reconnect.md b/.changeset/polite-remote-reconnect.md new file mode 100644 index 0000000..7a8e1d3 --- /dev/null +++ b/.changeset/polite-remote-reconnect.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Keep the selected remote machine during transient reconnects instead of switching the web UI back to Local. diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 8eb9dbf..af68508 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -65,6 +65,7 @@ import { appStyles } from "./shared"; const PI_WEB_STATUS_REFRESH_MS = 15 * 60 * 1000; const PI_WEB_STATUS_DEFER_MS = 750; +const REMOTE_ROUTE_RESTORE_RETRY_DELAYS_MS = [1_000, 3_000, 8_000, 15_000, 30_000] as const; const GLOBAL_SHORTCUT_LISTENER_OPTIONS = { capture: true } as const; const THEME_AUTO_ON_VALUE = "auto:on"; const THEME_AUTO_OFF_VALUE = "auto:off"; @@ -154,6 +155,10 @@ export class PiWebApp extends LitElement { private routeRestoreSeq = 0; private routeRestoreDepth = 0; private restoringRouteTerminalId: string | undefined; + private pendingRemoteRouteRestore: AppRoute | undefined; + private remoteRouteRestoreTimer: number | undefined; + private remoteRouteRestoreAttempt = 0; + private remoteRouteRestoreInProgress = false; private readonly plugins = createPluginRegistry(); private readonly loadedMachinePluginIds = new Set(); private readonly machinePluginLoadPromises = new Map>(); @@ -169,6 +174,7 @@ export class PiWebApp extends LitElement { }); private readonly onPageShow = () => { this.appShell.repairViewportPosition(); + this.retryPendingRemoteRouteRestoreSoon(); }; private readonly onFocus = () => { this.appShell.repairViewportPosition(); @@ -176,6 +182,7 @@ export class PiWebApp extends LitElement { this.schedulePiWebStatusRefresh(); void this.refreshMachineActivities(); void this.refreshWorkspaceDeletionRuns(); + this.retryPendingRemoteRouteRestoreSoon(); }; private readonly onVisibilityChange = () => { if (document.visibilityState === "visible") { @@ -184,6 +191,7 @@ export class PiWebApp extends LitElement { this.schedulePiWebStatusRefresh(); void this.refreshMachineActivities(); void this.refreshWorkspaceDeletionRuns(); + this.retryPendingRemoteRouteRestoreSoon(); } }; private readonly onSystemLightThemeChange = () => { @@ -240,6 +248,7 @@ export class PiWebApp extends LitElement { this.clearScheduledPiWebStatusRefresh(); if (this.workspaceDeletionPollTimer !== undefined) window.clearInterval(this.workspaceDeletionPollTimer); this.workspaceDeletionPollTimer = undefined; + this.clearPendingRemoteRouteRestore(); super.disconnectedCallback(); } @@ -257,13 +266,16 @@ export class PiWebApp extends LitElement { this.restoreSettingsRoute(); const route = readRoute(); await this.machines.loadMachines(route.machineId); - const machineFallbackMessage = this.state.error; const effectiveRoute = this.routeForSelectedMachine(route); + const initialRouteMachineHealth = this.state.machineStatuses[effectiveRoute.machineId ?? "local"]; if (effectiveRoute !== route) this.replaceRouteAndClearWorkspaceQuery(effectiveRoute); await this.projects.loadProjects(); - if (machineFallbackMessage !== "" && this.state.error === "") this.setState({ error: machineFallbackMessage }); await this.withChatScrollTransition(() => this.restoreRouteFor(effectiveRoute, false)); - this.rememberCurrentMachineNavigation(); + if (this.shouldDeferRemoteRouteRestore(effectiveRoute, initialRouteMachineHealth)) this.deferRemoteRouteRestore(effectiveRoute); + else { + this.clearPendingRemoteRouteRestore(); + this.rememberCurrentMachineNavigation(); + } await this.refreshWorkspaceDeletionRuns(); } @@ -430,6 +442,116 @@ export class PiWebApp extends LitElement { setNamespacedQueryKey(TERMINAL_ROUTE_NAMESPACE, "terminal", undefined, { replace: true }); } + private shouldDeferRemoteRouteRestore(route: AppRoute, routeMachineHealth = this.state.machineStatuses[route.machineId ?? "local"]): boolean { + const machineId = route.machineId ?? "local"; + const machine = this.state.selectedMachine; + if (machineId === "local" || machine?.id !== machineId || machine.kind !== "remote") return false; + if (routeMachineHealth?.ok !== false) return false; + if (route.projectId === undefined || route.projectId === "") return this.state.projects.length === 0; + return this.state.selectedProject?.id !== route.projectId; + } + + private deferRemoteRouteRestore(route: AppRoute): void { + this.pendingRemoteRouteRestore = route; + this.remoteRouteRestoreAttempt = 0; + this.setRemoteRouteRestoreMessage(route); + this.schedulePendingRemoteRouteRestore(); + } + + private retryPendingRemoteRouteRestoreSoon(): void { + if (this.pendingRemoteRouteRestore === undefined) return; + this.schedulePendingRemoteRouteRestore(0); + } + + private schedulePendingRemoteRouteRestore(delayMs = remoteRouteRestoreRetryDelay(this.remoteRouteRestoreAttempt)): void { + if (this.pendingRemoteRouteRestore === undefined) return; + this.clearPendingRemoteRouteRestoreTimer(); + this.remoteRouteRestoreTimer = window.setTimeout(() => { + this.remoteRouteRestoreTimer = undefined; + void this.retryPendingRemoteRouteRestore(); + }, delayMs); + } + + private async retryPendingRemoteRouteRestore(): Promise { + if (this.remoteRouteRestoreInProgress) return; + const route = this.pendingRemoteRouteRestore; + if (route === undefined) return; + if (!this.pendingRemoteRouteRestoreStillCurrent(route)) { + this.clearPendingRemoteRouteRestore(); + return; + } + + this.remoteRouteRestoreInProgress = true; + try { + const machineId = route.machineId ?? "local"; + const health = await this.machines.refreshMachineHealth(machineId); + if (!this.pendingRemoteRouteRestoreStillCurrent(route)) return; + if (health?.ok !== true) { + this.scheduleNextRemoteRouteRestoreAttempt(route); + return; + } + + await this.machines.refreshMachineRuntime(machineId); + if (!this.pendingRemoteRouteRestoreStillCurrent(route)) return; + await this.projects.loadProjects(); + if (!this.pendingRemoteRouteRestoreStillCurrent(route)) return; + if (this.state.error !== "") { + this.scheduleNextRemoteRouteRestoreAttempt(route); + return; + } + + await this.withChatScrollTransition(() => this.restoreRouteFor(route, false)); + if (!this.pendingRemoteRouteRestoreStillCurrent(route)) return; + this.clearPendingRemoteRouteRestore(); + this.rememberCurrentMachineNavigation(); + await this.refreshWorkspaceDeletionRuns(); + } finally { + this.remoteRouteRestoreInProgress = false; + } + } + + private scheduleNextRemoteRouteRestoreAttempt(route: AppRoute): void { + this.remoteRouteRestoreAttempt += 1; + if (this.remoteRouteRestoreAttempt >= REMOTE_ROUTE_RESTORE_RETRY_DELAYS_MS.length) { + this.setRemoteRouteRestoreMessage(route, { exhausted: true }); + this.clearPendingRemoteRouteRestore(); + return; + } + this.setRemoteRouteRestoreMessage(route); + this.schedulePendingRemoteRouteRestore(); + } + + private setRemoteRouteRestoreMessage(route: AppRoute, options: { exhausted?: boolean } = {}): void { + const machineId = route.machineId ?? "local"; + const machineName = this.state.machines.find((machine) => machine.id === machineId)?.name ?? this.state.selectedMachine?.name ?? "Remote machine"; + const health = this.state.machineStatuses[machineId]; + const detail = health?.error ?? (this.state.error === "" ? undefined : this.state.error); + const prefix = options.exhausted === true + ? `${machineName} is still unavailable.` + : `${machineName} is unavailable; reconnecting…`; + this.setState({ error: `${prefix}${detail === undefined ? "" : ` ${detail}`}` }); + } + + private pendingRemoteRouteRestoreStillCurrent(route: AppRoute): boolean { + const machineId = route.machineId ?? "local"; + return machineId !== "local" + && this.pendingRemoteRouteRestore === route + && this.state.selectedMachine?.id === machineId + && this.state.machines.some((machine) => machine.id === machineId); + } + + private clearPendingRemoteRouteRestore(): void { + this.clearPendingRemoteRouteRestoreTimer(); + this.pendingRemoteRouteRestore = undefined; + this.remoteRouteRestoreAttempt = 0; + } + + private clearPendingRemoteRouteRestoreTimer(): void { + if (this.remoteRouteRestoreTimer === undefined) return; + window.clearTimeout(this.remoteRouteRestoreTimer); + this.remoteRouteRestoreTimer = undefined; + } + private async restoreRouteMachine(route: AppRoute, updateUrl: boolean): Promise { const routeMachineId = route.machineId ?? "local"; if (this.state.selectedMachine?.id === routeMachineId) return; @@ -740,6 +862,8 @@ export class PiWebApp extends LitElement { private handleMachineChange(previous: AppState, next: AppState): void { if ((previous.selectedMachine?.id ?? "local") === (next.selectedMachine?.id ?? "local")) return; + const pendingMachineId = this.pendingRemoteRouteRestore?.machineId ?? "local"; + if (pendingMachineId !== (next.selectedMachine?.id ?? "local")) this.clearPendingRemoteRouteRestore(); this.sessions.clearActiveSession(); this.realtime.close(); this.connectRealtime(); @@ -1675,6 +1799,11 @@ function machineScopedKey(machineId: string, value: string): string { return JSON.stringify([machineId, value]); } +function remoteRouteRestoreRetryDelay(attempt: number): number { + const index = Math.min(attempt, REMOTE_ROUTE_RESTORE_RETRY_DELAYS_MS.length - 1); + return REMOTE_ROUTE_RESTORE_RETRY_DELAYS_MS[index] ?? 30_000; +} + function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } diff --git a/src/client/src/controllers/machineController.test.ts b/src/client/src/controllers/machineController.test.ts index f062a70..40190d4 100644 --- a/src/client/src/controllers/machineController.test.ts +++ b/src/client/src/controllers/machineController.test.ts @@ -33,7 +33,7 @@ describe("MachineController", () => { vi.restoreAllMocks(); }); - it("falls back to the local machine when the routed remote machine is offline", async () => { + it("keeps the routed remote machine selected while its health is offline", async () => { let state: AppState = initialAppState(); const setState = (patch: Partial) => { state = { ...state, ...patch }; }; const updateUrl = vi.fn(); @@ -50,12 +50,12 @@ describe("MachineController", () => { await controller.loadMachines(remoteMachine.id); - expect(state.selectedMachine).toEqual(localMachine); + expect(state.selectedMachine).toEqual(remoteMachine); expect(state.machineStatuses[remoteMachine.id]).toEqual(offlineHealth); - expect(state.error).toContain("Remote is offline"); + expect(state.error).toContain("Remote is unavailable"); }); - it("records offline health when the routed remote health request rejects", async () => { + it("records offline health without falling back when the routed remote health request rejects", async () => { let state: AppState = initialAppState(); const setState = (patch: Partial) => { state = { ...state, ...patch }; }; const updateUrl = vi.fn(); @@ -68,9 +68,26 @@ describe("MachineController", () => { await controller.loadMachines(remoteMachine.id); - expect(state.selectedMachine).toEqual(localMachine); + expect(state.selectedMachine).toEqual(remoteMachine); expect(state.machineStatuses[remoteMachine.id]).toMatchObject({ machineId: remoteMachine.id, ok: false, status: "offline", error: "Internal Server Error" }); - expect(state.error).toContain("Remote is offline"); + expect(state.error).toContain("Remote is unavailable"); + }); + + it("falls back to local when the routed machine is no longer configured", async () => { + let state: AppState = initialAppState(); + const setState = (patch: Partial) => { state = { ...state, ...patch }; }; + const updateUrl = vi.fn(); + const projects = { loadProjects: vi.fn() }; + + vi.spyOn(api, "machines").mockResolvedValue([localMachine]); + vi.spyOn(api, "health").mockResolvedValue({ machineId: "local", ok: true, checkedAt: "2026-05-26T00:00:01.000Z", status: "online" }); + + const controller = new MachineController(() => state, setState, updateUrl, projects); + + await controller.loadMachines(remoteMachine.id); + + expect(state.selectedMachine).toEqual(localMachine); + expect(state.error).toBe(""); }); it("returns the fallback machine without selecting it when requested", async () => { diff --git a/src/client/src/controllers/machineController.ts b/src/client/src/controllers/machineController.ts index 1b92520..a53221a 100644 --- a/src/client/src/controllers/machineController.ts +++ b/src/client/src/controllers/machineController.ts @@ -88,12 +88,14 @@ export class MachineController { } } - async refreshMachineHealth(machineId = this.getState().selectedMachine?.id ?? "local"): Promise { + async refreshMachineHealth(machineId = this.getState().selectedMachine?.id ?? "local"): Promise { try { const health = await api.health(machineId); this.setState({ machineStatuses: { ...this.getState().machineStatuses, [health.machineId]: health } }); + return health; } catch (error) { this.setState({ error: String(error) }); + return undefined; } } @@ -108,17 +110,15 @@ export class MachineController { private async selectInitialMachine(machines: Machine[], routeMachineId?: string): Promise { const requestedMachine = machines.find((machine) => machine.id === (routeMachineId ?? "local")); - if (requestedMachine?.kind !== "remote") return requestedMachine ?? this.localMachine(machines); + if (requestedMachine === undefined) return this.localMachine(machines); + if (requestedMachine.kind !== "remote") return requestedMachine; const health = await this.safeRemoteHealth(requestedMachine); - if (health.ok) return requestedMachine; - - const local = this.localMachine(machines); this.setState({ - error: `${requestedMachine.name} is offline; showing ${local?.name ?? "another machine"} instead.`, machineStatuses: { ...this.getState().machineStatuses, [health.machineId]: health }, + ...(health.ok ? {} : { error: `${requestedMachine.name} is unavailable; reconnecting…` }), }); - return local ?? requestedMachine; + return requestedMachine; } private async safeRemoteHealth(machine: Machine): Promise {