mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:20:43 +00:00
feat: add control UI responsiveness diagnostics
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -127,6 +127,7 @@ Imported themes are stored only in the current browser profile. They are not wri
|
||||
</Accordion>
|
||||
<Accordion title="Debug, logs, update">
|
||||
- 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.
|
||||
|
||||
|
||||
@@ -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<typeof startDebugPolling>[0]);
|
||||
}
|
||||
host.controlUiResponsivenessObserver ??= startControlUiResponsivenessObserver(
|
||||
host as unknown as Parameters<typeof startControlUiResponsivenessObserver>[0],
|
||||
);
|
||||
}
|
||||
|
||||
export function handleFirstUpdated(host: LifecycleHost) {
|
||||
@@ -120,6 +125,8 @@ export function handleDisconnected(host: LifecycleHost) {
|
||||
detachThemeListener(host as unknown as Parameters<typeof detachThemeListener>[0]);
|
||||
host.topbarObserver?.disconnect();
|
||||
host.topbarObserver = null;
|
||||
host.controlUiResponsivenessObserver?.disconnect();
|
||||
host.controlUiResponsivenessObserver = null;
|
||||
}
|
||||
|
||||
export function handleUpdated(host: LifecycleHost, changed: Map<PropertyKey, unknown>) {
|
||||
|
||||
@@ -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<string, ToolStreamEntry>();
|
||||
private toolStreamOrder: string[] = [];
|
||||
refreshSessionsAfterChat = new Set<string>();
|
||||
|
||||
176
ui/src/ui/control-ui-performance.test.ts
Normal file
176
ui/src/ui/control-ui-performance.test.ts
Normal file
@@ -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<typeof PerformanceObserver>[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<string, unknown> }>,
|
||||
eventLogBuffer: [] as Array<{ payload: Record<string, unknown> }>,
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> | 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user