feat(gateway/ui): command-lane diagnostics with debug page table and global busyness overlay (#125591)

* feat(gateway): expose command lane diagnostics

* feat(ui): add live debug busyness overlay

* fix(ui): show newest events in debug overlay and update diagnostics call-list tests

* test(ui): add lane and status fixtures to the mocked dashboard

* feat(ui): add System busyness entry to the account menu

* fix(gateway/ui): bound lane diagnostics, append-only descriptor, fail-visible lanes load

Addresses ClawSweeper review findings on #125591: diagnostics.lanes moves to
the append-only tail of the descriptor table, the Control UI lanes request
fails visibly instead of masking errors, and the RPC exports only static
lane snapshots plus a bounded dynamic-session aggregate composed in the new
command-lane-diagnostics module.

* chore(protocol): regenerate Kotlin gateway methods for diagnostics.lanes

* test(gateway/ui): register diagnostics.lanes in the 2026.8 train and mock it in the debug e2e
This commit is contained in:
Peter Steinberger
2026-08-18 01:10:32 -07:00
committed by GitHub
parent 43e0150aa4
commit ad475ce33a
31 changed files with 1255 additions and 13 deletions
@@ -591,6 +591,7 @@ enum class GatewayMethod(
ProgressCardPut("progressCard.put"),
ToolsGithubStatus("tools.github.status"),
ToolsGithubConfigure("tools.github.configure"),
DiagnosticsLanes("diagnostics.lanes"),
}
enum class GatewayEvent(
+76
View File
@@ -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).
+1
View File
@@ -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"]],
@@ -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",
+1
View File
@@ -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>;
+4 -2
View File
@@ -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);
@@ -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<typeof getCommandLaneDiagnostics>;
async function requestLaneDiagnostics(): Promise<LaneDiagnosticsPayload> {
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<void>((resolve) => {
markActive = resolve;
});
const activeRelease = new Promise<void>((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<void>((resolve) => {
markActive = resolve;
});
const activeRelease = new Promise<void>((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]);
}
});
});
@@ -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
+43
View File
@@ -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<string> = 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 };
}
+13
View File
@@ -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.
+13
View File
@@ -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;
+46
View File
@@ -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();
+2
View File
@@ -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;
+34 -1
View File
@@ -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<boolean>;
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;
+4
View File
@@ -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)}
></openclaw-command-palette>`
: nothing}
${isOptionalElementDefined(host.debugOverlayElement)
? html`<openclaw-debug-overlay></openclaw-debug-overlay>`
: nothing}
<div
class="shell ${chatLikeRoute ? "shell--chat" : ""} ${navCollapsed
? "shell--nav-collapsed"
+8
View File
@@ -44,6 +44,14 @@ export const COMMAND_PALETTE_ELEMENT = {
loadModule: () => 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",
@@ -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) {
<span slot="icon" class="nav-item__icon" aria-hidden="true">${icons.layoutGrid}</span>
<span class="sidebar-customize-menu__text">${t("agentChip.getApps")}</span>
</wa-dropdown-item>
<wa-dropdown-item class="sidebar-customize-menu__item" value="command:debug-overlay">
<span slot="icon" class="nav-item__icon" aria-hidden="true">${icons.activity}</span>
<span class="sidebar-customize-menu__text">${t("debug.overlay.title")}</span>
<span slot="details" class="session-menu__shortcut" aria-hidden="true"
>${DEBUG_OVERLAY_SHORTCUT_LABEL}</span
>
</wa-dropdown-item>
<wa-dropdown-item
class="sidebar-customize-menu__item sidebar-identity-menu__help"
value="command:help"
+20 -1
View File
@@ -51,6 +51,19 @@ suite.define(() => {
],
},
"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(
+26
View File
@@ -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]",
+13 -4
View File
@@ -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",
+41 -1
View File
@@ -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<CommandLaneDiagnostics> {
return client.request<CommandLaneDiagnostics>("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,
};
}
@@ -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));
}
@@ -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<T> = {
id: string;
titleKey: string;
load: (context: DebugOverlaySectionContext, signal: AbortSignal) => Promise<T>;
render: (value: T) => TemplateResult;
};
export type DebugOverlaySectionDescriptor = TypedDebugOverlaySectionDescriptor<unknown>;
function defineDebugOverlaySection<T>(
descriptor: TypedDebugOverlaySectionDescriptor<T>,
): 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`
<div class="debug-overlay__table-wrap">
<table class="data-table command-lanes-table command-lanes-table--compact">
<thead>
<tr>
<th>${t("debug.lanes.lane")}</th>
<th>${t("debug.lanes.active")}</th>
<th>${t("debug.lanes.queued")}</th>
<th>${t("debug.lanes.blocked")}</th>
</tr>
</thead>
<tbody>
${renderCommandLaneRows(diagnostics, { compact: true })}
</tbody>
</table>
</div>
`;
}
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`
<dl class="debug-overlay__metrics">
<div>
<dt>${t("debug.overlay.utilization")}</dt>
<dd class="mono">${utilization}</dd>
</div>
<div>
<dt>${t("debug.overlay.delayP99")}</dt>
<dd class="mono">${delay}</dd>
</div>
<div>
<dt>${t("debug.overlay.delayMax")}</dt>
<dd class="mono">${maxDelay}</dd>
</div>
${typeof status.uptimeMs === "number"
? html`<div>
<dt>${t("debug.overlay.uptime")}</dt>
<dd class="mono">${formatDurationHuman(status.uptimeMs)}</dd>
</div>`
: ""}
</dl>
`;
}
function renderActiveRuns(sessions: ActiveSession[]): TemplateResult {
return html`
<div class="debug-overlay__count">
${t("debug.overlay.activeRunsCount", { count: String(sessions.length) })}
</div>
${sessions.length > 0
? html`<ul class="debug-overlay__list">
${sessions.map((session) => {
const id = session.sessionId ?? session.key ?? t("common.unknown");
return html`<li class="mono" title=${id}>${truncateUtf16Safe(id, 32)}</li>`;
})}
</ul>`
: html`<div class="debug-overlay__empty">${t("debug.overlay.noActiveRuns")}</div>`}
`;
}
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`<ul class="debug-overlay__list debug-overlay__events">
${events.map(
(event) => html`<li>
<span class="mono">${event.event}</span>
<time>${formatRelativeTimestamp(event.ts)}</time>
</li>`,
)}
</ul>`
: html`<div class="debug-overlay__empty">${t("debug.noEvents")}</div>`;
}
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<StatusSectionValue>("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<ActiveSession & { hasActiveRun?: boolean }>;
}>("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,
}),
];
+193
View File
@@ -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<string, SectionState>();
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<void> {
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<void> => {
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`
<section class="debug-overlay__section">
<h3>${t(section.titleKey)}</h3>
${state.status === "loading"
? html`<div class="debug-overlay__empty">${t("common.loading")}</div>`
: state.status === "unavailable"
? html`<div class="debug-overlay__empty">${t("debug.overlay.unavailable")}</div>`
: section.render(state.value)}
</section>
`;
}
override render() {
if (!this.open) {
return nothing;
}
return html`
<aside class="debug-overlay" aria-label=${t("debug.overlay.title")}>
<header class="debug-overlay__header">
<div>
<div class="debug-overlay__eyebrow">${t("debug.overlay.eyebrow")}</div>
<h2>${t("debug.overlay.title")}</h2>
</div>
<button
type="button"
class="debug-overlay__close"
aria-label=${t("common.close")}
@click=${this.close}
>
×
</button>
</header>
<div class="debug-overlay__body">
${DEBUG_OVERLAY_SECTIONS.map((section) => this.renderSection(section))}
</div>
</aside>
`;
}
}
if (!customElements.get("openclaw-debug-overlay")) {
customElements.define("openclaw-debug-overlay", DebugOverlay);
}
+16 -1
View File
@@ -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`
+54
View File
@@ -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`
<tr class=${classes}>
<td class="mono command-lane-row__name">${lane.lane}</td>
<td class="mono">${lane.activeCount}/${lane.maxConcurrent}</td>
<td class="mono">${lane.queuedCount}</td>
${options.compact ? "" : html`<td>${group}</td>`}
<td class="mono">${lane.blockedBy ?? "—"}</td>
</tr>
`;
});
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`
<tr class=${classes}>
<td class="mono command-lane-row__name">
${t("debug.lanes.sessionLanes", { count: String(dynamic.laneCount) })}
</td>
<td class="mono">${dynamic.activeCount}</td>
<td class="mono">${dynamic.queuedCount}</td>
${options.compact ? "" : html`<td></td>`}
<td class="mono"></td>
</tr>
`);
}
return rows;
}
+67 -1
View File
@@ -9,7 +9,13 @@ import "./debug-page.ts";
import { renderDebug } from "./view.ts";
type DebugProps = Parameters<typeof renderDebug>[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<void>;
@@ -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> = {}): DebugProps {
@@ -87,6 +111,8 @@ function createProps(overrides: Partial<DebugProps> = {}): DebugProps {
health: null,
models: [],
heartbeat: null,
lanes: [],
dynamic: null,
diagnosticsError: null,
eventLog: [],
methods: [],
@@ -97,6 +123,7 @@ function createProps(overrides: Partial<DebugProps> = {}): 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", () => {
+40 -1
View File
@@ -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<string, unknown> | 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`
<button class="btn" @click=${props.onOpenOverlay}>
${t("debug.overlay.openWithShortcut", { shortcut: DEBUG_OVERLAY_SHORTCUT_LABEL })}
</button>
`,
},
html`
<div class="data-table-container command-lanes-table-wrap">
<table class="data-table command-lanes-table">
<thead>
<tr>
<th>${t("debug.lanes.lane")}</th>
<th>${t("debug.lanes.active")}</th>
<th>${t("debug.lanes.queued")}</th>
<th>${t("debug.lanes.group")}</th>
<th>${t("debug.lanes.blocked")}</th>
</tr>
</thead>
<tbody>
${renderCommandLaneRows({ lanes: props.lanes, dynamic: props.dynamic })}
</tbody>
</table>
</div>
`,
);
const rpcSection = renderSettingsSection(
{ title: t("debug.manualRpcTitle"), description: t("debug.manualRpcSubtitle") },
html`
@@ -194,7 +233,7 @@ ${unsafeHTML(highlightJsonHtml(JSON.stringify(props.models ?? [], null, 2)))}</p
);
return renderSettingsPage(
html`${snapshotsSection} ${rpcSection} ${modelsSection} ${eventLogSection}`,
html`${snapshotsSection} ${lanesSection} ${rpcSection} ${modelsSection} ${eventLogSection}`,
{ wide: true },
);
}
@@ -680,7 +680,7 @@ describe("gateway source replacement across reconnect with a reused client", ()
page.debugStatus = null;
const load = page.diagnosticsTask.run();
await waitForFast(() => expect(request).toHaveBeenCalledTimes(3));
await waitForFast(() => expect(request).toHaveBeenCalledTimes(4));
await replaceContext(page, client);
pending.resolve({ models: [{ id: "stale" }], stale: true });
await load;
+202
View File
@@ -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));
}
}
@@ -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(