UI: surface gateway restart reasons in dashboard disconnect state (#46580)

* UI: surface gateway shutdown reason

* UI: add gateway restart disconnect tests

* Changelog: add dashboard restart reason fix

* UI: cover reconnect shutdown state
This commit is contained in:
Vincent Koc 2026-03-14 14:31:26 -07:00 committed by GitHub
parent cbec476b6b
commit 39377b7a20
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 126 additions and 2 deletions

View File

@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai
- CI/channel test routing: move the built-in channel suites into `test:channels` and keep them out of `test:extensions`, so extension CI no longer fails after the channel migration while targeted test routing still sends Slack, Signal, and iMessage suites to the right lane. (#46066) Thanks @scoootscooob. - CI/channel test routing: move the built-in channel suites into `test:channels` and keep them out of `test:extensions`, so extension CI no longer fails after the channel migration while targeted test routing still sends Slack, Signal, and iMessage suites to the right lane. (#46066) Thanks @scoootscooob.
- Agents/usage tracking: stop forcing `supportsUsageInStreaming: false` on non-native openai-completions endpoints so providers like DashScope, DeepSeek, and other OpenAI-compatible backends report token usage and cost instead of showing all zeros. (#46142) - Agents/usage tracking: stop forcing `supportsUsageInStreaming: false` on non-native openai-completions endpoints so providers like DashScope, DeepSeek, and other OpenAI-compatible backends report token usage and cost instead of showing all zeros. (#46142)
- Node/startup: remove leftover debug `console.log("node host PATH: ...")` that printed the resolved PATH on every `openclaw node run` invocation. (#46411) - Node/startup: remove leftover debug `console.log("node host PATH: ...")` that printed the resolved PATH on every `openclaw node run` invocation. (#46411)
- Control UI/dashboard: preserve structured gateway shutdown reasons across restart disconnects so config-triggered restarts no longer fall back to `disconnected (1006): no reason`. (#46532) Thanks @vincentkoc.
## 2026.3.13 ## 2026.3.13

View File

@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { GATEWAY_EVENT_UPDATE_AVAILABLE } from "../../../src/gateway/events.js"; import { GATEWAY_EVENT_UPDATE_AVAILABLE } from "../../../src/gateway/events.js";
import { ConnectErrorDetailCodes } from "../../../src/gateway/protocol/connect-error-details.js"; import { ConnectErrorDetailCodes } from "../../../src/gateway/protocol/connect-error-details.js";
import { connectGateway, resolveControlUiClientVersion } from "./app-gateway.ts"; import { connectGateway, resolveControlUiClientVersion } from "./app-gateway.ts";
import type { GatewayHelloOk } from "./gateway.ts";
const loadChatHistoryMock = vi.hoisted(() => vi.fn(async () => undefined)); const loadChatHistoryMock = vi.hoisted(() => vi.fn(async () => undefined));
@ -9,6 +10,7 @@ type GatewayClientMock = {
start: ReturnType<typeof vi.fn>; start: ReturnType<typeof vi.fn>;
stop: ReturnType<typeof vi.fn>; stop: ReturnType<typeof vi.fn>;
options: { clientVersion?: string }; options: { clientVersion?: string };
emitHello: (hello?: GatewayHelloOk) => void;
emitClose: (info: { emitClose: (info: {
code: number; code: number;
reason?: string; reason?: string;
@ -39,6 +41,7 @@ vi.mock("./gateway.ts", () => {
constructor( constructor(
private opts: { private opts: {
clientVersion?: string; clientVersion?: string;
onHello?: (hello: GatewayHelloOk) => void;
onClose?: (info: { onClose?: (info: {
code: number; code: number;
reason: string; reason: string;
@ -52,6 +55,15 @@ vi.mock("./gateway.ts", () => {
start: this.start, start: this.start,
stop: this.stop, stop: this.stop,
options: { clientVersion: this.opts.clientVersion }, options: { clientVersion: this.opts.clientVersion },
emitHello: (hello) => {
this.opts.onHello?.(
hello ?? {
type: "hello-ok",
protocol: 3,
snapshot: {},
},
);
},
emitClose: (info) => { emitClose: (info) => {
this.opts.onClose?.({ this.opts.onClose?.({
code: info.code, code: info.code,
@ -356,6 +368,93 @@ describe("connectGateway", () => {
expect(host.lastErrorCode).toBe("AUTH_TOKEN_MISMATCH"); expect(host.lastErrorCode).toBe("AUTH_TOKEN_MISMATCH");
}); });
it("surfaces shutdown restart reasons before the socket closes", () => {
const host = createHost();
connectGateway(host);
const client = gatewayClientInstances[0];
expect(client).toBeDefined();
client.emitEvent({
event: "shutdown",
payload: {
reason: "config change requires gateway restart (plugins.installs)",
restartExpectedMs: 1500,
},
});
client.emitClose({ code: 1006 });
expect(host.lastError).toBe(
"Restarting: config change requires gateway restart (plugins.installs)",
);
expect(host.lastErrorCode).toBeNull();
});
it("clears pending shutdown messages on successful hello after reconnect", () => {
const host = createHost();
connectGateway(host);
const client = gatewayClientInstances[0];
expect(client).toBeDefined();
client.emitEvent({
event: "shutdown",
payload: {
reason: "config change",
restartExpectedMs: 1500,
},
});
client.emitClose({ code: 1006 });
expect(host.lastError).toBe("Restarting: config change");
client.emitHello();
expect(host.lastError).toBeNull();
client.emitClose({ code: 1006 });
expect(host.lastError).toBe("disconnected (1006): no reason");
});
it("keeps shutdown restart reasons on service restart closes", () => {
const host = createHost();
connectGateway(host);
const client = gatewayClientInstances[0];
expect(client).toBeDefined();
client.emitEvent({
event: "shutdown",
payload: {
reason: "gateway restarting",
restartExpectedMs: 1500,
},
});
client.emitClose({ code: 1012, reason: "service restart" });
expect(host.lastError).toBe("Restarting: gateway restarting");
expect(host.lastErrorCode).toBeNull();
});
it("prefers shutdown restart reasons over non-1012 close reasons", () => {
const host = createHost();
connectGateway(host);
const client = gatewayClientInstances[0];
expect(client).toBeDefined();
client.emitEvent({
event: "shutdown",
payload: {
reason: "gateway restarting",
restartExpectedMs: 1500,
},
});
client.emitClose({ code: 1001, reason: "going away" });
expect(host.lastError).toBe("Restarting: gateway restarting");
expect(host.lastErrorCode).toBeNull();
});
it("does not reload chat history for each live tool result event", () => { it("does not reload chat history for each live tool result event", () => {
const host = createHost(); const host = createHost();

View File

@ -91,6 +91,10 @@ type SessionDefaultsSnapshot = {
scope?: string; scope?: string;
}; };
type GatewayHostWithShutdownMessage = GatewayHost & {
pendingShutdownMessage?: string | null;
};
export function resolveControlUiClientVersion(params: { export function resolveControlUiClientVersion(params: {
gatewayUrl: string; gatewayUrl: string;
serverVersion: string | null; serverVersion: string | null;
@ -171,6 +175,8 @@ function applySessionDefaults(host: GatewayHost, defaults?: SessionDefaultsSnaps
} }
export function connectGateway(host: GatewayHost) { export function connectGateway(host: GatewayHost) {
const shutdownHost = host as GatewayHostWithShutdownMessage;
shutdownHost.pendingShutdownMessage = null;
host.lastError = null; host.lastError = null;
host.lastErrorCode = null; host.lastErrorCode = null;
host.hello = null; host.hello = null;
@ -195,6 +201,7 @@ export function connectGateway(host: GatewayHost) {
if (host.client !== client) { if (host.client !== client) {
return; return;
} }
shutdownHost.pendingShutdownMessage = null;
host.connected = true; host.connected = true;
host.lastError = null; host.lastError = null;
host.lastErrorCode = null; host.lastErrorCode = null;
@ -234,9 +241,10 @@ export function connectGateway(host: GatewayHost) {
: error.message; : error.message;
return; return;
} }
host.lastError = `disconnected (${code}): ${reason || "no reason"}`; host.lastError =
shutdownHost.pendingShutdownMessage ?? `disconnected (${code}): ${reason || "no reason"}`;
} else { } else {
host.lastError = null; host.lastError = shutdownHost.pendingShutdownMessage ?? null;
host.lastErrorCode = null; host.lastErrorCode = null;
} }
}, },
@ -347,6 +355,22 @@ function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) {
return; return;
} }
if (evt.event === "shutdown") {
const payload = evt.payload as { reason?: unknown; restartExpectedMs?: unknown } | undefined;
const reason =
payload && typeof payload.reason === "string" && payload.reason.trim()
? payload.reason.trim()
: "gateway stopping";
const shutdownMessage =
typeof payload?.restartExpectedMs === "number"
? `Restarting: ${reason}`
: `Disconnected: ${reason}`;
(host as GatewayHostWithShutdownMessage).pendingShutdownMessage = shutdownMessage;
host.lastError = shutdownMessage;
host.lastErrorCode = null;
return;
}
if (evt.event === "cron" && host.tab === "cron") { if (evt.event === "cron" && host.tab === "cron") {
void loadCron(host as unknown as Parameters<typeof loadCron>[0]); void loadCron(host as unknown as Parameters<typeof loadCron>[0]);
} }