diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt index 06632bdb2147..f4468f0e1bf8 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt @@ -591,6 +591,7 @@ enum class GatewayMethod( ProgressCardPut("progressCard.put"), ToolsGithubStatus("tools.github.status"), ToolsGithubConfigure("tools.github.configure"), + DiagnosticsLanes("diagnostics.lanes"), } enum class GatewayEvent( diff --git a/scripts/control-ui-mock-dev.ts b/scripts/control-ui-mock-dev.ts index 12eb69f550ca..287671c3eb4e 100644 --- a/scripts/control-ui-mock-dev.ts +++ b/scripts/control-ui-mock-dev.ts @@ -2442,6 +2442,82 @@ async function createChatPickerScenario( }, ], }, + // Saturated-main fixture so the debug page and overlay render queued and + // group-budget states, not just idle lanes. + "diagnostics.lanes": { + ts: baseTime, + lanes: [ + { + lane: "cron", + queuedCount: 0, + activeCount: 1, + maxConcurrent: 4, + draining: false, + generation: 1, + }, + { + lane: "cron-nested", + queuedCount: 0, + activeCount: 1, + maxConcurrent: 4, + draining: false, + generation: 1, + group: "cron-hooks", + groupActive: 2, + groupBudget: 4, + }, + { + lane: "hook-dispatch", + queuedCount: 2, + activeCount: 1, + maxConcurrent: 4, + draining: false, + generation: 1, + group: "cron-hooks", + groupActive: 2, + groupBudget: 4, + reservedForLane: 1, + blockedBy: "group-budget", + }, + { + lane: "main", + queuedCount: 3, + activeCount: 16, + maxConcurrent: 16, + draining: false, + generation: 7, + blockedBy: "lane", + }, + { + lane: "nested", + queuedCount: 0, + activeCount: 0, + maxConcurrent: 1, + draining: false, + generation: 1, + }, + { + lane: "subagent", + queuedCount: 5, + activeCount: 8, + maxConcurrent: 8, + draining: false, + generation: 4, + blockedBy: "lane", + }, + ], + dynamic: { + laneCount: 23, + activeCount: 9, + queuedCount: 4, + queuedLaneCount: 3, + }, + }, + status: { + eventLoop: { utilization: 0.42, delayP99Ms: 12, delayMaxMs: 87 }, + uptimeMs: 5_412_000, + }, + "last-heartbeat": { ts: baseTime }, "sessions.list": { cases: [ // Child fetches must precede the catch-all page case (subset match). diff --git a/src/gateway/method-scopes.test.ts b/src/gateway/method-scopes.test.ts index 79da3f252690..b4f6024dee06 100644 --- a/src/gateway/method-scopes.test.ts +++ b/src/gateway/method-scopes.test.ts @@ -118,6 +118,7 @@ describe("method scope resolution", () => { ["session.discussion.open", ["operator.write"]], ["environments.status", ["operator.read"]], ["diagnostics.stability", ["operator.read"]], + ["diagnostics.lanes", ["operator.read"]], ["gateway.restart.preflight", ["operator.read"]], ["skills.curator.status", ["operator.read"]], ["hooks.status", ["operator.read"]], diff --git a/src/gateway/methods/core-descriptors.since.test.ts b/src/gateway/methods/core-descriptors.since.test.ts index 755fcf9587c5..5f65132bbc33 100644 --- a/src/gateway/methods/core-descriptors.since.test.ts +++ b/src/gateway/methods/core-descriptors.since.test.ts @@ -81,6 +81,7 @@ const TRAIN_2026_7_METHODS = [ ] as const; const CURRENT_TRAIN_METHODS = [ + "diagnostics.lanes", "device.pair.setupStatus", "sessions.patchMany", "sessions.groups.update", diff --git a/src/gateway/methods/core-descriptors.ts b/src/gateway/methods/core-descriptors.ts index 0247e2a4c5d2..edd8aeb5f90c 100644 --- a/src/gateway/methods/core-descriptors.ts +++ b/src/gateway/methods/core-descriptors.ts @@ -552,6 +552,7 @@ const CORE_GATEWAY_METHOD_SPECS = [ "2026.8", { controlPlaneWrite: true }, ], + ["diagnostics.lanes", "diagnostics", "operator.read", "2026.8"], ] as const satisfies readonly CoreGatewayMethodSpecRow[]; export type CoreGatewayHandlerFamily = Exclude<(typeof CORE_GATEWAY_METHOD_SPECS)[number][1], null>; diff --git a/src/gateway/server-methods-list.test.ts b/src/gateway/server-methods-list.test.ts index 5afd1e0316ec..269d872e525b 100644 --- a/src/gateway/server-methods-list.test.ts +++ b/src/gateway/server-methods-list.test.ts @@ -72,7 +72,7 @@ describe("listGatewayMethods", () => { }); it("appends new methods after model probing without shifting older method indices", () => { - expect(listGatewayMethods().slice(-59)).toEqual([ + expect(listGatewayMethods().slice(-60)).toEqual([ "models.probe", "migrations.memory.plan", "migrations.memory.apply", @@ -132,6 +132,7 @@ describe("listGatewayMethods", () => { "progressCard.put", "tools.github.status", "tools.github.configure", + "diagnostics.lanes", ]); const methods = listGatewayMethods(); expect(methods.indexOf("node.pluginSurface.refresh")).toBe( @@ -237,7 +238,7 @@ describe("listGatewayMethods", () => { "exec.approval.get", ]); expect(methods).toContain("tts.speak"); - expect(coreMethods.slice(-66)).toEqual([ + expect(coreMethods.slice(-67)).toEqual([ "sessions.catalog.continue", "sessions.catalog.archive", "approval.get", @@ -304,6 +305,7 @@ describe("listGatewayMethods", () => { "progressCard.put", "tools.github.status", "tools.github.configure", + "diagnostics.lanes", ]); expect(methods.indexOf("approval.get")).toBeGreaterThan(methods.indexOf("tts.speak")); expect(methods.indexOf("approval.resolve")).toBe(methods.indexOf("approval.get") + 1); diff --git a/src/gateway/server-methods/diagnostics.test.ts b/src/gateway/server-methods/diagnostics.test.ts index 84df1a5bb5be..2468190a11e2 100644 --- a/src/gateway/server-methods/diagnostics.test.ts +++ b/src/gateway/server-methods/diagnostics.test.ts @@ -13,8 +13,36 @@ import { startDiagnosticStabilityRecorder, stopDiagnosticStabilityRecorder, } from "../../logging/diagnostic-stability.js"; +import { getCommandLaneDiagnostics } from "../../process/command-lane-diagnostics.js"; +import { + enqueueCommandInLane, + getCommandLaneSnapshot, + setCommandLaneConcurrency, +} from "../../process/command-queue.js"; +import { CommandLane } from "../../process/lanes.js"; import { diagnosticsHandlers } from "./diagnostics.js"; +type LaneDiagnosticsPayload = { + ts: number; +} & ReturnType; + +async function requestLaneDiagnostics(): Promise { + const respond = vi.fn(); + await expectDefined( + diagnosticsHandlers["diagnostics.lanes"], + 'diagnosticsHandlers["diagnostics.lanes"] test invariant', + )({ + req: { type: "req", id: "lanes", method: "diagnostics.lanes", params: {} }, + params: {}, + client: null, + isWebchatConnect: () => false, + context: {} as never, + respond, + }); + expect(respond).toHaveBeenCalledTimes(1); + return respond.mock.calls[0]?.[1] as LaneDiagnosticsPayload; +} + describe("diagnostics gateway methods", () => { beforeEach(() => { resetDiagnosticStabilityRecorderForTest(); @@ -130,4 +158,96 @@ describe("diagnostics gateway methods", () => { ], ]); }); + + it("returns every static command lane in sorted order with live capacity counts", async () => { + const lane = CommandLane.SkillWorkshopReview; + const originalConcurrency = getCommandLaneSnapshot(lane).maxConcurrent; + setCommandLaneConcurrency(lane, 1); + + let releaseActive!: () => void; + let markActive!: () => void; + const activeStarted = new Promise((resolve) => { + markActive = resolve; + }); + const activeRelease = new Promise((resolve) => { + releaseActive = resolve; + }); + const active = enqueueCommandInLane(lane, async () => { + markActive(); + await activeRelease; + }); + await activeStarted; + const queued = enqueueCommandInLane(lane, async () => undefined); + + try { + const payload = await requestLaneDiagnostics(); + expect(payload.ts).toBeGreaterThan(0); + expect(payload.lanes.map((snapshot) => snapshot.lane)).toEqual([ + CommandLane.Cron, + CommandLane.CronNested, + CommandLane.HookDispatch, + CommandLane.Main, + CommandLane.Nested, + CommandLane.SkillWorkshopReview, + CommandLane.Subagent, + CommandLane.SystemAgent, + ]); + expect(payload.lanes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + lane, + activeCount: 1, + queuedCount: 1, + maxConcurrent: 1, + blockedBy: "lane", + }), + ]), + ); + } finally { + releaseActive(); + await Promise.all([active, queued]); + setCommandLaneConcurrency(lane, originalConcurrency); + } + }); + + it("aggregates saturated dynamic session lanes without exporting their names", async () => { + const lane = `session:test-${Date.now()}`; + const before = await requestLaneDiagnostics(); + setCommandLaneConcurrency(lane, 1); + + let releaseActive!: () => void; + let markActive!: () => void; + const activeStarted = new Promise((resolve) => { + markActive = resolve; + }); + const activeRelease = new Promise((resolve) => { + releaseActive = resolve; + }); + const active = enqueueCommandInLane(lane, async () => { + markActive(); + await activeRelease; + }); + await activeStarted; + const queued = enqueueCommandInLane(lane, async () => undefined); + + try { + const payload = await requestLaneDiagnostics(); + const baseline = before.dynamic ?? { + laneCount: 0, + activeCount: 0, + queuedCount: 0, + queuedLaneCount: 0, + }; + expect(payload.lanes.map((snapshot) => snapshot.lane)).not.toContain(lane); + expect(payload.dynamic).toEqual({ + laneCount: baseline.laneCount + 1, + activeCount: baseline.activeCount + 1, + queuedCount: baseline.queuedCount + 1, + queuedLaneCount: baseline.queuedLaneCount + 1, + }); + } finally { + releaseActive(); + await Promise.all([active, queued]); + } + }); }); diff --git a/src/gateway/server-methods/diagnostics.ts b/src/gateway/server-methods/diagnostics.ts index 03575e14f8f0..0a3023a2c9c3 100644 --- a/src/gateway/server-methods/diagnostics.ts +++ b/src/gateway/server-methods/diagnostics.ts @@ -5,10 +5,14 @@ import { getDiagnosticStabilitySnapshot, normalizeDiagnosticStabilityQuery, } from "../../logging/diagnostic-stability.js"; +import { getCommandLaneDiagnostics } from "../../process/command-lane-diagnostics.js"; import type { GatewayRequestHandlers } from "./types.js"; /** Gateway handler for payload-free stability diagnostics. */ export const diagnosticsHandlers: GatewayRequestHandlers = { + "diagnostics.lanes": ({ respond }) => { + respond(true, { ts: Date.now(), ...getCommandLaneDiagnostics() }, undefined); + }, "diagnostics.stability": async ({ params, respond }) => { try { // Normalization owns parameter bounds so malformed diagnostic requests diff --git a/src/process/command-lane-diagnostics.ts b/src/process/command-lane-diagnostics.ts new file mode 100644 index 000000000000..b5e8d6838f0e --- /dev/null +++ b/src/process/command-lane-diagnostics.ts @@ -0,0 +1,43 @@ +// Bounded diagnostics composition over the command queue's lane totals. +// Static lanes get full snapshots; per-session (dynamic) lanes collapse into +// one aggregate so a saturation snapshot can never become an unbounded payload. +import { + type CommandLaneSnapshot, + getCommandLaneSnapshot, + listCommandLaneTotals, +} from "./command-queue.js"; +import { STATIC_COMMAND_LANES } from "./lanes.js"; + +type DynamicCommandLaneSummary = { + laneCount: number; + activeCount: number; + queuedCount: number; + queuedLaneCount: number; +}; + +const STATIC_COMMAND_LANE_SET: ReadonlySet = new Set(STATIC_COMMAND_LANES); + +export function getCommandLaneDiagnostics(): { + lanes: CommandLaneSnapshot[]; + dynamic: DynamicCommandLaneSummary | null; +} { + const lanes = [...STATIC_COMMAND_LANES].toSorted().map((lane) => getCommandLaneSnapshot(lane)); + const dynamic: DynamicCommandLaneSummary = { + laneCount: 0, + activeCount: 0, + queuedCount: 0, + queuedLaneCount: 0, + }; + for (const totals of listCommandLaneTotals()) { + if (STATIC_COMMAND_LANE_SET.has(totals.lane)) { + continue; + } + dynamic.laneCount += 1; + dynamic.activeCount += totals.activeCount; + dynamic.queuedCount += totals.queuedCount; + if (totals.queuedCount > 0) { + dynamic.queuedLaneCount += 1; + } + } + return { lanes, dynamic: dynamic.laneCount > 0 ? dynamic : null }; +} diff --git a/src/process/command-queue.ts b/src/process/command-queue.ts index 94b15c267e83..57a63b8c5bd6 100644 --- a/src/process/command-queue.ts +++ b/src/process/command-queue.ts @@ -681,6 +681,19 @@ export function getCommandLaneSnapshot(lane: string = CommandLane.Main): Command return createCommandLaneSnapshot(state); } +/** Per-lane work totals for every live lane; diagnostics composition lives in command-lane-diagnostics.ts. */ +export function listCommandLaneTotals(): Array<{ + lane: string; + activeCount: number; + queuedCount: number; +}> { + return [...getQueueState().lanes.values()].map((state) => ({ + lane: state.lane, + activeCount: state.activeTaskIds.size, + queuedCount: state.queue.length, + })); +} + /** * Active task ids for a lane. Ids are process-monotonic, so recovery can * detect a turn that started after a point in time it captured earlier. diff --git a/src/process/lanes.ts b/src/process/lanes.ts index af8abef50830..6b8e54bb344b 100644 --- a/src/process/lanes.ts +++ b/src/process/lanes.ts @@ -14,3 +14,16 @@ export const enum CommandLane { Subagent = "subagent", Nested = "nested", } + +// Keep the exported diagnostics inventory closed so per-session lanes cannot +// turn a saturation snapshot into an unbounded payload. +export const STATIC_COMMAND_LANES = [ + CommandLane.Main, + CommandLane.SystemAgent, + CommandLane.Cron, + CommandLane.CronNested, + CommandLane.HookDispatch, + CommandLane.SkillWorkshopReview, + CommandLane.Subagent, + CommandLane.Nested, +] as const; diff --git a/ui/src/app/app-host.test.ts b/ui/src/app/app-host.test.ts index ac2ff68eba51..0617c3e697d2 100644 --- a/ui/src/app/app-host.test.ts +++ b/ui/src/app/app-host.test.ts @@ -15,6 +15,7 @@ import { } from "../components/panel-toggle-contract.ts"; import { i18n } from "../i18n/index.ts"; import { SESSION_FACE_PREFERENCE_PARAM } from "../lib/sessions/route-navigation.ts"; +import { DEBUG_OVERLAY_TOGGLE_EVENT } from "../pages/debug/debug-overlay-contract.ts"; import { createStorageMock } from "../test-helpers/storage.ts"; import { selectShellRouteState } from "./app-host-route-state.ts"; import { @@ -81,6 +82,10 @@ type ShellLazySurfaceState = ShellKeyboardState & { commandPaletteElement: TestOptionalCustomElement; }; +type ShellDebugOverlayState = ShellKeyboardState & { + debugOverlayElement: TestOptionalCustomElement; +}; + type ShellApprovalLazyState = { approvalOverlay?: { show: () => void }; execApprovalElement: TestOptionalCustomElement; @@ -856,6 +861,47 @@ describe("OpenClaw shell keyboard shortcuts", () => { await vi.waitFor(() => expect(togglePalette).toHaveBeenCalledOnce()); }); + it("loads the debug overlay shortcut and ignores editable targets", async () => { + const element = createLazyElementSpec("debug overlay"); + const shell = document.createElement("openclaw-app-shell") as unknown as ShellDebugOverlayState; + shell.debugOverlayElement = element; + Object.defineProperty(shell, "updateComplete", { + get: () => Promise.resolve(true), + }); + const toggled = vi.fn(); + window.addEventListener(DEBUG_OVERLAY_TOGGLE_EVENT, toggled); + try { + const shortcut = new KeyboardEvent("keydown", { + key: "d", + code: "KeyD", + ctrlKey: true, + shiftKey: true, + cancelable: true, + }); + shell.handleDocumentKeydown(shortcut); + + expect(shortcut.defaultPrevented).toBe(true); + await vi.waitFor(() => expect(toggled).toHaveBeenCalledOnce()); + + const input = document.body.appendChild(document.createElement("input")); + input.addEventListener("keydown", (event) => shell.handleDocumentKeydown(event)); + const editableShortcut = new KeyboardEvent("keydown", { + key: "d", + code: "KeyD", + ctrlKey: true, + shiftKey: true, + bubbles: true, + cancelable: true, + }); + input.dispatchEvent(editableShortcut); + + expect(editableShortcut.defaultPrevented).toBe(false); + expect(toggled).toHaveBeenCalledOnce(); + } finally { + window.removeEventListener(DEBUG_OVERLAY_TOGGLE_EVENT, toggled); + } + }); + it("opens approvals after the modal module loads on demand", async () => { const element = createLazyElementSpec("exec approval modal"); const show = vi.fn(); diff --git a/ui/src/app/app-host.ts b/ui/src/app/app-host.ts index 741f17b7825b..bf194c16b748 100644 --- a/ui/src/app/app-host.ts +++ b/ui/src/app/app-host.ts @@ -65,6 +65,7 @@ import { BROWSER_PANEL_ELEMENT, COMMAND_PALETTE_ELEMENT, CUSTODIAN_PANEL_ELEMENT, + DEBUG_OVERLAY_ELEMENT, DESKTOP_PANEL_ELEMENT, EXEC_APPROVAL_ELEMENT, preloadOptionalElement, @@ -132,6 +133,7 @@ class OpenClawShell @state() routeState: ShellRouteState = {}; @state() nativeHistoryState: NativeHistoryState = readNativeHistoryState(); readonly commandPaletteElement = COMMAND_PALETTE_ELEMENT; + readonly debugOverlayElement = DEBUG_OVERLAY_ELEMENT; readonly terminalPanelElement = TERMINAL_PANEL_ELEMENT; readonly browserPanelElement = BROWSER_PANEL_ELEMENT; readonly desktopPanelElement = DESKTOP_PANEL_ELEMENT; diff --git a/ui/src/app/app-shell-chrome.ts b/ui/src/app/app-shell-chrome.ts index f1563541c4aa..0ccfcb0b824c 100644 --- a/ui/src/app/app-shell-chrome.ts +++ b/ui/src/app/app-shell-chrome.ts @@ -24,6 +24,10 @@ import { canCallGatewayMethod, isGatewayMethodAdvertised } from "../lib/gateway- import { resolveAsciiShortcutKey } from "../lib/keyboard-shortcuts.ts"; import { readSessionMethodAccess } from "../lib/session-method-access.ts"; import { isTerminalAvailable } from "../lib/terminal-availability.ts"; +import { + DEBUG_OVERLAY_REQUEST_EVENT, + DEBUG_OVERLAY_TOGGLE_EVENT, +} from "../pages/debug/debug-overlay-contract.ts"; import type { ShellRouteState } from "./app-host-route-state.ts"; import type { ApplicationContext, ApplicationNavigationOptions } from "./context.ts"; import { @@ -70,6 +74,7 @@ export interface ShellChromeHost extends HTMLElement { readonly onboardingMode: boolean; readonly updateComplete: Promise; readonly commandPaletteElement: OptionalCustomElement; + readonly debugOverlayElement: OptionalCustomElement; readonly terminalPanelElement: OptionalCustomElement; readonly browserPanelElement: OptionalCustomElement; readonly desktopPanelElement: OptionalCustomElement; @@ -112,6 +117,7 @@ export class ShellChromeOwner { host.addEventListener(COMMAND_PALETTE_TARGET_EVENT, this.handleCommandPaletteTarget); window.addEventListener(COMMAND_PALETTE_OPEN_EVENT, this.openPalette); window.addEventListener(SHELL_NAV_DRAWER_TOGGLE_EVENT, this.handleShellNavDrawerToggle); + window.addEventListener(DEBUG_OVERLAY_REQUEST_EVENT, this.handleDebugOverlayRequest); document.addEventListener("keydown", this.handleDocumentKeydown); window.addEventListener("resize", this.handleWindowResize); window.addEventListener("dragover", this.handleUnhandledFileDrag); @@ -134,6 +140,7 @@ export class ShellChromeOwner { host.removeEventListener(COMMAND_PALETTE_TARGET_EVENT, this.handleCommandPaletteTarget); window.removeEventListener(COMMAND_PALETTE_OPEN_EVENT, this.openPalette); window.removeEventListener(SHELL_NAV_DRAWER_TOGGLE_EVENT, this.handleShellNavDrawerToggle); + window.removeEventListener(DEBUG_OVERLAY_REQUEST_EVENT, this.handleDebugOverlayRequest); document.removeEventListener("keydown", this.handleDocumentKeydown); window.removeEventListener("resize", this.handleWindowResize); window.removeEventListener("dragover", this.handleUnhandledFileDrag); @@ -377,6 +384,19 @@ export class ShellChromeOwner { if (event.defaultPrevented) { return; } + const settingsModifier = event.metaKey !== event.ctrlKey && !event.altKey; + if (settingsModifier && event.shiftKey && resolveAsciiShortcutKey(event) === "d") { + const target = event.target; + if ( + target instanceof Element && + target.closest("input, textarea, [contenteditable]:not([contenteditable='false'])") + ) { + return; + } + event.preventDefault(); + this.toggleDebugOverlay(); + return; + } const plainKey = !event.altKey && !event.shiftKey && !event.metaKey && !event.ctrlKey; if (plainKey && event.key === "Escape" && this.isSettingsTakeover()) { if (host.navDrawerOpen) { @@ -391,7 +411,6 @@ export class ShellChromeOwner { host.exitSettings(); return; } - const settingsModifier = event.metaKey !== event.ctrlKey && !event.altKey; if (settingsModifier && event.shiftKey && event.code === "Comma") { event.preventDefault(); host.navigate("appearance"); @@ -404,6 +423,20 @@ export class ShellChromeOwner { } }; + private readonly handleDebugOverlayRequest = (): void => { + this.toggleDebugOverlay(); + }; + + private toggleDebugOverlay(): void { + const host = this.host; + void ensureOptionalElementForHost(host, host.debugOverlayElement) + .then(async () => { + await host.updateComplete; + window.dispatchEvent(new CustomEvent(DEBUG_OVERLAY_TOGGLE_EVENT)); + }) + .catch(() => undefined); + } + /** Open overlays and editable controls own Escape before settings can exit. */ shouldIgnoreSettingsEscape(event: KeyboardEvent): boolean { const host = this.host; diff --git a/ui/src/app/app-shell-view.ts b/ui/src/app/app-shell-view.ts index 0c2c2c7d2be3..5773e80d588e 100644 --- a/ui/src/app/app-shell-view.ts +++ b/ui/src/app/app-shell-view.ts @@ -87,6 +87,7 @@ export interface ShellViewHost { readonly runtime: ApplicationRuntime | undefined; readonly activeSessionKey: string; readonly commandPaletteElement: OptionalCustomElement; + readonly debugOverlayElement: OptionalCustomElement; readonly custodianMinimizeRequestId: number; readonly desktopNavigationExpanded: boolean; readonly execApprovalElement: OptionalCustomElement; @@ -470,6 +471,9 @@ export function renderApplicationShell(host: ShellViewHost) { .onSlashCommand=${(command: string) => host.handleCommandPaletteSlashCommand(command)} >` : nothing} + ${isOptionalElementDefined(host.debugOverlayElement) + ? html`` + : nothing}
import("../components/command-palette.ts"), } satisfies OptionalCustomElement; +const DEBUG_OVERLAY_TAG = "openclaw-debug-overlay"; + +export const DEBUG_OVERLAY_ELEMENT = { + tagName: DEBUG_OVERLAY_TAG, + label: DEBUG_OVERLAY_TAG, + loadModule: () => import("../pages/debug/debug-overlay.ts"), +} satisfies OptionalCustomElement; + export const TERMINAL_PANEL_ELEMENT = { tagName: "openclaw-terminal-panel", label: "terminal panel", diff --git a/ui/src/components/app-sidebar-agent-menu.ts b/ui/src/components/app-sidebar-agent-menu.ts index 81263cd39cbf..4d71e41cbe09 100644 --- a/ui/src/components/app-sidebar-agent-menu.ts +++ b/ui/src/components/app-sidebar-agent-menu.ts @@ -12,6 +12,10 @@ import { normalizeAgentLabel } from "../lib/agents/display.ts"; import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "../lib/external-link.ts"; import { openExternalUrlSafe } from "../lib/open-external-url.ts"; import { normalizeAgentId } from "../lib/sessions/session-key.ts"; +import { + DEBUG_OVERLAY_SHORTCUT_LABEL, + requestDebugOverlayToggle, +} from "../pages/debug/debug-overlay-contract.ts"; import { renderAgentSelectAvatar, renderAgentSelectCopy } from "./agent-select.ts"; import { icons, type IconName } from "./icons.ts"; import "./sidebar-build-chip.ts"; @@ -425,6 +429,9 @@ export function renderSidebarIdentityMenu(params: SidebarIdentityMenuParams) { case `${COMMAND_VALUE_PREFIX}apps`: params.onNavigate("apps"); break; + case `${COMMAND_VALUE_PREFIX}debug-overlay`: + requestDebugOverlayToggle(); + break; case `${COMMAND_VALUE_PREFIX}retry-connect`: params.onRetryConnect?.(); break; @@ -472,6 +479,13 @@ export function renderSidebarIdentityMenu(params: SidebarIdentityMenuParams) { ${t("agentChip.getApps")} + + + ${t("debug.overlay.title")} + + { ], }, "last-heartbeat": { ageMs: 1250, source: "gateway-heartbeat" }, + "diagnostics.lanes": { + lanes: [ + { + lane: "main", + queuedCount: 0, + activeCount: 0, + maxConcurrent: 16, + draining: false, + generation: 1, + }, + ], + dynamic: null, + }, }, }); @@ -70,7 +83,13 @@ suite.define(() => { }); await expect.poll(() => models.textContent()).toContain("gpt-5.6-luna"); - for (const method of ["status", "health", "models.list", "last-heartbeat"]) { + for (const method of [ + "status", + "health", + "models.list", + "last-heartbeat", + "diagnostics.lanes", + ]) { const requests = await gateway.getRequests(method); expect(requests.length).toBeGreaterThanOrEqual(1); expect(requests[0]?.params).toEqual( diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index fc676c955773..dc08b2b7171e 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -1347,6 +1347,32 @@ export const en: TranslationMap = { eventLogTitle: "Event Log", eventLogSubtitle: "Latest gateway events.", noEvents: "No events yet.", + lanes: { + title: "Lanes", + subtitle: "Live command-lane capacity and queue pressure.", + lane: "Lane", + sessionLanes: "Session lanes · {count}", + active: "Active", + queued: "Queued", + group: "Group", + blocked: "Blocked", + }, + overlay: { + title: "System busyness", + eyebrow: "Live diagnostics", + openWithShortcut: "Open overlay · {shortcut}", + unavailable: "Unavailable", + lanes: "Lanes", + status: "Event loop / status", + activeRuns: "Active runs", + events: "Events", + utilization: "Utilization", + delayP99: "Delay p99", + delayMax: "Delay max", + uptime: "Uptime", + activeRunsCount: "{count} active", + noActiveRuns: "No active runs.", + }, }, configForm: { redactedPlaceholder: "[redacted - click reveal to view]", diff --git a/ui/src/lib/gateway-diagnostics.test.ts b/ui/src/lib/gateway-diagnostics.test.ts index 8f4d84e60b54..0b52c4b9f1a5 100644 --- a/ui/src/lib/gateway-diagnostics.test.ts +++ b/ui/src/lib/gateway-diagnostics.test.ts @@ -4,9 +4,15 @@ import { loadGatewayDiagnostics } from "./gateway-diagnostics.ts"; describe("loadGatewayDiagnostics", () => { it("reads only the prepared model catalog during automatic diagnostics", async () => { - const request = vi.fn(async (method: string) => - method === "models.list" ? { models: [] } : {}, - ); + const request = vi.fn(async (method: string) => { + if (method === "models.list") { + return { models: [] }; + } + if (method === "diagnostics.lanes") { + return { lanes: [], dynamic: null }; + } + return {}; + }); await loadGatewayDiagnostics({ request } as unknown as GatewayBrowserClient, "writer"); @@ -18,7 +24,9 @@ describe("loadGatewayDiagnostics", () => { }); it("keeps diagnostics available without requesting models before agent selection", async () => { - const request = vi.fn(async (_method: string) => ({})); + const request = vi.fn(async (method: string) => + method === "diagnostics.lanes" ? { lanes: [], dynamic: null } : {}, + ); const result = await loadGatewayDiagnostics( { request } as unknown as GatewayBrowserClient, @@ -27,6 +35,7 @@ describe("loadGatewayDiagnostics", () => { expect(result.models).toEqual([]); expect(request.mock.calls.map(([method]) => method)).toEqual([ + "diagnostics.lanes", "status", "health", "last-heartbeat", diff --git a/ui/src/lib/gateway-diagnostics.ts b/ui/src/lib/gateway-diagnostics.ts index 48b2bc438d42..29842d35074b 100644 --- a/ui/src/lib/gateway-diagnostics.ts +++ b/ui/src/lib/gateway-diagnostics.ts @@ -1,13 +1,50 @@ import type { GatewayBrowserClient } from "../api/gateway.ts"; import type { HealthSnapshot, StatusSummary } from "../api/types.ts"; +type CommandLaneBlockReason = "lane" | "group-budget" | "sibling-reservation" | null; + +export type CommandLaneSnapshot = { + lane: string; + queuedCount: number; + activeCount: number; + maxConcurrent: number; + draining: boolean; + generation: number; + group?: string; + groupActive?: number; + groupBudget?: number; + reservedForLane?: number; + blockedBy?: CommandLaneBlockReason; +}; + +export type CommandLaneDynamicSummary = { + laneCount: number; + activeCount: number; + queuedCount: number; + queuedLaneCount: number; +}; + +export type CommandLaneDiagnostics = { + lanes: CommandLaneSnapshot[]; + dynamic: CommandLaneDynamicSummary | null; +}; + type GatewayDiagnosticsSnapshot = { status: StatusSummary; health: HealthSnapshot; models: unknown[]; heartbeat: unknown; + lanes: CommandLaneSnapshot[]; + dynamic: CommandLaneDynamicSummary | null; }; +export async function loadCommandLaneDiagnostics( + client: GatewayBrowserClient, + signal?: AbortSignal, +): Promise { + return client.request("diagnostics.lanes", {}, { signal }); +} + export async function loadGatewayDiagnostics( client: GatewayBrowserClient, agentId: string | null, @@ -16,11 +53,13 @@ export async function loadGatewayDiagnostics( const modelsRequest = agentId ? client.request("models.list", { agentId, preparedOnly: true }, { signal }) : Promise.resolve({ models: [] }); - const [status, health, models, heartbeat] = await Promise.all([ + const lanesRequest = loadCommandLaneDiagnostics(client, signal); + const [status, health, models, heartbeat, laneDiagnostics] = await Promise.all([ client.request("status", {}, { signal }), client.request("health", {}, { signal }), modelsRequest, client.request("last-heartbeat", {}, { signal }), + lanesRequest, ]); const modelPayload = models as { models?: unknown[] } | undefined; return { @@ -28,5 +67,6 @@ export async function loadGatewayDiagnostics( health: health as HealthSnapshot, models: Array.isArray(modelPayload?.models) ? modelPayload.models : [], heartbeat, + ...laneDiagnostics, }; } diff --git a/ui/src/pages/debug/debug-overlay-contract.ts b/ui/src/pages/debug/debug-overlay-contract.ts new file mode 100644 index 000000000000..83f03ba20717 --- /dev/null +++ b/ui/src/pages/debug/debug-overlay-contract.ts @@ -0,0 +1,12 @@ +export const DEBUG_OVERLAY_REQUEST_EVENT = "openclaw:debug-overlay-request"; +export const DEBUG_OVERLAY_TOGGLE_EVENT = "openclaw:debug-overlay-toggle"; + +export const DEBUG_OVERLAY_SHORTCUT_LABEL = /Mac|iP(hone|ad|od)/i.test( + globalThis.navigator?.platform ?? "", +) + ? "⌘⇧D" + : "Ctrl+Shift+D"; + +export function requestDebugOverlayToggle(): void { + window.dispatchEvent(new CustomEvent(DEBUG_OVERLAY_REQUEST_EVENT)); +} diff --git a/ui/src/pages/debug/debug-overlay-sections.ts b/ui/src/pages/debug/debug-overlay-sections.ts new file mode 100644 index 000000000000..1293f411614a --- /dev/null +++ b/ui/src/pages/debug/debug-overlay-sections.ts @@ -0,0 +1,184 @@ +import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; +import { html, type TemplateResult } from "lit"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import type { ApplicationGateway } from "../../app/gateway.ts"; +import { t } from "../../i18n/index.ts"; +import { + formatDurationCompact, + formatDurationHuman, + formatRelativeTimestamp, +} from "../../lib/format.ts"; +import { + loadCommandLaneDiagnostics, + type CommandLaneDiagnostics, +} from "../../lib/gateway-diagnostics.ts"; +import { renderCommandLaneRows } from "./lane-table.ts"; + +type DebugOverlaySectionContext = { + client: GatewayBrowserClient; + gateway: ApplicationGateway; +}; + +type TypedDebugOverlaySectionDescriptor = { + id: string; + titleKey: string; + load: (context: DebugOverlaySectionContext, signal: AbortSignal) => Promise; + render: (value: T) => TemplateResult; +}; + +export type DebugOverlaySectionDescriptor = TypedDebugOverlaySectionDescriptor; + +function defineDebugOverlaySection( + descriptor: TypedDebugOverlaySectionDescriptor, +): DebugOverlaySectionDescriptor { + return { + ...descriptor, + render: (value) => { + // SAFETY: This closure keeps each descriptor's load result paired with its own renderer. + return descriptor.render(value as T); + }, + }; +} + +type EventLoopSnapshot = { + utilization?: number; + delayP99Ms?: number; + delayMaxMs?: number; +}; + +type StatusSectionValue = { + eventLoop?: EventLoopSnapshot; + uptimeMs?: number; +}; + +type ActiveSession = { + key?: string; + sessionId?: string; +}; + +function renderLanes(diagnostics: CommandLaneDiagnostics): TemplateResult { + return html` +
+ + + + + + + + + + + ${renderCommandLaneRows(diagnostics, { compact: true })} + +
${t("debug.lanes.lane")}${t("debug.lanes.active")}${t("debug.lanes.queued")}${t("debug.lanes.blocked")}
+
+ `; +} + +function renderStatus(status: StatusSectionValue): TemplateResult { + const eventLoop = status.eventLoop; + const utilization = + typeof eventLoop?.utilization === "number" + ? `${Math.round(eventLoop.utilization * 100)}%` + : t("common.na"); + const delay = + typeof eventLoop?.delayP99Ms === "number" + ? formatDurationCompact(eventLoop.delayP99Ms) + : t("common.na"); + const maxDelay = + typeof eventLoop?.delayMaxMs === "number" + ? formatDurationCompact(eventLoop.delayMaxMs) + : t("common.na"); + return html` +
+
+
${t("debug.overlay.utilization")}
+
${utilization}
+
+
+
${t("debug.overlay.delayP99")}
+
${delay}
+
+
+
${t("debug.overlay.delayMax")}
+
${maxDelay}
+
+ ${typeof status.uptimeMs === "number" + ? html`
+
${t("debug.overlay.uptime")}
+
${formatDurationHuman(status.uptimeMs)}
+
` + : ""} +
+ `; +} + +function renderActiveRuns(sessions: ActiveSession[]): TemplateResult { + return html` +
+ ${t("debug.overlay.activeRunsCount", { count: String(sessions.length) })} +
+ ${sessions.length > 0 + ? html`
    + ${sessions.map((session) => { + const id = session.sessionId ?? session.key ?? t("common.unknown"); + return html`
  • ${truncateUtf16Safe(id, 32)}
  • `; + })} +
` + : html`
${t("debug.overlay.noActiveRuns")}
`} + `; +} + +function renderEvents(gateway: ApplicationGateway): TemplateResult { + // The store prepends: eventLog is newest-first, so the head is the live tail. + const events = gateway.eventLog.slice(0, 8); + return events.length > 0 + ? html`
    + ${events.map( + (event) => html`
  • + ${event.event} + +
  • `, + )} +
` + : html`
${t("debug.noEvents")}
`; +} + +export const DEBUG_OVERLAY_SECTIONS: readonly DebugOverlaySectionDescriptor[] = [ + defineDebugOverlaySection({ + id: "lanes", + titleKey: "debug.overlay.lanes", + load: (context, signal) => loadCommandLaneDiagnostics(context.client, signal), + render: renderLanes, + }), + defineDebugOverlaySection({ + id: "status", + titleKey: "debug.overlay.status", + load: async (context, signal) => { + const value = await context.client.request("status", {}, { signal }); + return { + eventLoop: value.eventLoop, + ...(typeof value.uptimeMs === "number" ? { uptimeMs: value.uptimeMs } : {}), + } satisfies StatusSectionValue; + }, + render: renderStatus, + }), + defineDebugOverlaySection({ + id: "active-runs", + titleKey: "debug.overlay.activeRuns", + load: async (context, signal) => { + const payload = await context.client.request<{ + sessions?: Array; + }>("sessions.list", {}, { signal }); + return (payload.sessions ?? []).filter((session) => session.hasActiveRun === true); + }, + render: renderActiveRuns, + }), + defineDebugOverlaySection({ + id: "events", + titleKey: "debug.overlay.events", + load: async (context) => context.gateway, + render: renderEvents, + }), +]; diff --git a/ui/src/pages/debug/debug-overlay.ts b/ui/src/pages/debug/debug-overlay.ts new file mode 100644 index 000000000000..2436c1d9d91a --- /dev/null +++ b/ui/src/pages/debug/debug-overlay.ts @@ -0,0 +1,193 @@ +import { consume } from "@lit/context"; +import { html, nothing } from "lit"; +import { state as litState } from "lit/decorators.js"; +import { applicationContext, type ApplicationContext } from "../../app/context.ts"; +import { t } from "../../i18n/index.ts"; +import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts"; +import { PollController } from "../../lit/poll-controller.ts"; +import "../../styles/debug.css"; +import { DEBUG_OVERLAY_TOGGLE_EVENT } from "./debug-overlay-contract.ts"; +import { + DEBUG_OVERLAY_SECTIONS, + type DebugOverlaySectionDescriptor, +} from "./debug-overlay-sections.ts"; + +const DEBUG_OVERLAY_POLL_INTERVAL_MS = 2000; + +type SectionState = + | { status: "loading" } + | { status: "ready"; value: unknown } + | { status: "unavailable" }; + +export class DebugOverlay extends OpenClawLightDomElement { + @consume({ context: applicationContext, subscribe: true }) + private context?: ApplicationContext; + + @litState() private open = false; + @litState() private sections = new Map(); + + private requestController: AbortController | null = null; + private requestActive = false; + private requestGeneration = 0; + private eventLogSource: ApplicationContext["gateway"] | null = null; + private unsubscribeEventLog: (() => void) | null = null; + private readonly polling = new PollController( + this, + DEBUG_OVERLAY_POLL_INTERVAL_MS, + () => void this.refreshSections(), + false, + ); + + override connectedCallback(): void { + super.connectedCallback(); + window.addEventListener(DEBUG_OVERLAY_TOGGLE_EVENT, this.handleToggle); + } + + override disconnectedCallback(): void { + window.removeEventListener(DEBUG_OVERLAY_TOGGLE_EVENT, this.handleToggle); + this.close(); + super.disconnectedCallback(); + } + + protected override updated(): void { + if (this.open) { + this.syncEventLogSubscription(); + } + } + + private readonly handleToggle = (): void => { + if (this.open) { + this.close(); + return; + } + this.open = true; + document.addEventListener("keydown", this.handleKeydown, true); + this.syncEventLogSubscription(); + this.sections = new Map( + DEBUG_OVERLAY_SECTIONS.map((section) => [section.id, { status: "loading" }]), + ); + void this.refreshSections(); + this.polling.start(); + }; + + private readonly handleKeydown = (event: KeyboardEvent): void => { + if (event.key !== "Escape" || event.defaultPrevented) { + return; + } + event.preventDefault(); + this.close(); + }; + + private readonly close = (): void => { + if (!this.open && !this.requestController && !this.unsubscribeEventLog) { + return; + } + this.open = false; + this.polling.stop(); + document.removeEventListener("keydown", this.handleKeydown, true); + this.requestGeneration += 1; + this.requestController?.abort(); + this.requestController = null; + this.requestActive = false; + this.unsubscribeEventLog?.(); + this.unsubscribeEventLog = null; + this.eventLogSource = null; + }; + + private syncEventLogSubscription(): void { + const gateway = this.context?.gateway ?? null; + if (!this.open || gateway === this.eventLogSource) { + return; + } + this.unsubscribeEventLog?.(); + this.eventLogSource = gateway; + this.unsubscribeEventLog = gateway?.subscribeEventLog(() => this.requestUpdate()) ?? null; + } + + private async refreshSections(): Promise { + const gateway = this.context?.gateway; + const client = gateway?.snapshot.phase === "connected" ? gateway.snapshot.client : null; + if (!this.open || this.requestActive) { + return; + } + if (!gateway || !client) { + this.sections = new Map( + DEBUG_OVERLAY_SECTIONS.map((section) => [section.id, { status: "unavailable" }]), + ); + return; + } + this.requestActive = true; + const generation = ++this.requestGeneration; + const controller = new AbortController(); + this.requestController?.abort(); + this.requestController = controller; + const requests = DEBUG_OVERLAY_SECTIONS.map(async (section): Promise => { + try { + const value = await section.load({ client, gateway }, controller.signal); + this.updateSection(generation, section.id, { status: "ready", value }); + } catch { + this.updateSection(generation, section.id, { status: "unavailable" }); + } + }); + await Promise.allSettled(requests); + if (!this.open || generation !== this.requestGeneration) { + return; + } + this.requestController = null; + this.requestActive = false; + } + + private updateSection(generation: number, id: string, state: SectionState): void { + if (!this.open || generation !== this.requestGeneration) { + return; + } + const next = new Map(this.sections); + next.set(id, state); + this.sections = next; + } + + private renderSection(section: DebugOverlaySectionDescriptor) { + const state = this.sections.get(section.id) ?? { status: "loading" }; + return html` +
+

${t(section.titleKey)}

+ ${state.status === "loading" + ? html`
${t("common.loading")}
` + : state.status === "unavailable" + ? html`
${t("debug.overlay.unavailable")}
` + : section.render(state.value)} +
+ `; + } + + override render() { + if (!this.open) { + return nothing; + } + return html` + + `; + } +} + +if (!customElements.get("openclaw-debug-overlay")) { + customElements.define("openclaw-debug-overlay", DebugOverlay); +} diff --git a/ui/src/pages/debug/debug-page.ts b/ui/src/pages/debug/debug-page.ts index 78d3e6e60bc1..e7ba09195ea2 100644 --- a/ui/src/pages/debug/debug-page.ts +++ b/ui/src/pages/debug/debug-page.ts @@ -9,11 +9,17 @@ import { titleForRoute } from "../../app-navigation.ts"; import { applicationContext, type ApplicationContext } from "../../app/context.ts"; import { renderSettingsWorkspace } from "../../components/settings-workspace.ts"; import { formatUiError } from "../../lib/format-error.ts"; -import { loadGatewayDiagnostics } from "../../lib/gateway-diagnostics.ts"; +import { + type CommandLaneDynamicSummary, + type CommandLaneSnapshot, + loadGatewayDiagnostics, +} from "../../lib/gateway-diagnostics.ts"; import { GatewayPageController } from "../../lit/gateway-page-controller.ts"; import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts"; import { PollController } from "../../lit/poll-controller.ts"; import { SubscriptionsController } from "../../lit/subscriptions-controller.ts"; +import "../../styles/debug.css"; +import { requestDebugOverlayToggle } from "./debug-overlay-contract.ts"; import { renderDebug } from "./view.ts"; const DEBUG_POLL_INTERVAL_MS = 3000; @@ -26,6 +32,8 @@ class DebugPage extends OpenClawLightDomElement { @state() private debugHealth: HealthSnapshot | null = null; @state() private debugModels: unknown[] = []; @state() private debugHeartbeat: unknown = null; + @state() private debugLanes: CommandLaneSnapshot[] = []; + @state() private debugDynamic: CommandLaneDynamicSummary | null = null; @state() private debugCallMethod = ""; @state() private debugCallParams = "{}"; @state() private debugCallResult: string | null = null; @@ -60,6 +68,8 @@ class DebugPage extends OpenClawLightDomElement { this.debugHealth = result.health; this.debugModels = result.models; this.debugHeartbeat = result.heartbeat; + this.debugLanes = result.lanes; + this.debugDynamic = result.dynamic; }, onError: (error) => { this.diagnosticsTaskActiveClient = null; @@ -73,6 +83,8 @@ class DebugPage extends OpenClawLightDomElement { this.debugHealth = null; this.debugModels = []; this.debugHeartbeat = null; + this.debugLanes = []; + this.debugDynamic = null; this.debugCallResult = null; this.debugCallError = null; this.debugDiagnosticsError = null; @@ -186,6 +198,8 @@ class DebugPage extends OpenClawLightDomElement { health: this.debugHealth, models: this.debugModels, heartbeat: this.debugHeartbeat, + lanes: this.debugLanes, + dynamic: this.debugDynamic, diagnosticsError: this.debugDiagnosticsError, eventLog: this.eventLog, methods: (this.context.gateway.snapshot.hello?.features?.methods ?? []).toSorted(), @@ -196,6 +210,7 @@ class DebugPage extends OpenClawLightDomElement { onCallMethodChange: (next) => (this.debugCallMethod = next), onCallParamsChange: (next) => (this.debugCallParams = next), onRefresh: () => void this.loadDiagnostics(), + onOpenOverlay: requestDebugOverlayToggle, onCall: () => void this.callDebugMethod(), }); return html` diff --git a/ui/src/pages/debug/lane-table.ts b/ui/src/pages/debug/lane-table.ts new file mode 100644 index 000000000000..32b498fc624e --- /dev/null +++ b/ui/src/pages/debug/lane-table.ts @@ -0,0 +1,54 @@ +import { html } from "lit"; +import { t } from "../../i18n/index.ts"; +import type { CommandLaneDiagnostics } from "../../lib/gateway-diagnostics.ts"; + +export function renderCommandLaneRows( + diagnostics: CommandLaneDiagnostics, + options: { compact?: boolean } = {}, +) { + const rows = diagnostics.lanes.map((lane) => { + const saturated = lane.activeCount >= lane.maxConcurrent; + const queued = lane.queuedCount > 0; + const classes = [ + "command-lane-row", + saturated ? "command-lane-row--saturated" : "", + queued ? "command-lane-row--queued" : "", + ] + .filter(Boolean) + .join(" "); + const group = lane.group + ? `${lane.group} · ${lane.groupActive ?? 0}/${lane.groupBudget ?? 0}` + : ""; + return html` + + ${lane.lane} + ${lane.activeCount}/${lane.maxConcurrent} + ${lane.queuedCount} + ${options.compact ? "" : html`${group}`} + ${lane.blockedBy ?? "—"} + + `; + }); + const dynamic = diagnostics.dynamic; + if (dynamic) { + const classes = [ + "command-lane-row", + "command-lane-row--dynamic", + dynamic.queuedCount > 0 ? "command-lane-row--queued" : "", + ] + .filter(Boolean) + .join(" "); + rows.push(html` + + + ${t("debug.lanes.sessionLanes", { count: String(dynamic.laneCount) })} + + ${dynamic.activeCount} + ${dynamic.queuedCount} + ${options.compact ? "" : html``} + — + + `); + } + return rows; +} diff --git a/ui/src/pages/debug/view.test.ts b/ui/src/pages/debug/view.test.ts index 5152ce5ef223..bb237a3e757e 100644 --- a/ui/src/pages/debug/view.test.ts +++ b/ui/src/pages/debug/view.test.ts @@ -9,7 +9,13 @@ import "./debug-page.ts"; import { renderDebug } from "./view.ts"; type DebugProps = Parameters[0]; -const DIAGNOSTIC_METHODS = ["status", "health", "models.list", "last-heartbeat"] as const; +const DIAGNOSTIC_METHODS = [ + "diagnostics.lanes", + "status", + "health", + "models.list", + "last-heartbeat", +] as const; type DiagnosticMethod = (typeof DIAGNOSTIC_METHODS)[number]; type TestDebugPage = HTMLElement & { @@ -22,6 +28,7 @@ type TestDebugPage = HTMLElement & { debugDiagnosticsError: string | null; debugHealth: unknown; debugHeartbeat: unknown; + debugLanes: unknown[]; debugModels: unknown[]; debugStatus: unknown; loadDiagnostics: () => Promise; @@ -68,6 +75,22 @@ function diagnosticResponse(method: string, marker = "initial"): unknown { return { models: [{ id: marker }] }; case "last-heartbeat": return { source: marker }; + case "diagnostics.lanes": + return { + ts: 1, + lanes: [ + { + lane: marker, + activeCount: 1, + queuedCount: 2, + maxConcurrent: 1, + draining: false, + generation: 0, + blockedBy: "lane", + }, + ], + dynamic: null, + }; default: throw new Error(`Unexpected diagnostics method: ${method}`); } @@ -78,6 +101,7 @@ function expectSnapshots(page: TestDebugPage, marker: string): void { expect(page.debugHealth).toEqual({ marker, ok: true }); expect(page.debugModels).toEqual([{ id: marker }]); expect(page.debugHeartbeat).toEqual({ source: marker }); + expect(page.debugLanes).toEqual([expect.objectContaining({ lane: marker })]); } function createProps(overrides: Partial = {}): DebugProps { @@ -87,6 +111,8 @@ function createProps(overrides: Partial = {}): DebugProps { health: null, models: [], heartbeat: null, + lanes: [], + dynamic: null, diagnosticsError: null, eventLog: [], methods: [], @@ -97,6 +123,7 @@ function createProps(overrides: Partial = {}): DebugProps { onCallMethodChange: () => undefined, onCallParamsChange: () => undefined, onRefresh: () => undefined, + onOpenOverlay: () => undefined, onCall: () => undefined, ...overrides, }; @@ -170,6 +197,45 @@ describe("renderDebug", () => { expect(container.textContent).toContain("gateway"); expect(container.textContent).not.toContain("Invalid Date"); }); + + it("renders lane diagnostics as an emphasized table", () => { + const container = document.createElement("div"); + render( + renderDebug( + createProps({ + lanes: [ + { + lane: "main", + activeCount: 2, + queuedCount: 3, + maxConcurrent: 2, + draining: false, + generation: 0, + group: "interactive", + groupActive: 2, + groupBudget: 4, + blockedBy: "lane", + }, + ], + dynamic: { + laneCount: 23, + activeCount: 9, + queuedCount: 4, + queuedLaneCount: 3, + }, + }), + ), + container, + ); + + const row = container.querySelector(".command-lane-row"); + expect(row?.classList).toContain("command-lane-row--saturated"); + expect(row?.classList).toContain("command-lane-row--queued"); + expect(normalizedText(row)).toContain("main 2/2 3 interactive · 2/4 lane"); + expect(normalizedText(container.querySelector(".command-lane-row--dynamic"))).toContain( + "Session lanes · 23 9 4 —", + ); + }); }); describe("DebugPage", () => { diff --git a/ui/src/pages/debug/view.ts b/ui/src/pages/debug/view.ts index a395cdac46cb..3807875b81b1 100644 --- a/ui/src/pages/debug/view.ts +++ b/ui/src/pages/debug/view.ts @@ -12,7 +12,13 @@ import { } from "../../components/settings-ui.ts"; import { t } from "../../i18n/index.ts"; import { formatTimeMs } from "../../lib/format.ts"; +import type { + CommandLaneDynamicSummary, + CommandLaneSnapshot, +} from "../../lib/gateway-diagnostics.ts"; import { formatEventPayload } from "../../lib/presenter.ts"; +import { DEBUG_OVERLAY_SHORTCUT_LABEL } from "./debug-overlay-contract.ts"; +import { renderCommandLaneRows } from "./lane-table.ts"; type DebugProps = { loading: boolean; @@ -20,6 +26,8 @@ type DebugProps = { health: Record | null; models: unknown[]; heartbeat: unknown; + lanes: CommandLaneSnapshot[]; + dynamic: CommandLaneDynamicSummary | null; diagnosticsError: string | null; eventLog: readonly EventLogEntry[]; methods: string[]; @@ -30,6 +38,7 @@ type DebugProps = { onCallMethodChange: (next: string) => void; onCallParamsChange: (next: string) => void; onRefresh: () => void; + onOpenOverlay: () => void; onCall: () => void; }; @@ -118,6 +127,36 @@ export function renderDebug(props: DebugProps) { `, ); + const lanesSection = renderSettingsSection( + { + title: t("debug.lanes.title"), + description: t("debug.lanes.subtitle"), + actions: html` + + `, + }, + html` +
+ + + + + + + + + + + + ${renderCommandLaneRows({ lanes: props.lanes, dynamic: props.dynamic })} + +
${t("debug.lanes.lane")}${t("debug.lanes.active")}${t("debug.lanes.queued")}${t("debug.lanes.group")}${t("debug.lanes.blocked")}
+
+ `, + ); + const rpcSection = renderSettingsSection( { title: t("debug.manualRpcTitle"), description: t("debug.manualRpcSubtitle") }, html` @@ -194,7 +233,7 @@ ${unsafeHTML(highlightJsonHtml(JSON.stringify(props.models ?? [], null, 2)))}

expect(request).toHaveBeenCalledTimes(3)); + await waitForFast(() => expect(request).toHaveBeenCalledTimes(4)); await replaceContext(page, client); pending.resolve({ models: [{ id: "stale" }], stale: true }); await load; diff --git a/ui/src/styles/debug.css b/ui/src/styles/debug.css new file mode 100644 index 000000000000..37a4244763c4 --- /dev/null +++ b/ui/src/styles/debug.css @@ -0,0 +1,202 @@ +.command-lanes-table-wrap { + max-height: 420px; +} + +.command-lanes-table td { + font-variant-numeric: tabular-nums; +} + +.command-lanes-table .command-lane-row--queued { + background: var(--warn-subtle); +} + +.command-lanes-table .command-lane-row--saturated { + background: var(--danger-subtle); +} + +.command-lane-row__name { + max-width: 320px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +openclaw-debug-overlay { + display: contents; +} + +.debug-overlay { + position: fixed; + right: 16px; + bottom: 16px; + z-index: var(--z-toast); + display: flex; + width: min(560px, calc(100vw - 32px)); + max-height: min(78vh, 760px); + flex-direction: column; + overflow: hidden; + color: var(--text); + background: color-mix(in srgb, var(--bg-elevated) 90%, transparent); + border: 1px solid var(--border-strong); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-xl); + backdrop-filter: blur(18px) saturate(1.15); +} + +.debug-overlay__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 13px 14px 11px; + border-bottom: 1px solid var(--border); +} + +.debug-overlay__header h2, +.debug-overlay__section h3 { + margin: 0; +} + +.debug-overlay__header h2 { + font-size: 15px; + letter-spacing: -0.01em; +} + +.debug-overlay__eyebrow { + margin-bottom: 2px; + color: var(--muted); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.debug-overlay__close { + display: grid; + width: 30px; + height: 30px; + padding: 0; + place-items: center; + color: var(--muted); + font-size: 20px; + line-height: 1; + background: transparent; + border: 1px solid transparent; + border-radius: var(--radius-sm); +} + +.debug-overlay__close:hover { + color: var(--text); + background: var(--bg-hover); + border-color: var(--border); +} + +.debug-overlay__body { + overflow-y: auto; + overscroll-behavior: contain; +} + +.debug-overlay__section { + padding: 12px 14px 14px; + border-bottom: 1px solid var(--border); +} + +.debug-overlay__section:last-child { + border-bottom: 0; +} + +.debug-overlay__section h3 { + margin-bottom: 8px; + color: var(--muted); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.debug-overlay__table-wrap { + max-height: 250px; + overflow: auto; +} + +.command-lanes-table--compact { + font-size: 11px; +} + +.command-lanes-table--compact th, +.command-lanes-table--compact td { + padding: 6px 8px; +} + +.debug-overlay__metrics { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; + margin: 0; +} + +.debug-overlay__metrics > div { + min-width: 0; + padding: 8px; + background: var(--bg-hover); + border: 1px solid var(--border); + border-radius: var(--radius-sm); +} + +.debug-overlay__metrics dt { + overflow: hidden; + color: var(--muted); + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.debug-overlay__metrics dd { + margin: 3px 0 0; + font-size: 12px; +} + +.debug-overlay__count, +.debug-overlay__empty { + color: var(--muted); + font-size: 11px; +} + +.debug-overlay__list { + display: grid; + gap: 5px; + margin: 8px 0 0; + padding: 0; + font-size: 11px; + list-style: none; +} + +.debug-overlay__list li { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.debug-overlay__events li { + display: flex; + justify-content: space-between; + gap: 12px; +} + +.debug-overlay__events time { + flex: none; + color: var(--muted); +} + +@media (max-width: 560px) { + .debug-overlay { + right: 8px; + bottom: 8px; + width: calc(100vw - 16px); + max-height: calc(100vh - 16px); + } + + .debug-overlay__metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} diff --git a/ui/src/test-helpers/app-sidebar-cases/identity-menu.ts b/ui/src/test-helpers/app-sidebar-cases/identity-menu.ts index ff1946816b92..3914e21d29d1 100644 --- a/ui/src/test-helpers/app-sidebar-cases/identity-menu.ts +++ b/ui/src/test-helpers/app-sidebar-cases/identity-menu.ts @@ -49,6 +49,7 @@ describe("AppSidebar footer identity menu", () => { "command:usage", "command:pair-mobile", "command:apps", + "command:debug-overlay", "command:help", ]); expect(menu?.querySelector(".sidebar-identity-menu__header")?.textContent?.trim()).toBe(