fix(control-ui): keep the dashboard mounted with a reconnect banner on gateway drops (#100479)

* fix(control-ui): keep the dashboard mounted with a reconnect banner on gateway drops

Once a session is established, a dropped gateway WebSocket no longer
unmounts the dashboard into the login gate. The client's close handler
now reports willRetry (the same fact that drives its reconnect
scheduling), the gateway store derives a `reconnecting` snapshot state
from it (everConnected && willRetry), and the app shell stays mounted
with an amber "Gateway connection lost - reconnecting" banner plus a
Retry now action while the client retries with backoff. The login gate
is reserved for first connects, credential rejections, and manual gate
submissions; event-gap recovery also no longer flashes the gate.

createApplicationGateway moved from bootstrap.ts to gateway-store.ts
with an injectable client factory for direct behavior tests. Adds the
previously unstyled `.callout.warn` variant and a "Connection loss and
reconnect" docs section.

Fixes #100475

* chore(i18n): regenerate control-ui locale bundles for connection banner strings

* chore(control-ui): unbreak CI - add reconnecting to overlays snapshot fixture, regenerate docs map

* chore(i18n): re-sync locale metadata after rebase onto refreshed main locales

* chore(i18n): refresh raw-copy baseline after rebase
This commit is contained in:
Peter Steinberger
2026-07-06 02:55:07 +01:00
committed by GitHub
parent 4a5bdc6ae3
commit e596e2850d
75 changed files with 954 additions and 340 deletions
+37
View File
@@ -446,6 +446,7 @@ describe("GatewayBrowserClient", () => {
);
expect(closeErrorDetails.code).toBe("BROWSER_WEBSOCKET_SECURITY_ERROR");
expect(closeErrorDetails.browserErrorName).toBe("SecurityError");
expect(close.willRetry).toBe(false);
expect(wsInstances).toHaveLength(0);
await vi.advanceTimersByTimeAsync(30_000);
@@ -483,6 +484,7 @@ describe("GatewayBrowserClient", () => {
expect(closeErrorDetails.code).toBe("BROWSER_WEBSOCKET_CONSTRUCTOR_ERROR");
expect(closeErrorDetails.browserErrorName).toBe("TypeError");
expect(closeErrorDetails.browserMessage).toBe("constructor failed");
expect(close.willRetry).toBe(false);
expect(wsInstances).toHaveLength(0);
await vi.advanceTimersByTimeAsync(30_000);
@@ -742,6 +744,7 @@ describe("GatewayBrowserClient", () => {
code: 1006,
reason: "socket lost",
error: undefined,
willRetry: true,
});
expect(consoleError).toHaveBeenCalledWith(
"[gateway] close handler error:",
@@ -1005,6 +1008,7 @@ describe("GatewayBrowserClient", () => {
retryable: false,
retryAfterMs: undefined,
},
willRetry: false,
});
} finally {
client.stop();
@@ -1102,6 +1106,7 @@ describe("GatewayBrowserClient", () => {
retryable: false,
retryAfterMs: undefined,
},
willRetry: false,
});
client.stop();
@@ -1326,6 +1331,38 @@ describe("GatewayBrowserClient", () => {
vi.useRealTimers();
});
it("reports willRetry=false on credential rejections so the UI can fall back to the login gate", async () => {
useNodeFakeTimers();
const onClose = vi.fn();
const client = new GatewayBrowserClient({
url: "ws://127.0.0.1:18789",
password: "wrong-password",
onClose,
});
const { ws, connectFrame } = await startConnect(client);
ws.emitMessage({
type: "res",
id: connectFrame.id,
ok: false,
error: {
code: "INVALID_REQUEST",
message: "unauthorized",
details: { code: "AUTH_PASSWORD_MISMATCH" },
},
});
await expectSocketClosed(ws);
ws.emitClose(4008, "connect failed");
const close = requireFirstMockArg(onClose, "close");
expect(close.willRetry).toBe(false);
await vi.advanceTimersByTimeAsync(30_000);
expect(wsInstances).toHaveLength(1);
vi.useRealTimers();
});
});
describe("shouldRetryWithDeviceToken", () => {
+24 -10
View File
@@ -298,7 +298,12 @@ export type GatewayBrowserClientOptions = {
instanceId?: string;
onHello?: (hello: GatewayHelloOk) => void;
onEvent?: (evt: GatewayEventFrame) => void;
onClose?: (info: { code: number; reason: string; error?: GatewayErrorInfo }) => void;
onClose?: (info: {
code: number;
reason: string;
error?: GatewayErrorInfo;
willRetry: boolean;
}) => void;
onGap?: (info: { expected: number; received: number }) => void;
onRequestTiming?: (timing: GatewayRequestTiming) => void;
onConnectTiming?: (timing: GatewayConnectTiming) => void;
@@ -549,6 +554,9 @@ export class GatewayBrowserClient {
? "security error"
: "websocket error",
error,
// Constructor failures (bad URL, mixed content) never resolve on
// their own; no reconnect is scheduled for them.
willRetry: false,
});
return;
}
@@ -579,15 +587,16 @@ export class GatewayBrowserClient {
return;
}
this.flushPending(new Error(`gateway closed (${ev.code}): ${reason}`));
this.notifyClose({ code: ev.code, reason, error: connectError });
const connectErrorCode = resolveGatewayErrorDetailCode(connectError);
if (connectErrorCode === ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH) {
if (this.pendingDeviceTokenRetry) {
this.scheduleReconnect();
}
return;
}
if (!isNonRecoverableAuthError(connectError)) {
// willRetry drives both the reconnect scheduling below and the app
// layer's "still reconnecting vs gave up" rendering; keep them in sync.
const willRetry =
!this.closed &&
(connectErrorCode === ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH
? this.pendingDeviceTokenRetry
: !isNonRecoverableAuthError(connectError));
this.notifyClose({ code: ev.code, reason, error: connectError, willRetry });
if (willRetry) {
this.scheduleReconnect();
}
});
@@ -985,7 +994,12 @@ export class GatewayBrowserClient {
}
}
private notifyClose(info: { code: number; reason: string; error?: GatewayErrorInfo }): void {
private notifyClose(info: {
code: number;
reason: string;
error?: GatewayErrorInfo;
willRetry: boolean;
}): void {
try {
this.opts.onClose?.(info);
} catch (err) {