Skip to content

Commit 8a352c8

Browse files
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 <[email protected]>
1 parent b40da2c commit 8a352c8

28 files changed

+8656
-380
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai
2121
- Onboarding: add Moonshot (.cn) auth choice and keep the China base URL when preserving defaults. (#7180) Thanks @waynelwz.
2222
- Onboarding: add xAI (Grok) auth choice and provider defaults. (#9885) Thanks @grp06.
2323
- Docs: clarify tmux send-keys for TUI by splitting text and Enter. (#7737) Thanks @Wangnov.
24+
- Web UI: add Token Usage dashboard with session analytics. (#8462) Thanks @mcinteerj.
2425
- Docs: mirror the landing page revamp for zh-CN (features, quickstart, docs directory, network model, credits). (#8994) Thanks @joshp123.
2526
- Docs: strengthen secure DM mode guidance for multi-user inboxes with an explicit warning and example. (#9377) Thanks @Shrinija17.
2627
- Docs: document `activeHours` heartbeat field with timezone resolution chain and example. (#9366) Thanks @unisone.
@@ -53,6 +54,7 @@ Docs: https://docs.openclaw.ai
5354
- Web UI: resolve header logo path when `gateway.controlUi.basePath` is set. (#7178) Thanks @Yeom-JinHo.
5455
- Web UI: apply button styling to the new-messages indicator.
5556
- Onboarding: infer auth choice from non-interactive API key flags. (#8484) Thanks @f-trycua.
57+
- Usage: include estimated cost when breakdown is missing and keep `usage.cost` days support. (#8462) Thanks @mcinteerj.
5658
- Security: keep untrusted channel metadata out of system prompts (Slack/Discord). Thanks @KonstantinMirin.
5759
- Security: redact channel credentials (tokens, passwords, API keys, secrets) from gateway config APIs and preserve secrets during Control UI round-trips. (#9858) Thanks @abdelsfane.
5860
- Discord: treat allowlisted senders as owner for system-prompt identity hints while keeping channel topics untrusted.

apps/macos/Sources/OpenClawProtocol/GatewayModels.swift

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1119,6 +1119,35 @@ public struct SessionsCompactParams: Codable, Sendable {
11191119
}
11201120
}
11211121

1122+
public struct SessionsUsageParams: Codable, Sendable {
1123+
public let key: String?
1124+
public let startdate: String?
1125+
public let enddate: String?
1126+
public let limit: Int?
1127+
public let includecontextweight: Bool?
1128+
1129+
public init(
1130+
key: String?,
1131+
startdate: String?,
1132+
enddate: String?,
1133+
limit: Int?,
1134+
includecontextweight: Bool?
1135+
) {
1136+
self.key = key
1137+
self.startdate = startdate
1138+
self.enddate = enddate
1139+
self.limit = limit
1140+
self.includecontextweight = includecontextweight
1141+
}
1142+
private enum CodingKeys: String, CodingKey {
1143+
case key
1144+
case startdate = "startDate"
1145+
case enddate = "endDate"
1146+
case limit
1147+
case includecontextweight = "includeContextWeight"
1148+
}
1149+
}
1150+
11221151
public struct ConfigGetParams: Codable, Sendable {
11231152
}
11241153

apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1119,6 +1119,35 @@ public struct SessionsCompactParams: Codable, Sendable {
11191119
}
11201120
}
11211121

1122+
public struct SessionsUsageParams: Codable, Sendable {
1123+
public let key: String?
1124+
public let startdate: String?
1125+
public let enddate: String?
1126+
public let limit: Int?
1127+
public let includecontextweight: Bool?
1128+
1129+
public init(
1130+
key: String?,
1131+
startdate: String?,
1132+
enddate: String?,
1133+
limit: Int?,
1134+
includecontextweight: Bool?
1135+
) {
1136+
self.key = key
1137+
self.startdate = startdate
1138+
self.enddate = enddate
1139+
self.limit = limit
1140+
self.includecontextweight = includecontextweight
1141+
}
1142+
private enum CodingKeys: String, CodingKey {
1143+
case key
1144+
case startdate = "startDate"
1145+
case enddate = "endDate"
1146+
case limit
1147+
case includecontextweight = "includeContextWeight"
1148+
}
1149+
}
1150+
11221151
public struct ConfigGetParams: Codable, Sendable {
11231152
}
11241153

scripts/write-cli-compat.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,18 @@ const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
66
const distDir = path.join(rootDir, "dist");
77
const cliDir = path.join(distDir, "cli");
88

9-
const candidates = fs
10-
.readdirSync(distDir)
11-
.filter((entry) => entry.startsWith("daemon-cli-") && entry.endsWith(".js"));
9+
const findCandidates = () =>
10+
fs
11+
.readdirSync(distDir)
12+
.filter((entry) => entry.startsWith("daemon-cli-") && entry.endsWith(".js"));
13+
14+
// In rare cases, build output can land slightly after this script starts (depending on FS timing).
15+
// Retry briefly to avoid flaky builds.
16+
let candidates = findCandidates();
17+
for (let i = 0; i < 10 && candidates.length === 0; i++) {
18+
await new Promise((resolve) => setTimeout(resolve, 50));
19+
candidates = findCandidates();
20+
}
1221

1322
if (candidates.length === 0) {
1423
throw new Error("No daemon-cli bundle found in dist; cannot write legacy CLI shim.");

src/gateway/protocol/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,8 @@ import {
161161
SessionsResetParamsSchema,
162162
type SessionsResolveParams,
163163
SessionsResolveParamsSchema,
164+
type SessionsUsageParams,
165+
SessionsUsageParamsSchema,
164166
type ShutdownEvent,
165167
ShutdownEventSchema,
166168
type SkillsBinsParams,
@@ -271,6 +273,8 @@ export const validateSessionsDeleteParams = ajv.compile<SessionsDeleteParams>(
271273
export const validateSessionsCompactParams = ajv.compile<SessionsCompactParams>(
272274
SessionsCompactParamsSchema,
273275
);
276+
export const validateSessionsUsageParams =
277+
ajv.compile<SessionsUsageParams>(SessionsUsageParamsSchema);
274278
export const validateConfigGetParams = ajv.compile<ConfigGetParams>(ConfigGetParamsSchema);
275279
export const validateConfigSetParams = ajv.compile<ConfigSetParams>(ConfigSetParamsSchema);
276280
export const validateConfigApplyParams = ajv.compile<ConfigApplyParams>(ConfigApplyParamsSchema);
@@ -412,6 +416,7 @@ export {
412416
SessionsResetParamsSchema,
413417
SessionsDeleteParamsSchema,
414418
SessionsCompactParamsSchema,
419+
SessionsUsageParamsSchema,
415420
ConfigGetParamsSchema,
416421
ConfigSetParamsSchema,
417422
ConfigApplyParamsSchema,
@@ -541,6 +546,7 @@ export type {
541546
SessionsResetParams,
542547
SessionsDeleteParams,
543548
SessionsCompactParams,
549+
SessionsUsageParams,
544550
CronJob,
545551
CronListParams,
546552
CronStatusParams,

src/gateway/protocol/schema/protocol-schemas.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ import {
117117
SessionsPreviewParamsSchema,
118118
SessionsResetParamsSchema,
119119
SessionsResolveParamsSchema,
120+
SessionsUsageParamsSchema,
120121
} from "./sessions.js";
121122
import { PresenceEntrySchema, SnapshotSchema, StateVersionSchema } from "./snapshot.js";
122123
import {
@@ -168,6 +169,7 @@ export const ProtocolSchemas: Record<string, TSchema> = {
168169
SessionsResetParams: SessionsResetParamsSchema,
169170
SessionsDeleteParams: SessionsDeleteParamsSchema,
170171
SessionsCompactParams: SessionsCompactParamsSchema,
172+
SessionsUsageParams: SessionsUsageParamsSchema,
171173
ConfigGetParams: ConfigGetParamsSchema,
172174
ConfigSetParams: ConfigSetParamsSchema,
173175
ConfigApplyParams: ConfigApplyParamsSchema,

src/gateway/protocol/schema/sessions.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,3 +101,19 @@ export const SessionsCompactParamsSchema = Type.Object(
101101
},
102102
{ additionalProperties: false },
103103
);
104+
105+
export const SessionsUsageParamsSchema = Type.Object(
106+
{
107+
/** Specific session key to analyze; if omitted returns all sessions. */
108+
key: Type.Optional(NonEmptyString),
109+
/** Start date for range filter (YYYY-MM-DD). */
110+
startDate: Type.Optional(Type.String({ pattern: "^\\d{4}-\\d{2}-\\d{2}$" })),
111+
/** End date for range filter (YYYY-MM-DD). */
112+
endDate: Type.Optional(Type.String({ pattern: "^\\d{4}-\\d{2}-\\d{2}$" })),
113+
/** Maximum sessions to return (default 50). */
114+
limit: Type.Optional(Type.Integer({ minimum: 1 })),
115+
/** Include context weight breakdown (systemPromptReport). */
116+
includeContextWeight: Type.Optional(Type.Boolean()),
117+
},
118+
{ additionalProperties: false },
119+
);

src/gateway/protocol/schema/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ import type {
110110
SessionsPreviewParamsSchema,
111111
SessionsResetParamsSchema,
112112
SessionsResolveParamsSchema,
113+
SessionsUsageParamsSchema,
113114
} from "./sessions.js";
114115
import type { PresenceEntrySchema, SnapshotSchema, StateVersionSchema } from "./snapshot.js";
115116
import type {
@@ -157,6 +158,7 @@ export type SessionsPatchParams = Static<typeof SessionsPatchParamsSchema>;
157158
export type SessionsResetParams = Static<typeof SessionsResetParamsSchema>;
158159
export type SessionsDeleteParams = Static<typeof SessionsDeleteParamsSchema>;
159160
export type SessionsCompactParams = Static<typeof SessionsCompactParamsSchema>;
161+
export type SessionsUsageParams = Static<typeof SessionsUsageParamsSchema>;
160162
export type ConfigGetParams = Static<typeof ConfigGetParamsSchema>;
161163
export type ConfigSetParams = Static<typeof ConfigSetParamsSchema>;
162164
export type ConfigApplyParams = Static<typeof ConfigApplyParamsSchema>;
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
vi.mock("../../infra/session-cost-usage.js", async () => {
4+
const actual = await vi.importActual<typeof import("../../infra/session-cost-usage.js")>(
5+
"../../infra/session-cost-usage.js",
6+
);
7+
return {
8+
...actual,
9+
loadCostUsageSummary: vi.fn(async () => ({
10+
updatedAt: Date.now(),
11+
startDate: "2026-02-01",
12+
endDate: "2026-02-02",
13+
daily: [],
14+
totals: { totalTokens: 1, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalCost: 0 },
15+
})),
16+
};
17+
});
18+
19+
import { loadCostUsageSummary } from "../../infra/session-cost-usage.js";
20+
import { __test } from "./usage.js";
21+
22+
describe("gateway usage helpers", () => {
23+
beforeEach(() => {
24+
__test.costUsageCache.clear();
25+
vi.useRealTimers();
26+
vi.clearAllMocks();
27+
});
28+
29+
it("parseDateToMs accepts YYYY-MM-DD and rejects invalid input", () => {
30+
expect(__test.parseDateToMs("2026-02-05")).toBe(Date.UTC(2026, 1, 5));
31+
expect(__test.parseDateToMs(" 2026-02-05 ")).toBe(Date.UTC(2026, 1, 5));
32+
expect(__test.parseDateToMs("2026-2-5")).toBeUndefined();
33+
expect(__test.parseDateToMs("nope")).toBeUndefined();
34+
expect(__test.parseDateToMs(undefined)).toBeUndefined();
35+
});
36+
37+
it("parseDays coerces strings/numbers to integers", () => {
38+
expect(__test.parseDays(7.9)).toBe(7);
39+
expect(__test.parseDays("30")).toBe(30);
40+
expect(__test.parseDays("")).toBeUndefined();
41+
expect(__test.parseDays("nope")).toBeUndefined();
42+
});
43+
44+
it("parseDateRange uses explicit start/end (inclusive end of day)", () => {
45+
const range = __test.parseDateRange({ startDate: "2026-02-01", endDate: "2026-02-02" });
46+
expect(range.startMs).toBe(Date.UTC(2026, 1, 1));
47+
expect(range.endMs).toBe(Date.UTC(2026, 1, 2) + 24 * 60 * 60 * 1000 - 1);
48+
});
49+
50+
it("parseDateRange clamps days to at least 1 and defaults to 30 days", () => {
51+
vi.useFakeTimers();
52+
vi.setSystemTime(new Date("2026-02-05T12:34:56.000Z"));
53+
const oneDay = __test.parseDateRange({ days: 0 });
54+
expect(oneDay.endMs).toBe(Date.UTC(2026, 1, 5) + 24 * 60 * 60 * 1000 - 1);
55+
expect(oneDay.startMs).toBe(Date.UTC(2026, 1, 5));
56+
57+
const def = __test.parseDateRange({});
58+
expect(def.endMs).toBe(Date.UTC(2026, 1, 5) + 24 * 60 * 60 * 1000 - 1);
59+
expect(def.startMs).toBe(Date.UTC(2026, 1, 5) - 29 * 24 * 60 * 60 * 1000);
60+
});
61+
62+
it("loadCostUsageSummaryCached caches within TTL", async () => {
63+
vi.useFakeTimers();
64+
vi.setSystemTime(new Date("2026-02-05T00:00:00.000Z"));
65+
66+
const config = {} as unknown as ReturnType<import("../../config/config.js").loadConfig>;
67+
const a = await __test.loadCostUsageSummaryCached({
68+
startMs: 1,
69+
endMs: 2,
70+
config,
71+
});
72+
const b = await __test.loadCostUsageSummaryCached({
73+
startMs: 1,
74+
endMs: 2,
75+
config,
76+
});
77+
78+
expect(a.totals.totalTokens).toBe(1);
79+
expect(b.totals.totalTokens).toBe(1);
80+
expect(vi.mocked(loadCostUsageSummary)).toHaveBeenCalledTimes(1);
81+
});
82+
});

0 commit comments

Comments
 (0)