mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 04:47:03 -06:00
fix: isolate debug diagnostics failures (#117546)
This commit is contained in:
@@ -65,6 +65,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Control UI debug diagnostics:** keep last-good status, health, model, and heartbeat snapshots visible when refreshes fail, show the failure inside Snapshots, isolate it from Manual RPC state, and prevent older manual calls from overwriting newer ones. Thanks @shakkernerd.
|
||||
- **Control UI read-only preferences:** keep personal preference edits browser-local without attempting unauthorized config writes or claiming server sync, preserve offline intent for a later authorized reconnect, and restore the current server value on local reset. Thanks @shakkernerd.
|
||||
- **Control UI owner handoff:** give browsers opened by host-issued dashboard and graphical onboarding links durable administrator access, including same-browser recovery from a limited credential, while keeping generic, Telegram, mobile, and ordinary scope-upgrade paths bounded. Thanks @shakkernerd.
|
||||
- **Control UI agent and skill permissions:** gate Agents, Skills, Skill Workshop, and delayed mutation dispatches by the current Gateway method catalog and operator scopes while preserving read-only browsing and legacy Gateway compatibility. Fixes #119176. Thanks @shakkernerd.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { consume } from "@lit/context";
|
||||
import { initialState, Task, TaskStatus } from "@lit/task";
|
||||
import { html, nothing } from "lit";
|
||||
import { html } from "lit";
|
||||
import { state } from "lit/decorators.js";
|
||||
import type { EventLogEntry } from "../../api/event-log.ts";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
@@ -188,6 +188,7 @@ class DebugPage extends OpenClawLightDomElement {
|
||||
health: this.debugHealth,
|
||||
models: this.debugModels,
|
||||
heartbeat: this.debugHeartbeat,
|
||||
diagnosticsError: this.debugDiagnosticsError,
|
||||
eventLog: this.eventLog,
|
||||
methods: (this.context.gateway.snapshot.hello?.features?.methods ?? []).toSorted(),
|
||||
callMethod: this.debugCallMethod,
|
||||
@@ -199,19 +200,13 @@ class DebugPage extends OpenClawLightDomElement {
|
||||
onRefresh: () => void this.loadDiagnostics(),
|
||||
onCall: () => void this.callDebugMethod(),
|
||||
});
|
||||
const body = html`
|
||||
${this.debugDiagnosticsError
|
||||
? html`<div class="callout danger" role="alert">${this.debugDiagnosticsError}</div>`
|
||||
: nothing}
|
||||
${debugView}
|
||||
`;
|
||||
return html`
|
||||
<section class="content-header">
|
||||
<div>
|
||||
<div class="page-title">${titleForRoute("debug")}</div>
|
||||
</div>
|
||||
</section>
|
||||
${renderSettingsWorkspace(body)}
|
||||
${renderSettingsWorkspace(debugView)}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
+88
-108
@@ -9,15 +9,22 @@ import "./debug-page.ts";
|
||||
import { renderDebug } from "./view.ts";
|
||||
|
||||
type DebugProps = Parameters<typeof renderDebug>[0];
|
||||
const DIAGNOSTIC_METHODS = ["status", "health", "models.list", "last-heartbeat"] as const;
|
||||
type DiagnosticMethod = (typeof DIAGNOSTIC_METHODS)[number];
|
||||
|
||||
type TestDebugPage = HTMLElement & {
|
||||
readonly updateComplete: Promise<boolean>;
|
||||
callDebugMethod: () => Promise<void>;
|
||||
context: ApplicationContext;
|
||||
debugCallError: string | null;
|
||||
debugCallMethod: string;
|
||||
debugCallResult: string | null;
|
||||
debugDiagnosticsError: string | null;
|
||||
debugHealth: unknown;
|
||||
debugHeartbeat: unknown;
|
||||
debugModels: unknown[];
|
||||
debugStatus: unknown;
|
||||
loadDiagnostics: () => Promise<void>;
|
||||
requestUpdate: () => void;
|
||||
updateComplete: Promise<boolean>;
|
||||
};
|
||||
|
||||
function deferred<T>() {
|
||||
@@ -34,13 +41,8 @@ async function mountDebugPage(
|
||||
request: (method: string) => Promise<unknown>,
|
||||
): Promise<TestDebugPage> {
|
||||
const client = { request } as unknown as GatewayBrowserClient;
|
||||
const snapshot = {
|
||||
phase: "connected",
|
||||
client,
|
||||
hello: { features: { methods: ["manual.first", "manual.latest"] } },
|
||||
} as ApplicationGatewaySnapshot;
|
||||
const gateway = {
|
||||
snapshot,
|
||||
snapshot: { phase: "connected", client } as ApplicationGatewaySnapshot,
|
||||
eventLog: [],
|
||||
subscribe: () => () => undefined,
|
||||
subscribeEventLog: () => () => undefined,
|
||||
@@ -49,35 +51,31 @@ async function mountDebugPage(
|
||||
page.context = { basePath: "", gateway } as ApplicationContext;
|
||||
document.body.append(page);
|
||||
await vi.waitFor(() => expect(page.debugStatus).not.toBeNull());
|
||||
await page.updateComplete;
|
||||
return page;
|
||||
}
|
||||
|
||||
function clickManualCall(page: TestDebugPage): void {
|
||||
const button = [...page.querySelectorAll("button")].find(
|
||||
(candidate) => candidate.textContent?.trim() === "Call",
|
||||
);
|
||||
if (!button) {
|
||||
throw new Error("Expected the rendered manual RPC Call button");
|
||||
}
|
||||
button.click();
|
||||
}
|
||||
|
||||
function diagnosticResponse(method: string): unknown {
|
||||
function diagnosticResponse(method: string, marker = "initial"): unknown {
|
||||
switch (method) {
|
||||
case "status":
|
||||
return { version: "healthy" };
|
||||
return { version: marker };
|
||||
case "health":
|
||||
return { ok: true };
|
||||
return { marker, ok: true };
|
||||
case "models.list":
|
||||
return { models: [] };
|
||||
return { models: [{ id: marker }] };
|
||||
case "last-heartbeat":
|
||||
return null;
|
||||
return { source: marker };
|
||||
default:
|
||||
throw new Error(`Unexpected diagnostics method: ${method}`);
|
||||
}
|
||||
}
|
||||
|
||||
function expectSnapshots(page: TestDebugPage, marker: string): void {
|
||||
expect(page.debugStatus).toEqual({ version: marker });
|
||||
expect(page.debugHealth).toEqual({ marker, ok: true });
|
||||
expect(page.debugModels).toEqual([{ id: marker }]);
|
||||
expect(page.debugHeartbeat).toEqual({ source: marker });
|
||||
}
|
||||
|
||||
function createProps(overrides: Partial<DebugProps> = {}): DebugProps {
|
||||
return {
|
||||
loading: false,
|
||||
@@ -85,6 +83,7 @@ function createProps(overrides: Partial<DebugProps> = {}): DebugProps {
|
||||
health: null,
|
||||
models: [],
|
||||
heartbeat: null,
|
||||
diagnosticsError: null,
|
||||
eventLog: [],
|
||||
methods: [],
|
||||
callMethod: "",
|
||||
@@ -103,18 +102,18 @@ function normalizedText(element: Element | null | undefined): string | undefined
|
||||
return element?.textContent?.replace(/\s+/gu, " ").trim();
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.stubGlobal("localStorage", createStorageMock());
|
||||
await i18n.setLocale("en");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
document.body.replaceChildren();
|
||||
await i18n.setLocale("en");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("renderDebug", () => {
|
||||
beforeEach(async () => {
|
||||
vi.stubGlobal("localStorage", createStorageMock());
|
||||
await i18n.setLocale("en");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
document.body.replaceChildren();
|
||||
await i18n.setLocale("en");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("keeps the security audit command styled as monospace", async () => {
|
||||
await i18n.setLocale("zh-CN");
|
||||
const container = document.createElement("div");
|
||||
@@ -167,7 +166,9 @@ describe("renderDebug", () => {
|
||||
expect(container.textContent).toContain("gateway");
|
||||
expect(container.textContent).not.toContain("Invalid Date");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DebugPage", () => {
|
||||
it.each([
|
||||
{ label: "response", staleError: false },
|
||||
{ label: "error", staleError: true },
|
||||
@@ -175,114 +176,93 @@ describe("renderDebug", () => {
|
||||
"ignores an older manual RPC $label after the latest call succeeds",
|
||||
async ({ staleError }) => {
|
||||
const older = deferred<unknown>();
|
||||
const latest = deferred<unknown>();
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "manual.first") {
|
||||
return older.promise;
|
||||
}
|
||||
if (method === "manual.latest") {
|
||||
return latest.promise;
|
||||
return { result: "latest response" };
|
||||
}
|
||||
return diagnosticResponse(method);
|
||||
});
|
||||
const page = await mountDebugPage(request);
|
||||
|
||||
page.debugCallMethod = "manual.first";
|
||||
await page.updateComplete;
|
||||
clickManualCall(page);
|
||||
const olderCall = page.callDebugMethod();
|
||||
page.debugCallMethod = "manual.latest";
|
||||
await page.updateComplete;
|
||||
clickManualCall(page);
|
||||
await vi.waitFor(() => expect(request).toHaveBeenCalledWith("manual.latest", {}));
|
||||
|
||||
latest.resolve({ result: "latest response" });
|
||||
await vi.waitFor(() => expect(page.textContent).toContain("latest response"));
|
||||
await page.callDebugMethod();
|
||||
if (staleError) {
|
||||
older.reject(new Error("stale manual failure"));
|
||||
} else {
|
||||
older.resolve({ result: "stale response" });
|
||||
}
|
||||
await older.promise.catch(() => undefined);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await page.updateComplete;
|
||||
await olderCall;
|
||||
|
||||
expect(page.textContent).toContain("latest response");
|
||||
expect(page.textContent).not.toContain("stale response");
|
||||
expect(page.textContent).not.toContain("stale manual failure");
|
||||
expect(page.debugCallResult).toContain("latest response");
|
||||
expect(page.debugCallResult).not.toContain("stale response");
|
||||
expect(page.debugCallError).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("reports polling failures separately from manual RPC and clears them on recovery", async () => {
|
||||
it.each(DIAGNOSTIC_METHODS)(
|
||||
"preserves every last-good snapshot and recovers after %s fails",
|
||||
async (failedMethod) => {
|
||||
let failure: DiagnosticMethod | null = null;
|
||||
let marker = "initial";
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === failure) {
|
||||
throw new Error(`${method} unavailable`);
|
||||
}
|
||||
return diagnosticResponse(method, marker);
|
||||
});
|
||||
const page = await mountDebugPage(request);
|
||||
expectSnapshots(page, "initial");
|
||||
|
||||
marker = "uncommitted";
|
||||
failure = failedMethod;
|
||||
await page.loadDiagnostics();
|
||||
await page.updateComplete;
|
||||
|
||||
expect(page.debugDiagnosticsError).toContain(`${failedMethod} unavailable`);
|
||||
expectSnapshots(page, "initial");
|
||||
const alert = page.querySelector<HTMLElement>('[role="alert"]');
|
||||
expect(alert?.closest(".settings-section")?.querySelector("h2")?.textContent.trim()).toBe(
|
||||
"Snapshots",
|
||||
);
|
||||
expect(alert?.classList).toContain("settings-row");
|
||||
expect(page.querySelector(".callout")).toBeNull();
|
||||
|
||||
marker = "recovered";
|
||||
failure = null;
|
||||
await page.loadDiagnostics();
|
||||
|
||||
expect(page.debugDiagnosticsError).toBeNull();
|
||||
expectSnapshots(page, "recovered");
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps failed Manual RPC state separate from diagnostics failure and recovery", async () => {
|
||||
let diagnosticsUnavailable = false;
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "manual.latest") {
|
||||
return { result: "manual response" };
|
||||
throw new Error("manual request failed");
|
||||
}
|
||||
if (method === "status" && diagnosticsUnavailable) {
|
||||
if (method === "health" && diagnosticsUnavailable) {
|
||||
throw new Error("background snapshots unavailable");
|
||||
}
|
||||
return diagnosticResponse(method);
|
||||
});
|
||||
const page = await mountDebugPage(request);
|
||||
page.debugCallMethod = "manual.latest";
|
||||
await page.updateComplete;
|
||||
clickManualCall(page);
|
||||
await vi.waitFor(() => expect(page.textContent).toContain("manual response"));
|
||||
await page.callDebugMethod();
|
||||
|
||||
expect(page.debugCallError).toContain("manual request failed");
|
||||
expect(page.debugDiagnosticsError).toBeNull();
|
||||
|
||||
diagnosticsUnavailable = true;
|
||||
await page.loadDiagnostics();
|
||||
await page.updateComplete;
|
||||
|
||||
expect(page.querySelector('[role="alert"]')?.textContent).toContain(
|
||||
"background snapshots unavailable",
|
||||
);
|
||||
expect(page.textContent).not.toContain("Call failed");
|
||||
expect(page.debugCallError).toBeNull();
|
||||
expect(page.debugCallResult).toContain("manual response");
|
||||
|
||||
diagnosticsUnavailable = false;
|
||||
await page.loadDiagnostics();
|
||||
await page.updateComplete;
|
||||
|
||||
expect(page.querySelector('[role="alert"]')).toBeNull();
|
||||
expect(page.textContent).toContain("manual response");
|
||||
});
|
||||
|
||||
it("clears a failed snapshots alert when the Gateway source changes", async () => {
|
||||
let diagnosticsUnavailable = false;
|
||||
const initialRequest = vi.fn(async (method: string) => {
|
||||
if (method === "status" && diagnosticsUnavailable) {
|
||||
throw new Error("old Gateway snapshots unavailable");
|
||||
}
|
||||
return diagnosticResponse(method);
|
||||
});
|
||||
const page = await mountDebugPage(initialRequest);
|
||||
diagnosticsUnavailable = true;
|
||||
await page.loadDiagnostics();
|
||||
await page.updateComplete;
|
||||
expect(page.querySelector('[role="alert"]')?.textContent).toContain(
|
||||
"old Gateway snapshots unavailable",
|
||||
);
|
||||
|
||||
const nextStatus = deferred<unknown>();
|
||||
const nextRequest = vi.fn(async (method: string) =>
|
||||
method === "status" ? nextStatus.promise : diagnosticResponse(method),
|
||||
);
|
||||
const nextClient = { request: nextRequest } as unknown as GatewayBrowserClient;
|
||||
const nextGateway = {
|
||||
...page.context.gateway,
|
||||
snapshot: { ...page.context.gateway.snapshot, client: nextClient },
|
||||
subscribe: () => () => undefined,
|
||||
subscribeEventLog: () => () => undefined,
|
||||
} as ApplicationContext["gateway"];
|
||||
page.context = { basePath: "", gateway: nextGateway } as ApplicationContext;
|
||||
page.requestUpdate();
|
||||
await page.updateComplete;
|
||||
|
||||
expect(page.querySelector('[role="alert"]')).toBeNull();
|
||||
nextStatus.resolve({ version: "replacement" });
|
||||
await vi.waitFor(() => expect(page.debugStatus).toEqual({ version: "replacement" }));
|
||||
expect(page.debugDiagnosticsError).toContain("background snapshots unavailable");
|
||||
expect(page.debugCallError).toContain("manual request failed");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ type DebugProps = {
|
||||
health: Record<string, unknown> | null;
|
||||
models: unknown[];
|
||||
heartbeat: unknown;
|
||||
diagnosticsError: string | null;
|
||||
eventLog: readonly EventLogEntry[];
|
||||
methods: string[];
|
||||
callMethod: string;
|
||||
@@ -72,6 +73,22 @@ function renderSecurityRow(props: DebugProps) {
|
||||
});
|
||||
}
|
||||
|
||||
function renderDiagnosticsError(error: string | null) {
|
||||
if (!error) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<div class="settings-row" role="alert">
|
||||
<div class="settings-row__text">
|
||||
<span class="settings-row__title">
|
||||
${renderSettingsStatus({ kind: "danger", label: t("common.failed") })}
|
||||
</span>
|
||||
<span class="settings-row__desc">${error}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderEventRow(evt: EventLogEntry) {
|
||||
return renderSettingsRow({
|
||||
title: evt.event,
|
||||
@@ -94,7 +111,8 @@ export function renderDebug(props: DebugProps) {
|
||||
`,
|
||||
},
|
||||
html`
|
||||
${renderSecurityRow(props)} ${renderJsonRow(t("debug.status"), props.status)}
|
||||
${renderDiagnosticsError(props.diagnosticsError)} ${renderSecurityRow(props)}
|
||||
${renderJsonRow(t("debug.status"), props.status)}
|
||||
${renderJsonRow(t("debug.health"), props.health)}
|
||||
${renderJsonRow(t("debug.lastHeartbeat"), props.heartbeat)}
|
||||
`,
|
||||
|
||||
@@ -603,13 +603,14 @@ describe("gateway source replacement across reconnect with a reused client", ()
|
||||
expect(page.logsCursor).toBeNull();
|
||||
});
|
||||
|
||||
it("clears diagnostics loaded by the previous provider", async () => {
|
||||
it("clears diagnostics data and errors loaded by the previous provider", async () => {
|
||||
const client = {} as GatewayBrowserClient;
|
||||
const page = createPage("openclaw-debug-page", contextWithClient(client)) as TestPage & {
|
||||
debugStatus: unknown;
|
||||
debugHealth: unknown;
|
||||
debugModels: unknown[];
|
||||
debugHeartbeat: unknown;
|
||||
debugDiagnosticsError: string | null;
|
||||
};
|
||||
document.body.append(page);
|
||||
await page.updateComplete;
|
||||
@@ -617,6 +618,7 @@ describe("gateway source replacement across reconnect with a reused client", ()
|
||||
page.debugHealth = { ok: true };
|
||||
page.debugModels = [{ id: "old" }];
|
||||
page.debugHeartbeat = { provider: "old" };
|
||||
page.debugDiagnosticsError = "old diagnostics failure";
|
||||
|
||||
await replaceContext(page, client);
|
||||
|
||||
@@ -624,6 +626,7 @@ describe("gateway source replacement across reconnect with a reused client", ()
|
||||
expect(page.debugHealth).toBeNull();
|
||||
expect(page.debugModels).toEqual([]);
|
||||
expect(page.debugHeartbeat).toBeNull();
|
||||
expect(page.debugDiagnosticsError).toBeNull();
|
||||
});
|
||||
|
||||
it("discards diagnostics from a replaced provider that reuses its client", async () => {
|
||||
|
||||
Reference in New Issue
Block a user