From 64fcc8a1aadb02e7bde492737b4c622e1fea716f Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sat, 2 May 2026 04:33:36 -0500 Subject: [PATCH] fix(control-ui): measure and decouple slow refreshes Add Control UI timing attribution for tab visibility, refresh phases, secondary refresh groups, and gateway RPCs. Decouple slow Overview, Cron, and Nodes secondary refreshes from primary tab feedback, and report Cron runs timing from the controller's real ok/error/skipped load outcome. Also keeps telemetry secret-safe by excluding request params and response bodies, adds focused regression coverage for RPC attribution and stale/failed background refreshes, and carries the latest-main Discord test fixture correction needed for check:test-types. Fixes #64004. --- CHANGELOG.md | 1 + ...-handler.preflight-channel-context.test.ts | 2 +- src/cli/plugins-install-persist.test.ts | 3 - .../zod-schema.providers.lazy-runtime.test.ts | 8 +- ui/src/ui/app-gateway.ts | 7 + ...p-settings.refresh-active-tab.node.test.ts | 210 ++++++++++++++++- ui/src/ui/app-settings.ts | 213 ++++++++++++------ ui/src/ui/control-ui-performance.ts | 170 ++++++++++++++ ui/src/ui/controllers/cron.test.ts | 15 +- ui/src/ui/controllers/cron.ts | 11 +- ui/src/ui/gateway.node.test.ts | 92 ++++++++ ui/src/ui/gateway.ts | 44 +++- 12 files changed, 686 insertions(+), 90 deletions(-) create mode 100644 ui/src/ui/control-ui-performance.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 53f3c6dd542..5477a9d70b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,7 @@ Docs: https://docs.openclaw.ai - Voice Call: add `sessionScope: "per-call"` for fresh per-call agent memory while preserving the default per-phone caller history. Fixes #45280. Thanks @pondcountry. - Music generation: raise too-small tool timeouts to the provider-safe 10-second floor and collapse cascading abort fallback errors into a clearer root-cause summary. Thanks @shakkernerd. - Memory-core/dreaming: include the primary runtime workspace in multi-agent dreaming sweeps without mixing main-agent session transcripts into configured subagent workspaces. Fixes #70014. Thanks @ttomiczek. +- Control UI: add tab/RPC timing attribution and decouple slow Overview/Cron secondary refreshes so Sessions navigation gets immediate visible feedback. Refs #64004. Thanks @WaMaSeDu. - Telegram/startup: use the existing `getMe` request guard for the gateway bot probe instead of a fixed 2.5-second budget, and honor higher `timeoutSeconds` configs for slow Telegram API paths. Fixes #75783. Thanks @tankotan. - Telegram/models: make model picker confirmations say selections are session-scoped and do not change the agent's persistent default. Fixes #75965. Thanks @sd1114820. - Control UI/slash commands: keep fallback command metadata on a browser-safe registry path, so provider thinking runtime imports cannot blank the Web UI with `process is not defined`. Fixes #75987. Thanks @novkien. diff --git a/extensions/discord/src/monitor/message-handler.preflight-channel-context.test.ts b/extensions/discord/src/monitor/message-handler.preflight-channel-context.test.ts index 957ecec0123..dba32a83f76 100644 --- a/extensions/discord/src/monitor/message-handler.preflight-channel-context.test.ts +++ b/extensions/discord/src/monitor/message-handler.preflight-channel-context.test.ts @@ -9,7 +9,7 @@ describe("resolveDiscordPreflightChannelContext", () => { channelName: "\uC2E4\uD5D8", guildName: "Guild", guildInfo: null, - threadChannel: undefined, + threadChannel: null, }); expect(context.configChannelSlug).toBe(""); diff --git a/src/cli/plugins-install-persist.test.ts b/src/cli/plugins-install-persist.test.ts index 7c78ca768f3..b26934b23c2 100644 --- a/src/cli/plugins-install-persist.test.ts +++ b/src/cli/plugins-install-persist.test.ts @@ -183,7 +183,6 @@ describe("persistPluginInstall", () => { expect(loadPluginManifestRegistry).toHaveBeenCalledWith( expect.objectContaining({ config: enabledConfig, - installRecords: {}, }), ); expect(next.plugins?.entries?.["legacy-memory-a"]?.enabled).toBe(true); @@ -254,7 +253,6 @@ describe("persistPluginInstall", () => { expect(loadPluginManifestRegistry).toHaveBeenCalledWith( expect.objectContaining({ config: enabledConfig, - installRecords: {}, }), ); expect(next.plugins?.entries?.["legacy-memory-a"]?.enabled).toBe(true); @@ -311,7 +309,6 @@ describe("persistPluginInstall", () => { expect(loadPluginManifestRegistry).toHaveBeenCalledWith( expect.objectContaining({ config: enabledConfig, - installRecords: {}, }), ); expect(next).toEqual(enabledConfig); diff --git a/src/config/zod-schema.providers.lazy-runtime.test.ts b/src/config/zod-schema.providers.lazy-runtime.test.ts index d90f5dee8eb..4a51db22f24 100644 --- a/src/config/zod-schema.providers.lazy-runtime.test.ts +++ b/src/config/zod-schema.providers.lazy-runtime.test.ts @@ -33,8 +33,8 @@ describe("ChannelsSchema bundled runtime loading", () => { }), })); vi.doMock("../plugins/plugin-metadata-snapshot.js", () => ({ - loadPluginMetadataSnapshot: () => ({ - manifestRegistry: loadPluginManifestRegistryMock({ includeDisabled: true }), + loadPluginMetadataSnapshot: (options?: Record) => ({ + manifestRegistry: loadPluginManifestRegistryMock(options), }), })); vi.doMock("../plugins/bundled-channel-config-metadata.js", () => ({ @@ -97,7 +97,7 @@ describe("ChannelsSchema bundled runtime loading", () => { expect(loadPluginManifestRegistryMock.mock.calls).toContainEqual([ expect.objectContaining({ - includeDisabled: true, + config: {}, }), ]); expect(collectBundledChannelConfigsMock).not.toHaveBeenCalled(); @@ -136,7 +136,7 @@ describe("ChannelsSchema bundled runtime loading", () => { expect(loadPluginManifestRegistryMock.mock.calls).toContainEqual([ expect.objectContaining({ - includeDisabled: true, + config: {}, }), ]); expect(collectBundledChannelConfigsMock).toHaveBeenCalledTimes(1); diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index e6673fc3aea..fafe0d3367d 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -20,6 +20,7 @@ import { handleAgentEvent, resetToolStream, type AgentEventPayload } from "./app import { shouldReloadHistoryForFinalEvent } from "./chat-event-reload.ts"; import { parseChatSideResult, type ChatSideResult } from "./chat/side-result.ts"; import { formatConnectError } from "./connect-error.ts"; +import { recordControlUiRpcTiming } from "./control-ui-performance.ts"; import { loadAgents, type AgentsState } from "./controllers/agents.ts"; import { loadAssistantIdentity, @@ -511,6 +512,12 @@ export function connectGateway(host: GatewayHost, options?: ConnectGatewayOption } handleGatewayEvent(host, evt); }, + onRequestTiming: (timing) => { + if (host.client !== client) { + return; + } + recordControlUiRpcTiming(host, timing); + }, onGap: ({ expected, received }) => { if (host.client !== client) { return; diff --git a/ui/src/ui/app-settings.refresh-active-tab.node.test.ts b/ui/src/ui/app-settings.refresh-active-tab.node.test.ts index 3466897693e..84ecfae6980 100644 --- a/ui/src/ui/app-settings.refresh-active-tab.node.test.ts +++ b/ui/src/ui/app-settings.refresh-active-tab.node.test.ts @@ -1,6 +1,8 @@ // @vitest-environment node import { beforeEach, describe, expect, it, vi } from "vitest"; +type CronRunsLoadStatus = "ok" | "error" | "skipped"; + const mocks = vi.hoisted(() => ({ refreshChatMock: vi.fn(async () => {}), scheduleChatScrollMock: vi.fn(), @@ -15,8 +17,17 @@ const mocks = vi.hoisted(() => ({ loadConfigSchemaMock: vi.fn(async () => {}), loadCronStatusMock: vi.fn(async () => {}), loadCronJobsPageMock: vi.fn(async () => {}), - loadCronRunsMock: vi.fn(async () => {}), + loadCronRunsMock: vi.fn<() => Promise>(async () => "ok"), + loadDebugMock: vi.fn(async () => {}), + loadDevicesMock: vi.fn(async () => {}), + loadExecApprovalsMock: vi.fn(async () => {}), loadLogsMock: vi.fn(async () => {}), + loadModelAuthStatusStateMock: vi.fn(async () => {}), + loadNodesMock: vi.fn(async () => {}), + loadPresenceMock: vi.fn(async () => {}), + loadSessionsMock: vi.fn(async () => {}), + loadSkillsMock: vi.fn(async () => {}), + loadUsageMock: vi.fn(async () => {}), })); vi.mock("./app-chat.ts", () => ({ @@ -51,11 +62,38 @@ vi.mock("./controllers/cron.ts", () => ({ loadCronJobsPage: mocks.loadCronJobsPageMock, loadCronRuns: mocks.loadCronRunsMock, })); +vi.mock("./controllers/debug.ts", () => ({ + loadDebug: mocks.loadDebugMock, +})); +vi.mock("./controllers/devices.ts", () => ({ + loadDevices: mocks.loadDevicesMock, +})); +vi.mock("./controllers/exec-approvals.ts", () => ({ + loadExecApprovals: mocks.loadExecApprovalsMock, +})); vi.mock("./controllers/logs.ts", () => ({ loadLogs: mocks.loadLogsMock, })); +vi.mock("./controllers/model-auth-status.ts", () => ({ + loadModelAuthStatusState: mocks.loadModelAuthStatusStateMock, +})); +vi.mock("./controllers/nodes.ts", () => ({ + loadNodes: mocks.loadNodesMock, +})); +vi.mock("./controllers/presence.ts", () => ({ + loadPresence: mocks.loadPresenceMock, +})); +vi.mock("./controllers/sessions.ts", () => ({ + loadSessions: mocks.loadSessionsMock, +})); +vi.mock("./controllers/skills.ts", () => ({ + loadSkills: mocks.loadSkillsMock, +})); +vi.mock("./controllers/usage.ts", () => ({ + loadUsage: mocks.loadUsageMock, +})); -import { refreshActiveTab } from "./app-settings.ts"; +import { refreshActiveTab, setTab } from "./app-settings.ts"; function createHost() { return { @@ -72,9 +110,13 @@ function createHost() { logsAtBottom: false, eventLog: [], eventLogBuffer: [], + requestUpdate: vi.fn(), + updateComplete: Promise.resolve(), cronRunsScope: "all", cronRunsJobId: null as string | null, sessionKey: "main", + settings: {}, + basePath: "", }; } @@ -150,4 +192,168 @@ describe("refreshActiveTab", () => { expect(mocks.loadLogsMock).toHaveBeenCalledWith(host, { reset: true }); expect(mocks.scheduleLogsScrollMock).toHaveBeenCalledWith(host, true); }); + + it("records tab visible timing without waiting for the tab refresh RPC", async () => { + const host = createHost(); + host.tab = "chat"; + let resolveSessions!: () => void; + mocks.loadSessionsMock.mockReturnValueOnce( + new Promise((resolve) => { + resolveSessions = resolve; + }), + ); + + setTab(host as never, "sessions"); + + expect(host.requestUpdate).toHaveBeenCalled(); + await vi.waitFor(() => { + expect(host.eventLogBuffer).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + event: "control-ui.tab.visible", + payload: expect.objectContaining({ + previousTab: "chat", + tab: "sessions", + durationMs: expect.any(Number), + }), + }), + ]), + ); + }); + + resolveSessions(); + }); + + it("does not wait for secondary overview refreshes before resolving", async () => { + const host = createHost(); + host.tab = "overview"; + mocks.loadUsageMock.mockReturnValueOnce(new Promise(() => undefined)); + + const refresh = refreshActiveTab(host as never); + const outcome = await Promise.race([ + refresh.then(() => "resolved" as const), + new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 0)), + ]); + + expect(outcome).toBe("resolved"); + expect(mocks.loadChannelsMock).toHaveBeenCalled(); + expect(mocks.loadSessionsMock).toHaveBeenCalled(); + expect(mocks.loadUsageMock).toHaveBeenCalled(); + }); + + it("records overview secondary refresh duration and aggregate status", async () => { + const host = createHost(); + host.tab = "overview"; + let resolveUsage!: () => void; + mocks.loadUsageMock.mockReturnValueOnce( + new Promise((resolve) => { + resolveUsage = resolve; + }), + ); + mocks.loadSkillsMock.mockRejectedValueOnce(new Error("skills failed")); + + await refreshActiveTab(host as never); + resolveUsage(); + + await vi.waitFor(() => { + expect(host.eventLogBuffer).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + event: "control-ui.overview.secondary", + payload: expect.objectContaining({ + phase: "end", + status: "error", + durationMs: expect.any(Number), + }), + }), + ]), + ); + }); + }); + + it("does not wait for cron runs before resolving the cron tab refresh", async () => { + const host = createHost(); + host.tab = "cron"; + mocks.loadCronRunsMock.mockReturnValueOnce(new Promise<"ok">(() => undefined)); + + const refresh = refreshActiveTab(host as never); + const outcome = await Promise.race([ + refresh.then(() => "resolved" as const), + new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 0)), + ]); + + expect(outcome).toBe("resolved"); + expect(mocks.loadChannelsMock).toHaveBeenCalledWith(host, false); + expect(mocks.loadCronStatusMock).toHaveBeenCalledOnce(); + expect(mocks.loadCronJobsPageMock).toHaveBeenCalledOnce(); + expect(mocks.loadCronRunsMock).toHaveBeenCalledOnce(); + }); + + it("records failed cron runs status from the controller outcome", async () => { + const host = createHost(); + host.tab = "cron"; + mocks.loadCronRunsMock.mockResolvedValueOnce("error" as const); + + await expect(refreshActiveTab(host as never)).resolves.toBeUndefined(); + await Promise.resolve(); + + expect(host.eventLogBuffer).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + event: "control-ui.cron.runs", + payload: expect.objectContaining({ + phase: "end", + status: "error", + durationMs: expect.any(Number), + }), + }), + ]), + ); + }); + + it("contains rejected cron runs refreshes without failing the primary cron tab refresh", async () => { + const host = createHost(); + host.tab = "cron"; + mocks.loadCronRunsMock.mockRejectedValueOnce(new Error("cron runs slow path failed")); + + await expect(refreshActiveTab(host as never)).resolves.toBeUndefined(); + await Promise.resolve(); + + expect(host.eventLogBuffer).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + event: "control-ui.cron.runs", + payload: expect.objectContaining({ + phase: "end", + status: "error", + durationMs: expect.any(Number), + }), + }), + ]), + ); + }); + + it("does not record stale cron run timing after leaving the cron tab", async () => { + const host = createHost(); + host.tab = "cron"; + let resolveRuns!: () => void; + mocks.loadCronRunsMock.mockReturnValueOnce( + new Promise<"ok">((resolve) => { + resolveRuns = () => resolve("ok"); + }), + ); + + await refreshActiveTab(host as never); + host.tab = "chat"; + resolveRuns(); + await Promise.resolve(); + + expect(host.eventLogBuffer).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + event: "control-ui.cron.runs", + }), + ]), + ); + }); }); diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index 9e368971d1e..d52dd954486 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -8,6 +8,14 @@ import { stopDebugPolling, } from "./app-polling.ts"; import { scheduleChatScroll, scheduleLogsScroll } from "./app-scroll.ts"; +import { + beginControlUiRefresh, + controlUiNowMs, + finishControlUiRefresh, + recordControlUiPerformanceEvent, + roundedControlUiDurationMs, + scheduleControlUiTabVisibleTiming, +} from "./control-ui-performance.ts"; import { loadAgentFiles, type AgentFilesState } from "./controllers/agent-files.ts"; import { loadAgentIdentities, @@ -92,6 +100,12 @@ type SettingsHost = { pendingGatewayUrl?: string | null; systemThemeCleanup?: (() => void) | null; pendingGatewayToken?: string | null; + requestUpdate?: () => void; + updateComplete?: Promise; + controlUiRefreshSeq?: number; + controlUiTabPaintSeq?: number; + controlUiOverviewRefreshSeq?: number; + controlUiCronRefreshSeq?: number; dreamingStatusLoading: boolean; dreamingStatusError: string | null; dreamingStatus: import("./controllers/dreaming.js").DreamingStatus | null; @@ -334,71 +348,76 @@ async function refreshAgentsTab(host: SettingsHost, app: SettingsAppHost) { export async function refreshActiveTab(host: SettingsHost) { const app = host as unknown as SettingsAppHost; - switch (host.tab) { - case "config": - case "communications": - case "appearance": - case "automation": - case "infrastructure": - case "aiAgents": - await loadConfigSchema(app); - await loadConfig(app); - return; - case "overview": - await loadOverview(host); - return; - case "channels": - await loadChannelsTab(host); - return; - case "instances": - await loadPresence(app); - return; - case "usage": - await loadUsage(app); - return; - case "sessions": - await loadSessions(app); - return; - case "cron": - await loadCron(host); - return; - case "skills": - await loadSkills(app); - return; - case "agents": - await refreshAgentsTab(host, app); - return; - case "nodes": - await loadNodes(app); - await loadDevices(app); - await loadConfig(app); - await loadExecApprovals(app); - return; - case "dreams": - await loadConfig(app); - await Promise.all([ - loadDreamingStatus(app), - loadDreamDiary(app), - loadWikiImportInsights(app), - loadWikiMemoryPalace(app), - ]); - return; - case "chat": - await refreshChat(host as unknown as Parameters[0]); - scheduleChatScroll( - host as unknown as Parameters[0], - !host.chatHasAutoScrolled, - ); - return; - case "debug": - await loadDebug(app); - host.eventLog = host.eventLogBuffer; - return; - case "logs": - host.logsAtBottom = true; - await loadLogs(app, { reset: true }); - scheduleLogsScroll(host as unknown as Parameters[0], true); - return; + const refreshRun = beginControlUiRefresh(host, host.tab); + try { + switch (host.tab) { + case "config": + case "communications": + case "appearance": + case "automation": + case "infrastructure": + case "aiAgents": + await loadConfigSchema(app); + await loadConfig(app); + break; + case "overview": + await loadOverview(host); + break; + case "channels": + await loadChannelsTab(host); + break; + case "instances": + await loadPresence(app); + break; + case "usage": + await loadUsage(app); + break; + case "sessions": + await loadSessions(app); + break; + case "cron": + await loadCron(host); + break; + case "skills": + await loadSkills(app); + break; + case "agents": + await refreshAgentsTab(host, app); + break; + case "nodes": + await loadNodes(app); + await Promise.allSettled([loadDevices(app), loadConfig(app), loadExecApprovals(app)]); + break; + case "dreams": + await loadConfig(app); + await Promise.all([ + loadDreamingStatus(app), + loadDreamDiary(app), + loadWikiImportInsights(app), + loadWikiMemoryPalace(app), + ]); + break; + case "chat": + await refreshChat(host as unknown as Parameters[0]); + scheduleChatScroll( + host as unknown as Parameters[0], + !host.chatHasAutoScrolled, + ); + break; + case "debug": + await loadDebug(app); + host.eventLog = host.eventLogBuffer; + break; + case "logs": + host.logsAtBottom = true; + await loadLogs(app, { reset: true }); + scheduleLogsScroll(host as unknown as Parameters[0], true); + break; + } + finishControlUiRefresh(host, refreshRun, "ok"); + } catch (err) { + finishControlUiRefresh(host, refreshRun, "error"); + throw err; } } @@ -544,6 +563,9 @@ function applyTabSelection( ) { const prev = host.tab; host.tab = next; + if (prev !== next) { + scheduleControlUiTabVisibleTiming(host, prev, next); + } // Cleanup chat module state when navigating away from chat if (prev === "chat" && next !== "chat") { @@ -602,12 +624,24 @@ export function syncUrlWithSessionKey(host: SettingsHost, sessionKey: string, re export async function loadOverview(host: SettingsHost, opts?: { refresh?: boolean }) { const app = host as SettingsAppHost; + const overviewSeq = (host.controlUiOverviewRefreshSeq ?? 0) + 1; + host.controlUiOverviewRefreshSeq = overviewSeq; + const isCurrentOverviewRefresh = () => + host.controlUiOverviewRefreshSeq === overviewSeq && host.tab === "overview"; + await Promise.allSettled([ loadChannels(app, false), loadPresence(app), loadSessions(app), loadCronStatus(app), loadCronJobsPage(app), + ]); + if (isCurrentOverviewRefresh()) { + buildAttentionItems(app); + } + + const secondaryStartedAtMs = controlUiNowMs(); + void Promise.allSettled([ loadDebug(app), loadSkills(app), loadUsage(app), @@ -615,8 +649,23 @@ export async function loadOverview(host: SettingsHost, opts?: { refresh?: boolea // `refresh: true` bypasses the gateway's 60s auth-status cache so a // user-initiated refresh surfaces post-re-auth state immediately. loadModelAuthStatusState(app, { refresh: opts?.refresh }), - ]); - buildAttentionItems(app); + ]).then((results) => { + if (!isCurrentOverviewRefresh()) { + return; + } + const status = results.some((result) => result.status === "rejected") ? "error" : "ok"; + buildAttentionItems(app); + recordControlUiPerformanceEvent( + app, + "control-ui.overview.secondary", + { + phase: "end", + status, + durationMs: roundedControlUiDurationMs(controlUiNowMs() - secondaryStartedAtMs), + }, + { console: false }, + ); + }); } export function hasOperatorReadAccess( @@ -788,10 +837,28 @@ export async function loadChannelsTab(host: SettingsHost) { export async function loadCron(host: SettingsHost) { const app = host as unknown as SettingsAppHost; const activeCronJobId = app.cronRunsScope === "job" ? app.cronRunsJobId : null; - await Promise.all([ - loadChannels(app, false), - loadCronStatus(app), - loadCronJobsPage(app), - loadCronRuns(app, activeCronJobId), - ]); + const cronSeq = (host.controlUiCronRefreshSeq ?? 0) + 1; + host.controlUiCronRefreshSeq = cronSeq; + const isCurrentCronRefresh = () => + host.controlUiCronRefreshSeq === cronSeq && host.tab === "cron"; + const runsStartedAtMs = controlUiNowMs(); + const runsRefresh = loadCronRuns(app, activeCronJobId) + .catch(() => "error" as const) + .then((status) => { + if (!isCurrentCronRefresh()) { + return; + } + recordControlUiPerformanceEvent( + app, + "control-ui.cron.runs", + { + phase: "end", + status, + durationMs: roundedControlUiDurationMs(controlUiNowMs() - runsStartedAtMs), + }, + { console: false }, + ); + }); + void runsRefresh; + await Promise.all([loadChannels(app, false), loadCronStatus(app), loadCronJobsPage(app)]); } diff --git a/ui/src/ui/control-ui-performance.ts b/ui/src/ui/control-ui-performance.ts new file mode 100644 index 00000000000..bec55728eba --- /dev/null +++ b/ui/src/ui/control-ui-performance.ts @@ -0,0 +1,170 @@ +import type { EventLogEntry } from "./app-events.ts"; +import type { GatewayRequestTiming } from "./gateway.ts"; +import type { Tab } from "./navigation.ts"; + +type ControlUiPerformanceHost = { + tab: Tab; + eventLog?: unknown[]; + eventLogBuffer?: unknown[]; + requestUpdate?: () => void; + updateComplete?: Promise; + controlUiRefreshSeq?: number; + controlUiTabPaintSeq?: number; +}; + +export type ControlUiRefreshRun = { + seq: number; + tab: Tab; + startedAtMs: number; +}; + +const EVENT_LOG_LIMIT = 250; +const SLOW_RPC_MS = 1_000; + +export function controlUiNowMs(): number { + return typeof performance !== "undefined" && typeof performance.now === "function" + ? performance.now() + : Date.now(); +} + +export function roundedControlUiDurationMs(durationMs: number): number { + return Math.max(0, Math.round(durationMs)); +} + +function runAfterMicrotask(callback: () => void): void { + if (typeof queueMicrotask === "function") { + queueMicrotask(callback); + return; + } + void Promise.resolve().then(callback); +} + +function runAfterPaint(callback: () => void): void { + const raf = + typeof window !== "undefined" && typeof window.requestAnimationFrame === "function" + ? window.requestAnimationFrame.bind(window) + : null; + if (!raf) { + runAfterMicrotask(callback); + return; + } + raf(() => raf(callback)); +} + +function logPerformanceEvent(event: string, payload: Record, warn: boolean) { + const logger = warn ? console.warn : console.debug; + if (typeof logger !== "function") { + return; + } + logger(`[openclaw] ${event}`, payload); +} + +export function recordControlUiPerformanceEvent( + host: ControlUiPerformanceHost, + event: string, + payload: Record, + opts?: { warn?: boolean; console?: boolean }, +) { + const entry: EventLogEntry = { ts: Date.now(), event, payload }; + if (Array.isArray(host.eventLogBuffer)) { + host.eventLogBuffer = [entry, ...host.eventLogBuffer].slice(0, EVENT_LOG_LIMIT); + if (host.tab === "debug" || host.tab === "overview") { + host.eventLog = host.eventLogBuffer; + } + } + if (opts?.console === false) { + return; + } + logPerformanceEvent(event, payload, opts?.warn === true); +} + +export function scheduleControlUiTabVisibleTiming( + host: ControlUiPerformanceHost, + previousTab: Tab, + tab: Tab, +) { + const seq = (host.controlUiTabPaintSeq ?? 0) + 1; + host.controlUiTabPaintSeq = seq; + const startedAtMs = controlUiNowMs(); + host.requestUpdate?.(); + + const record = () => { + if (host.controlUiTabPaintSeq !== seq || host.tab !== tab) { + return; + } + recordControlUiPerformanceEvent(host, "control-ui.tab.visible", { + previousTab, + tab, + durationMs: roundedControlUiDurationMs(controlUiNowMs() - startedAtMs), + }); + }; + + void Promise.resolve(host.updateComplete) + .catch(() => undefined) + .then(() => runAfterPaint(record)); +} + +export function beginControlUiRefresh( + host: ControlUiPerformanceHost, + tab: Tab, +): ControlUiRefreshRun { + const seq = (host.controlUiRefreshSeq ?? 0) + 1; + host.controlUiRefreshSeq = seq; + const run = { seq, tab, startedAtMs: controlUiNowMs() }; + recordControlUiPerformanceEvent( + host, + "control-ui.refresh", + { tab, phase: "start" }, + { console: false }, + ); + return run; +} + +export function isCurrentControlUiRefresh( + host: ControlUiPerformanceHost, + run: ControlUiRefreshRun, +): boolean { + return host.controlUiRefreshSeq === run.seq && host.tab === run.tab; +} + +export function finishControlUiRefresh( + host: ControlUiPerformanceHost, + run: ControlUiRefreshRun, + status: "ok" | "error", +) { + if (!isCurrentControlUiRefresh(host, run)) { + return; + } + recordControlUiPerformanceEvent( + host, + "control-ui.refresh", + { + tab: run.tab, + phase: "end", + status, + durationMs: roundedControlUiDurationMs(controlUiNowMs() - run.startedAtMs), + }, + { console: false }, + ); +} + +export function recordControlUiRpcTiming( + host: ControlUiPerformanceHost, + timing: GatewayRequestTiming, +) { + const durationMs = roundedControlUiDurationMs(timing.durationMs); + const warn = !timing.ok || durationMs >= SLOW_RPC_MS; + recordControlUiPerformanceEvent( + host, + "control-ui.rpc", + { + id: timing.id, + method: timing.method, + ok: timing.ok, + durationMs, + slow: durationMs >= SLOW_RPC_MS, + errorCode: timing.errorCode, + }, + { warn }, + ); +} diff --git a/ui/src/ui/controllers/cron.test.ts b/ui/src/ui/controllers/cron.test.ts index dd84560bf28..fe359283290 100644 --- a/ui/src/ui/controllers/cron.test.ts +++ b/ui/src/ui/controllers/cron.test.ts @@ -1279,7 +1279,7 @@ describe("cron controller", () => { client: { request } as unknown as CronState["client"], }); - await loadCronRuns(state, "job-1"); + await expect(loadCronRuns(state, "job-1")).resolves.toBe("ok"); expect(state.cronRuns).toHaveLength(1); expect(state.cronRunsHasMore).toBe(true); @@ -1289,6 +1289,19 @@ describe("cron controller", () => { expect(state.cronRuns[1]?.summary).toBe("older"); }); + it("returns an error status when run history loading fails", async () => { + const request = vi.fn(async () => { + throw new Error("cron.runs unavailable"); + }); + const state = createState({ + client: { request } as unknown as CronState["client"], + }); + + await expect(loadCronRuns(state, null)).resolves.toBe("error"); + + expect(state.cronError).toBe("Error: cron.runs unavailable"); + }); + it("runs cron job in due mode when requested", async () => { const request = vi.fn(async (method: string, payload?: unknown) => { if (method === "cron.run") { diff --git a/ui/src/ui/controllers/cron.ts b/ui/src/ui/controllers/cron.ts index c8337514ebc..93e493c99b7 100644 --- a/ui/src/ui/controllers/cron.ts +++ b/ui/src/ui/controllers/cron.ts @@ -42,6 +42,7 @@ export type CronFieldErrors = Partial>; export type CronJobsScheduleKindFilter = "all" | "at" | "every" | "cron"; export type CronJobsLastStatusFilter = "all" | "ok" | "error" | "skipped"; +export type CronRunsLoadStatus = "ok" | "error" | "skipped"; export type CronState = { client: GatewayBrowserClient | null; @@ -762,19 +763,19 @@ export async function loadCronRuns( state: CronState, jobId: string | null, opts?: { append?: boolean }, -) { +): Promise { if (!state.client || !state.connected) { - return; + return "skipped"; } const scope = state.cronRunsScope; const activeJobId = jobId ?? state.cronRunsJobId; if (scope === "job" && !activeJobId) { clearCronRunsPage(state); - return; + return "skipped"; } const append = opts?.append === true; if (append && !state.cronRunsHasMore) { - return; + return "skipped"; } try { if (append) { @@ -811,8 +812,10 @@ export async function loadCronRuns( state.cronRunsTotal = Math.max(meta.total, state.cronRuns.length); state.cronRunsHasMore = meta.hasMore; state.cronRunsNextOffset = meta.nextOffset; + return "ok"; } catch (err) { state.cronError = String(err); + return "error"; } finally { if (append) { state.cronRunsLoadingMore = false; diff --git a/ui/src/ui/gateway.node.test.ts b/ui/src/ui/gateway.node.test.ts index 36baae9d303..a77d2cedd11 100644 --- a/ui/src/ui/gateway.node.test.ts +++ b/ui/src/ui/gateway.node.test.ts @@ -236,6 +236,98 @@ describe("GatewayBrowserClient", () => { expect(connectFrame.params?.scopes).toEqual([...CONTROL_UI_OPERATOR_SCOPES]); }); + it("reports request timing for attributed RPC latency", async () => { + const onRequestTiming = vi.fn(); + const client = new GatewayBrowserClient({ + url: "ws://127.0.0.1:18789", + token: "shared-auth-token", + onRequestTiming, + }); + + const { ws, connectFrame } = await startConnect(client); + ws.emitMessage({ + type: "res", + id: connectFrame.id, + ok: true, + payload: { + type: "hello-ok", + protocol: 3, + auth: { role: "operator", scopes: [] }, + }, + }); + onRequestTiming.mockClear(); + + const request = client.request("sessions.list", { includeGlobal: true }); + const frame = JSON.parse(ws.sent.at(-1) ?? "{}") as { id?: string; method?: string }; + expect(frame.method).toBe("sessions.list"); + + ws.emitMessage({ + type: "res", + id: frame.id, + ok: true, + payload: { sessions: [] }, + }); + + await expect(request).resolves.toEqual({ sessions: [] }); + expect(onRequestTiming).toHaveBeenCalledWith( + expect.objectContaining({ + id: frame.id, + method: "sessions.list", + ok: true, + durationMs: expect.any(Number), + }), + ); + }); + + it("reports failed request timing without including request params", async () => { + const onRequestTiming = vi.fn(); + const client = new GatewayBrowserClient({ + url: "ws://127.0.0.1:18789", + token: "shared-auth-token", + onRequestTiming, + }); + + const { ws, connectFrame } = await startConnect(client); + ws.emitMessage({ + type: "res", + id: connectFrame.id, + ok: true, + payload: { + type: "hello-ok", + protocol: 3, + auth: { role: "operator", scopes: [] }, + }, + }); + onRequestTiming.mockClear(); + + const request = client.request("config.get", { token: "do-not-log" }); + const frame = JSON.parse(ws.sent.at(-1) ?? "{}") as { id?: string; method?: string }; + expect(frame.method).toBe("config.get"); + + ws.emitMessage({ + type: "res", + id: frame.id, + ok: false, + error: { code: "CONFIG_ERROR", message: "config failed" }, + }); + + await expect(request).rejects.toMatchObject({ gatewayCode: "CONFIG_ERROR" }); + expect(onRequestTiming).toHaveBeenCalledWith( + expect.not.objectContaining({ + params: expect.anything(), + }), + ); + expect(onRequestTiming).toHaveBeenCalledWith( + expect.objectContaining({ + id: frame.id, + method: "config.get", + ok: false, + errorCode: "CONFIG_ERROR", + durationMs: expect.any(Number), + }), + ); + }); + it("prefers explicit shared auth over cached device tokens", async () => { const client = new GatewayBrowserClient({ url: "ws://127.0.0.1:18789", diff --git a/ui/src/ui/gateway.ts b/ui/src/ui/gateway.ts index 0b9f056646c..d432e5a3504 100644 --- a/ui/src/ui/gateway.ts +++ b/ui/src/ui/gateway.ts @@ -136,6 +136,8 @@ export type GatewayHelloOk = { type Pending = { resolve: (value: unknown) => void; reject: (err: unknown) => void; + method: string; + startedAtMs: number; }; type SelectedConnectAuth = { @@ -226,10 +228,21 @@ export type GatewayBrowserClientOptions = { onEvent?: (evt: GatewayEventFrame) => void; onClose?: (info: { code: number; reason: string; error?: GatewayErrorInfo }) => void; onGap?: (info: { expected: number; received: number }) => void; + onRequestTiming?: (timing: GatewayRequestTiming) => void; }; export type GatewayEventListener = (evt: GatewayEventFrame) => void; +export type GatewayRequestTiming = { + id: string; + method: string; + ok: boolean; + durationMs: number; + startedAtMs: number; + endedAtMs: number; + errorCode?: string; +}; + // 4008 = application-defined code (browser rejects 1008 "Policy Violation") const CONNECT_FAILED_CLOSE_CODE = 4008; const STARTUP_RETRY_CLOSE_CODE = 4013; @@ -397,12 +410,36 @@ export class GatewayBrowserClient { } private flushPending(err: Error) { - for (const [, p] of this.pending) { + for (const [id, p] of this.pending) { + this.emitRequestTiming(id, p, false, "CLIENT_CLOSED"); p.reject(err); } this.pending.clear(); } + private nowMs(): number { + return typeof performance !== "undefined" && typeof performance.now === "function" + ? performance.now() + : Date.now(); + } + + private emitRequestTiming(id: string, pending: Pending, ok: boolean, errorCode?: string): void { + const endedAtMs = this.nowMs(); + try { + this.opts.onRequestTiming?.({ + id, + method: pending.method, + ok, + durationMs: Math.max(0, endedAtMs - pending.startedAtMs), + startedAtMs: pending.startedAtMs, + endedAtMs, + errorCode, + }); + } catch (err) { + console.error("[gateway] request timing handler error:", err); + } + } + private buildConnectClient(): GatewayConnectClientInfo { return { id: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.CONTROL_UI, @@ -631,8 +668,10 @@ export class GatewayBrowserClient { } this.pending.delete(res.id); if (res.ok) { + this.emitRequestTiming(res.id, pending, true); pending.resolve(res.payload); } else { + this.emitRequestTiming(res.id, pending, false, res.error?.code); pending.reject( new GatewayRequestError({ code: res.error?.code ?? "UNAVAILABLE", @@ -697,8 +736,9 @@ export class GatewayBrowserClient { } const id = generateUUID(); const frame = { type: "req", id, method, params }; + const startedAtMs = this.nowMs(); const p = new Promise((resolve, reject) => { - this.pending.set(id, { resolve: (v) => resolve(v as T), reject }); + this.pending.set(id, { resolve: (v) => resolve(v as T), reject, method, startedAtMs }); }); ws.send(JSON.stringify(frame)); return p;