mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 11:55:47 -06:00
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:
committed by
GitHub
parent
43e0150aa4
commit
ad475ce33a
@@ -591,6 +591,7 @@ enum class GatewayMethod(
|
||||
ProgressCardPut("progressCard.put"),
|
||||
ToolsGithubStatus("tools.github.status"),
|
||||
ToolsGithubConfigure("tools.github.configure"),
|
||||
DiagnosticsLanes("diagnostics.lanes"),
|
||||
}
|
||||
|
||||
enum class GatewayEvent(
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
];
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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`
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user