From 826786b114a45ca5f9b341d5216911837fc55a12 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 08:04:10 +0100 Subject: [PATCH] feat: add control UI responsiveness diagnostics --- CHANGELOG.md | 1 + docs/web/control-ui.md | 1 + ui/src/ui/app-lifecycle.ts | 7 + ui/src/ui/app.ts | 1 + ui/src/ui/control-ui-performance.test.ts | 176 +++++++++++++++++++++++ ui/src/ui/control-ui-performance.ts | 117 +++++++++++++++ 6 files changed, 303 insertions(+) create mode 100644 ui/src/ui/control-ui-performance.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d06682602e..5f6ce2fb540 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai ### Changes - Gateway/startup: keep model-catalog test helpers, run-session lookup code, QR pairing helpers, and TypeBox memory-tool schema construction out of hot startup import paths, reducing default gateway benchmark plugin-load and memory pressure. +- Control UI/performance: record browser long animation frame or long task entries in the debug event log when supported, making slow dashboard renders easier to attribute from the UI. - Channels/streaming: add unified `streaming.mode: "progress"` drafts with auto single-word status labels and shared progress configuration across Discord, Telegram, Matrix, Slack, and Microsoft Teams. - Slack/streaming: add `streaming.progress.render: "rich"` for Block Kit progress drafts backed by structured progress line data. - Slack/streaming: keep the newest rich progress lines when Block Kit limits trim long progress drafts. Thanks @vincentkoc. diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index a896f7dcfc8..d3b0c029196 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -127,6 +127,7 @@ Imported themes are stored only in the current browser profile. They are not wri - Debug: status/health/models snapshots + event log + manual RPC calls (`status`, `health`, `models.list`). + - The event log includes Control UI refresh/RPC timings plus browser responsiveness entries for long animation frames or long tasks when the browser exposes those PerformanceObserver entry types. - Logs: live tail of gateway file logs with filter/export (`logs.tail`). - Update: run a package/git update + restart (`update.run`) with a restart report, then poll `update.status` after reconnect to verify the running gateway version. diff --git a/ui/src/ui/app-lifecycle.ts b/ui/src/ui/app-lifecycle.ts index 3ba28f2de9a..6166c512ac7 100644 --- a/ui/src/ui/app-lifecycle.ts +++ b/ui/src/ui/app-lifecycle.ts @@ -15,6 +15,7 @@ import { syncTabWithLocation, syncThemeWithSettings, } from "./app-settings.ts"; +import { startControlUiResponsivenessObserver } from "./control-ui-performance.ts"; import { loadControlUiBootstrapConfig } from "./controllers/control-ui-bootstrap.ts"; import type { Tab } from "./navigation.ts"; @@ -52,6 +53,7 @@ type LifecycleHost = { chatScrollTimeout?: number | null; logsScrollFrame?: number | null; controlUiTabPaintSeq?: number; + controlUiResponsivenessObserver?: { disconnect: () => void } | null; popStateHandler: () => void; topbarObserver: ResizeObserver | null; }; @@ -77,6 +79,9 @@ export function handleConnected(host: LifecycleHost) { if (host.tab === "debug") { startDebugPolling(host as unknown as Parameters[0]); } + host.controlUiResponsivenessObserver ??= startControlUiResponsivenessObserver( + host as unknown as Parameters[0], + ); } export function handleFirstUpdated(host: LifecycleHost) { @@ -120,6 +125,8 @@ export function handleDisconnected(host: LifecycleHost) { detachThemeListener(host as unknown as Parameters[0]); host.topbarObserver?.disconnect(); host.topbarObserver = null; + host.controlUiResponsivenessObserver?.disconnect(); + host.controlUiResponsivenessObserver = null; } export function handleUpdated(host: LifecycleHost, changed: Map) { diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 54431bc0cc8..6b366cde4fe 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -564,6 +564,7 @@ export class OpenClawApp extends LitElement { private logsPollInterval: number | null = null; private debugPollInterval: number | null = null; private logsScrollFrame: number | null = null; + private controlUiResponsivenessObserver: { disconnect: () => void } | null = null; private toolStreamById = new Map(); private toolStreamOrder: string[] = []; refreshSessionsAfterChat = new Set(); diff --git a/ui/src/ui/control-ui-performance.test.ts b/ui/src/ui/control-ui-performance.test.ts new file mode 100644 index 00000000000..d8688639e8b --- /dev/null +++ b/ui/src/ui/control-ui-performance.test.ts @@ -0,0 +1,176 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + recordControlUiPerformanceEvent, + startControlUiResponsivenessObserver, +} from "./control-ui-performance.ts"; + +const originalPerformanceObserver = globalThis.PerformanceObserver; + +type ObserverCallback = ConstructorParameters[0]; + +function installPerformanceObserverMock(options: { + supportedEntryTypes: string[]; + observe?: (options: PerformanceObserverInit) => void; +}) { + let callback: ObserverCallback | null = null; + const disconnect = vi.fn(); + class MockPerformanceObserver { + static supportedEntryTypes = options.supportedEntryTypes; + constructor(nextCallback: ObserverCallback) { + callback = nextCallback; + } + observe(observeOptions: PerformanceObserverInit) { + options.observe?.(observeOptions); + } + disconnect() { + disconnect(); + } + } + Object.defineProperty(globalThis, "PerformanceObserver", { + configurable: true, + value: MockPerformanceObserver, + }); + return { + disconnect, + emit(entries: PerformanceEntry[]) { + callback?.( + { + getEntries: () => entries, + } as PerformanceObserverEntryList, + {} as PerformanceObserver, + ); + }, + }; +} + +function createHost() { + return { + tab: "chat" as const, + eventLog: [] as Array<{ payload: Record }>, + eventLogBuffer: [] as Array<{ payload: Record }>, + }; +} + +afterEach(() => { + Object.defineProperty(globalThis, "PerformanceObserver", { + configurable: true, + value: originalPerformanceObserver, + }); +}); + +describe("recordControlUiPerformanceEvent", () => { + it("keeps the performance event buffer bounded", () => { + const host = createHost(); + + for (let i = 0; i < 260; i += 1) { + recordControlUiPerformanceEvent(host, "control-ui.test", { i }, { console: false }); + } + + expect(host.eventLogBuffer).toHaveLength(250); + expect(host.eventLogBuffer[0]?.payload).toEqual({ i: 259 }); + expect(host.eventLogBuffer.at(-1)?.payload).toEqual({ i: 10 }); + }); +}); + +describe("startControlUiResponsivenessObserver", () => { + it("records long animation frames with script attribution", () => { + const observe = vi.fn(); + const mock = installPerformanceObserverMock({ + supportedEntryTypes: ["longtask", "long-animation-frame"], + observe, + }); + const host = createHost(); + + const observer = startControlUiResponsivenessObserver(host); + mock.emit([ + { + name: "long-frame", + startTime: 12.4, + duration: 83.6, + blockingDuration: 42.2, + scripts: [ + { + duration: 12.1, + sourceURL: "http://localhost/assets/a.js?token=redacted", + }, + { + duration: 50.8, + invoker: "event-listener", + sourceURL: "http://localhost/assets/app.js?token=redacted#hash", + sourceFunctionName: "renderApp", + }, + ], + } as unknown as PerformanceEntry, + ]); + observer?.disconnect(); + + expect(observe).toHaveBeenCalledWith({ type: "long-animation-frame", buffered: true }); + expect(mock.disconnect).toHaveBeenCalledOnce(); + expect(host.eventLogBuffer).toEqual([ + expect.objectContaining({ + event: "control-ui.long-animation-frame", + payload: expect.objectContaining({ + tab: "chat", + name: "long-frame", + startTimeMs: 12, + durationMs: 84, + blockingDurationMs: 42, + scriptCount: 2, + topScript: { + durationMs: 51, + invoker: "event-listener", + sourceUrl: "/assets/app.js", + sourceFunctionName: "renderApp", + }, + }), + }), + ]); + }); + + it("falls back to long task entries when long animation frames are unavailable", () => { + const observe = vi.fn(); + const mock = installPerformanceObserverMock({ + supportedEntryTypes: ["longtask"], + observe, + }); + const host = createHost(); + + startControlUiResponsivenessObserver(host); + mock.emit([ + { + name: "self", + startTime: 5, + duration: 51, + } as unknown as PerformanceEntry, + { + name: "small", + startTime: 10, + duration: 49, + } as unknown as PerformanceEntry, + ]); + + expect(observe).toHaveBeenCalledWith({ type: "longtask", buffered: true }); + expect(host.eventLogBuffer).toEqual([ + expect.objectContaining({ + event: "control-ui.longtask", + payload: expect.objectContaining({ + name: "self", + durationMs: 51, + }), + }), + ]); + }); + + it("returns null when responsiveness entries are unsupported or observe fails", () => { + installPerformanceObserverMock({ supportedEntryTypes: [] }); + expect(startControlUiResponsivenessObserver(createHost())).toBeNull(); + + installPerformanceObserverMock({ + supportedEntryTypes: ["longtask"], + observe: () => { + throw new Error("unsupported"); + }, + }); + expect(startControlUiResponsivenessObserver(createHost())).toBeNull(); + }); +}); diff --git a/ui/src/ui/control-ui-performance.ts b/ui/src/ui/control-ui-performance.ts index 8a2ba8365f3..32cfb7a96ca 100644 --- a/ui/src/ui/control-ui-performance.ts +++ b/ui/src/ui/control-ui-performance.ts @@ -21,6 +21,28 @@ export type ControlUiRefreshRun = { const EVENT_LOG_LIMIT = 250; const SLOW_RPC_MS = 1_000; +const RESPONSIVENESS_ENTRY_MS = 50; + +type ControlUiResponsivenessObserver = { + disconnect: () => void; +}; + +type PerformanceObserverCtor = { + readonly supportedEntryTypes?: readonly string[]; + new (callback: PerformanceObserverCallback): PerformanceObserver; +}; + +type LongAnimationFrameScriptTiming = { + duration?: number; + invoker?: string; + sourceURL?: string; + sourceFunctionName?: string; +}; + +type ResponsivenessPerformanceEntry = PerformanceEntry & { + blockingDuration?: number; + scripts?: LongAnimationFrameScriptTiming[]; +}; export function controlUiNowMs(): number { return typeof performance !== "undefined" && typeof performance.now === "function" @@ -169,3 +191,98 @@ export function recordControlUiRpcTiming( { warn }, ); } + +function getPerformanceObserverCtor(): PerformanceObserverCtor | null { + const observer = globalThis.PerformanceObserver; + return typeof observer === "function" ? (observer as PerformanceObserverCtor) : null; +} + +function normalizeScriptSourceUrl(sourceUrl: string | undefined): string | undefined { + if (!sourceUrl) { + return undefined; + } + try { + const url = new URL(sourceUrl, globalThis.location?.href); + return url.pathname; + } catch { + return sourceUrl.split(/[?#]/, 1)[0]; + } +} + +function getTopLongAnimationFrameScript( + scripts: LongAnimationFrameScriptTiming[] | undefined, +): Record | undefined { + if (!Array.isArray(scripts) || scripts.length === 0) { + return undefined; + } + let topScript: LongAnimationFrameScriptTiming | undefined; + for (const script of scripts) { + if (!topScript || (script.duration ?? 0) > (topScript.duration ?? 0)) { + topScript = script; + } + } + if (!topScript) { + return undefined; + } + return { + durationMs: roundedControlUiDurationMs(topScript.duration ?? 0), + invoker: topScript.invoker, + sourceUrl: normalizeScriptSourceUrl(topScript.sourceURL), + sourceFunctionName: topScript.sourceFunctionName, + }; +} + +function recordResponsivenessEntry( + host: ControlUiPerformanceHost, + entryType: "long-animation-frame" | "longtask", + entry: ResponsivenessPerformanceEntry, +) { + const durationMs = roundedControlUiDurationMs(entry.duration); + if (durationMs < RESPONSIVENESS_ENTRY_MS) { + return; + } + recordControlUiPerformanceEvent( + host, + `control-ui.${entryType}`, + { + tab: host.tab, + name: entry.name, + startTimeMs: roundedControlUiDurationMs(entry.startTime), + durationMs, + blockingDurationMs: + typeof entry.blockingDuration === "number" + ? roundedControlUiDurationMs(entry.blockingDuration) + : undefined, + scriptCount: Array.isArray(entry.scripts) ? entry.scripts.length : undefined, + topScript: getTopLongAnimationFrameScript(entry.scripts), + }, + { warn: true }, + ); +} + +export function startControlUiResponsivenessObserver( + host: ControlUiPerformanceHost, +): ControlUiResponsivenessObserver | null { + const Observer = getPerformanceObserverCtor(); + const supportedEntryTypes = Observer?.supportedEntryTypes ?? []; + const entryType = supportedEntryTypes.includes("long-animation-frame") + ? "long-animation-frame" + : supportedEntryTypes.includes("longtask") + ? "longtask" + : null; + if (!Observer || !entryType) { + return null; + } + + const observer = new Observer((list) => { + for (const entry of list.getEntries() as ResponsivenessPerformanceEntry[]) { + recordResponsivenessEntry(host, entryType, entry); + } + }); + try { + observer.observe({ type: entryType, buffered: true }); + } catch { + return null; + } + return observer; +}