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.
This commit is contained in:
Val Alexander
2026-05-02 04:33:36 -05:00
committed by GitHub
parent 0a6584a231
commit 64fcc8a1aa
12 changed files with 686 additions and 90 deletions

View File

@@ -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.

View File

@@ -9,7 +9,7 @@ describe("resolveDiscordPreflightChannelContext", () => {
channelName: "\uC2E4\uD5D8",
guildName: "Guild",
guildInfo: null,
threadChannel: undefined,
threadChannel: null,
});
expect(context.configChannelSlug).toBe("");

View File

@@ -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);

View File

@@ -33,8 +33,8 @@ describe("ChannelsSchema bundled runtime loading", () => {
}),
}));
vi.doMock("../plugins/plugin-metadata-snapshot.js", () => ({
loadPluginMetadataSnapshot: () => ({
manifestRegistry: loadPluginManifestRegistryMock({ includeDisabled: true }),
loadPluginMetadataSnapshot: (options?: Record<string, unknown>) => ({
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);

View File

@@ -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;

View File

@@ -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<CronRunsLoadStatus>>(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<void>((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<void>(() => 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<void>((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",
}),
]),
);
});
});

View File

@@ -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<unknown>;
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<typeof refreshChat>[0]);
scheduleChatScroll(
host as unknown as Parameters<typeof scheduleChatScroll>[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<typeof scheduleLogsScroll>[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<typeof refreshChat>[0]);
scheduleChatScroll(
host as unknown as Parameters<typeof scheduleChatScroll>[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<typeof scheduleLogsScroll>[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)]);
}

View File

@@ -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<unknown>;
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<string, unknown>, 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<string, unknown>,
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 },
);
}

View File

@@ -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") {

View File

@@ -42,6 +42,7 @@ export type CronFieldErrors = Partial<Record<CronFieldKey, string>>;
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<CronRunsLoadStatus> {
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;

View File

@@ -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",

View File

@@ -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<T>((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;