Archived
fix: preserve remote route during transient reconnects
This commit is contained in:
@@ -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.
|
||||
@@ -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<string>();
|
||||
private readonly machinePluginLoadPromises = new Map<string, Promise<void>>();
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<AppState>) => { 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<AppState>) => { 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<AppState>) => { 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 () => {
|
||||
|
||||
@@ -88,12 +88,14 @@ export class MachineController {
|
||||
}
|
||||
}
|
||||
|
||||
async refreshMachineHealth(machineId = this.getState().selectedMachine?.id ?? "local"): Promise<void> {
|
||||
async refreshMachineHealth(machineId = this.getState().selectedMachine?.id ?? "local"): Promise<MachineHealth | undefined> {
|
||||
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<Machine | undefined> {
|
||||
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<MachineHealth> {
|
||||
|
||||
Reference in New Issue
Block a user