mirror of https://github.com/openclaw/openclaw.git
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:
parent
cbec476b6b
commit
39377b7a20
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue