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;