Web UI: add token usage dashboard (#10072)

* feat(ui): Token Usage dashboard with session analytics

Adds a comprehensive Token Usage view to the dashboard:

Backend:
- Extended session-cost-usage.ts with per-session daily breakdown
- Added date range filtering (startMs/endMs) to API endpoints
- New sessions.usage, sessions.usage.timeseries, sessions.usage.logs endpoints
- Cost breakdown by token type (input/output/cache read/write)

Frontend:
- Two-column layout: Daily chart + breakdown | Sessions list
- Interactive daily bar chart with click-to-filter and shift-click range select
- Session detail panel with usage timeline, conversation logs, context weight
- Filter chips for active day/session selections
- Toggle between tokens/cost view modes (default: cost)
- Responsive design for smaller screens

UX improvements:
- 21-day default date range
- Debounced date input (400ms)
- Session list shows filtered totals when days selected
- Context weight breakdown shows skills, tools, files contribution

* fix(ui): restore gatewayUrl validation and syncUrlWithSessionKey signature

- Restore normalizeGatewayUrl() to validate ws:/wss: protocol
- Restore isTopLevelWindow() guard for iframe security
- Revert syncUrlWithSessionKey signature (host param was unused)

* feat(ui): Token Usage dashboard with session analytics

Adds a comprehensive Token Usage view to the dashboard:

Backend:
- Extended session-cost-usage.ts with per-session daily breakdown
- Added date range filtering (startMs/endMs) to API endpoints
- New sessions.usage, sessions.usage.timeseries, sessions.usage.logs endpoints
- Cost breakdown by token type (input/output/cache read/write)

Frontend:
- Two-column layout: Daily chart + breakdown | Sessions list
- Interactive daily bar chart with click-to-filter and shift-click range select
- Session detail panel with usage timeline, conversation logs, context weight
- Filter chips for active day/session selections
- Toggle between tokens/cost view modes (default: cost)
- Responsive design for smaller screens

UX improvements:
- 21-day default date range
- Debounced date input (400ms)
- Session list shows filtered totals when days selected
- Context weight breakdown shows skills, tools, files contribution

* fix: usage dashboard data + cost handling (#8462) (thanks @mcinteerj)

* Usage: enrich metrics dashboard

* Usage: add latency + model trends

* Gateway: improve usage log parsing

* UI: add usage query helpers

* UI: client-side usage filter + debounce

* Build: harden write-cli-compat timing

* UI: add conversation log filters

* UI: fix usage dashboard lint + state

* Web UI: default usage dates to local day

* Protocol: sync session usage params (#8462) (thanks @mcinteerj, @TakHoffman)

---------

Co-authored-by: Jake McInteer <mcinteerj@gmail.com>
This commit is contained in:
Tak Hoffman
2026-02-05 22:35:46 -06:00
committed by GitHub
parent b40da2cb7a
commit 8a352c8f9d
28 changed files with 8663 additions and 387 deletions

View File

@@ -3,7 +3,11 @@ import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { loadCostUsageSummary, loadSessionCostSummary } from "./session-cost-usage.js";
import {
discoverAllSessions,
loadCostUsageSummary,
loadSessionCostSummary,
} from "./session-cost-usage.js";
describe("session cost usage", () => {
it("aggregates daily totals with log cost and pricing fallback", async () => {
@@ -140,4 +144,100 @@ describe("session cost usage", () => {
expect(summary?.totalTokens).toBe(30);
expect(summary?.lastActivity).toBeGreaterThan(0);
});
it("captures message counts, tool usage, and model usage", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cost-session-meta-"));
const sessionFile = path.join(root, "session.jsonl");
const start = new Date("2026-02-01T10:00:00.000Z");
const end = new Date("2026-02-01T10:05:00.000Z");
const entries = [
{
type: "message",
timestamp: start.toISOString(),
message: {
role: "user",
content: "Hello",
},
},
{
type: "message",
timestamp: end.toISOString(),
message: {
role: "assistant",
provider: "openai",
model: "gpt-5.2",
stopReason: "error",
content: [
{ type: "text", text: "Checking" },
{ type: "tool_use", name: "weather" },
{ type: "tool_result", is_error: true },
],
usage: {
input: 12,
output: 18,
totalTokens: 30,
cost: { total: 0.02 },
},
},
},
];
await fs.writeFile(
sessionFile,
entries.map((entry) => JSON.stringify(entry)).join("\n"),
"utf-8",
);
const summary = await loadSessionCostSummary({ sessionFile });
expect(summary?.messageCounts).toEqual({
total: 2,
user: 1,
assistant: 1,
toolCalls: 1,
toolResults: 1,
errors: 2,
});
expect(summary?.toolUsage?.totalCalls).toBe(1);
expect(summary?.toolUsage?.uniqueTools).toBe(1);
expect(summary?.toolUsage?.tools[0]?.name).toBe("weather");
expect(summary?.modelUsage?.[0]?.provider).toBe("openai");
expect(summary?.modelUsage?.[0]?.model).toBe("gpt-5.2");
expect(summary?.durationMs).toBe(5 * 60 * 1000);
expect(summary?.latency?.count).toBe(1);
expect(summary?.latency?.avgMs).toBe(5 * 60 * 1000);
expect(summary?.latency?.p95Ms).toBe(5 * 60 * 1000);
expect(summary?.dailyLatency?.[0]?.date).toBe("2026-02-01");
expect(summary?.dailyLatency?.[0]?.count).toBe(1);
expect(summary?.dailyModelUsage?.[0]?.date).toBe("2026-02-01");
expect(summary?.dailyModelUsage?.[0]?.model).toBe("gpt-5.2");
});
it("does not exclude sessions with mtime after endMs during discovery", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-discover-"));
const sessionsDir = path.join(root, "agents", "main", "sessions");
await fs.mkdir(sessionsDir, { recursive: true });
const sessionFile = path.join(sessionsDir, "sess-late.jsonl");
await fs.writeFile(sessionFile, "", "utf-8");
const now = Date.now();
await fs.utimes(sessionFile, now / 1000, now / 1000);
const originalState = process.env.OPENCLAW_STATE_DIR;
process.env.OPENCLAW_STATE_DIR = root;
try {
const sessions = await discoverAllSessions({
startMs: now - 7 * 24 * 60 * 60 * 1000,
endMs: now - 24 * 60 * 60 * 1000,
});
expect(sessions.length).toBe(1);
expect(sessions[0]?.sessionId).toBe("sess-late");
} finally {
if (originalState === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = originalState;
}
}
});
});