From 8a352c8f9dfd03b5afadb6c86421949141921110 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Thu, 5 Feb 2026 22:35:46 -0600 Subject: [PATCH] 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 --- CHANGELOG.md | 2 + .../OpenClawProtocol/GatewayModels.swift | 29 + .../OpenClawProtocol/GatewayModels.swift | 29 + scripts/write-cli-compat.ts | 15 +- src/gateway/protocol/index.ts | 6 + .../protocol/schema/protocol-schemas.ts | 2 + src/gateway/protocol/schema/sessions.ts | 16 + src/gateway/protocol/schema/types.ts | 2 + src/gateway/server-methods/usage.test.ts | 82 + src/gateway/server-methods/usage.ts | 708 ++- src/gateway/session-utils.fs.test.ts | 37 + src/gateway/session-utils.fs.ts | 29 +- src/infra/session-cost-usage.test.ts | 102 +- src/infra/session-cost-usage.ts | 876 ++- src/utils/transcript-tools.test.ts | 66 + src/utils/transcript-tools.ts | 73 + ui/src/ui/app-render.helpers.ts | 6 +- ui/src/ui/app-render.ts | 617 +- ui/src/ui/app-settings.ts | 62 +- ui/src/ui/app-view-state.ts | 78 +- ui/src/ui/app.ts | 56 +- ui/src/ui/controllers/usage.ts | 107 + ui/src/ui/navigation.ts | 10 +- ui/src/ui/types.ts | 235 +- ui/src/ui/usage-helpers.node.test.ts | 43 + ui/src/ui/usage-helpers.ts | 321 + ui/src/ui/views/usage.ts | 5432 +++++++++++++++++ ui/vitest.node.config.ts | 9 + 28 files changed, 8663 insertions(+), 387 deletions(-) create mode 100644 src/gateway/server-methods/usage.test.ts create mode 100644 src/utils/transcript-tools.test.ts create mode 100644 src/utils/transcript-tools.ts create mode 100644 ui/src/ui/controllers/usage.ts create mode 100644 ui/src/ui/usage-helpers.node.test.ts create mode 100644 ui/src/ui/usage-helpers.ts create mode 100644 ui/src/ui/views/usage.ts create mode 100644 ui/vitest.node.config.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ff08b6f4e..d1ee4e37e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai - Onboarding: add Moonshot (.cn) auth choice and keep the China base URL when preserving defaults. (#7180) Thanks @waynelwz. - Onboarding: add xAI (Grok) auth choice and provider defaults. (#9885) Thanks @grp06. - Docs: clarify tmux send-keys for TUI by splitting text and Enter. (#7737) Thanks @Wangnov. +- Web UI: add Token Usage dashboard with session analytics. (#8462) Thanks @mcinteerj. - Docs: mirror the landing page revamp for zh-CN (features, quickstart, docs directory, network model, credits). (#8994) Thanks @joshp123. - Docs: strengthen secure DM mode guidance for multi-user inboxes with an explicit warning and example. (#9377) Thanks @Shrinija17. - Docs: document `activeHours` heartbeat field with timezone resolution chain and example. (#9366) Thanks @unisone. @@ -53,6 +54,7 @@ Docs: https://docs.openclaw.ai - Web UI: resolve header logo path when `gateway.controlUi.basePath` is set. (#7178) Thanks @Yeom-JinHo. - Web UI: apply button styling to the new-messages indicator. - Onboarding: infer auth choice from non-interactive API key flags. (#8484) Thanks @f-trycua. +- Usage: include estimated cost when breakdown is missing and keep `usage.cost` days support. (#8462) Thanks @mcinteerj. - Security: keep untrusted channel metadata out of system prompts (Slack/Discord). Thanks @KonstantinMirin. - Security: redact channel credentials (tokens, passwords, API keys, secrets) from gateway config APIs and preserve secrets during Control UI round-trips. (#9858) Thanks @abdelsfane. - Discord: treat allowlisted senders as owner for system-prompt identity hints while keeping channel topics untrusted. diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift index 1021de5cc2..dd3cfb50a1 100644 --- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift @@ -1119,6 +1119,35 @@ public struct SessionsCompactParams: Codable, Sendable { } } +public struct SessionsUsageParams: Codable, Sendable { + public let key: String? + public let startdate: String? + public let enddate: String? + public let limit: Int? + public let includecontextweight: Bool? + + public init( + key: String?, + startdate: String?, + enddate: String?, + limit: Int?, + includecontextweight: Bool? + ) { + self.key = key + self.startdate = startdate + self.enddate = enddate + self.limit = limit + self.includecontextweight = includecontextweight + } + private enum CodingKeys: String, CodingKey { + case key + case startdate = "startDate" + case enddate = "endDate" + case limit + case includecontextweight = "includeContextWeight" + } +} + public struct ConfigGetParams: Codable, Sendable { } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 1021de5cc2..dd3cfb50a1 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -1119,6 +1119,35 @@ public struct SessionsCompactParams: Codable, Sendable { } } +public struct SessionsUsageParams: Codable, Sendable { + public let key: String? + public let startdate: String? + public let enddate: String? + public let limit: Int? + public let includecontextweight: Bool? + + public init( + key: String?, + startdate: String?, + enddate: String?, + limit: Int?, + includecontextweight: Bool? + ) { + self.key = key + self.startdate = startdate + self.enddate = enddate + self.limit = limit + self.includecontextweight = includecontextweight + } + private enum CodingKeys: String, CodingKey { + case key + case startdate = "startDate" + case enddate = "endDate" + case limit + case includecontextweight = "includeContextWeight" + } +} + public struct ConfigGetParams: Codable, Sendable { } diff --git a/scripts/write-cli-compat.ts b/scripts/write-cli-compat.ts index 925c0cec54..27b265618b 100644 --- a/scripts/write-cli-compat.ts +++ b/scripts/write-cli-compat.ts @@ -6,9 +6,18 @@ const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") const distDir = path.join(rootDir, "dist"); const cliDir = path.join(distDir, "cli"); -const candidates = fs - .readdirSync(distDir) - .filter((entry) => entry.startsWith("daemon-cli-") && entry.endsWith(".js")); +const findCandidates = () => + fs + .readdirSync(distDir) + .filter((entry) => entry.startsWith("daemon-cli-") && entry.endsWith(".js")); + +// In rare cases, build output can land slightly after this script starts (depending on FS timing). +// Retry briefly to avoid flaky builds. +let candidates = findCandidates(); +for (let i = 0; i < 10 && candidates.length === 0; i++) { + await new Promise((resolve) => setTimeout(resolve, 50)); + candidates = findCandidates(); +} if (candidates.length === 0) { throw new Error("No daemon-cli bundle found in dist; cannot write legacy CLI shim."); diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index f6e1813013..f89facc237 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -161,6 +161,8 @@ import { SessionsResetParamsSchema, type SessionsResolveParams, SessionsResolveParamsSchema, + type SessionsUsageParams, + SessionsUsageParamsSchema, type ShutdownEvent, ShutdownEventSchema, type SkillsBinsParams, @@ -271,6 +273,8 @@ export const validateSessionsDeleteParams = ajv.compile( export const validateSessionsCompactParams = ajv.compile( SessionsCompactParamsSchema, ); +export const validateSessionsUsageParams = + ajv.compile(SessionsUsageParamsSchema); export const validateConfigGetParams = ajv.compile(ConfigGetParamsSchema); export const validateConfigSetParams = ajv.compile(ConfigSetParamsSchema); export const validateConfigApplyParams = ajv.compile(ConfigApplyParamsSchema); @@ -412,6 +416,7 @@ export { SessionsResetParamsSchema, SessionsDeleteParamsSchema, SessionsCompactParamsSchema, + SessionsUsageParamsSchema, ConfigGetParamsSchema, ConfigSetParamsSchema, ConfigApplyParamsSchema, @@ -541,6 +546,7 @@ export type { SessionsResetParams, SessionsDeleteParams, SessionsCompactParams, + SessionsUsageParams, CronJob, CronListParams, CronStatusParams, diff --git a/src/gateway/protocol/schema/protocol-schemas.ts b/src/gateway/protocol/schema/protocol-schemas.ts index 87d87d03bc..23918ef6d3 100644 --- a/src/gateway/protocol/schema/protocol-schemas.ts +++ b/src/gateway/protocol/schema/protocol-schemas.ts @@ -117,6 +117,7 @@ import { SessionsPreviewParamsSchema, SessionsResetParamsSchema, SessionsResolveParamsSchema, + SessionsUsageParamsSchema, } from "./sessions.js"; import { PresenceEntrySchema, SnapshotSchema, StateVersionSchema } from "./snapshot.js"; import { @@ -168,6 +169,7 @@ export const ProtocolSchemas: Record = { SessionsResetParams: SessionsResetParamsSchema, SessionsDeleteParams: SessionsDeleteParamsSchema, SessionsCompactParams: SessionsCompactParamsSchema, + SessionsUsageParams: SessionsUsageParamsSchema, ConfigGetParams: ConfigGetParamsSchema, ConfigSetParams: ConfigSetParamsSchema, ConfigApplyParams: ConfigApplyParamsSchema, diff --git a/src/gateway/protocol/schema/sessions.ts b/src/gateway/protocol/schema/sessions.ts index ab6bbb12a7..a4363542f5 100644 --- a/src/gateway/protocol/schema/sessions.ts +++ b/src/gateway/protocol/schema/sessions.ts @@ -101,3 +101,19 @@ export const SessionsCompactParamsSchema = Type.Object( }, { additionalProperties: false }, ); + +export const SessionsUsageParamsSchema = Type.Object( + { + /** Specific session key to analyze; if omitted returns all sessions. */ + key: Type.Optional(NonEmptyString), + /** Start date for range filter (YYYY-MM-DD). */ + startDate: Type.Optional(Type.String({ pattern: "^\\d{4}-\\d{2}-\\d{2}$" })), + /** End date for range filter (YYYY-MM-DD). */ + endDate: Type.Optional(Type.String({ pattern: "^\\d{4}-\\d{2}-\\d{2}$" })), + /** Maximum sessions to return (default 50). */ + limit: Type.Optional(Type.Integer({ minimum: 1 })), + /** Include context weight breakdown (systemPromptReport). */ + includeContextWeight: Type.Optional(Type.Boolean()), + }, + { additionalProperties: false }, +); diff --git a/src/gateway/protocol/schema/types.ts b/src/gateway/protocol/schema/types.ts index 6bc9bff5e2..f89b3d9561 100644 --- a/src/gateway/protocol/schema/types.ts +++ b/src/gateway/protocol/schema/types.ts @@ -110,6 +110,7 @@ import type { SessionsPreviewParamsSchema, SessionsResetParamsSchema, SessionsResolveParamsSchema, + SessionsUsageParamsSchema, } from "./sessions.js"; import type { PresenceEntrySchema, SnapshotSchema, StateVersionSchema } from "./snapshot.js"; import type { @@ -157,6 +158,7 @@ export type SessionsPatchParams = Static; export type SessionsResetParams = Static; export type SessionsDeleteParams = Static; export type SessionsCompactParams = Static; +export type SessionsUsageParams = Static; export type ConfigGetParams = Static; export type ConfigSetParams = Static; export type ConfigApplyParams = Static; diff --git a/src/gateway/server-methods/usage.test.ts b/src/gateway/server-methods/usage.test.ts new file mode 100644 index 0000000000..e7b5fe30ce --- /dev/null +++ b/src/gateway/server-methods/usage.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../infra/session-cost-usage.js", async () => { + const actual = await vi.importActual( + "../../infra/session-cost-usage.js", + ); + return { + ...actual, + loadCostUsageSummary: vi.fn(async () => ({ + updatedAt: Date.now(), + startDate: "2026-02-01", + endDate: "2026-02-02", + daily: [], + totals: { totalTokens: 1, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalCost: 0 }, + })), + }; +}); + +import { loadCostUsageSummary } from "../../infra/session-cost-usage.js"; +import { __test } from "./usage.js"; + +describe("gateway usage helpers", () => { + beforeEach(() => { + __test.costUsageCache.clear(); + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it("parseDateToMs accepts YYYY-MM-DD and rejects invalid input", () => { + expect(__test.parseDateToMs("2026-02-05")).toBe(Date.UTC(2026, 1, 5)); + expect(__test.parseDateToMs(" 2026-02-05 ")).toBe(Date.UTC(2026, 1, 5)); + expect(__test.parseDateToMs("2026-2-5")).toBeUndefined(); + expect(__test.parseDateToMs("nope")).toBeUndefined(); + expect(__test.parseDateToMs(undefined)).toBeUndefined(); + }); + + it("parseDays coerces strings/numbers to integers", () => { + expect(__test.parseDays(7.9)).toBe(7); + expect(__test.parseDays("30")).toBe(30); + expect(__test.parseDays("")).toBeUndefined(); + expect(__test.parseDays("nope")).toBeUndefined(); + }); + + it("parseDateRange uses explicit start/end (inclusive end of day)", () => { + const range = __test.parseDateRange({ startDate: "2026-02-01", endDate: "2026-02-02" }); + expect(range.startMs).toBe(Date.UTC(2026, 1, 1)); + expect(range.endMs).toBe(Date.UTC(2026, 1, 2) + 24 * 60 * 60 * 1000 - 1); + }); + + it("parseDateRange clamps days to at least 1 and defaults to 30 days", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-02-05T12:34:56.000Z")); + const oneDay = __test.parseDateRange({ days: 0 }); + expect(oneDay.endMs).toBe(Date.UTC(2026, 1, 5) + 24 * 60 * 60 * 1000 - 1); + expect(oneDay.startMs).toBe(Date.UTC(2026, 1, 5)); + + const def = __test.parseDateRange({}); + expect(def.endMs).toBe(Date.UTC(2026, 1, 5) + 24 * 60 * 60 * 1000 - 1); + expect(def.startMs).toBe(Date.UTC(2026, 1, 5) - 29 * 24 * 60 * 60 * 1000); + }); + + it("loadCostUsageSummaryCached caches within TTL", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-02-05T00:00:00.000Z")); + + const config = {} as unknown as ReturnType; + const a = await __test.loadCostUsageSummaryCached({ + startMs: 1, + endMs: 2, + config, + }); + const b = await __test.loadCostUsageSummaryCached({ + startMs: 1, + endMs: 2, + config, + }); + + expect(a.totals.totalTokens).toBe(1); + expect(b.totals.totalTokens).toBe(1); + expect(vi.mocked(loadCostUsageSummary)).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/gateway/server-methods/usage.ts b/src/gateway/server-methods/usage.ts index 550217a5db..f1ab0d4269 100644 --- a/src/gateway/server-methods/usage.ts +++ b/src/gateway/server-methods/usage.ts @@ -1,20 +1,68 @@ -import type { CostUsageSummary } from "../../infra/session-cost-usage.js"; +import fs from "node:fs"; +import type { SessionEntry, SessionSystemPromptReport } from "../../config/sessions/types.js"; +import type { + CostUsageSummary, + SessionCostSummary, + SessionDailyLatency, + SessionDailyModelUsage, + SessionMessageCounts, + SessionLatencyStats, + SessionModelUsage, + SessionToolUsage, +} from "../../infra/session-cost-usage.js"; import type { GatewayRequestHandlers } from "./types.js"; import { loadConfig } from "../../config/config.js"; +import { resolveSessionFilePath } from "../../config/sessions/paths.js"; import { loadProviderUsageSummary } from "../../infra/provider-usage.js"; -import { loadCostUsageSummary } from "../../infra/session-cost-usage.js"; +import { + loadCostUsageSummary, + loadSessionCostSummary, + loadSessionUsageTimeSeries, + discoverAllSessions, +} from "../../infra/session-cost-usage.js"; +import { parseAgentSessionKey } from "../../routing/session-key.js"; +import { + ErrorCodes, + errorShape, + formatValidationErrors, + validateSessionsUsageParams, +} from "../protocol/index.js"; +import { loadCombinedSessionStoreForGateway, loadSessionEntry } from "../session-utils.js"; const COST_USAGE_CACHE_TTL_MS = 30_000; +type DateRange = { startMs: number; endMs: number }; + type CostUsageCacheEntry = { summary?: CostUsageSummary; updatedAt?: number; inFlight?: Promise; }; -const costUsageCache = new Map(); +const costUsageCache = new Map(); -const parseDays = (raw: unknown): number => { +/** + * Parse a date string (YYYY-MM-DD) to start of day timestamp in UTC. + * Returns undefined if invalid. + */ +const parseDateToMs = (raw: unknown): number | undefined => { + if (typeof raw !== "string" || !raw.trim()) { + return undefined; + } + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(raw.trim()); + if (!match) { + return undefined; + } + const [, year, month, day] = match; + // Use UTC to ensure consistent behavior across timezones + const ms = Date.UTC(parseInt(year), parseInt(month) - 1, parseInt(day)); + if (Number.isNaN(ms)) { + return undefined; + } + return ms; +}; + +const parseDays = (raw: unknown): number | undefined => { if (typeof raw === "number" && Number.isFinite(raw)) { return Math.floor(raw); } @@ -24,16 +72,51 @@ const parseDays = (raw: unknown): number => { return Math.floor(parsed); } } - return 30; + return undefined; +}; + +/** + * Get date range from params (startDate/endDate or days). + * Falls back to last 30 days if not provided. + */ +const parseDateRange = (params: { + startDate?: unknown; + endDate?: unknown; + days?: unknown; +}): DateRange => { + const now = new Date(); + // Use UTC for consistent date handling + const todayStartMs = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); + const todayEndMs = todayStartMs + 24 * 60 * 60 * 1000 - 1; + + const startMs = parseDateToMs(params.startDate); + const endMs = parseDateToMs(params.endDate); + + if (startMs !== undefined && endMs !== undefined) { + // endMs should be end of day + return { startMs, endMs: endMs + 24 * 60 * 60 * 1000 - 1 }; + } + + const days = parseDays(params.days); + if (days !== undefined) { + const clampedDays = Math.max(1, days); + const start = todayStartMs - (clampedDays - 1) * 24 * 60 * 60 * 1000; + return { startMs: start, endMs: todayEndMs }; + } + + // Default to last 30 days + const defaultStartMs = todayStartMs - 29 * 24 * 60 * 60 * 1000; + return { startMs: defaultStartMs, endMs: todayEndMs }; }; async function loadCostUsageSummaryCached(params: { - days: number; + startMs: number; + endMs: number; config: ReturnType; }): Promise { - const days = Math.max(1, params.days); + const cacheKey = `${params.startMs}-${params.endMs}`; const now = Date.now(); - const cached = costUsageCache.get(days); + const cached = costUsageCache.get(cacheKey); if (cached?.summary && cached.updatedAt && now - cached.updatedAt < COST_USAGE_CACHE_TTL_MS) { return cached.summary; } @@ -46,9 +129,13 @@ async function loadCostUsageSummaryCached(params: { } const entry: CostUsageCacheEntry = cached ?? {}; - const inFlight = loadCostUsageSummary({ days, config: params.config }) + const inFlight = loadCostUsageSummary({ + startMs: params.startMs, + endMs: params.endMs, + config: params.config, + }) .then((summary) => { - costUsageCache.set(days, { summary, updatedAt: Date.now() }); + costUsageCache.set(cacheKey, { summary, updatedAt: Date.now() }); return summary; }) .catch((err) => { @@ -58,15 +145,15 @@ async function loadCostUsageSummaryCached(params: { throw err; }) .finally(() => { - const current = costUsageCache.get(days); + const current = costUsageCache.get(cacheKey); if (current?.inFlight === inFlight) { current.inFlight = undefined; - costUsageCache.set(days, current); + costUsageCache.set(cacheKey, current); } }); entry.inFlight = inFlight; - costUsageCache.set(days, entry); + costUsageCache.set(cacheKey, entry); if (entry.summary) { return entry.summary; @@ -74,6 +161,70 @@ async function loadCostUsageSummaryCached(params: { return await inFlight; } +// Exposed for unit tests (kept as a single export to avoid widening the public API surface). +export const __test = { + parseDateToMs, + parseDays, + parseDateRange, + loadCostUsageSummaryCached, + costUsageCache, +}; + +export type SessionUsageEntry = { + key: string; + label?: string; + sessionId?: string; + updatedAt?: number; + agentId?: string; + channel?: string; + chatType?: string; + origin?: { + label?: string; + provider?: string; + surface?: string; + chatType?: string; + from?: string; + to?: string; + accountId?: string; + threadId?: string | number; + }; + modelOverride?: string; + providerOverride?: string; + modelProvider?: string; + model?: string; + usage: SessionCostSummary | null; + contextWeight?: SessionSystemPromptReport | null; +}; + +export type SessionsUsageAggregates = { + messages: SessionMessageCounts; + tools: SessionToolUsage; + byModel: SessionModelUsage[]; + byProvider: SessionModelUsage[]; + byAgent: Array<{ agentId: string; totals: CostUsageSummary["totals"] }>; + byChannel: Array<{ channel: string; totals: CostUsageSummary["totals"] }>; + latency?: SessionLatencyStats; + dailyLatency?: SessionDailyLatency[]; + modelDaily?: SessionDailyModelUsage[]; + daily: Array<{ + date: string; + tokens: number; + cost: number; + messages: number; + toolCalls: number; + errors: number; + }>; +}; + +export type SessionsUsageResult = { + updatedAt: number; + startDate: string; + endDate: string; + sessions: SessionUsageEntry[]; + totals: CostUsageSummary["totals"]; + aggregates: SessionsUsageAggregates; +}; + export const usageHandlers: GatewayRequestHandlers = { "usage.status": async ({ respond }) => { const summary = await loadProviderUsageSummary(); @@ -81,8 +232,535 @@ export const usageHandlers: GatewayRequestHandlers = { }, "usage.cost": async ({ respond, params }) => { const config = loadConfig(); - const days = parseDays(params?.days); - const summary = await loadCostUsageSummaryCached({ days, config }); + const { startMs, endMs } = parseDateRange({ + startDate: params?.startDate, + endDate: params?.endDate, + days: params?.days, + }); + const summary = await loadCostUsageSummaryCached({ startMs, endMs, config }); respond(true, summary, undefined); }, + "sessions.usage": async ({ respond, params }) => { + if (!validateSessionsUsageParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid sessions.usage params: ${formatValidationErrors(validateSessionsUsageParams.errors)}`, + ), + ); + return; + } + + const p = params; + const config = loadConfig(); + const { startMs, endMs } = parseDateRange({ + startDate: p.startDate, + endDate: p.endDate, + }); + const limit = typeof p.limit === "number" && Number.isFinite(p.limit) ? p.limit : 50; + const includeContextWeight = p.includeContextWeight ?? false; + const specificKey = typeof p.key === "string" ? p.key.trim() : null; + + // Load session store for named sessions + const { store } = loadCombinedSessionStoreForGateway(config); + const now = Date.now(); + + // Merge discovered sessions with store entries + type MergedEntry = { + key: string; + sessionId: string; + sessionFile: string; + label?: string; + updatedAt: number; + storeEntry?: SessionEntry; + firstUserMessage?: string; + }; + + const mergedEntries: MergedEntry[] = []; + + // Optimization: If a specific key is requested, skip full directory scan + if (specificKey) { + // Check if it's a named session in the store + const storeEntry = store[specificKey]; + let sessionId = storeEntry?.sessionId ?? specificKey; + + // Resolve the session file path + const sessionFile = resolveSessionFilePath(sessionId, storeEntry); + + try { + const stats = fs.statSync(sessionFile); + if (stats.isFile()) { + mergedEntries.push({ + key: specificKey, + sessionId, + sessionFile, + label: storeEntry?.label, + updatedAt: storeEntry?.updatedAt ?? stats.mtimeMs, + storeEntry, + }); + } + } catch { + // File doesn't exist - no results for this key + } + } else { + // Full discovery for list view + const discoveredSessions = await discoverAllSessions({ + startMs, + endMs, + }); + + // Build a map of sessionId -> store entry for quick lookup + const storeBySessionId = new Map(); + for (const [key, entry] of Object.entries(store)) { + if (entry?.sessionId) { + storeBySessionId.set(entry.sessionId, { key, entry }); + } + } + + for (const discovered of discoveredSessions) { + const storeMatch = storeBySessionId.get(discovered.sessionId); + if (storeMatch) { + // Named session from store + mergedEntries.push({ + key: storeMatch.key, + sessionId: discovered.sessionId, + sessionFile: discovered.sessionFile, + label: storeMatch.entry.label, + updatedAt: storeMatch.entry.updatedAt ?? discovered.mtime, + storeEntry: storeMatch.entry, + }); + } else { + // Unnamed session - use session ID as key, no label + mergedEntries.push({ + key: discovered.sessionId, + sessionId: discovered.sessionId, + sessionFile: discovered.sessionFile, + label: undefined, // No label for unnamed sessions + updatedAt: discovered.mtime, + }); + } + } + } + + // Sort by most recent first + mergedEntries.sort((a, b) => b.updatedAt - a.updatedAt); + + // Apply limit + const limitedEntries = mergedEntries.slice(0, limit); + + // Load usage for each session + const sessions: SessionUsageEntry[] = []; + const aggregateTotals = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + totalCost: 0, + inputCost: 0, + outputCost: 0, + cacheReadCost: 0, + cacheWriteCost: 0, + missingCostEntries: 0, + }; + const aggregateMessages: SessionMessageCounts = { + total: 0, + user: 0, + assistant: 0, + toolCalls: 0, + toolResults: 0, + errors: 0, + }; + const toolAggregateMap = new Map(); + const byModelMap = new Map(); + const byProviderMap = new Map(); + const byAgentMap = new Map(); + const byChannelMap = new Map(); + const dailyAggregateMap = new Map< + string, + { + date: string; + tokens: number; + cost: number; + messages: number; + toolCalls: number; + errors: number; + } + >(); + const latencyTotals = { + count: 0, + sum: 0, + min: Number.POSITIVE_INFINITY, + max: 0, + p95Max: 0, + }; + const dailyLatencyMap = new Map< + string, + { date: string; count: number; sum: number; min: number; max: number; p95Max: number } + >(); + const modelDailyMap = new Map(); + + const emptyTotals = (): CostUsageSummary["totals"] => ({ + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + totalCost: 0, + inputCost: 0, + outputCost: 0, + cacheReadCost: 0, + cacheWriteCost: 0, + missingCostEntries: 0, + }); + const mergeTotals = ( + target: CostUsageSummary["totals"], + source: CostUsageSummary["totals"], + ) => { + target.input += source.input; + target.output += source.output; + target.cacheRead += source.cacheRead; + target.cacheWrite += source.cacheWrite; + target.totalTokens += source.totalTokens; + target.totalCost += source.totalCost; + target.inputCost += source.inputCost; + target.outputCost += source.outputCost; + target.cacheReadCost += source.cacheReadCost; + target.cacheWriteCost += source.cacheWriteCost; + target.missingCostEntries += source.missingCostEntries; + }; + + for (const merged of limitedEntries) { + const usage = await loadSessionCostSummary({ + sessionId: merged.sessionId, + sessionEntry: merged.storeEntry, + sessionFile: merged.sessionFile, + config, + startMs, + endMs, + }); + + if (usage) { + aggregateTotals.input += usage.input; + aggregateTotals.output += usage.output; + aggregateTotals.cacheRead += usage.cacheRead; + aggregateTotals.cacheWrite += usage.cacheWrite; + aggregateTotals.totalTokens += usage.totalTokens; + aggregateTotals.totalCost += usage.totalCost; + aggregateTotals.inputCost += usage.inputCost; + aggregateTotals.outputCost += usage.outputCost; + aggregateTotals.cacheReadCost += usage.cacheReadCost; + aggregateTotals.cacheWriteCost += usage.cacheWriteCost; + aggregateTotals.missingCostEntries += usage.missingCostEntries; + } + + const agentId = parseAgentSessionKey(merged.key)?.agentId; + const channel = merged.storeEntry?.channel ?? merged.storeEntry?.origin?.provider; + const chatType = merged.storeEntry?.chatType ?? merged.storeEntry?.origin?.chatType; + + if (usage) { + if (usage.messageCounts) { + aggregateMessages.total += usage.messageCounts.total; + aggregateMessages.user += usage.messageCounts.user; + aggregateMessages.assistant += usage.messageCounts.assistant; + aggregateMessages.toolCalls += usage.messageCounts.toolCalls; + aggregateMessages.toolResults += usage.messageCounts.toolResults; + aggregateMessages.errors += usage.messageCounts.errors; + } + + if (usage.toolUsage) { + for (const tool of usage.toolUsage.tools) { + toolAggregateMap.set(tool.name, (toolAggregateMap.get(tool.name) ?? 0) + tool.count); + } + } + + if (usage.modelUsage) { + for (const entry of usage.modelUsage) { + const modelKey = `${entry.provider ?? "unknown"}::${entry.model ?? "unknown"}`; + const modelExisting = + byModelMap.get(modelKey) ?? + ({ + provider: entry.provider, + model: entry.model, + count: 0, + totals: emptyTotals(), + } as SessionModelUsage); + modelExisting.count += entry.count; + mergeTotals(modelExisting.totals, entry.totals); + byModelMap.set(modelKey, modelExisting); + + const providerKey = entry.provider ?? "unknown"; + const providerExisting = + byProviderMap.get(providerKey) ?? + ({ + provider: entry.provider, + model: undefined, + count: 0, + totals: emptyTotals(), + } as SessionModelUsage); + providerExisting.count += entry.count; + mergeTotals(providerExisting.totals, entry.totals); + byProviderMap.set(providerKey, providerExisting); + } + } + + if (usage.latency) { + const { count, avgMs, minMs, maxMs, p95Ms } = usage.latency; + if (count > 0) { + latencyTotals.count += count; + latencyTotals.sum += avgMs * count; + latencyTotals.min = Math.min(latencyTotals.min, minMs); + latencyTotals.max = Math.max(latencyTotals.max, maxMs); + latencyTotals.p95Max = Math.max(latencyTotals.p95Max, p95Ms); + } + } + + if (usage.dailyLatency) { + for (const day of usage.dailyLatency) { + const existing = dailyLatencyMap.get(day.date) ?? { + date: day.date, + count: 0, + sum: 0, + min: Number.POSITIVE_INFINITY, + max: 0, + p95Max: 0, + }; + existing.count += day.count; + existing.sum += day.avgMs * day.count; + existing.min = Math.min(existing.min, day.minMs); + existing.max = Math.max(existing.max, day.maxMs); + existing.p95Max = Math.max(existing.p95Max, day.p95Ms); + dailyLatencyMap.set(day.date, existing); + } + } + + if (usage.dailyModelUsage) { + for (const entry of usage.dailyModelUsage) { + const key = `${entry.date}::${entry.provider ?? "unknown"}::${entry.model ?? "unknown"}`; + const existing = + modelDailyMap.get(key) ?? + ({ + date: entry.date, + provider: entry.provider, + model: entry.model, + tokens: 0, + cost: 0, + count: 0, + } as SessionDailyModelUsage); + existing.tokens += entry.tokens; + existing.cost += entry.cost; + existing.count += entry.count; + modelDailyMap.set(key, existing); + } + } + + if (agentId) { + const agentTotals = byAgentMap.get(agentId) ?? emptyTotals(); + mergeTotals(agentTotals, usage); + byAgentMap.set(agentId, agentTotals); + } + + if (channel) { + const channelTotals = byChannelMap.get(channel) ?? emptyTotals(); + mergeTotals(channelTotals, usage); + byChannelMap.set(channel, channelTotals); + } + + if (usage.dailyBreakdown) { + for (const day of usage.dailyBreakdown) { + const daily = dailyAggregateMap.get(day.date) ?? { + date: day.date, + tokens: 0, + cost: 0, + messages: 0, + toolCalls: 0, + errors: 0, + }; + daily.tokens += day.tokens; + daily.cost += day.cost; + dailyAggregateMap.set(day.date, daily); + } + } + + if (usage.dailyMessageCounts) { + for (const day of usage.dailyMessageCounts) { + const daily = dailyAggregateMap.get(day.date) ?? { + date: day.date, + tokens: 0, + cost: 0, + messages: 0, + toolCalls: 0, + errors: 0, + }; + daily.messages += day.total; + daily.toolCalls += day.toolCalls; + daily.errors += day.errors; + dailyAggregateMap.set(day.date, daily); + } + } + } + + sessions.push({ + key: merged.key, + label: merged.label, + sessionId: merged.sessionId, + updatedAt: merged.updatedAt, + agentId, + channel, + chatType, + origin: merged.storeEntry?.origin, + modelOverride: merged.storeEntry?.modelOverride, + providerOverride: merged.storeEntry?.providerOverride, + modelProvider: merged.storeEntry?.modelProvider, + model: merged.storeEntry?.model, + usage, + contextWeight: includeContextWeight + ? (merged.storeEntry?.systemPromptReport ?? null) + : undefined, + }); + } + + // Format dates back to YYYY-MM-DD strings + const formatDateStr = (ms: number) => { + const d = new Date(ms); + return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`; + }; + + const aggregates: SessionsUsageAggregates = { + messages: aggregateMessages, + tools: { + totalCalls: Array.from(toolAggregateMap.values()).reduce((sum, count) => sum + count, 0), + uniqueTools: toolAggregateMap.size, + tools: Array.from(toolAggregateMap.entries()) + .map(([name, count]) => ({ name, count })) + .toSorted((a, b) => b.count - a.count), + }, + byModel: Array.from(byModelMap.values()).toSorted((a, b) => { + const costDiff = b.totals.totalCost - a.totals.totalCost; + if (costDiff !== 0) { + return costDiff; + } + return b.totals.totalTokens - a.totals.totalTokens; + }), + byProvider: Array.from(byProviderMap.values()).toSorted((a, b) => { + const costDiff = b.totals.totalCost - a.totals.totalCost; + if (costDiff !== 0) { + return costDiff; + } + return b.totals.totalTokens - a.totals.totalTokens; + }), + byAgent: Array.from(byAgentMap.entries()) + .map(([id, totals]) => ({ agentId: id, totals })) + .toSorted((a, b) => b.totals.totalCost - a.totals.totalCost), + byChannel: Array.from(byChannelMap.entries()) + .map(([name, totals]) => ({ channel: name, totals })) + .toSorted((a, b) => b.totals.totalCost - a.totals.totalCost), + latency: + latencyTotals.count > 0 + ? { + count: latencyTotals.count, + avgMs: latencyTotals.sum / latencyTotals.count, + minMs: latencyTotals.min === Number.POSITIVE_INFINITY ? 0 : latencyTotals.min, + maxMs: latencyTotals.max, + p95Ms: latencyTotals.p95Max, + } + : undefined, + dailyLatency: Array.from(dailyLatencyMap.values()) + .map((entry) => ({ + date: entry.date, + count: entry.count, + avgMs: entry.count ? entry.sum / entry.count : 0, + minMs: entry.min === Number.POSITIVE_INFINITY ? 0 : entry.min, + maxMs: entry.max, + p95Ms: entry.p95Max, + })) + .toSorted((a, b) => a.date.localeCompare(b.date)), + modelDaily: Array.from(modelDailyMap.values()).toSorted( + (a, b) => a.date.localeCompare(b.date) || b.cost - a.cost, + ), + daily: Array.from(dailyAggregateMap.values()).toSorted((a, b) => + a.date.localeCompare(b.date), + ), + }; + + const result: SessionsUsageResult = { + updatedAt: now, + startDate: formatDateStr(startMs), + endDate: formatDateStr(endMs), + sessions, + totals: aggregateTotals, + aggregates, + }; + + respond(true, result, undefined); + }, + "sessions.usage.timeseries": async ({ respond, params }) => { + const key = typeof params?.key === "string" ? params.key.trim() : null; + if (!key) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "key is required for timeseries"), + ); + return; + } + + const config = loadConfig(); + const { entry } = loadSessionEntry(key); + + // For discovered sessions (not in store), try using key as sessionId directly + const sessionId = entry?.sessionId ?? key; + const sessionFile = entry?.sessionFile ?? resolveSessionFilePath(key); + + const timeseries = await loadSessionUsageTimeSeries({ + sessionId, + sessionEntry: entry, + sessionFile, + config, + maxPoints: 200, + }); + + if (!timeseries) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `No transcript found for session: ${key}`), + ); + return; + } + + respond(true, timeseries, undefined); + }, + "sessions.usage.logs": async ({ respond, params }) => { + const key = typeof params?.key === "string" ? params.key.trim() : null; + if (!key) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "key is required for logs")); + return; + } + + const limit = + typeof params?.limit === "number" && Number.isFinite(params.limit) + ? Math.min(params.limit, 1000) + : 200; + + const config = loadConfig(); + const { entry } = loadSessionEntry(key); + + // For discovered sessions (not in store), try using key as sessionId directly + const sessionId = entry?.sessionId ?? key; + const sessionFile = entry?.sessionFile ?? resolveSessionFilePath(key); + + const { loadSessionLogs } = await import("../../infra/session-cost-usage.js"); + const logs = await loadSessionLogs({ + sessionId, + sessionEntry: entry, + sessionFile, + config, + limit, + }); + + respond(true, { logs: logs ?? [] }, undefined); + }, }; diff --git a/src/gateway/session-utils.fs.test.ts b/src/gateway/session-utils.fs.test.ts index 56a5a059b6..3d04223d4a 100644 --- a/src/gateway/session-utils.fs.test.ts +++ b/src/gateway/session-utils.fs.test.ts @@ -383,6 +383,43 @@ describe("readSessionPreviewItemsFromTranscript", () => { expect(result[1]?.text).toContain("call weather"); }); + test("detects tool calls from tool_use/tool_call blocks and toolName field", () => { + const sessionId = "preview-session-tools"; + const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); + const lines = [ + JSON.stringify({ type: "session", version: 1, id: sessionId }), + JSON.stringify({ message: { role: "assistant", content: "Hi" } }), + JSON.stringify({ + message: { + role: "assistant", + toolName: "camera", + content: [ + { type: "tool_use", name: "read" }, + { type: "tool_call", name: "write" }, + ], + }, + }), + JSON.stringify({ message: { role: "assistant", content: "Done" } }), + ]; + fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); + + const result = readSessionPreviewItemsFromTranscript( + sessionId, + storePath, + undefined, + undefined, + 3, + 120, + ); + + expect(result.map((item) => item.role)).toEqual(["assistant", "tool", "assistant"]); + expect(result[1]?.text).toContain("call"); + expect(result[1]?.text).toContain("camera"); + expect(result[1]?.text).toContain("read"); + // Preview text may not list every tool name; it should at least hint there were multiple calls. + expect(result[1]?.text).toMatch(/\+\d+/); + }); + test("truncates preview text to max chars", () => { const sessionId = "preview-truncate"; const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); diff --git a/src/gateway/session-utils.fs.ts b/src/gateway/session-utils.fs.ts index 936ad94198..421bae3f0d 100644 --- a/src/gateway/session-utils.fs.ts +++ b/src/gateway/session-utils.fs.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import type { SessionPreviewItem } from "./session-utils.types.js"; import { resolveSessionTranscriptPath } from "../config/sessions.js"; +import { extractToolCallNames, hasToolCall } from "../utils/transcript-tools.js"; import { stripEnvelope } from "./chat-sanitize.js"; export function readSessionMessages( @@ -292,35 +293,11 @@ function extractPreviewText(message: TranscriptPreviewMessage): string | null { } function isToolCall(message: TranscriptPreviewMessage): boolean { - if (message.toolName || message.tool_name) { - return true; - } - if (!Array.isArray(message.content)) { - return false; - } - return message.content.some((entry) => { - if (entry?.name) { - return true; - } - const raw = typeof entry?.type === "string" ? entry.type.toLowerCase() : ""; - return raw === "toolcall" || raw === "tool_call"; - }); + return hasToolCall(message as Record); } function extractToolNames(message: TranscriptPreviewMessage): string[] { - const names: string[] = []; - if (Array.isArray(message.content)) { - for (const entry of message.content) { - if (typeof entry?.name === "string" && entry.name.trim()) { - names.push(entry.name.trim()); - } - } - } - const toolName = typeof message.toolName === "string" ? message.toolName : message.tool_name; - if (typeof toolName === "string" && toolName.trim()) { - names.push(toolName.trim()); - } - return names; + return extractToolCallNames(message as Record); } function extractMediaSummary(message: TranscriptPreviewMessage): string | null { diff --git a/src/infra/session-cost-usage.test.ts b/src/infra/session-cost-usage.test.ts index bb598bcb76..7ff330e84b 100644 --- a/src/infra/session-cost-usage.test.ts +++ b/src/infra/session-cost-usage.test.ts @@ -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; + } + } + }); }); diff --git a/src/infra/session-cost-usage.ts b/src/infra/session-cost-usage.ts index 3e592825a7..30f4304e1d 100644 --- a/src/infra/session-cost-usage.ts +++ b/src/infra/session-cost-usage.ts @@ -9,16 +9,41 @@ import { resolveSessionFilePath, resolveSessionTranscriptsDirForAgent, } from "../config/sessions/paths.js"; +import { countToolResults, extractToolCallNames } from "../utils/transcript-tools.js"; import { estimateUsageCost, resolveModelCostConfig } from "../utils/usage-format.js"; +type CostBreakdown = { + total?: number; + input?: number; + output?: number; + cacheRead?: number; + cacheWrite?: number; +}; + type ParsedUsageEntry = { usage: NormalizedUsage; costTotal?: number; + costBreakdown?: CostBreakdown; provider?: string; model?: string; timestamp?: Date; }; +type ParsedTranscriptEntry = { + message: Record; + role?: "user" | "assistant"; + timestamp?: Date; + durationMs?: number; + usage?: NormalizedUsage; + costTotal?: number; + costBreakdown?: CostBreakdown; + provider?: string; + model?: string; + stopReason?: string; + toolNames: string[]; + toolResultCounts: { total: number; errors: number }; +}; + export type CostUsageTotals = { input: number; output: number; @@ -26,6 +51,11 @@ export type CostUsageTotals = { cacheWrite: number; totalTokens: number; totalCost: number; + // Cost breakdown by token type (from actual API data when available) + inputCost: number; + outputCost: number; + cacheReadCost: number; + cacheWriteCost: number; missingCostEntries: number; }; @@ -40,10 +70,80 @@ export type CostUsageSummary = { totals: CostUsageTotals; }; +export type SessionDailyUsage = { + date: string; // YYYY-MM-DD + tokens: number; + cost: number; +}; + +export type SessionDailyMessageCounts = { + date: string; // YYYY-MM-DD + total: number; + user: number; + assistant: number; + toolCalls: number; + toolResults: number; + errors: number; +}; + +export type SessionLatencyStats = { + count: number; + avgMs: number; + p95Ms: number; + minMs: number; + maxMs: number; +}; + +export type SessionDailyLatency = SessionLatencyStats & { + date: string; // YYYY-MM-DD +}; + +export type SessionDailyModelUsage = { + date: string; // YYYY-MM-DD + provider?: string; + model?: string; + tokens: number; + cost: number; + count: number; +}; + +export type SessionMessageCounts = { + total: number; + user: number; + assistant: number; + toolCalls: number; + toolResults: number; + errors: number; +}; + +export type SessionToolUsage = { + totalCalls: number; + uniqueTools: number; + tools: Array<{ name: string; count: number }>; +}; + +export type SessionModelUsage = { + provider?: string; + model?: string; + count: number; + totals: CostUsageTotals; +}; + export type SessionCostSummary = CostUsageTotals & { sessionId?: string; sessionFile?: string; + firstActivity?: number; lastActivity?: number; + durationMs?: number; + activityDates?: string[]; // YYYY-MM-DD dates when session had activity + dailyBreakdown?: SessionDailyUsage[]; // Per-day token/cost breakdown + dailyMessageCounts?: SessionDailyMessageCounts[]; + dailyLatency?: SessionDailyLatency[]; + dailyModelUsage?: SessionDailyModelUsage[]; + messageCounts?: SessionMessageCounts; + toolUsage?: SessionToolUsage; + modelUsage?: SessionModelUsage[]; + latency?: SessionLatencyStats; }; const emptyTotals = (): CostUsageTotals => ({ @@ -53,6 +153,10 @@ const emptyTotals = (): CostUsageTotals => ({ cacheWrite: 0, totalTokens: 0, totalCost: 0, + inputCost: 0, + outputCost: 0, + cacheReadCost: 0, + cacheWriteCost: 0, missingCostEntries: 0, }); @@ -66,20 +170,28 @@ const toFiniteNumber = (value: unknown): number | undefined => { return value; }; -const extractCostTotal = (usageRaw?: UsageLike | null): number | undefined => { +const extractCostBreakdown = (usageRaw?: UsageLike | null): CostBreakdown | undefined => { if (!usageRaw || typeof usageRaw !== "object") { return undefined; } const record = usageRaw as Record; const cost = record.cost as Record | undefined; - const total = toFiniteNumber(cost?.total); - if (total === undefined) { + if (!cost) { return undefined; } - if (total < 0) { + + const total = toFiniteNumber(cost.total); + if (total === undefined || total < 0) { return undefined; } - return total; + + return { + total, + input: toFiniteNumber(cost.input), + output: toFiniteNumber(cost.output), + cacheRead: toFiniteNumber(cost.cacheRead), + cacheWrite: toFiniteNumber(cost.cacheWrite), + }; }; const parseTimestamp = (entry: Record): Date | undefined => { @@ -101,39 +213,69 @@ const parseTimestamp = (entry: Record): Date | undefined => { return undefined; }; -const parseUsageEntry = (entry: Record): ParsedUsageEntry | null => { +const parseTranscriptEntry = (entry: Record): ParsedTranscriptEntry | null => { const message = entry.message as Record | undefined; - const role = message?.role; - if (role !== "assistant") { + if (!message || typeof message !== "object") { + return null; + } + + const roleRaw = message.role; + const role = roleRaw === "user" || roleRaw === "assistant" ? roleRaw : undefined; + if (!role) { return null; } const usageRaw = - (message?.usage as UsageLike | undefined) ?? (entry.usage as UsageLike | undefined); - const usage = normalizeUsage(usageRaw); - if (!usage) { - return null; - } + (message.usage as UsageLike | undefined) ?? (entry.usage as UsageLike | undefined); + const usage = usageRaw ? (normalizeUsage(usageRaw) ?? undefined) : undefined; const provider = - (typeof message?.provider === "string" ? message?.provider : undefined) ?? + (typeof message.provider === "string" ? message.provider : undefined) ?? (typeof entry.provider === "string" ? entry.provider : undefined); const model = - (typeof message?.model === "string" ? message?.model : undefined) ?? + (typeof message.model === "string" ? message.model : undefined) ?? (typeof entry.model === "string" ? entry.model : undefined); + const costBreakdown = extractCostBreakdown(usageRaw); + const stopReason = typeof message.stopReason === "string" ? message.stopReason : undefined; + const durationMs = toFiniteNumber(message.durationMs ?? entry.durationMs); + return { + message, + role, + timestamp: parseTimestamp(entry), + durationMs, usage, - costTotal: extractCostTotal(usageRaw), + costTotal: costBreakdown?.total, + costBreakdown, provider, model, - timestamp: parseTimestamp(entry), + stopReason, + toolNames: extractToolCallNames(message), + toolResultCounts: countToolResults(message), }; }; const formatDayKey = (date: Date): string => date.toLocaleDateString("en-CA", { timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone }); +const computeLatencyStats = (values: number[]): SessionLatencyStats | undefined => { + if (!values.length) { + return undefined; + } + const sorted = values.toSorted((a, b) => a - b); + const total = sorted.reduce((sum, v) => sum + v, 0); + const count = sorted.length; + const p95Index = Math.max(0, Math.ceil(count * 0.95) - 1); + return { + count, + avgMs: total / count, + p95Ms: sorted[p95Index] ?? sorted[count - 1], + minMs: sorted[0], + maxMs: sorted[count - 1], + }; +}; + const applyUsageTotals = (totals: CostUsageTotals, usage: NormalizedUsage) => { totals.input += usage.input ?? 0; totals.output += usage.output ?? 0; @@ -145,6 +287,18 @@ const applyUsageTotals = (totals: CostUsageTotals, usage: NormalizedUsage) => { totals.totalTokens += totalTokens; }; +const applyCostBreakdown = (totals: CostUsageTotals, costBreakdown: CostBreakdown | undefined) => { + if (costBreakdown === undefined || costBreakdown.total === undefined) { + return; + } + totals.totalCost += costBreakdown.total; + totals.inputCost += costBreakdown.input ?? 0; + totals.outputCost += costBreakdown.output ?? 0; + totals.cacheReadCost += costBreakdown.cacheRead ?? 0; + totals.cacheWriteCost += costBreakdown.cacheWrite ?? 0; +}; + +// Legacy function for backwards compatibility (no cost breakdown available) const applyCostTotal = (totals: CostUsageTotals, costTotal: number | undefined) => { if (costTotal === undefined) { totals.missingCostEntries += 1; @@ -153,10 +307,10 @@ const applyCostTotal = (totals: CostUsageTotals, costTotal: number | undefined) totals.totalCost += costTotal; }; -async function scanUsageFile(params: { +async function scanTranscriptFile(params: { filePath: string; config?: OpenClawConfig; - onEntry: (entry: ParsedUsageEntry) => void; + onEntry: (entry: ParsedTranscriptEntry) => void; }): Promise { const fileStream = fs.createReadStream(params.filePath, { encoding: "utf-8" }); const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity }); @@ -168,12 +322,12 @@ async function scanUsageFile(params: { } try { const parsed = JSON.parse(trimmed) as Record; - const entry = parseUsageEntry(parsed); + const entry = parseTranscriptEntry(parsed); if (!entry) { continue; } - if (entry.costTotal === undefined) { + if (entry.usage && entry.costTotal === undefined) { const cost = resolveModelCostConfig({ provider: entry.provider, model: entry.model, @@ -189,16 +343,52 @@ async function scanUsageFile(params: { } } +async function scanUsageFile(params: { + filePath: string; + config?: OpenClawConfig; + onEntry: (entry: ParsedUsageEntry) => void; +}): Promise { + await scanTranscriptFile({ + filePath: params.filePath, + config: params.config, + onEntry: (entry) => { + if (!entry.usage) { + return; + } + params.onEntry({ + usage: entry.usage, + costTotal: entry.costTotal, + costBreakdown: entry.costBreakdown, + provider: entry.provider, + model: entry.model, + timestamp: entry.timestamp, + }); + }, + }); +} + export async function loadCostUsageSummary(params?: { - days?: number; + startMs?: number; + endMs?: number; + days?: number; // Deprecated, for backwards compatibility config?: OpenClawConfig; agentId?: string; }): Promise { - const days = Math.max(1, Math.floor(params?.days ?? 30)); const now = new Date(); - const since = new Date(now); - since.setDate(since.getDate() - (days - 1)); - const sinceTime = since.getTime(); + let sinceTime: number; + let untilTime: number; + + if (params?.startMs !== undefined && params?.endMs !== undefined) { + sinceTime = params.startMs; + untilTime = params.endMs; + } else { + // Fallback to days-based calculation for backwards compatibility + const days = Math.max(1, Math.floor(params?.days ?? 30)); + const since = new Date(now); + since.setDate(since.getDate() - (days - 1)); + sinceTime = since.getTime(); + untilTime = now.getTime(); + } const dailyMap = new Map(); const totals = emptyTotals(); @@ -215,6 +405,7 @@ export async function loadCostUsageSummary(params?: { if (!stats) { return null; } + // Include file if it was modified after our start time if (stats.mtimeMs < sinceTime) { return null; } @@ -229,17 +420,25 @@ export async function loadCostUsageSummary(params?: { config: params?.config, onEntry: (entry) => { const ts = entry.timestamp?.getTime(); - if (!ts || ts < sinceTime) { + if (!ts || ts < sinceTime || ts > untilTime) { return; } const dayKey = formatDayKey(entry.timestamp ?? now); const bucket = dailyMap.get(dayKey) ?? emptyTotals(); applyUsageTotals(bucket, entry.usage); - applyCostTotal(bucket, entry.costTotal); + if (entry.costBreakdown?.total !== undefined) { + applyCostBreakdown(bucket, entry.costBreakdown); + } else { + applyCostTotal(bucket, entry.costTotal); + } dailyMap.set(dayKey, bucket); applyUsageTotals(totals, entry.usage); - applyCostTotal(totals, entry.costTotal); + if (entry.costBreakdown?.total !== undefined) { + applyCostBreakdown(totals, entry.costBreakdown); + } else { + applyCostTotal(totals, entry.costTotal); + } }, }); } @@ -248,6 +447,9 @@ export async function loadCostUsageSummary(params?: { .map(([date, bucket]) => Object.assign({ date }, bucket)) .toSorted((a, b) => a.date.localeCompare(b.date)); + // Calculate days for backwards compatibility in response + const days = Math.ceil((untilTime - sinceTime) / (24 * 60 * 60 * 1000)) + 1; + return { updatedAt: Date.now(), days, @@ -256,11 +458,111 @@ export async function loadCostUsageSummary(params?: { }; } +export type DiscoveredSession = { + sessionId: string; + sessionFile: string; + mtime: number; + firstUserMessage?: string; +}; + +/** + * Scan all transcript files to discover sessions not in the session store. + * Returns basic metadata for each discovered session. + */ +export async function discoverAllSessions(params?: { + agentId?: string; + startMs?: number; + endMs?: number; +}): Promise { + const sessionsDir = resolveSessionTranscriptsDirForAgent(params?.agentId); + const entries = await fs.promises.readdir(sessionsDir, { withFileTypes: true }).catch(() => []); + + const discovered: DiscoveredSession[] = []; + + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith(".jsonl")) { + continue; + } + + const filePath = path.join(sessionsDir, entry.name); + const stats = await fs.promises.stat(filePath).catch(() => null); + if (!stats) { + continue; + } + + // Filter by date range if provided + if (params?.startMs && stats.mtimeMs < params.startMs) { + continue; + } + // Do not exclude by endMs: a session can have activity in range even if it continued later. + + // Extract session ID from filename (remove .jsonl) + const sessionId = entry.name.slice(0, -6); + + // Try to read first user message for label extraction + let firstUserMessage: string | undefined; + try { + const fileStream = fs.createReadStream(filePath, { encoding: "utf-8" }); + const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity }); + + for await (const line of rl) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + try { + const parsed = JSON.parse(trimmed) as Record; + const message = parsed.message as Record | undefined; + if (message?.role === "user") { + const content = message.content; + if (typeof content === "string") { + firstUserMessage = content.slice(0, 100); + } else if (Array.isArray(content)) { + for (const block of content) { + if ( + typeof block === "object" && + block && + (block as Record).type === "text" + ) { + const text = (block as Record).text; + if (typeof text === "string") { + firstUserMessage = text.slice(0, 100); + } + break; + } + } + } + break; // Found first user message + } + } catch { + // Skip malformed lines + } + } + rl.close(); + fileStream.destroy(); + } catch { + // Ignore read errors + } + + discovered.push({ + sessionId, + sessionFile: filePath, + mtime: stats.mtimeMs, + firstUserMessage, + }); + } + + // Sort by mtime descending (most recent first) + return discovered.toSorted((a, b) => b.mtime - a.mtime); +} + export async function loadSessionCostSummary(params: { sessionId?: string; sessionEntry?: SessionEntry; sessionFile?: string; config?: OpenClawConfig; + startMs?: number; + endMs?: number; }): Promise { const sessionFile = params.sessionFile ?? @@ -270,25 +572,521 @@ export async function loadSessionCostSummary(params: { } const totals = emptyTotals(); + let firstActivity: number | undefined; let lastActivity: number | undefined; + const activityDatesSet = new Set(); + const dailyMap = new Map(); + const dailyMessageMap = new Map(); + const dailyLatencyMap = new Map(); + const dailyModelUsageMap = new Map(); + const messageCounts: SessionMessageCounts = { + total: 0, + user: 0, + assistant: 0, + toolCalls: 0, + toolResults: 0, + errors: 0, + }; + const toolUsageMap = new Map(); + const modelUsageMap = new Map(); + const errorStopReasons = new Set(["error", "aborted", "timeout"]); + const latencyValues: number[] = []; + let lastUserTimestamp: number | undefined; + const MAX_LATENCY_MS = 12 * 60 * 60 * 1000; + + await scanTranscriptFile({ + filePath: sessionFile, + config: params.config, + onEntry: (entry) => { + const ts = entry.timestamp?.getTime(); + + // Filter by date range if specified + if (params.startMs !== undefined && ts !== undefined && ts < params.startMs) { + return; + } + if (params.endMs !== undefined && ts !== undefined && ts > params.endMs) { + return; + } + + if (ts !== undefined) { + if (!firstActivity || ts < firstActivity) { + firstActivity = ts; + } + if (!lastActivity || ts > lastActivity) { + lastActivity = ts; + } + } + + if (entry.role === "user") { + messageCounts.user += 1; + messageCounts.total += 1; + if (entry.timestamp) { + lastUserTimestamp = entry.timestamp.getTime(); + } + } + if (entry.role === "assistant") { + messageCounts.assistant += 1; + messageCounts.total += 1; + const ts = entry.timestamp?.getTime(); + if (ts !== undefined) { + const latencyMs = + entry.durationMs ?? + (lastUserTimestamp !== undefined ? Math.max(0, ts - lastUserTimestamp) : undefined); + if ( + latencyMs !== undefined && + Number.isFinite(latencyMs) && + latencyMs <= MAX_LATENCY_MS + ) { + latencyValues.push(latencyMs); + const dayKey = formatDayKey(entry.timestamp ?? new Date(ts)); + const dailyLatencies = dailyLatencyMap.get(dayKey) ?? []; + dailyLatencies.push(latencyMs); + dailyLatencyMap.set(dayKey, dailyLatencies); + } + } + } + + if (entry.toolNames.length > 0) { + messageCounts.toolCalls += entry.toolNames.length; + for (const name of entry.toolNames) { + toolUsageMap.set(name, (toolUsageMap.get(name) ?? 0) + 1); + } + } + + if (entry.toolResultCounts.total > 0) { + messageCounts.toolResults += entry.toolResultCounts.total; + messageCounts.errors += entry.toolResultCounts.errors; + } + + if (entry.stopReason && errorStopReasons.has(entry.stopReason)) { + messageCounts.errors += 1; + } + + if (entry.timestamp) { + const dayKey = formatDayKey(entry.timestamp); + activityDatesSet.add(dayKey); + const daily = dailyMessageMap.get(dayKey) ?? { + date: dayKey, + total: 0, + user: 0, + assistant: 0, + toolCalls: 0, + toolResults: 0, + errors: 0, + }; + daily.total += entry.role === "user" || entry.role === "assistant" ? 1 : 0; + if (entry.role === "user") { + daily.user += 1; + } else if (entry.role === "assistant") { + daily.assistant += 1; + } + daily.toolCalls += entry.toolNames.length; + daily.toolResults += entry.toolResultCounts.total; + daily.errors += entry.toolResultCounts.errors; + if (entry.stopReason && errorStopReasons.has(entry.stopReason)) { + daily.errors += 1; + } + dailyMessageMap.set(dayKey, daily); + } + + if (!entry.usage) { + return; + } + + applyUsageTotals(totals, entry.usage); + if (entry.costBreakdown?.total !== undefined) { + applyCostBreakdown(totals, entry.costBreakdown); + } else { + applyCostTotal(totals, entry.costTotal); + } + + if (entry.timestamp) { + const dayKey = formatDayKey(entry.timestamp); + const entryTokens = + (entry.usage.input ?? 0) + + (entry.usage.output ?? 0) + + (entry.usage.cacheRead ?? 0) + + (entry.usage.cacheWrite ?? 0); + const entryCost = + entry.costBreakdown?.total ?? + (entry.costBreakdown + ? (entry.costBreakdown.input ?? 0) + + (entry.costBreakdown.output ?? 0) + + (entry.costBreakdown.cacheRead ?? 0) + + (entry.costBreakdown.cacheWrite ?? 0) + : (entry.costTotal ?? 0)); + + const existing = dailyMap.get(dayKey) ?? { tokens: 0, cost: 0 }; + dailyMap.set(dayKey, { + tokens: existing.tokens + entryTokens, + cost: existing.cost + entryCost, + }); + + if (entry.provider || entry.model) { + const modelKey = `${dayKey}::${entry.provider ?? "unknown"}::${entry.model ?? "unknown"}`; + const dailyModel = + dailyModelUsageMap.get(modelKey) ?? + ({ + date: dayKey, + provider: entry.provider, + model: entry.model, + tokens: 0, + cost: 0, + count: 0, + } as SessionDailyModelUsage); + dailyModel.tokens += entryTokens; + dailyModel.cost += entryCost; + dailyModel.count += 1; + dailyModelUsageMap.set(modelKey, dailyModel); + } + } + + if (entry.provider || entry.model) { + const key = `${entry.provider ?? "unknown"}::${entry.model ?? "unknown"}`; + const existing = + modelUsageMap.get(key) ?? + ({ + provider: entry.provider, + model: entry.model, + count: 0, + totals: emptyTotals(), + } as SessionModelUsage); + existing.count += 1; + applyUsageTotals(existing.totals, entry.usage); + if (entry.costBreakdown?.total !== undefined) { + applyCostBreakdown(existing.totals, entry.costBreakdown); + } else { + applyCostTotal(existing.totals, entry.costTotal); + } + modelUsageMap.set(key, existing); + } + }, + }); + + // Convert daily map to sorted array + const dailyBreakdown: SessionDailyUsage[] = Array.from(dailyMap.entries()) + .map(([date, data]) => ({ date, tokens: data.tokens, cost: data.cost })) + .toSorted((a, b) => a.date.localeCompare(b.date)); + + const dailyMessageCounts: SessionDailyMessageCounts[] = Array.from( + dailyMessageMap.values(), + ).toSorted((a, b) => a.date.localeCompare(b.date)); + + const dailyLatency: SessionDailyLatency[] = Array.from(dailyLatencyMap.entries()) + .map(([date, values]) => { + const stats = computeLatencyStats(values); + if (!stats) { + return null; + } + return { date, ...stats }; + }) + .filter((entry): entry is SessionDailyLatency => Boolean(entry)) + .toSorted((a, b) => a.date.localeCompare(b.date)); + + const dailyModelUsage: SessionDailyModelUsage[] = Array.from( + dailyModelUsageMap.values(), + ).toSorted((a, b) => a.date.localeCompare(b.date) || b.cost - a.cost); + + const toolUsage: SessionToolUsage | undefined = toolUsageMap.size + ? { + totalCalls: Array.from(toolUsageMap.values()).reduce((sum, count) => sum + count, 0), + uniqueTools: toolUsageMap.size, + tools: Array.from(toolUsageMap.entries()) + .map(([name, count]) => ({ name, count })) + .toSorted((a, b) => b.count - a.count), + } + : undefined; + + const modelUsage = modelUsageMap.size + ? Array.from(modelUsageMap.values()).toSorted((a, b) => { + const costDiff = b.totals.totalCost - a.totals.totalCost; + if (costDiff !== 0) { + return costDiff; + } + return b.totals.totalTokens - a.totals.totalTokens; + }) + : undefined; + + return { + sessionId: params.sessionId, + sessionFile, + firstActivity, + lastActivity, + durationMs: + firstActivity !== undefined && lastActivity !== undefined + ? Math.max(0, lastActivity - firstActivity) + : undefined, + activityDates: Array.from(activityDatesSet).toSorted(), + dailyBreakdown, + dailyMessageCounts, + dailyLatency: dailyLatency.length ? dailyLatency : undefined, + dailyModelUsage: dailyModelUsage.length ? dailyModelUsage : undefined, + messageCounts, + toolUsage, + modelUsage, + latency: computeLatencyStats(latencyValues), + ...totals, + }; +} + +export type SessionUsageTimePoint = { + timestamp: number; + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + totalTokens: number; + cost: number; + cumulativeTokens: number; + cumulativeCost: number; +}; + +export type SessionUsageTimeSeries = { + sessionId?: string; + points: SessionUsageTimePoint[]; +}; + +export async function loadSessionUsageTimeSeries(params: { + sessionId?: string; + sessionEntry?: SessionEntry; + sessionFile?: string; + config?: OpenClawConfig; + maxPoints?: number; +}): Promise { + const sessionFile = + params.sessionFile ?? + (params.sessionId ? resolveSessionFilePath(params.sessionId, params.sessionEntry) : undefined); + if (!sessionFile || !fs.existsSync(sessionFile)) { + return null; + } + + const points: SessionUsageTimePoint[] = []; + let cumulativeTokens = 0; + let cumulativeCost = 0; await scanUsageFile({ filePath: sessionFile, config: params.config, onEntry: (entry) => { - applyUsageTotals(totals, entry.usage); - applyCostTotal(totals, entry.costTotal); const ts = entry.timestamp?.getTime(); - if (ts && (!lastActivity || ts > lastActivity)) { - lastActivity = ts; + if (!ts) { + return; } + + const input = entry.usage.input ?? 0; + const output = entry.usage.output ?? 0; + const cacheRead = entry.usage.cacheRead ?? 0; + const cacheWrite = entry.usage.cacheWrite ?? 0; + const totalTokens = entry.usage.total ?? input + output + cacheRead + cacheWrite; + const cost = entry.costTotal ?? 0; + + cumulativeTokens += totalTokens; + cumulativeCost += cost; + + points.push({ + timestamp: ts, + input, + output, + cacheRead, + cacheWrite, + totalTokens, + cost, + cumulativeTokens, + cumulativeCost, + }); }, }); - return { - sessionId: params.sessionId, - sessionFile, - lastActivity, - ...totals, - }; + // Sort by timestamp + const sortedPoints = points.toSorted((a, b) => a.timestamp - b.timestamp); + + // Optionally downsample if too many points + const maxPoints = params.maxPoints ?? 100; + if (sortedPoints.length > maxPoints) { + const step = Math.ceil(sortedPoints.length / maxPoints); + const downsampled: SessionUsageTimePoint[] = []; + for (let i = 0; i < sortedPoints.length; i += step) { + downsampled.push(sortedPoints[i]); + } + // Always include the last point + if (downsampled[downsampled.length - 1] !== sortedPoints[sortedPoints.length - 1]) { + downsampled.push(sortedPoints[sortedPoints.length - 1]); + } + return { sessionId: params.sessionId, points: downsampled }; + } + + return { sessionId: params.sessionId, points: sortedPoints }; +} + +export type SessionLogEntry = { + timestamp: number; + role: "user" | "assistant" | "tool" | "toolResult"; + content: string; + tokens?: number; + cost?: number; +}; + +export async function loadSessionLogs(params: { + sessionId?: string; + sessionEntry?: SessionEntry; + sessionFile?: string; + config?: OpenClawConfig; + limit?: number; +}): Promise { + const sessionFile = + params.sessionFile ?? + (params.sessionId ? resolveSessionFilePath(params.sessionId, params.sessionEntry) : undefined); + if (!sessionFile || !fs.existsSync(sessionFile)) { + return null; + } + + const logs: SessionLogEntry[] = []; + const limit = params.limit ?? 50; + + const fileStream = fs.createReadStream(sessionFile, { encoding: "utf-8" }); + const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity }); + + for await (const line of rl) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + try { + const parsed = JSON.parse(trimmed) as Record; + const message = parsed.message as Record | undefined; + if (!message) { + continue; + } + + const role = message.role as string | undefined; + if (role !== "user" && role !== "assistant" && role !== "tool" && role !== "toolResult") { + continue; + } + + const contentParts: string[] = []; + const rawToolName = message.toolName ?? message.tool_name ?? message.name ?? message.tool; + const toolName = + typeof rawToolName === "string" && rawToolName.trim() ? rawToolName.trim() : undefined; + if (role === "tool" || role === "toolResult") { + contentParts.push(`[Tool: ${toolName ?? "tool"}]`); + contentParts.push("[Tool Result]"); + } + + // Extract content + const rawContent = message.content; + if (typeof rawContent === "string") { + contentParts.push(rawContent); + } else if (Array.isArray(rawContent)) { + // Handle content blocks (text, tool_use, etc.) + const contentText = rawContent + .map((block: unknown) => { + if (typeof block === "string") { + return block; + } + const b = block as Record; + if (b.type === "text" && typeof b.text === "string") { + return b.text; + } + if (b.type === "tool_use") { + const name = typeof b.name === "string" ? b.name : "unknown"; + return `[Tool: ${name}]`; + } + if (b.type === "tool_result") { + return `[Tool Result]`; + } + return ""; + }) + .filter(Boolean) + .join("\n"); + if (contentText) { + contentParts.push(contentText); + } + } + + // OpenAI-style tool calls stored outside the content array. + const rawToolCalls = + message.tool_calls ?? message.toolCalls ?? message.function_call ?? message.functionCall; + const toolCalls = Array.isArray(rawToolCalls) + ? rawToolCalls + : rawToolCalls + ? [rawToolCalls] + : []; + if (toolCalls.length > 0) { + for (const call of toolCalls) { + const callObj = call as Record; + const directName = typeof callObj.name === "string" ? callObj.name : undefined; + const fn = callObj.function as Record | undefined; + const fnName = typeof fn?.name === "string" ? fn.name : undefined; + const name = directName ?? fnName ?? "unknown"; + contentParts.push(`[Tool: ${name}]`); + } + } + + let content = contentParts.join("\n").trim(); + if (!content) { + continue; + } + + // Truncate very long content + const maxLen = 2000; + if (content.length > maxLen) { + content = content.slice(0, maxLen) + "…"; + } + + // Get timestamp + let timestamp = 0; + if (typeof parsed.timestamp === "string") { + timestamp = new Date(parsed.timestamp).getTime(); + } else if (typeof message.timestamp === "number") { + timestamp = message.timestamp; + } + + // Get usage for assistant messages + let tokens: number | undefined; + let cost: number | undefined; + if (role === "assistant") { + const usageRaw = message.usage as Record | undefined; + const usage = normalizeUsage(usageRaw); + if (usage) { + tokens = + usage.total ?? + (usage.input ?? 0) + + (usage.output ?? 0) + + (usage.cacheRead ?? 0) + + (usage.cacheWrite ?? 0); + const breakdown = extractCostBreakdown(usageRaw); + if (breakdown?.total !== undefined) { + cost = breakdown.total; + } else { + const costConfig = resolveModelCostConfig({ + provider: message.provider as string | undefined, + model: message.model as string | undefined, + config: params.config, + }); + cost = estimateUsageCost({ usage, cost: costConfig }); + } + } + } + + logs.push({ + timestamp, + role, + content, + tokens, + cost, + }); + } catch { + // Ignore malformed lines + } + } + + // Sort by timestamp and limit + const sortedLogs = logs.toSorted((a, b) => a.timestamp - b.timestamp); + + // Return most recent logs + if (sortedLogs.length > limit) { + return sortedLogs.slice(-limit); + } + + return sortedLogs; } diff --git a/src/utils/transcript-tools.test.ts b/src/utils/transcript-tools.test.ts new file mode 100644 index 0000000000..0596a4421f --- /dev/null +++ b/src/utils/transcript-tools.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { countToolResults, extractToolCallNames, hasToolCall } from "./transcript-tools.js"; + +describe("transcript-tools", () => { + describe("extractToolCallNames", () => { + it("extracts tool name from message.toolName/tool_name", () => { + expect(extractToolCallNames({ toolName: " weather " })).toEqual(["weather"]); + expect(extractToolCallNames({ tool_name: "notes" })).toEqual(["notes"]); + }); + + it("extracts tool call names from content blocks (tool_use/toolcall/tool_call)", () => { + const names = extractToolCallNames({ + content: [ + { type: "text", text: "hi" }, + { type: "tool_use", name: "read" }, + { type: "toolcall", name: "exec" }, + { type: "tool_call", name: "write" }, + ], + }); + expect(new Set(names)).toEqual(new Set(["read", "exec", "write"])); + }); + + it("normalizes type and trims names; de-dupes", () => { + const names = extractToolCallNames({ + content: [ + { type: " TOOL_CALL ", name: " read " }, + { type: "tool_call", name: "read" }, + { type: "tool_call", name: "" }, + ], + toolName: "read", + }); + expect(names).toEqual(["read"]); + }); + }); + + describe("hasToolCall", () => { + it("returns true when tool call names exist", () => { + expect(hasToolCall({ toolName: "weather" })).toBe(true); + expect(hasToolCall({ content: [{ type: "tool_use", name: "read" }] })).toBe(true); + }); + + it("returns false when no tool calls exist", () => { + expect(hasToolCall({})).toBe(false); + expect(hasToolCall({ content: [{ type: "text", text: "hi" }] })).toBe(false); + }); + }); + + describe("countToolResults", () => { + it("counts tool_result blocks and tool_result_error blocks; tracks errors via is_error", () => { + expect( + countToolResults({ + content: [ + { type: "tool_result" }, + { type: "tool_result", is_error: true }, + { type: "tool_result_error" }, + { type: "text", text: "ignore" }, + ], + }), + ).toEqual({ total: 3, errors: 1 }); + }); + + it("handles non-array content", () => { + expect(countToolResults({ content: "nope" })).toEqual({ total: 0, errors: 0 }); + }); + }); +}); diff --git a/src/utils/transcript-tools.ts b/src/utils/transcript-tools.ts new file mode 100644 index 0000000000..9ef6178ef3 --- /dev/null +++ b/src/utils/transcript-tools.ts @@ -0,0 +1,73 @@ +type ToolResultCounts = { + total: number; + errors: number; +}; + +const TOOL_CALL_TYPES = new Set(["tool_use", "toolcall", "tool_call"]); +const TOOL_RESULT_TYPES = new Set(["tool_result", "tool_result_error"]); + +const normalizeType = (value: unknown): string => { + if (typeof value !== "string") { + return ""; + } + return value.trim().toLowerCase(); +}; + +export const extractToolCallNames = (message: Record): string[] => { + const names = new Set(); + const toolNameRaw = message.toolName ?? message.tool_name; + if (typeof toolNameRaw === "string" && toolNameRaw.trim()) { + names.add(toolNameRaw.trim()); + } + + const content = message.content; + if (!Array.isArray(content)) { + return Array.from(names); + } + + for (const entry of content) { + if (!entry || typeof entry !== "object") { + continue; + } + const block = entry as Record; + const type = normalizeType(block.type); + if (!TOOL_CALL_TYPES.has(type)) { + continue; + } + const name = block.name; + if (typeof name === "string" && name.trim()) { + names.add(name.trim()); + } + } + + return Array.from(names); +}; + +export const hasToolCall = (message: Record): boolean => + extractToolCallNames(message).length > 0; + +export const countToolResults = (message: Record): ToolResultCounts => { + const content = message.content; + if (!Array.isArray(content)) { + return { total: 0, errors: 0 }; + } + + let total = 0; + let errors = 0; + for (const entry of content) { + if (!entry || typeof entry !== "object") { + continue; + } + const block = entry as Record; + const type = normalizeType(block.type); + if (!TOOL_RESULT_TYPES.has(type)) { + continue; + } + total += 1; + if (block.is_error === true) { + errors += 1; + } + } + + return { total, errors }; +}; diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index 997684b372..d2bc9aa906 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -105,7 +105,11 @@ export function renderChatControls(state: AppViewState) { lastActiveSessionKey: next, }); void state.loadAssistantIdentity(); - syncUrlWithSessionKey(next, true); + syncUrlWithSessionKey( + state as unknown as Parameters[0], + next, + true, + ); void loadChatHistory(state as unknown as ChatState); }} > diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 0c6acc092a..f5c71c5792 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -1,18 +1,17 @@ import { html, nothing } from "lit"; import type { AppViewState } from "./app-view-state.ts"; +import type { UsageState } from "./controllers/usage.ts"; import { parseAgentSessionKey } from "../../../src/routing/session-key.js"; -import { ChatHost, refreshChatAvatar } from "./app-chat.ts"; +import { refreshChatAvatar } from "./app-chat.ts"; import { renderChatControls, renderTab, renderThemeToggle } from "./app-render.helpers.ts"; -import { OpenClawApp } from "./app.ts"; import { loadAgentFileContent, loadAgentFiles, saveAgentFile } from "./controllers/agent-files.ts"; import { loadAgentIdentities, loadAgentIdentity } from "./controllers/agent-identity.ts"; import { loadAgentSkills } from "./controllers/agent-skills.ts"; import { loadAgents } from "./controllers/agents.ts"; import { loadChannels } from "./controllers/channels.ts"; -import { ChatState, loadChatHistory } from "./controllers/chat.ts"; +import { loadChatHistory } from "./controllers/chat.ts"; import { applyConfig, - ConfigState, loadConfig, runUpdate, saveConfig, @@ -40,7 +39,7 @@ import { saveExecApprovals, updateExecApprovalsFormValue, } from "./controllers/exec-approvals.ts"; -import { loadLogs, LogsState } from "./controllers/logs.ts"; +import { loadLogs } from "./controllers/logs.ts"; import { loadNodes } from "./controllers/nodes.ts"; import { loadPresence } from "./controllers/presence.ts"; import { deleteSession, loadSessions, patchSession } from "./controllers/sessions.ts"; @@ -51,9 +50,18 @@ import { updateSkillEdit, updateSkillEnabled, } from "./controllers/skills.ts"; +import { loadUsage, loadSessionTimeSeries, loadSessionLogs } from "./controllers/usage.ts"; import { icons } from "./icons.ts"; import { normalizeBasePath, TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation.ts"; -import { ConfigUiHints } from "./types.ts"; + +// Module-scope debounce for usage date changes (avoids type-unsafe hacks on state object) +let usageDateDebounceTimeout: number | null = null; +const debouncedLoadUsage = (state: UsageState) => { + if (usageDateDebounceTimeout) { + clearTimeout(usageDateDebounceTimeout); + } + usageDateDebounceTimeout = window.setTimeout(() => void loadUsage(state), 400); +}; import { renderAgents } from "./views/agents.ts"; import { renderChannels } from "./views/channels.ts"; import { renderChat } from "./views/chat.ts"; @@ -68,6 +76,7 @@ import { renderNodes } from "./views/nodes.ts"; import { renderOverview } from "./views/overview.ts"; import { renderSessions } from "./views/sessions.ts"; import { renderSkills } from "./views/skills.ts"; +import { renderUsage } from "./views/usage.ts"; const AVATAR_DATA_RE = /^data:/i; const AVATAR_HTTP_RE = /^https?:\/\//i; @@ -98,36 +107,14 @@ export function renderApp(state: AppViewState) { const showThinking = state.onboarding ? false : state.settings.chatShowThinking; const assistantAvatarUrl = resolveAssistantAvatarUrl(state); const chatAvatarUrl = state.chatAvatarUrl ?? assistantAvatarUrl ?? null; - const logoBase = normalizeBasePath(state.basePath); - const logoHref = logoBase ? `${logoBase}/favicon.svg` : "/favicon.svg"; const configValue = state.configForm ?? (state.configSnapshot?.config as Record | null); + const basePath = normalizeBasePath(state.basePath ?? ""); const resolvedAgentId = state.agentsSelectedId ?? state.agentsList?.defaultId ?? state.agentsList?.agents?.[0]?.id ?? null; - const ensureAgentListEntry = (agentId: string) => { - const snapshot = (state.configForm ?? - (state.configSnapshot?.config as Record | null)) as { - agents?: { list?: unknown[] }; - } | null; - const listRaw = snapshot?.agents?.list; - const list = Array.isArray(listRaw) ? listRaw : []; - let index = list.findIndex( - (entry) => - entry && - typeof entry === "object" && - "id" in entry && - (entry as { id?: string }).id === agentId, - ); - if (index < 0) { - const nextList = [...list, { id: agentId }]; - updateConfigFormValue(state as unknown as ConfigState, ["agents", "list"], nextList); - index = nextList.length - 1; - } - return index; - }; return html`
@@ -147,7 +134,7 @@ export function renderApp(state: AppViewState) {
OPENCLAW
@@ -212,8 +199,8 @@ export function renderApp(state: AppViewState) {
-
${titleForTab(state.tab)}
-
${subtitleForTab(state.tab)}
+ ${state.tab === "usage" ? nothing : html`
${titleForTab(state.tab)}
`} + ${state.tab === "usage" ? nothing : html`
${subtitleForTab(state.tab)}
`}
${state.lastError ? html`
${state.lastError}
` : nothing} @@ -239,7 +226,7 @@ export function renderApp(state: AppViewState) { onSessionKeyChange: (next) => { state.sessionKey = next; state.chatMessage = ""; - (state as unknown as OpenClawApp).resetToolStream(); + state.resetToolStream(); state.applySettings({ ...state.settings, sessionKey: next, @@ -268,7 +255,7 @@ export function renderApp(state: AppViewState) { configSchema: state.configSchema, configSchemaLoading: state.configSchemaLoading, configForm: state.configForm, - configUiHints: state.configUiHints as ConfigUiHints, + configUiHints: state.configUiHints, configSaving: state.configSaving, configFormDirty: state.configFormDirty, nostrProfileFormState: state.nostrProfileFormState, @@ -277,8 +264,7 @@ export function renderApp(state: AppViewState) { onWhatsAppStart: (force) => state.handleWhatsAppStart(force), onWhatsAppWait: () => state.handleWhatsAppWait(), onWhatsAppLogout: () => state.handleWhatsAppLogout(), - onConfigPatch: (path, value) => - updateConfigFormValue(state as unknown as ConfigState, path, value), + onConfigPatch: (path, value) => updateConfigFormValue(state, path, value), onConfigSave: () => state.handleChannelConfigSave(), onConfigReload: () => state.handleChannelConfigReload(), onNostrProfileEdit: (accountId, profile) => @@ -329,6 +315,269 @@ export function renderApp(state: AppViewState) { : nothing } + ${ + state.tab === "usage" + ? renderUsage({ + loading: state.usageLoading, + error: state.usageError, + startDate: state.usageStartDate, + endDate: state.usageEndDate, + sessions: state.usageResult?.sessions ?? [], + sessionsLimitReached: (state.usageResult?.sessions?.length ?? 0) >= 1000, + totals: state.usageResult?.totals ?? null, + aggregates: state.usageResult?.aggregates ?? null, + costDaily: state.usageCostSummary?.daily ?? [], + selectedSessions: state.usageSelectedSessions, + selectedDays: state.usageSelectedDays, + selectedHours: state.usageSelectedHours, + chartMode: state.usageChartMode, + dailyChartMode: state.usageDailyChartMode, + timeSeriesMode: state.usageTimeSeriesMode, + timeSeriesBreakdownMode: state.usageTimeSeriesBreakdownMode, + timeSeries: state.usageTimeSeries, + timeSeriesLoading: state.usageTimeSeriesLoading, + sessionLogs: state.usageSessionLogs, + sessionLogsLoading: state.usageSessionLogsLoading, + sessionLogsExpanded: state.usageSessionLogsExpanded, + logFilterRoles: state.usageLogFilterRoles, + logFilterTools: state.usageLogFilterTools, + logFilterHasTools: state.usageLogFilterHasTools, + logFilterQuery: state.usageLogFilterQuery, + query: state.usageQuery, + queryDraft: state.usageQueryDraft, + sessionSort: state.usageSessionSort, + sessionSortDir: state.usageSessionSortDir, + recentSessions: state.usageRecentSessions, + sessionsTab: state.usageSessionsTab, + visibleColumns: + state.usageVisibleColumns as import("./views/usage.ts").UsageColumnId[], + timeZone: state.usageTimeZone, + contextExpanded: state.usageContextExpanded, + headerPinned: state.usageHeaderPinned, + onStartDateChange: (date) => { + state.usageStartDate = date; + state.usageSelectedDays = []; + state.usageSelectedHours = []; + state.usageSelectedSessions = []; + debouncedLoadUsage(state); + }, + onEndDateChange: (date) => { + state.usageEndDate = date; + state.usageSelectedDays = []; + state.usageSelectedHours = []; + state.usageSelectedSessions = []; + debouncedLoadUsage(state); + }, + onRefresh: () => loadUsage(state), + onTimeZoneChange: (zone) => { + state.usageTimeZone = zone; + }, + onToggleContextExpanded: () => { + state.usageContextExpanded = !state.usageContextExpanded; + }, + onToggleSessionLogsExpanded: () => { + state.usageSessionLogsExpanded = !state.usageSessionLogsExpanded; + }, + onLogFilterRolesChange: (next) => { + state.usageLogFilterRoles = next; + }, + onLogFilterToolsChange: (next) => { + state.usageLogFilterTools = next; + }, + onLogFilterHasToolsChange: (next) => { + state.usageLogFilterHasTools = next; + }, + onLogFilterQueryChange: (next) => { + state.usageLogFilterQuery = next; + }, + onLogFilterClear: () => { + state.usageLogFilterRoles = []; + state.usageLogFilterTools = []; + state.usageLogFilterHasTools = false; + state.usageLogFilterQuery = ""; + }, + onToggleHeaderPinned: () => { + state.usageHeaderPinned = !state.usageHeaderPinned; + }, + onSelectHour: (hour, shiftKey) => { + if (shiftKey && state.usageSelectedHours.length > 0) { + const allHours = Array.from({ length: 24 }, (_, i) => i); + const lastSelected = + state.usageSelectedHours[state.usageSelectedHours.length - 1]; + const lastIdx = allHours.indexOf(lastSelected); + const thisIdx = allHours.indexOf(hour); + if (lastIdx !== -1 && thisIdx !== -1) { + const [start, end] = + lastIdx < thisIdx ? [lastIdx, thisIdx] : [thisIdx, lastIdx]; + const range = allHours.slice(start, end + 1); + state.usageSelectedHours = [ + ...new Set([...state.usageSelectedHours, ...range]), + ]; + } + } else { + if (state.usageSelectedHours.includes(hour)) { + state.usageSelectedHours = state.usageSelectedHours.filter((h) => h !== hour); + } else { + state.usageSelectedHours = [...state.usageSelectedHours, hour]; + } + } + }, + onQueryDraftChange: (query) => { + state.usageQueryDraft = query; + if (state.usageQueryDebounceTimer) { + window.clearTimeout(state.usageQueryDebounceTimer); + } + state.usageQueryDebounceTimer = window.setTimeout(() => { + state.usageQuery = state.usageQueryDraft; + state.usageQueryDebounceTimer = null; + }, 250); + }, + onApplyQuery: () => { + if (state.usageQueryDebounceTimer) { + window.clearTimeout(state.usageQueryDebounceTimer); + state.usageQueryDebounceTimer = null; + } + state.usageQuery = state.usageQueryDraft; + }, + onClearQuery: () => { + if (state.usageQueryDebounceTimer) { + window.clearTimeout(state.usageQueryDebounceTimer); + state.usageQueryDebounceTimer = null; + } + state.usageQueryDraft = ""; + state.usageQuery = ""; + }, + onSessionSortChange: (sort) => { + state.usageSessionSort = sort; + }, + onSessionSortDirChange: (dir) => { + state.usageSessionSortDir = dir; + }, + onSessionsTabChange: (tab) => { + state.usageSessionsTab = tab; + }, + onToggleColumn: (column) => { + if (state.usageVisibleColumns.includes(column)) { + state.usageVisibleColumns = state.usageVisibleColumns.filter( + (entry) => entry !== column, + ); + } else { + state.usageVisibleColumns = [...state.usageVisibleColumns, column]; + } + }, + onSelectSession: (key, shiftKey) => { + state.usageTimeSeries = null; + state.usageSessionLogs = null; + state.usageRecentSessions = [ + key, + ...state.usageRecentSessions.filter((entry) => entry !== key), + ].slice(0, 8); + + if (shiftKey && state.usageSelectedSessions.length > 0) { + // Shift-click: select range from last selected to this session + // Sort sessions same way as displayed (by tokens or cost descending) + const isTokenMode = state.usageChartMode === "tokens"; + const sortedSessions = [...(state.usageResult?.sessions ?? [])].toSorted( + (a, b) => { + const valA = isTokenMode + ? (a.usage?.totalTokens ?? 0) + : (a.usage?.totalCost ?? 0); + const valB = isTokenMode + ? (b.usage?.totalTokens ?? 0) + : (b.usage?.totalCost ?? 0); + return valB - valA; + }, + ); + const allKeys = sortedSessions.map((s) => s.key); + const lastSelected = + state.usageSelectedSessions[state.usageSelectedSessions.length - 1]; + const lastIdx = allKeys.indexOf(lastSelected); + const thisIdx = allKeys.indexOf(key); + if (lastIdx !== -1 && thisIdx !== -1) { + const [start, end] = + lastIdx < thisIdx ? [lastIdx, thisIdx] : [thisIdx, lastIdx]; + const range = allKeys.slice(start, end + 1); + const newSelection = [...new Set([...state.usageSelectedSessions, ...range])]; + state.usageSelectedSessions = newSelection; + } + } else { + // Regular click: focus a single session (so details always open). + // Click the focused session again to clear selection. + if ( + state.usageSelectedSessions.length === 1 && + state.usageSelectedSessions[0] === key + ) { + state.usageSelectedSessions = []; + } else { + state.usageSelectedSessions = [key]; + } + } + + // Load timeseries/logs only if exactly one session selected + if (state.usageSelectedSessions.length === 1) { + void loadSessionTimeSeries(state, state.usageSelectedSessions[0]); + void loadSessionLogs(state, state.usageSelectedSessions[0]); + } + }, + onSelectDay: (day, shiftKey) => { + if (shiftKey && state.usageSelectedDays.length > 0) { + // Shift-click: select range from last selected to this day + const allDays = (state.usageCostSummary?.daily ?? []).map((d) => d.date); + const lastSelected = + state.usageSelectedDays[state.usageSelectedDays.length - 1]; + const lastIdx = allDays.indexOf(lastSelected); + const thisIdx = allDays.indexOf(day); + if (lastIdx !== -1 && thisIdx !== -1) { + const [start, end] = + lastIdx < thisIdx ? [lastIdx, thisIdx] : [thisIdx, lastIdx]; + const range = allDays.slice(start, end + 1); + // Merge with existing selection + const newSelection = [...new Set([...state.usageSelectedDays, ...range])]; + state.usageSelectedDays = newSelection; + } + } else { + // Regular click: toggle single day + if (state.usageSelectedDays.includes(day)) { + state.usageSelectedDays = state.usageSelectedDays.filter((d) => d !== day); + } else { + state.usageSelectedDays = [day]; + } + } + }, + onChartModeChange: (mode) => { + state.usageChartMode = mode; + }, + onDailyChartModeChange: (mode) => { + state.usageDailyChartMode = mode; + }, + onTimeSeriesModeChange: (mode) => { + state.usageTimeSeriesMode = mode; + }, + onTimeSeriesBreakdownChange: (mode) => { + state.usageTimeSeriesBreakdownMode = mode; + }, + onClearDays: () => { + state.usageSelectedDays = []; + }, + onClearHours: () => { + state.usageSelectedHours = []; + }, + onClearSessions: () => { + state.usageSelectedSessions = []; + state.usageTimeSeries = null; + state.usageSessionLogs = null; + }, + onClearFilters: () => { + state.usageSelectedDays = []; + state.usageSelectedHours = []; + state.usageSelectedSessions = []; + state.usageTimeSeries = null; + state.usageSessionLogs = null; + }, + }) + : nothing + } + ${ state.tab === "cron" ? renderCron({ @@ -444,17 +693,7 @@ export function renderApp(state: AppViewState) { void state.loadCron(); } }, - onLoadFiles: (agentId) => { - void (async () => { - await loadAgentFiles(state, agentId); - if (state.agentFileActive) { - await loadAgentFileContent(state, agentId, state.agentFileActive, { - force: true, - preserveDraft: true, - }); - } - })(); - }, + onLoadFiles: (agentId) => loadAgentFiles(state, agentId), onSelectFile: (name) => { state.agentFileActive = name; if (!resolvedAgentId) { @@ -497,19 +736,12 @@ export function renderApp(state: AppViewState) { } const basePath = ["agents", "list", index, "tools"]; if (profile) { - updateConfigFormValue( - state as unknown as ConfigState, - [...basePath, "profile"], - profile, - ); + updateConfigFormValue(state, [...basePath, "profile"], profile); } else { - removeConfigFormValue(state as unknown as ConfigState, [ - ...basePath, - "profile", - ]); + removeConfigFormValue(state, [...basePath, "profile"]); } if (clearAllow) { - removeConfigFormValue(state as unknown as ConfigState, [...basePath, "allow"]); + removeConfigFormValue(state, [...basePath, "allow"]); } }, onToolsOverridesChange: (agentId, alsoAllow, deny) => { @@ -532,29 +764,18 @@ export function renderApp(state: AppViewState) { } const basePath = ["agents", "list", index, "tools"]; if (alsoAllow.length > 0) { - updateConfigFormValue( - state as unknown as ConfigState, - [...basePath, "alsoAllow"], - alsoAllow, - ); + updateConfigFormValue(state, [...basePath, "alsoAllow"], alsoAllow); } else { - removeConfigFormValue(state as unknown as ConfigState, [ - ...basePath, - "alsoAllow", - ]); + removeConfigFormValue(state, [...basePath, "alsoAllow"]); } if (deny.length > 0) { - updateConfigFormValue( - state as unknown as ConfigState, - [...basePath, "deny"], - deny, - ); + updateConfigFormValue(state, [...basePath, "deny"], deny); } else { - removeConfigFormValue(state as unknown as ConfigState, [...basePath, "deny"]); + removeConfigFormValue(state, [...basePath, "deny"]); } }, - onConfigReload: () => loadConfig(state as unknown as ConfigState), - onConfigSave: () => saveConfig(state as unknown as ConfigState), + onConfigReload: () => loadConfig(state), + onConfigSave: () => saveConfig(state), onChannelsRefresh: () => loadChannels(state, false), onCronRefresh: () => state.loadCron(), onSkillsFilterChange: (next) => (state.skillsFilter = next), @@ -599,11 +820,7 @@ export function renderApp(state: AppViewState) { } else { next.delete(normalizedSkill); } - updateConfigFormValue( - state as unknown as ConfigState, - ["agents", "list", index, "skills"], - [...next], - ); + updateConfigFormValue(state, ["agents", "list", index, "skills"], [...next]); }, onAgentSkillsClear: (agentId) => { if (!configValue) { @@ -623,12 +840,7 @@ export function renderApp(state: AppViewState) { if (index < 0) { return; } - removeConfigFormValue(state as unknown as ConfigState, [ - "agents", - "list", - index, - "skills", - ]); + removeConfigFormValue(state, ["agents", "list", index, "skills"]); }, onAgentSkillsDisableAll: (agentId) => { if (!configValue) { @@ -648,58 +860,32 @@ export function renderApp(state: AppViewState) { if (index < 0) { return; } - updateConfigFormValue( - state as unknown as ConfigState, - ["agents", "list", index, "skills"], - [], - ); + updateConfigFormValue(state, ["agents", "list", index, "skills"], []); }, onModelChange: (agentId, modelId) => { if (!configValue) { return; } - const defaultId = state.agentsList?.defaultId ?? null; - if (defaultId && agentId === defaultId) { - const basePath = ["agents", "defaults", "model"]; - const defaults = - (configValue as { agents?: { defaults?: { model?: unknown } } }).agents - ?.defaults ?? {}; - const existing = defaults.model; - if (!modelId) { - removeConfigFormValue(state as unknown as ConfigState, basePath); - return; - } - if (existing && typeof existing === "object" && !Array.isArray(existing)) { - const fallbacks = (existing as { fallbacks?: unknown }).fallbacks; - const next = { - primary: modelId, - ...(Array.isArray(fallbacks) ? { fallbacks } : {}), - }; - updateConfigFormValue(state as unknown as ConfigState, basePath, next); - } else { - updateConfigFormValue(state as unknown as ConfigState, basePath, { - primary: modelId, - }); - } + const list = (configValue as { agents?: { list?: unknown[] } }).agents?.list; + if (!Array.isArray(list)) { + return; + } + const index = list.findIndex( + (entry) => + entry && + typeof entry === "object" && + "id" in entry && + (entry as { id?: string }).id === agentId, + ); + if (index < 0) { return; } - - const index = ensureAgentListEntry(agentId); const basePath = ["agents", "list", index, "model"]; if (!modelId) { - removeConfigFormValue(state as unknown as ConfigState, basePath); + removeConfigFormValue(state, basePath); return; } - const list = ( - (state.configForm ?? - (state.configSnapshot?.config as Record | null)) as { - agents?: { list?: unknown[] }; - } - )?.agents?.list; - const entry = - Array.isArray(list) && list[index] - ? (list[index] as { model?: unknown }) - : null; + const entry = list[index] as { model?: unknown }; const existing = entry?.model; if (existing && typeof existing === "object" && !Array.isArray(existing)) { const fallbacks = (existing as { fallbacks?: unknown }).fallbacks; @@ -707,70 +893,33 @@ export function renderApp(state: AppViewState) { primary: modelId, ...(Array.isArray(fallbacks) ? { fallbacks } : {}), }; - updateConfigFormValue(state as unknown as ConfigState, basePath, next); + updateConfigFormValue(state, basePath, next); } else { - updateConfigFormValue(state as unknown as ConfigState, basePath, modelId); + updateConfigFormValue(state, basePath, modelId); } }, onModelFallbacksChange: (agentId, fallbacks) => { if (!configValue) { return; } - const normalized = fallbacks.map((name) => name.trim()).filter(Boolean); - const defaultId = state.agentsList?.defaultId ?? null; - if (defaultId && agentId === defaultId) { - const basePath = ["agents", "defaults", "model"]; - const defaults = - (configValue as { agents?: { defaults?: { model?: unknown } } }).agents - ?.defaults ?? {}; - const existing = defaults.model; - const resolvePrimary = () => { - if (typeof existing === "string") { - return existing.trim() || null; - } - if (existing && typeof existing === "object" && !Array.isArray(existing)) { - const primary = (existing as { primary?: unknown }).primary; - if (typeof primary === "string") { - const trimmed = primary.trim(); - return trimmed || null; - } - } - return null; - }; - const primary = resolvePrimary(); - if (normalized.length === 0) { - if (primary) { - updateConfigFormValue(state as unknown as ConfigState, basePath, { - primary, - }); - } else { - removeConfigFormValue(state as unknown as ConfigState, basePath); - } - return; - } - const next = primary - ? { primary, fallbacks: normalized } - : { fallbacks: normalized }; - updateConfigFormValue(state as unknown as ConfigState, basePath, next); + const list = (configValue as { agents?: { list?: unknown[] } }).agents?.list; + if (!Array.isArray(list)) { + return; + } + const index = list.findIndex( + (entry) => + entry && + typeof entry === "object" && + "id" in entry && + (entry as { id?: string }).id === agentId, + ); + if (index < 0) { return; } - - const index = ensureAgentListEntry(agentId); const basePath = ["agents", "list", index, "model"]; - const list = ( - (state.configForm ?? - (state.configSnapshot?.config as Record | null)) as { - agents?: { list?: unknown[] }; - } - )?.agents?.list; - const entry = - Array.isArray(list) && list[index] - ? (list[index] as { model?: unknown }) - : null; - const existing = entry?.model; - if (!existing) { - return; - } + const entry = list[index] as { model?: unknown }; + const normalized = fallbacks.map((name) => name.trim()).filter(Boolean); + const existing = entry.model; const resolvePrimary = () => { if (typeof existing === "string") { return existing.trim() || null; @@ -787,16 +936,16 @@ export function renderApp(state: AppViewState) { const primary = resolvePrimary(); if (normalized.length === 0) { if (primary) { - updateConfigFormValue(state as unknown as ConfigState, basePath, primary); + updateConfigFormValue(state, basePath, primary); } else { - removeConfigFormValue(state as unknown as ConfigState, basePath); + removeConfigFormValue(state, basePath); } return; } const next = primary ? { primary, fallbacks: normalized } : { fallbacks: normalized }; - updateConfigFormValue(state as unknown as ConfigState, basePath, next); + updateConfigFormValue(state, basePath, next); }, }) : nothing @@ -853,7 +1002,7 @@ export function renderApp(state: AppViewState) { onDeviceRotate: (deviceId, role, scopes) => rotateDeviceToken(state, { deviceId, role, scopes }), onDeviceRevoke: (deviceId, role) => revokeDeviceToken(state, { deviceId, role }), - onLoadConfig: () => loadConfig(state as unknown as ConfigState), + onLoadConfig: () => loadConfig(state), onLoadExecApprovals: () => { const target = state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId @@ -863,28 +1012,20 @@ export function renderApp(state: AppViewState) { }, onBindDefault: (nodeId) => { if (nodeId) { - updateConfigFormValue( - state as unknown as ConfigState, - ["tools", "exec", "node"], - nodeId, - ); + updateConfigFormValue(state, ["tools", "exec", "node"], nodeId); } else { - removeConfigFormValue(state as unknown as ConfigState, [ - "tools", - "exec", - "node", - ]); + removeConfigFormValue(state, ["tools", "exec", "node"]); } }, onBindAgent: (agentIndex, nodeId) => { const basePath = ["agents", "list", agentIndex, "tools", "exec", "node"]; if (nodeId) { - updateConfigFormValue(state as unknown as ConfigState, basePath, nodeId); + updateConfigFormValue(state, basePath, nodeId); } else { - removeConfigFormValue(state as unknown as ConfigState, basePath); + removeConfigFormValue(state, basePath); } }, - onSaveBindings: () => saveConfig(state as unknown as ConfigState), + onSaveBindings: () => saveConfig(state), onExecApprovalsTargetChange: (kind, nodeId) => { state.execApprovalsTarget = kind; state.execApprovalsTargetNodeId = nodeId; @@ -919,29 +1060,30 @@ export function renderApp(state: AppViewState) { state.chatMessage = ""; state.chatAttachments = []; state.chatStream = null; + state.chatStreamStartedAt = null; state.chatRunId = null; - (state as unknown as OpenClawApp).chatStreamStartedAt = null; state.chatQueue = []; - (state as unknown as OpenClawApp).resetToolStream(); - (state as unknown as OpenClawApp).resetChatScroll(); + state.resetToolStream(); + state.resetChatScroll(); state.applySettings({ ...state.settings, sessionKey: next, lastActiveSessionKey: next, }); void state.loadAssistantIdentity(); - void loadChatHistory(state as unknown as ChatState); - void refreshChatAvatar(state as unknown as ChatHost); + void loadChatHistory(state); + void refreshChatAvatar(state); }, thinkingLevel: state.chatThinkingLevel, showThinking, loading: state.chatLoading, sending: state.chatSending, + compactionStatus: state.compactionStatus, assistantAvatarUrl: chatAvatarUrl, messages: state.chatMessages, toolMessages: state.chatToolMessages, stream: state.chatStream, - streamStartedAt: null, + streamStartedAt: state.chatStreamStartedAt, draft: state.chatMessage, queue: state.chatQueue, connected: state.connected, @@ -951,10 +1093,8 @@ export function renderApp(state: AppViewState) { sessions: state.sessionsResult, focusMode: chatFocus, onRefresh: () => { - return Promise.all([ - loadChatHistory(state as unknown as ChatState), - refreshChatAvatar(state as unknown as ChatHost), - ]); + state.resetToolStream(); + return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]); }, onToggleFocusMode: () => { if (state.onboarding) { @@ -965,28 +1105,25 @@ export function renderApp(state: AppViewState) { chatFocusMode: !state.settings.chatFocusMode, }); }, - onChatScroll: (event) => (state as unknown as OpenClawApp).handleChatScroll(event), + onChatScroll: (event) => state.handleChatScroll(event), onDraftChange: (next) => (state.chatMessage = next), attachments: state.chatAttachments, onAttachmentsChange: (next) => (state.chatAttachments = next), - onSend: () => (state as unknown as OpenClawApp).handleSendChat(), + onSend: () => state.handleSendChat(), canAbort: Boolean(state.chatRunId), - onAbort: () => void (state as unknown as OpenClawApp).handleAbortChat(), - onQueueRemove: (id) => (state as unknown as OpenClawApp).removeQueuedMessage(id), - onNewSession: () => - (state as unknown as OpenClawApp).handleSendChat("/new", { restoreDraft: true }), + onAbort: () => void state.handleAbortChat(), + onQueueRemove: (id) => state.removeQueuedMessage(id), + onNewSession: () => state.handleSendChat("/new", { restoreDraft: true }), showNewMessages: state.chatNewMessagesBelow, onScrollToBottom: () => state.scrollToBottom(), // Sidebar props for tool output viewing - sidebarOpen: (state as unknown as OpenClawApp).sidebarOpen, - sidebarContent: (state as unknown as OpenClawApp).sidebarContent, - sidebarError: (state as unknown as OpenClawApp).sidebarError, - splitRatio: (state as unknown as OpenClawApp).splitRatio, - onOpenSidebar: (content: string) => - (state as unknown as OpenClawApp).handleOpenSidebar(content), - onCloseSidebar: () => (state as unknown as OpenClawApp).handleCloseSidebar(), - onSplitRatioChange: (ratio: number) => - (state as unknown as OpenClawApp).handleSplitRatioChange(ratio), + sidebarOpen: state.sidebarOpen, + sidebarContent: state.sidebarContent, + sidebarError: state.sidebarError, + splitRatio: state.splitRatio, + onOpenSidebar: (content: string) => state.handleOpenSidebar(content), + onCloseSidebar: () => state.handleCloseSidebar(), + onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio), assistantName: state.assistantName, assistantAvatar: state.assistantAvatar, }) @@ -1007,31 +1144,28 @@ export function renderApp(state: AppViewState) { connected: state.connected, schema: state.configSchema, schemaLoading: state.configSchemaLoading, - uiHints: state.configUiHints as ConfigUiHints, + uiHints: state.configUiHints, formMode: state.configFormMode, formValue: state.configForm, originalValue: state.configFormOriginal, - searchQuery: (state as unknown as OpenClawApp).configSearchQuery, - activeSection: (state as unknown as OpenClawApp).configActiveSection, - activeSubsection: (state as unknown as OpenClawApp).configActiveSubsection, + searchQuery: state.configSearchQuery, + activeSection: state.configActiveSection, + activeSubsection: state.configActiveSubsection, onRawChange: (next) => { state.configRaw = next; }, onFormModeChange: (mode) => (state.configFormMode = mode), - onFormPatch: (path, value) => - updateConfigFormValue(state as unknown as OpenClawApp, path, value), - onSearchChange: (query) => - ((state as unknown as OpenClawApp).configSearchQuery = query), + onFormPatch: (path, value) => updateConfigFormValue(state, path, value), + onSearchChange: (query) => (state.configSearchQuery = query), onSectionChange: (section) => { - (state as unknown as OpenClawApp).configActiveSection = section; - (state as unknown as OpenClawApp).configActiveSubsection = null; + state.configActiveSection = section; + state.configActiveSubsection = null; }, - onSubsectionChange: (section) => - ((state as unknown as OpenClawApp).configActiveSubsection = section), - onReload: () => loadConfig(state as unknown as OpenClawApp), - onSave: () => saveConfig(state as unknown as OpenClawApp), - onApply: () => applyConfig(state as unknown as OpenClawApp), - onUpdate: () => runUpdate(state as unknown as OpenClawApp), + onSubsectionChange: (section) => (state.configActiveSubsection = section), + onReload: () => loadConfig(state), + onSave: () => saveConfig(state), + onApply: () => applyConfig(state), + onUpdate: () => runUpdate(state), }) : nothing } @@ -1073,10 +1207,9 @@ export function renderApp(state: AppViewState) { state.logsLevelFilters = { ...state.logsLevelFilters, [level]: enabled }; }, onToggleAutoFollow: (next) => (state.logsAutoFollow = next), - onRefresh: () => loadLogs(state as unknown as LogsState, { reset: true }), - onExport: (lines, label) => - (state as unknown as OpenClawApp).exportLogs(lines, label), - onScroll: (event) => (state as unknown as OpenClawApp).handleLogsScroll(event), + onRefresh: () => loadLogs(state, { reset: true }), + onExport: (lines, label) => state.exportLogs(lines, label), + onScroll: (event) => state.handleLogsScroll(event), }) : nothing } diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index f537ff1eab..bd74ad0019 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -1,4 +1,5 @@ import type { OpenClawApp } from "./app.ts"; +import type { AgentsListResult } from "./types.ts"; import { refreshChat } from "./app-chat.ts"; import { startLogsPolling, @@ -35,6 +36,7 @@ import { resolveTheme, type ResolvedTheme, type ThemeMode } from "./theme.ts"; type SettingsHost = { settings: UiSettings; + password?: string; theme: ThemeMode; themeResolved: ResolvedTheme; applySessionKey: string; @@ -46,35 +48,14 @@ type SettingsHost = { eventLog: unknown[]; eventLogBuffer: unknown[]; basePath: string; + agentsList?: AgentsListResult | null; + agentsSelectedId?: string | null; + agentsPanel?: "overview" | "files" | "tools" | "skills" | "channels" | "cron"; themeMedia: MediaQueryList | null; themeMediaHandler: ((event: MediaQueryListEvent) => void) | null; pendingGatewayUrl?: string | null; }; -function isTopLevelWindow(): boolean { - try { - return window.top === window.self; - } catch { - return false; - } -} - -function normalizeGatewayUrl(raw: string): string | null { - const trimmed = raw.trim(); - if (!trimmed) { - return null; - } - try { - const parsed = new URL(trimmed); - if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") { - return null; - } - return trimmed; - } catch { - return null; - } -} - export function applySettings(host: SettingsHost, next: UiSettings) { const normalized = { ...next, @@ -117,6 +98,10 @@ export function applySettingsFromUrl(host: SettingsHost) { } if (passwordRaw != null) { + const password = passwordRaw.trim(); + if (password) { + (host as { password: string }).password = password; + } params.delete("password"); shouldCleanUrl = true; } @@ -134,8 +119,8 @@ export function applySettingsFromUrl(host: SettingsHost) { } if (gatewayUrlRaw != null) { - const gatewayUrl = normalizeGatewayUrl(gatewayUrlRaw); - if (gatewayUrl && gatewayUrl !== host.settings.gatewayUrl && isTopLevelWindow()) { + const gatewayUrl = gatewayUrlRaw.trim(); + if (gatewayUrl && gatewayUrl !== host.settings.gatewayUrl) { host.pendingGatewayUrl = gatewayUrl; } params.delete("gatewayUrl"); @@ -205,24 +190,23 @@ export async function refreshActiveTab(host: SettingsHost) { await loadSkills(host as unknown as OpenClawApp); } if (host.tab === "agents") { - const app = host as unknown as OpenClawApp; - await loadAgents(app); - await loadConfig(app); - const agentIds = app.agentsList?.agents?.map((entry) => entry.id) ?? []; + await loadAgents(host as unknown as OpenClawApp); + await loadConfig(host as unknown as OpenClawApp); + const agentIds = host.agentsList?.agents?.map((entry) => entry.id) ?? []; if (agentIds.length > 0) { - void loadAgentIdentities(app, agentIds); + void loadAgentIdentities(host as unknown as OpenClawApp, agentIds); } const agentId = - app.agentsSelectedId ?? app.agentsList?.defaultId ?? app.agentsList?.agents?.[0]?.id; + host.agentsSelectedId ?? host.agentsList?.defaultId ?? host.agentsList?.agents?.[0]?.id; if (agentId) { - void loadAgentIdentity(app, agentId); - if (app.agentsPanel === "skills") { - void loadAgentSkills(app, agentId); + void loadAgentIdentity(host as unknown as OpenClawApp, agentId); + if (host.agentsPanel === "skills") { + void loadAgentSkills(host as unknown as OpenClawApp, agentId); } - if (app.agentsPanel === "channels") { - void loadChannels(app, false); + if (host.agentsPanel === "channels") { + void loadChannels(host as unknown as OpenClawApp, false); } - if (app.agentsPanel === "cron") { + if (host.agentsPanel === "cron") { void loadCron(host); } } @@ -397,7 +381,7 @@ export function syncUrlWithTab(host: SettingsHost, tab: Tab, replace: boolean) { } } -export function syncUrlWithSessionKey(sessionKey: string, replace: boolean) { +export function syncUrlWithSessionKey(host: SettingsHost, sessionKey: string, replace: boolean) { if (typeof window === "undefined") { return; } diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index 20d9dc44f0..7cb87310d1 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -1,4 +1,5 @@ import type { EventLogEntry } from "./app-events.ts"; +import type { CompactionStatus } from "./app-tool-stream.ts"; import type { DevicePairingList } from "./controllers/devices.ts"; import type { ExecApprovalRequest } from "./controllers/exec-approval.ts"; import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals.ts"; @@ -14,6 +15,7 @@ import type { AgentIdentityResult, ChannelsStatusSnapshot, ConfigSnapshot, + ConfigUiHints, CronJob, CronRunLogEntry, CronStatus, @@ -22,12 +24,16 @@ import type { LogLevel, NostrProfile, PresenceEntry, + SessionsUsageResult, + CostUsageSummary, + SessionUsageTimeSeries, SessionsListResult, SkillStatusReport, StatusSummary, } from "./types.ts"; import type { ChatAttachment, ChatQueueItem, CronFormState } from "./ui-types.ts"; import type { NostrProfileFormState } from "./views/channels.nostr-profile-form.ts"; +import type { SessionLogEntry } from "./views/usage.ts"; export type AppViewState = { settings: UiSettings; @@ -52,13 +58,19 @@ export type AppViewState = { chatMessages: unknown[]; chatToolMessages: unknown[]; chatStream: string | null; + chatStreamStartedAt: number | null; chatRunId: string | null; + compactionStatus: CompactionStatus | null; chatAvatarUrl: string | null; chatThinkingLevel: string | null; chatQueue: ChatQueueItem[]; nodesLoading: boolean; nodes: Array>; chatNewMessagesBelow: boolean; + sidebarOpen: boolean; + sidebarContent: string | null; + sidebarError: string | null; + splitRatio: number; scrollToBottom: () => void; devicesLoading: boolean; devicesError: string | null; @@ -83,13 +95,18 @@ export type AppViewState = { configSaving: boolean; configApplying: boolean; updateRunning: boolean; + applySessionKey: string; configSnapshot: ConfigSnapshot | null; configSchema: unknown; + configSchemaVersion: string | null; configSchemaLoading: boolean; - configUiHints: Record; + configUiHints: ConfigUiHints; configForm: Record | null; configFormOriginal: Record | null; configFormMode: "form" | "raw"; + configSearchQuery: string; + configActiveSection: string | null; + configActiveSubsection: string | null; channelsLoading: boolean; channelsSnapshot: ChannelsStatusSnapshot | null; channelsError: string | null; @@ -131,6 +148,39 @@ export type AppViewState = { sessionsFilterLimit: string; sessionsIncludeGlobal: boolean; sessionsIncludeUnknown: boolean; + usageLoading: boolean; + usageResult: SessionsUsageResult | null; + usageCostSummary: CostUsageSummary | null; + usageError: string | null; + usageStartDate: string; + usageEndDate: string; + usageSelectedSessions: string[]; + usageSelectedDays: string[]; + usageSelectedHours: number[]; + usageChartMode: "tokens" | "cost"; + usageDailyChartMode: "total" | "by-type"; + usageTimeSeriesMode: "cumulative" | "per-turn"; + usageTimeSeriesBreakdownMode: "total" | "by-type"; + usageTimeSeries: SessionUsageTimeSeries | null; + usageTimeSeriesLoading: boolean; + usageSessionLogs: SessionLogEntry[] | null; + usageSessionLogsLoading: boolean; + usageSessionLogsExpanded: boolean; + usageQuery: string; + usageQueryDraft: string; + usageQueryDebounceTimer: number | null; + usageSessionSort: "tokens" | "cost" | "recent" | "messages" | "errors"; + usageSessionSortDir: "asc" | "desc"; + usageRecentSessions: string[]; + usageTimeZone: "local" | "utc"; + usageContextExpanded: boolean; + usageHeaderPinned: boolean; + usageSessionsTab: "all" | "recent"; + usageVisibleColumns: string[]; + usageLogFilterRoles: import("./views/usage.js").SessionLogRole[]; + usageLogFilterTools: string[]; + usageLogFilterHasTools: boolean; + usageLogFilterQuery: string; cronLoading: boolean; cronJobs: CronJob[]; cronStatus: CronStatus | null; @@ -163,7 +213,13 @@ export type AppViewState = { logsLevelFilters: Record; logsAutoFollow: boolean; logsTruncated: boolean; + logsCursor: number | null; + logsLastFetchAt: number | null; + logsLimit: number; + logsMaxBytes: number; + logsAtBottom: boolean; client: GatewayBrowserClient | null; + refreshSessionsAfterChat: Set; connect: () => void; setTab: (tab: Tab) => void; setTheme: (theme: ThemeMode, context?: ThemeTransitionContext) => void; @@ -214,13 +270,15 @@ export type AppViewState = { setPassword: (next: string) => void; setSessionKey: (next: string) => void; setChatMessage: (next: string) => void; - handleChatSend: () => Promise; - handleChatAbort: () => Promise; - handleChatSelectQueueItem: (id: string) => void; - handleChatDropQueueItem: (id: string) => void; - handleChatClearQueue: () => void; - handleLogsFilterChange: (next: string) => void; - handleLogsLevelFilterToggle: (level: LogLevel) => void; - handleLogsAutoFollowToggle: (next: boolean) => void; - handleCallDebugMethod: (method: string, params: string) => Promise; + handleSendChat: (messageOverride?: string, opts?: { restoreDraft?: boolean }) => Promise; + handleAbortChat: () => Promise; + removeQueuedMessage: (id: string) => void; + handleChatScroll: (event: Event) => void; + resetToolStream: () => void; + resetChatScroll: () => void; + exportLogs: (lines: string[], label: string) => void; + handleLogsScroll: (event: Event) => void; + handleOpenSidebar: (content: string) => void; + handleCloseSidebar: () => void; + handleSplitRatioChange: (ratio: number) => void; }; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index f918a5bd5d..d79bc9ac6c 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -74,6 +74,7 @@ import { import { resetToolStream as resetToolStreamInternal, type ToolStreamEntry, + type CompactionStatus, } from "./app-tool-stream.ts"; import { resolveInjectedAssistantIdentity } from "./assistant-identity.ts"; import { loadAssistantIdentity as loadAssistantIdentityInternal } from "./controllers/assistant-identity.ts"; @@ -130,7 +131,7 @@ export class OpenClawApp extends LitElement { @state() chatStream: string | null = null; @state() chatStreamStartedAt: number | null = null; @state() chatRunId: string | null = null; - @state() compactionStatus: import("./app-tool-stream.ts").CompactionStatus | null = null; + @state() compactionStatus: CompactionStatus | null = null; @state() chatAvatarUrl: string | null = null; @state() chatThinkingLevel: string | null = null; @state() chatQueue: ChatQueueItem[] = []; @@ -226,6 +227,59 @@ export class OpenClawApp extends LitElement { @state() sessionsIncludeGlobal = true; @state() sessionsIncludeUnknown = false; + @state() usageLoading = false; + @state() usageResult: import("./types.js").SessionsUsageResult | null = null; + @state() usageCostSummary: import("./types.js").CostUsageSummary | null = null; + @state() usageError: string | null = null; + @state() usageStartDate = (() => { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + })(); + @state() usageEndDate = (() => { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + })(); + @state() usageSelectedSessions: string[] = []; + @state() usageSelectedDays: string[] = []; + @state() usageSelectedHours: number[] = []; + @state() usageChartMode: "tokens" | "cost" = "tokens"; + @state() usageDailyChartMode: "total" | "by-type" = "by-type"; + @state() usageTimeSeriesMode: "cumulative" | "per-turn" = "per-turn"; + @state() usageTimeSeriesBreakdownMode: "total" | "by-type" = "by-type"; + @state() usageTimeSeries: import("./types.js").SessionUsageTimeSeries | null = null; + @state() usageTimeSeriesLoading = false; + @state() usageSessionLogs: import("./views/usage.js").SessionLogEntry[] | null = null; + @state() usageSessionLogsLoading = false; + @state() usageSessionLogsExpanded = false; + // Applied query (used to filter the already-loaded sessions list client-side). + @state() usageQuery = ""; + // Draft query text (updates immediately as the user types; applied via debounce or "Search"). + @state() usageQueryDraft = ""; + @state() usageSessionSort: "tokens" | "cost" | "recent" | "messages" | "errors" = "recent"; + @state() usageSessionSortDir: "desc" | "asc" = "desc"; + @state() usageRecentSessions: string[] = []; + @state() usageTimeZone: "local" | "utc" = "local"; + @state() usageContextExpanded = false; + @state() usageHeaderPinned = false; + @state() usageSessionsTab: "all" | "recent" = "all"; + @state() usageVisibleColumns: string[] = [ + "channel", + "agent", + "provider", + "model", + "messages", + "tools", + "errors", + "duration", + ]; + @state() usageLogFilterRoles: import("./views/usage.js").SessionLogRole[] = []; + @state() usageLogFilterTools: string[] = []; + @state() usageLogFilterHasTools = false; + @state() usageLogFilterQuery = ""; + + // Non-reactive (don’t trigger renders just for timer bookkeeping). + usageQueryDebounceTimer: number | null = null; + @state() cronLoading = false; @state() cronJobs: CronJob[] = []; @state() cronStatus: CronStatus | null = null; diff --git a/ui/src/ui/controllers/usage.ts b/ui/src/ui/controllers/usage.ts new file mode 100644 index 0000000000..6c15900573 --- /dev/null +++ b/ui/src/ui/controllers/usage.ts @@ -0,0 +1,107 @@ +import type { GatewayBrowserClient } from "../gateway.ts"; +import type { SessionsUsageResult, CostUsageSummary, SessionUsageTimeSeries } from "../types.ts"; +import type { SessionLogEntry } from "../views/usage.ts"; + +export type UsageState = { + client: GatewayBrowserClient | null; + connected: boolean; + usageLoading: boolean; + usageResult: SessionsUsageResult | null; + usageCostSummary: CostUsageSummary | null; + usageError: string | null; + usageStartDate: string; + usageEndDate: string; + usageSelectedSessions: string[]; + usageSelectedDays: string[]; + usageTimeSeries: SessionUsageTimeSeries | null; + usageTimeSeriesLoading: boolean; + usageSessionLogs: SessionLogEntry[] | null; + usageSessionLogsLoading: boolean; +}; + +export async function loadUsage( + state: UsageState, + overrides?: { + startDate?: string; + endDate?: string; + }, +) { + if (!state.client || !state.connected) { + return; + } + if (state.usageLoading) { + return; + } + state.usageLoading = true; + state.usageError = null; + try { + const startDate = overrides?.startDate ?? state.usageStartDate; + const endDate = overrides?.endDate ?? state.usageEndDate; + + // Load both endpoints in parallel + const [sessionsRes, costRes] = await Promise.all([ + state.client.request("sessions.usage", { + startDate, + endDate, + limit: 1000, // Cap at 1000 sessions + includeContextWeight: true, + }), + state.client.request("usage.cost", { startDate, endDate }), + ]); + + if (sessionsRes) { + state.usageResult = sessionsRes as SessionsUsageResult; + } + if (costRes) { + state.usageCostSummary = costRes as CostUsageSummary; + } + } catch (err) { + state.usageError = String(err); + } finally { + state.usageLoading = false; + } +} + +export async function loadSessionTimeSeries(state: UsageState, sessionKey: string) { + if (!state.client || !state.connected) { + return; + } + if (state.usageTimeSeriesLoading) { + return; + } + state.usageTimeSeriesLoading = true; + state.usageTimeSeries = null; + try { + const res = await state.client.request("sessions.usage.timeseries", { key: sessionKey }); + if (res) { + state.usageTimeSeries = res as SessionUsageTimeSeries; + } + } catch { + // Silently fail - time series is optional + state.usageTimeSeries = null; + } finally { + state.usageTimeSeriesLoading = false; + } +} + +export async function loadSessionLogs(state: UsageState, sessionKey: string) { + if (!state.client || !state.connected) { + return; + } + if (state.usageSessionLogsLoading) { + return; + } + state.usageSessionLogsLoading = true; + state.usageSessionLogs = null; + try { + const res = await state.client.request("sessions.usage.logs", { key: sessionKey, limit: 500 }); + if (res && Array.isArray((res as { logs: SessionLogEntry[] }).logs)) { + state.usageSessionLogs = (res as { logs: SessionLogEntry[] }).logs; + } + } catch { + // Silently fail - logs are optional + state.usageSessionLogs = null; + } finally { + state.usageSessionLogsLoading = false; + } +} diff --git a/ui/src/ui/navigation.ts b/ui/src/ui/navigation.ts index 38bb90a955..c4208fb50c 100644 --- a/ui/src/ui/navigation.ts +++ b/ui/src/ui/navigation.ts @@ -4,7 +4,7 @@ export const TAB_GROUPS = [ { label: "Chat", tabs: ["chat"] }, { label: "Control", - tabs: ["overview", "channels", "instances", "sessions", "cron"], + tabs: ["overview", "channels", "instances", "sessions", "usage", "cron"], }, { label: "Agent", tabs: ["agents", "skills", "nodes"] }, { label: "Settings", tabs: ["config", "debug", "logs"] }, @@ -16,6 +16,7 @@ export type Tab = | "channels" | "instances" | "sessions" + | "usage" | "cron" | "skills" | "nodes" @@ -30,6 +31,7 @@ const TAB_PATHS: Record = { channels: "/channels", instances: "/instances", sessions: "/sessions", + usage: "/usage", cron: "/cron", skills: "/skills", nodes: "/nodes", @@ -134,6 +136,8 @@ export function iconForTab(tab: Tab): IconName { return "radio"; case "sessions": return "fileText"; + case "usage": + return "barChart"; case "cron": return "loader"; case "skills": @@ -163,6 +167,8 @@ export function titleForTab(tab: Tab) { return "Instances"; case "sessions": return "Sessions"; + case "usage": + return "Usage"; case "cron": return "Cron Jobs"; case "skills": @@ -194,6 +200,8 @@ export function subtitleForTab(tab: Tab) { return "Presence beacons from connected clients and nodes."; case "sessions": return "Inspect active sessions and adjust per-session defaults."; + case "usage": + return ""; case "cron": return "Schedule wakeups and recurring agent runs."; case "skills": diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index 27a1132bf2..d1d3f432b5 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -302,20 +302,20 @@ export type ConfigSchemaResponse = { }; export type PresenceEntry = { - deviceFamily?: string | null; - host?: string | null; instanceId?: string | null; + host?: string | null; ip?: string | null; - lastInputSeconds?: number | null; - mode?: string | null; - modelIdentifier?: string | null; + version?: string | null; platform?: string | null; + deviceFamily?: string | null; + modelIdentifier?: string | null; + roles?: string[] | null; + scopes?: string[] | null; + mode?: string | null; + lastInputSeconds?: number | null; reason?: string | null; - roles?: Array | null; - scopes?: Array | null; text?: string | null; ts?: number | null; - version?: string | null; }; export type GatewaySessionsDefaults = { @@ -424,6 +424,223 @@ export type SessionsPatchResult = { }; }; +export type SessionsUsageEntry = { + key: string; + label?: string; + sessionId?: string; + updatedAt?: number; + agentId?: string; + channel?: string; + chatType?: string; + origin?: { + label?: string; + provider?: string; + surface?: string; + chatType?: string; + from?: string; + to?: string; + accountId?: string; + threadId?: string | number; + }; + modelOverride?: string; + providerOverride?: string; + modelProvider?: string; + model?: string; + usage: { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + totalTokens: number; + totalCost: number; + inputCost?: number; + outputCost?: number; + cacheReadCost?: number; + cacheWriteCost?: number; + missingCostEntries: number; + firstActivity?: number; + lastActivity?: number; + durationMs?: number; + activityDates?: string[]; // YYYY-MM-DD dates when session had activity + dailyBreakdown?: Array<{ date: string; tokens: number; cost: number }>; + dailyMessageCounts?: Array<{ + date: string; + total: number; + user: number; + assistant: number; + toolCalls: number; + toolResults: number; + errors: number; + }>; + dailyLatency?: Array<{ + date: string; + count: number; + avgMs: number; + p95Ms: number; + minMs: number; + maxMs: number; + }>; + dailyModelUsage?: Array<{ + date: string; + provider?: string; + model?: string; + tokens: number; + cost: number; + count: number; + }>; + messageCounts?: { + total: number; + user: number; + assistant: number; + toolCalls: number; + toolResults: number; + errors: number; + }; + toolUsage?: { + totalCalls: number; + uniqueTools: number; + tools: Array<{ name: string; count: number }>; + }; + modelUsage?: Array<{ + provider?: string; + model?: string; + count: number; + totals: SessionsUsageTotals; + }>; + latency?: { + count: number; + avgMs: number; + p95Ms: number; + minMs: number; + maxMs: number; + }; + } | null; + contextWeight?: { + systemPrompt: { chars: number; projectContextChars: number; nonProjectContextChars: number }; + skills: { promptChars: number; entries: Array<{ name: string; blockChars: number }> }; + tools: { + listChars: number; + schemaChars: number; + entries: Array<{ name: string; summaryChars: number; schemaChars: number }>; + }; + injectedWorkspaceFiles: Array<{ + name: string; + path: string; + rawChars: number; + injectedChars: number; + truncated: boolean; + }>; + } | null; +}; + +export type SessionsUsageTotals = { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + totalTokens: number; + totalCost: number; + inputCost: number; + outputCost: number; + cacheReadCost: number; + cacheWriteCost: number; + missingCostEntries: number; +}; + +export type SessionsUsageResult = { + updatedAt: number; + startDate: string; + endDate: string; + sessions: SessionsUsageEntry[]; + totals: SessionsUsageTotals; + aggregates: { + messages: { + total: number; + user: number; + assistant: number; + toolCalls: number; + toolResults: number; + errors: number; + }; + tools: { + totalCalls: number; + uniqueTools: number; + tools: Array<{ name: string; count: number }>; + }; + byModel: Array<{ + provider?: string; + model?: string; + count: number; + totals: SessionsUsageTotals; + }>; + byProvider: Array<{ + provider?: string; + model?: string; + count: number; + totals: SessionsUsageTotals; + }>; + byAgent: Array<{ agentId: string; totals: SessionsUsageTotals }>; + byChannel: Array<{ channel: string; totals: SessionsUsageTotals }>; + latency?: { + count: number; + avgMs: number; + p95Ms: number; + minMs: number; + maxMs: number; + }; + dailyLatency?: Array<{ + date: string; + count: number; + avgMs: number; + p95Ms: number; + minMs: number; + maxMs: number; + }>; + modelDaily?: Array<{ + date: string; + provider?: string; + model?: string; + tokens: number; + cost: number; + count: number; + }>; + daily: Array<{ + date: string; + tokens: number; + cost: number; + messages: number; + toolCalls: number; + errors: number; + }>; + }; +}; + +export type CostUsageDailyEntry = SessionsUsageTotals & { date: string }; + +export type CostUsageSummary = { + updatedAt: number; + days: number; + daily: CostUsageDailyEntry[]; + totals: SessionsUsageTotals; +}; + +export type SessionUsageTimePoint = { + timestamp: number; + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + totalTokens: number; + cost: number; + cumulativeTokens: number; + cumulativeCost: number; +}; + +export type SessionUsageTimeSeries = { + sessionId?: string; + points: SessionUsageTimePoint[]; +}; + export type CronSchedule = | { kind: "at"; at: string } | { kind: "every"; everyMs: number; anchorMs?: number } @@ -506,10 +723,10 @@ export type SkillStatusEntry = { name: string; description: string; source: string; - bundled?: boolean; filePath: string; baseDir: string; skillKey: string; + bundled?: boolean; primaryEnv?: string; emoji?: string; homepage?: string; diff --git a/ui/src/ui/usage-helpers.node.test.ts b/ui/src/ui/usage-helpers.node.test.ts new file mode 100644 index 0000000000..441c64ab16 --- /dev/null +++ b/ui/src/ui/usage-helpers.node.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { extractQueryTerms, filterSessionsByQuery, parseToolSummary } from "./usage-helpers.ts"; + +describe("usage-helpers", () => { + it("tokenizes query terms including quoted strings", () => { + const terms = extractQueryTerms('agent:main "model:gpt-5.2" has:errors'); + expect(terms.map((t) => t.raw)).toEqual(["agent:main", "model:gpt-5.2", "has:errors"]); + }); + + it("matches key: glob filters against session keys", () => { + const session = { + key: "agent:main:cron:16234bc?token=dev-token", + label: "agent:main:cron:16234bc?token=dev-token", + usage: { totalTokens: 100, totalCost: 0 }, + }; + const matches = filterSessionsByQuery([session], "key:agent:main:cron*"); + expect(matches.sessions).toHaveLength(1); + }); + + it("supports numeric filters like minTokens/maxTokens", () => { + const a = { key: "a", label: "a", usage: { totalTokens: 100, totalCost: 0 } }; + const b = { key: "b", label: "b", usage: { totalTokens: 5, totalCost: 0 } }; + expect(filterSessionsByQuery([a, b], "minTokens:10").sessions).toEqual([a]); + expect(filterSessionsByQuery([a, b], "maxTokens:10").sessions).toEqual([b]); + }); + + it("warns on unknown keys and invalid numbers", () => { + const session = { key: "a", usage: { totalTokens: 10, totalCost: 0 } }; + const res = filterSessionsByQuery([session], "wat:1 minTokens:wat"); + expect(res.warnings.some((w) => w.includes("Unknown filter"))).toBe(true); + expect(res.warnings.some((w) => w.includes("Invalid number"))).toBe(true); + }); + + it("parses tool summaries from compact session logs", () => { + const res = parseToolSummary( + "[Tool: read]\n[Tool Result]\n[Tool: exec]\n[Tool: read]\n[Tool Result]", + ); + expect(res.summary).toContain("read"); + expect(res.summary).toContain("exec"); + expect(res.tools[0]?.[0]).toBe("read"); + expect(res.tools[0]?.[1]).toBe(2); + }); +}); diff --git a/ui/src/ui/usage-helpers.ts b/ui/src/ui/usage-helpers.ts new file mode 100644 index 0000000000..a8ac116ced --- /dev/null +++ b/ui/src/ui/usage-helpers.ts @@ -0,0 +1,321 @@ +export type UsageQueryTerm = { + key?: string; + value: string; + raw: string; +}; + +export type UsageQueryResult = { + sessions: TSession[]; + warnings: string[]; +}; + +// Minimal shape required for query filtering. The usage view's real session type contains more fields. +export type UsageSessionQueryTarget = { + key: string; + label?: string; + sessionId?: string; + agentId?: string; + channel?: string; + chatType?: string; + modelProvider?: string; + providerOverride?: string; + origin?: { provider?: string }; + model?: string; + contextWeight?: unknown; + usage?: { + totalTokens?: number; + totalCost?: number; + messageCounts?: { total?: number; errors?: number }; + toolUsage?: { totalCalls?: number; tools?: Array<{ name: string }> }; + modelUsage?: Array<{ provider?: string; model?: string }>; + } | null; +}; + +const QUERY_KEYS = new Set([ + "agent", + "channel", + "chat", + "provider", + "model", + "tool", + "label", + "key", + "session", + "id", + "has", + "mintokens", + "maxtokens", + "mincost", + "maxcost", + "minmessages", + "maxmessages", +]); + +const normalizeQueryText = (value: string): string => value.trim().toLowerCase(); + +const globToRegex = (pattern: string): RegExp => { + const escaped = pattern + .replace(/[.+^${}()|[\]\\]/g, "\\$&") + .replace(/\*/g, ".*") + .replace(/\?/g, "."); + return new RegExp(`^${escaped}$`, "i"); +}; + +const parseQueryNumber = (value: string): number | null => { + let raw = value.trim().toLowerCase(); + if (!raw) { + return null; + } + if (raw.startsWith("$")) { + raw = raw.slice(1); + } + let multiplier = 1; + if (raw.endsWith("k")) { + multiplier = 1_000; + raw = raw.slice(0, -1); + } else if (raw.endsWith("m")) { + multiplier = 1_000_000; + raw = raw.slice(0, -1); + } + const parsed = Number(raw); + if (!Number.isFinite(parsed)) { + return null; + } + return parsed * multiplier; +}; + +export const extractQueryTerms = (query: string): UsageQueryTerm[] => { + // Tokenize by whitespace, but allow quoted values with spaces. + const rawTokens = query.match(/"[^"]+"|\S+/g) ?? []; + return rawTokens.map((token) => { + const cleaned = token.replace(/^"|"$/g, ""); + const idx = cleaned.indexOf(":"); + if (idx > 0) { + const key = cleaned.slice(0, idx); + const value = cleaned.slice(idx + 1); + return { key, value, raw: cleaned }; + } + return { value: cleaned, raw: cleaned }; + }); +}; + +const getSessionText = (session: UsageSessionQueryTarget): string[] => { + const items: Array = [session.label, session.key, session.sessionId]; + return items.filter((item): item is string => Boolean(item)).map((item) => item.toLowerCase()); +}; + +const getSessionProviders = (session: UsageSessionQueryTarget): string[] => { + const providers = new Set(); + if (session.modelProvider) { + providers.add(session.modelProvider.toLowerCase()); + } + if (session.providerOverride) { + providers.add(session.providerOverride.toLowerCase()); + } + if (session.origin?.provider) { + providers.add(session.origin.provider.toLowerCase()); + } + for (const entry of session.usage?.modelUsage ?? []) { + if (entry.provider) { + providers.add(entry.provider.toLowerCase()); + } + } + return Array.from(providers); +}; + +const getSessionModels = (session: UsageSessionQueryTarget): string[] => { + const models = new Set(); + if (session.model) { + models.add(session.model.toLowerCase()); + } + for (const entry of session.usage?.modelUsage ?? []) { + if (entry.model) { + models.add(entry.model.toLowerCase()); + } + } + return Array.from(models); +}; + +const getSessionTools = (session: UsageSessionQueryTarget): string[] => + (session.usage?.toolUsage?.tools ?? []).map((tool) => tool.name.toLowerCase()); + +export const matchesUsageQuery = ( + session: UsageSessionQueryTarget, + term: UsageQueryTerm, +): boolean => { + const value = normalizeQueryText(term.value ?? ""); + if (!value) { + return true; + } + if (!term.key) { + return getSessionText(session).some((text) => text.includes(value)); + } + + const key = normalizeQueryText(term.key); + switch (key) { + case "agent": + return session.agentId?.toLowerCase().includes(value) ?? false; + case "channel": + return session.channel?.toLowerCase().includes(value) ?? false; + case "chat": + return session.chatType?.toLowerCase().includes(value) ?? false; + case "provider": + return getSessionProviders(session).some((provider) => provider.includes(value)); + case "model": + return getSessionModels(session).some((model) => model.includes(value)); + case "tool": + return getSessionTools(session).some((tool) => tool.includes(value)); + case "label": + return session.label?.toLowerCase().includes(value) ?? false; + case "key": + case "session": + case "id": + if (value.includes("*") || value.includes("?")) { + const regex = globToRegex(value); + return ( + regex.test(session.key) || (session.sessionId ? regex.test(session.sessionId) : false) + ); + } + return ( + session.key.toLowerCase().includes(value) || + (session.sessionId?.toLowerCase().includes(value) ?? false) + ); + case "has": + switch (value) { + case "tools": + return (session.usage?.toolUsage?.totalCalls ?? 0) > 0; + case "errors": + return (session.usage?.messageCounts?.errors ?? 0) > 0; + case "context": + return Boolean(session.contextWeight); + case "usage": + return Boolean(session.usage); + case "model": + return getSessionModels(session).length > 0; + case "provider": + return getSessionProviders(session).length > 0; + default: + return true; + } + case "mintokens": { + const threshold = parseQueryNumber(value); + if (threshold === null) { + return true; + } + return (session.usage?.totalTokens ?? 0) >= threshold; + } + case "maxtokens": { + const threshold = parseQueryNumber(value); + if (threshold === null) { + return true; + } + return (session.usage?.totalTokens ?? 0) <= threshold; + } + case "mincost": { + const threshold = parseQueryNumber(value); + if (threshold === null) { + return true; + } + return (session.usage?.totalCost ?? 0) >= threshold; + } + case "maxcost": { + const threshold = parseQueryNumber(value); + if (threshold === null) { + return true; + } + return (session.usage?.totalCost ?? 0) <= threshold; + } + case "minmessages": { + const threshold = parseQueryNumber(value); + if (threshold === null) { + return true; + } + return (session.usage?.messageCounts?.total ?? 0) >= threshold; + } + case "maxmessages": { + const threshold = parseQueryNumber(value); + if (threshold === null) { + return true; + } + return (session.usage?.messageCounts?.total ?? 0) <= threshold; + } + default: + return true; + } +}; + +export const filterSessionsByQuery = ( + sessions: TSession[], + query: string, +): UsageQueryResult => { + const terms = extractQueryTerms(query); + if (terms.length === 0) { + return { sessions, warnings: [] }; + } + + const warnings: string[] = []; + for (const term of terms) { + if (!term.key) { + continue; + } + const normalizedKey = normalizeQueryText(term.key); + if (!QUERY_KEYS.has(normalizedKey)) { + warnings.push(`Unknown filter: ${term.key}`); + continue; + } + if (term.value === "") { + warnings.push(`Missing value for ${term.key}`); + } + if (normalizedKey === "has") { + const allowed = new Set(["tools", "errors", "context", "usage", "model", "provider"]); + if (term.value && !allowed.has(normalizeQueryText(term.value))) { + warnings.push(`Unknown has:${term.value}`); + } + } + if ( + ["mintokens", "maxtokens", "mincost", "maxcost", "minmessages", "maxmessages"].includes( + normalizedKey, + ) + ) { + if (term.value && parseQueryNumber(term.value) === null) { + warnings.push(`Invalid number for ${term.key}`); + } + } + } + + const filtered = sessions.filter((session) => + terms.every((term) => matchesUsageQuery(session, term)), + ); + return { sessions: filtered, warnings }; +}; + +export function parseToolSummary(content: string) { + const lines = content.split("\n"); + const toolCounts = new Map(); + const nonToolLines: string[] = []; + for (const line of lines) { + const match = /^\[Tool:\s*([^\]]+)\]/.exec(line.trim()); + if (match) { + const name = match[1]; + toolCounts.set(name, (toolCounts.get(name) ?? 0) + 1); + continue; + } + if (line.trim().startsWith("[Tool Result]")) { + continue; + } + nonToolLines.push(line); + } + const sortedTools = Array.from(toolCounts.entries()).toSorted((a, b) => b[1] - a[1]); + const totalCalls = sortedTools.reduce((sum, [, count]) => sum + count, 0); + const summary = + sortedTools.length > 0 + ? `Tools: ${sortedTools + .map(([name, count]) => `${name}×${count}`) + .join(", ")} (${totalCalls} calls)` + : ""; + return { + tools: sortedTools, + summary, + cleanContent: nonToolLines.join("\n").trim(), + }; +} diff --git a/ui/src/ui/views/usage.ts b/ui/src/ui/views/usage.ts new file mode 100644 index 0000000000..37139cbfae --- /dev/null +++ b/ui/src/ui/views/usage.ts @@ -0,0 +1,5432 @@ +import { html, svg, nothing } from "lit"; +import { extractQueryTerms, filterSessionsByQuery, parseToolSummary } from "../usage-helpers.ts"; + +// Inline styles for usage view (app uses light DOM, so static styles don't work) +const usageStylesString = ` + .usage-page-header { + margin: 4px 0 12px; + } + .usage-page-title { + font-size: 28px; + font-weight: 700; + letter-spacing: -0.02em; + margin-bottom: 4px; + } + .usage-page-subtitle { + font-size: 13px; + color: var(--text-muted); + margin: 0 0 12px; + } + /* ===== FILTERS & HEADER ===== */ + .usage-filters-inline { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; + } + .usage-filters-inline select { + padding: 6px 10px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg); + color: var(--text); + font-size: 13px; + } + .usage-filters-inline input[type="date"] { + padding: 6px 10px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg); + color: var(--text); + font-size: 13px; + } + .usage-filters-inline input[type="text"] { + padding: 6px 10px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg); + color: var(--text); + font-size: 13px; + min-width: 180px; + } + .usage-filters-inline .btn-sm { + padding: 6px 12px; + font-size: 14px; + } + .usage-refresh-indicator { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + background: rgba(255, 77, 77, 0.1); + border-radius: 4px; + font-size: 12px; + color: #ff4d4d; + } + .usage-refresh-indicator::before { + content: ""; + width: 10px; + height: 10px; + border: 2px solid #ff4d4d; + border-top-color: transparent; + border-radius: 50%; + animation: usage-spin 0.6s linear infinite; + } + @keyframes usage-spin { + to { transform: rotate(360deg); } + } + .active-filters { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + } + .filter-chip { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px 4px 12px; + background: var(--accent-subtle); + border: 1px solid var(--accent); + border-radius: 16px; + font-size: 12px; + } + .filter-chip-label { + color: var(--accent); + font-weight: 500; + } + .filter-chip-remove { + background: none; + border: none; + color: var(--accent); + cursor: pointer; + padding: 2px 4px; + font-size: 14px; + line-height: 1; + opacity: 0.7; + transition: opacity 0.15s; + } + .filter-chip-remove:hover { + opacity: 1; + } + .filter-clear-btn { + padding: 4px 10px !important; + font-size: 12px !important; + line-height: 1 !important; + margin-left: 8px; + } + .usage-query-bar { + display: grid; + grid-template-columns: minmax(220px, 1fr) auto; + gap: 10px; + align-items: center; + /* Keep the dropdown filter row from visually touching the query row. */ + margin-bottom: 10px; + } + .usage-query-actions { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: nowrap; + justify-self: end; + } + .usage-query-actions .btn { + height: 34px; + padding: 0 14px; + border-radius: 999px; + font-weight: 600; + font-size: 13px; + line-height: 1; + border: 1px solid var(--border); + background: var(--bg-secondary); + color: var(--text); + box-shadow: none; + transition: background 0.15s, border-color 0.15s, color 0.15s; + } + .usage-query-actions .btn:hover { + background: var(--bg); + border-color: var(--border-strong); + } + .usage-action-btn { + height: 34px; + padding: 0 14px; + border-radius: 999px; + font-weight: 600; + font-size: 13px; + line-height: 1; + border: 1px solid var(--border); + background: var(--bg-secondary); + color: var(--text); + box-shadow: none; + transition: background 0.15s, border-color 0.15s, color 0.15s; + } + .usage-action-btn:hover { + background: var(--bg); + border-color: var(--border-strong); + } + .usage-primary-btn { + background: #ff4d4d; + color: #fff; + border-color: #ff4d4d; + box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.12); + } + .btn.usage-primary-btn { + background: #ff4d4d !important; + border-color: #ff4d4d !important; + color: #fff !important; + } + .usage-primary-btn:hover { + background: #e64545; + border-color: #e64545; + } + .btn.usage-primary-btn:hover { + background: #e64545 !important; + border-color: #e64545 !important; + } + .usage-primary-btn:disabled { + background: rgba(255, 77, 77, 0.18); + border-color: rgba(255, 77, 77, 0.3); + color: #ff4d4d; + box-shadow: none; + cursor: default; + opacity: 1; + } + .usage-primary-btn[disabled] { + background: rgba(255, 77, 77, 0.18) !important; + border-color: rgba(255, 77, 77, 0.3) !important; + color: #ff4d4d !important; + opacity: 1 !important; + } + .usage-secondary-btn { + background: var(--bg-secondary); + color: var(--text); + border-color: var(--border); + } + .usage-query-input { + width: 100%; + min-width: 220px; + padding: 6px 10px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg); + color: var(--text); + font-size: 13px; + } + .usage-query-suggestions { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 6px; + } + .usage-query-suggestion { + padding: 4px 8px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--bg-secondary); + font-size: 11px; + color: var(--text); + cursor: pointer; + transition: background 0.15s; + } + .usage-query-suggestion:hover { + background: var(--bg-hover); + } + .usage-filter-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin-top: 14px; + } + details.usage-filter-select { + position: relative; + border: 1px solid var(--border); + border-radius: 10px; + padding: 6px 10px; + background: var(--bg); + font-size: 12px; + min-width: 140px; + } + details.usage-filter-select summary { + cursor: pointer; + list-style: none; + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + font-weight: 500; + } + details.usage-filter-select summary::-webkit-details-marker { + display: none; + } + .usage-filter-badge { + font-size: 11px; + color: var(--text-muted); + } + .usage-filter-popover { + position: absolute; + left: 0; + top: calc(100% + 6px); + background: var(--bg); + border: 1px solid var(--border); + border-radius: 10px; + padding: 10px; + box-shadow: 0 10px 30px rgba(0,0,0,0.08); + min-width: 220px; + z-index: 20; + } + .usage-filter-actions { + display: flex; + gap: 6px; + margin-bottom: 8px; + } + .usage-filter-actions button { + border-radius: 999px; + padding: 4px 10px; + font-size: 11px; + } + .usage-filter-options { + display: flex; + flex-direction: column; + gap: 6px; + max-height: 200px; + overflow: auto; + } + .usage-filter-option { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + } + .usage-query-hint { + font-size: 11px; + color: var(--text-muted); + } + .usage-query-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 6px; + } + .usage-query-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--bg-secondary); + font-size: 11px; + } + .usage-query-chip button { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: 0; + line-height: 1; + } + .usage-header { + display: flex; + flex-direction: column; + gap: 10px; + background: var(--bg); + } + .usage-header.pinned { + position: sticky; + top: 12px; + z-index: 6; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.06); + } + .usage-pin-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--bg-secondary); + font-size: 11px; + color: var(--text); + cursor: pointer; + } + .usage-pin-btn.active { + background: var(--accent-subtle); + border-color: var(--accent); + color: var(--accent); + } + .usage-header-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + } + .usage-header-title { + display: flex; + align-items: center; + gap: 10px; + } + .usage-header-metrics { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + } + .usage-metric-badge { + display: inline-flex; + align-items: baseline; + gap: 6px; + padding: 2px 8px; + border-radius: 999px; + border: 1px solid var(--border); + background: transparent; + font-size: 11px; + color: var(--text-muted); + } + .usage-metric-badge strong { + font-size: 12px; + color: var(--text); + } + .usage-controls { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + } + .usage-controls .active-filters { + flex: 1 1 100%; + } + .usage-controls input[type="date"] { + min-width: 140px; + } + .usage-presets { + display: inline-flex; + gap: 6px; + flex-wrap: wrap; + } + .usage-presets .btn { + padding: 4px 8px; + font-size: 11px; + } + .usage-quick-filters { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; + } + .usage-select { + min-width: 120px; + padding: 6px 10px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg); + color: var(--text); + font-size: 12px; + } + .usage-export-menu summary { + cursor: pointer; + font-weight: 500; + color: var(--text); + list-style: none; + display: inline-flex; + align-items: center; + gap: 6px; + } + .usage-export-menu summary::-webkit-details-marker { + display: none; + } + .usage-export-menu { + position: relative; + } + .usage-export-button { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--bg); + font-size: 12px; + } + .usage-export-popover { + position: absolute; + right: 0; + top: calc(100% + 6px); + background: var(--bg); + border: 1px solid var(--border); + border-radius: 10px; + padding: 8px; + box-shadow: 0 10px 30px rgba(0,0,0,0.08); + min-width: 160px; + z-index: 10; + } + .usage-export-list { + display: flex; + flex-direction: column; + gap: 6px; + } + .usage-export-item { + text-align: left; + padding: 6px 10px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--bg-secondary); + font-size: 12px; + } + .usage-summary-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 12px; + margin-top: 12px; + } + .usage-summary-card { + padding: 12px; + border-radius: 8px; + background: var(--bg-secondary); + border: 1px solid var(--border); + } + .usage-mosaic { + margin-top: 16px; + padding: 16px; + } + .usage-mosaic-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; + } + .usage-mosaic-title { + font-weight: 600; + } + .usage-mosaic-sub { + font-size: 12px; + color: var(--text-muted); + } + .usage-mosaic-grid { + display: grid; + grid-template-columns: minmax(200px, 1fr) minmax(260px, 2fr); + gap: 16px; + align-items: start; + } + .usage-mosaic-section { + background: var(--bg-subtle); + border: 1px solid var(--border); + border-radius: 10px; + padding: 12px; + } + .usage-mosaic-section-title { + font-size: 12px; + font-weight: 600; + margin-bottom: 10px; + display: flex; + align-items: center; + justify-content: space-between; + } + .usage-mosaic-total { + font-size: 20px; + font-weight: 700; + } + .usage-daypart-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(90px, 1fr)); + gap: 8px; + } + .usage-daypart-cell { + border-radius: 8px; + padding: 10px; + color: var(--text); + background: rgba(255, 77, 77, 0.08); + border: 1px solid rgba(255, 77, 77, 0.2); + display: flex; + flex-direction: column; + gap: 4px; + } + .usage-daypart-label { + font-size: 12px; + font-weight: 600; + } + .usage-daypart-value { + font-size: 14px; + } + .usage-hour-grid { + display: grid; + grid-template-columns: repeat(24, minmax(6px, 1fr)); + gap: 4px; + } + .usage-hour-cell { + height: 28px; + border-radius: 6px; + background: rgba(255, 77, 77, 0.1); + border: 1px solid rgba(255, 77, 77, 0.2); + cursor: pointer; + transition: border-color 0.15s, box-shadow 0.15s; + } + .usage-hour-cell.selected { + border-color: rgba(255, 77, 77, 0.8); + box-shadow: 0 0 0 2px rgba(255, 77, 77, 0.2); + } + .usage-hour-labels { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 6px; + margin-top: 8px; + font-size: 11px; + color: var(--text-muted); + } + .usage-hour-legend { + display: flex; + gap: 8px; + align-items: center; + margin-top: 10px; + font-size: 11px; + color: var(--text-muted); + } + .usage-hour-legend span { + display: inline-block; + width: 14px; + height: 10px; + border-radius: 4px; + background: rgba(255, 77, 77, 0.15); + border: 1px solid rgba(255, 77, 77, 0.2); + } + .usage-calendar-labels { + display: grid; + grid-template-columns: repeat(7, minmax(10px, 1fr)); + gap: 6px; + font-size: 10px; + color: var(--text-muted); + margin-bottom: 6px; + } + .usage-calendar { + display: grid; + grid-template-columns: repeat(7, minmax(10px, 1fr)); + gap: 6px; + } + .usage-calendar-cell { + height: 18px; + border-radius: 4px; + border: 1px solid rgba(255, 77, 77, 0.2); + background: rgba(255, 77, 77, 0.08); + } + .usage-calendar-cell.empty { + background: transparent; + border-color: transparent; + } + .usage-summary-title { + font-size: 11px; + color: var(--text-muted); + margin-bottom: 6px; + display: inline-flex; + align-items: center; + gap: 6px; + } + .usage-info { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + margin-left: 6px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--bg); + font-size: 10px; + color: var(--text-muted); + cursor: help; + } + .usage-summary-value { + font-size: 16px; + font-weight: 600; + color: var(--text-strong); + } + .usage-summary-value.good { + color: #1f8f4e; + } + .usage-summary-value.warn { + color: #c57a00; + } + .usage-summary-value.bad { + color: #c9372c; + } + .usage-summary-hint { + font-size: 10px; + color: var(--text-muted); + cursor: help; + border: 1px solid var(--border); + border-radius: 999px; + padding: 0 6px; + line-height: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + } + .usage-summary-sub { + font-size: 11px; + color: var(--text-muted); + margin-top: 4px; + } + .usage-list { + display: flex; + flex-direction: column; + gap: 8px; + } + .usage-list-item { + display: flex; + justify-content: space-between; + gap: 12px; + font-size: 12px; + color: var(--text); + align-items: flex-start; + } + .usage-list-value { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; + text-align: right; + } + .usage-list-sub { + font-size: 11px; + color: var(--text-muted); + } + .usage-list-item.button { + border: none; + background: transparent; + padding: 0; + text-align: left; + cursor: pointer; + } + .usage-list-item.button:hover { + color: var(--text-strong); + } + .usage-list-item .muted { + font-size: 11px; + } + .usage-error-list { + display: flex; + flex-direction: column; + gap: 10px; + } + .usage-error-row { + display: grid; + grid-template-columns: 1fr auto; + gap: 8px; + align-items: center; + font-size: 12px; + } + .usage-error-date { + font-weight: 600; + } + .usage-error-rate { + font-variant-numeric: tabular-nums; + } + .usage-error-sub { + grid-column: 1 / -1; + font-size: 11px; + color: var(--text-muted); + } + .usage-badges { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 8px; + } + .usage-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 2px 8px; + border: 1px solid var(--border); + border-radius: 999px; + font-size: 11px; + background: var(--bg); + color: var(--text); + } + .usage-meta-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 12px; + } + .usage-meta-item { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 12px; + } + .usage-meta-item span { + color: var(--text-muted); + font-size: 11px; + } + .usage-insights-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 16px; + margin-top: 12px; + } + .usage-insight-card { + padding: 14px; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--bg-secondary); + } + .usage-insight-title { + font-size: 12px; + font-weight: 600; + margin-bottom: 10px; + } + .usage-insight-subtitle { + font-size: 11px; + color: var(--text-muted); + margin-top: 6px; + } + /* ===== CHART TOGGLE ===== */ + .chart-toggle { + display: flex; + background: var(--bg); + border-radius: 6px; + overflow: hidden; + border: 1px solid var(--border); + } + .chart-toggle .toggle-btn { + padding: 6px 14px; + font-size: 13px; + background: transparent; + border: none; + color: var(--text-muted); + cursor: pointer; + transition: all 0.15s; + } + .chart-toggle .toggle-btn:hover { + color: var(--text); + } + .chart-toggle .toggle-btn.active { + background: #ff4d4d; + color: white; + } + .chart-toggle.small .toggle-btn { + padding: 4px 8px; + font-size: 11px; + } + .sessions-toggle { + border-radius: 4px; + } + .sessions-toggle .toggle-btn { + border-radius: 4px; + } + .daily-chart-header { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 8px; + margin-bottom: 6px; + } + + /* ===== DAILY BAR CHART ===== */ + .daily-chart { + margin-top: 12px; + } + .daily-chart-bars { + display: flex; + align-items: flex-end; + height: 200px; + gap: 4px; + padding: 8px 4px 36px; + } + .daily-bar-wrapper { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + height: 100%; + justify-content: flex-end; + cursor: pointer; + position: relative; + border-radius: 4px 4px 0 0; + transition: background 0.15s; + min-width: 0; + } + .daily-bar-wrapper:hover { + background: var(--bg-hover); + } + .daily-bar-wrapper.selected { + background: var(--accent-subtle); + } + .daily-bar-wrapper.selected .daily-bar { + background: var(--accent); + } + .daily-bar { + width: 100%; + max-width: var(--bar-max-width, 32px); + background: #ff4d4d; + border-radius: 3px 3px 0 0; + min-height: 2px; + transition: all 0.15s; + overflow: hidden; + } + .daily-bar-wrapper:hover .daily-bar { + background: #cc3d3d; + } + .daily-bar-label { + position: absolute; + bottom: -28px; + font-size: 10px; + color: var(--text-muted); + white-space: nowrap; + text-align: center; + transform: rotate(-35deg); + transform-origin: top center; + } + .daily-bar-total { + position: absolute; + top: -16px; + left: 50%; + transform: translateX(-50%); + font-size: 10px; + color: var(--text-muted); + white-space: nowrap; + } + .daily-bar-tooltip { + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + padding: 8px 12px; + font-size: 12px; + white-space: nowrap; + z-index: 100; + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + pointer-events: none; + opacity: 0; + transition: opacity 0.15s; + } + .daily-bar-wrapper:hover .daily-bar-tooltip { + opacity: 1; + } + + /* ===== COST/TOKEN BREAKDOWN BAR ===== */ + .cost-breakdown { + margin-top: 18px; + padding: 16px; + background: var(--bg-secondary); + border-radius: 8px; + } + .cost-breakdown-header { + font-weight: 600; + font-size: 15px; + letter-spacing: -0.02em; + margin-bottom: 12px; + color: var(--text-strong); + } + .cost-breakdown-bar { + height: 28px; + background: var(--bg); + border-radius: 6px; + overflow: hidden; + display: flex; + } + .cost-segment { + height: 100%; + transition: width 0.3s ease; + position: relative; + } + .cost-segment.output { + background: #ef4444; + } + .cost-segment.input { + background: #f59e0b; + } + .cost-segment.cache-write { + background: #10b981; + } + .cost-segment.cache-read { + background: #06b6d4; + } + .cost-breakdown-legend { + display: flex; + flex-wrap: wrap; + gap: 16px; + margin-top: 12px; + } + .cost-breakdown-total { + margin-top: 10px; + font-size: 12px; + color: var(--text-muted); + } + .legend-item { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--text); + cursor: help; + } + .legend-dot { + width: 10px; + height: 10px; + border-radius: 2px; + flex-shrink: 0; + } + .legend-dot.output { + background: #ef4444; + } + .legend-dot.input { + background: #f59e0b; + } + .legend-dot.cache-write { + background: #10b981; + } + .legend-dot.cache-read { + background: #06b6d4; + } + .legend-dot.system { + background: #ff4d4d; + } + .legend-dot.skills { + background: #8b5cf6; + } + .legend-dot.tools { + background: #ec4899; + } + .legend-dot.files { + background: #f59e0b; + } + .cost-breakdown-note { + margin-top: 10px; + font-size: 11px; + color: var(--text-muted); + line-height: 1.4; + } + + /* ===== SESSION BARS (scrollable list) ===== */ + .session-bars { + margin-top: 16px; + max-height: 400px; + overflow-y: auto; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg); + } + .session-bar-row { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 14px; + border-bottom: 1px solid var(--border); + cursor: pointer; + transition: background 0.15s; + } + .session-bar-row:last-child { + border-bottom: none; + } + .session-bar-row:hover { + background: var(--bg-hover); + } + .session-bar-row.selected { + background: var(--accent-subtle); + } + .session-bar-label { + flex: 1 1 auto; + min-width: 0; + font-size: 13px; + color: var(--text); + display: flex; + flex-direction: column; + gap: 2px; + } + .session-bar-title { + /* Prefer showing the full name; wrap instead of truncating. */ + white-space: normal; + overflow-wrap: anywhere; + word-break: break-word; + } + .session-bar-meta { + font-size: 10px; + color: var(--text-muted); + font-weight: 400; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .session-bar-track { + flex: 0 0 90px; + height: 6px; + background: var(--bg-secondary); + border-radius: 4px; + overflow: hidden; + opacity: 0.6; + } + .session-bar-fill { + height: 100%; + background: rgba(255, 77, 77, 0.7); + border-radius: 4px; + transition: width 0.3s ease; + } + .session-bar-value { + flex: 0 0 70px; + text-align: right; + font-size: 12px; + font-family: var(--font-mono); + color: var(--text-muted); + } + .session-bar-actions { + display: inline-flex; + align-items: center; + gap: 8px; + flex: 0 0 auto; + } + .session-copy-btn { + height: 26px; + padding: 0 10px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--bg-secondary); + font-size: 11px; + font-weight: 600; + color: var(--text-muted); + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; + } + .session-copy-btn:hover { + background: var(--bg); + border-color: var(--border-strong); + color: var(--text); + } + + /* ===== TIME SERIES CHART ===== */ + .session-timeseries { + margin-top: 24px; + padding: 16px; + background: var(--bg-secondary); + border-radius: 8px; + } + .timeseries-header-row { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + } + .timeseries-controls { + display: flex; + gap: 6px; + align-items: center; + } + .timeseries-header { + font-weight: 600; + color: var(--text); + } + .timeseries-chart { + width: 100%; + overflow: hidden; + } + .timeseries-svg { + width: 100%; + height: auto; + display: block; + } + .timeseries-svg .axis-label { + font-size: 10px; + fill: var(--text-muted); + } + .timeseries-svg .ts-area { + fill: #ff4d4d; + fill-opacity: 0.1; + } + .timeseries-svg .ts-line { + fill: none; + stroke: #ff4d4d; + stroke-width: 2; + } + .timeseries-svg .ts-dot { + fill: #ff4d4d; + transition: r 0.15s, fill 0.15s; + } + .timeseries-svg .ts-dot:hover { + r: 5; + } + .timeseries-svg .ts-bar { + fill: #ff4d4d; + transition: fill 0.15s; + } + .timeseries-svg .ts-bar:hover { + fill: #cc3d3d; + } + .timeseries-svg .ts-bar.output { fill: #ef4444; } + .timeseries-svg .ts-bar.input { fill: #f59e0b; } + .timeseries-svg .ts-bar.cache-write { fill: #10b981; } + .timeseries-svg .ts-bar.cache-read { fill: #06b6d4; } + .timeseries-summary { + margin-top: 12px; + font-size: 13px; + color: var(--text-muted); + display: flex; + flex-wrap: wrap; + gap: 8px; + } + .timeseries-loading { + padding: 24px; + text-align: center; + color: var(--text-muted); + } + + /* ===== SESSION LOGS ===== */ + .session-logs { + margin-top: 24px; + background: var(--bg-secondary); + border-radius: 8px; + overflow: hidden; + } + .session-logs-header { + padding: 10px 14px; + font-weight: 600; + border-bottom: 1px solid var(--border); + display: flex; + justify-content: space-between; + align-items: center; + font-size: 13px; + background: var(--bg-secondary); + } + .session-logs-loading { + padding: 24px; + text-align: center; + color: var(--text-muted); + } + .session-logs-list { + max-height: 400px; + overflow-y: auto; + } + .session-log-entry { + padding: 10px 14px; + border-bottom: 1px solid var(--border); + display: flex; + flex-direction: column; + gap: 6px; + background: var(--bg); + } + .session-log-entry:last-child { + border-bottom: none; + } + .session-log-entry.user { + border-left: 3px solid var(--accent); + } + .session-log-entry.assistant { + border-left: 3px solid var(--border-strong); + } + .session-log-meta { + display: flex; + gap: 8px; + align-items: center; + font-size: 11px; + color: var(--text-muted); + flex-wrap: wrap; + } + .session-log-role { + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + font-size: 10px; + padding: 2px 6px; + border-radius: 999px; + background: var(--bg-secondary); + border: 1px solid var(--border); + } + .session-log-entry.user .session-log-role { + color: var(--accent); + } + .session-log-entry.assistant .session-log-role { + color: var(--text-muted); + } + .session-log-content { + font-size: 13px; + line-height: 1.5; + color: var(--text); + white-space: pre-wrap; + word-break: break-word; + background: var(--bg-secondary); + border-radius: 8px; + padding: 8px 10px; + border: 1px solid var(--border); + max-height: 220px; + overflow-y: auto; + } + + /* ===== CONTEXT WEIGHT BREAKDOWN ===== */ + .context-weight-breakdown { + margin-top: 24px; + padding: 16px; + background: var(--bg-secondary); + border-radius: 8px; + } + .context-weight-breakdown .context-weight-header { + font-weight: 600; + font-size: 13px; + margin-bottom: 4px; + color: var(--text); + } + .context-weight-desc { + font-size: 12px; + color: var(--text-muted); + margin: 0 0 12px 0; + } + .context-stacked-bar { + height: 24px; + background: var(--bg); + border-radius: 6px; + overflow: hidden; + display: flex; + } + .context-segment { + height: 100%; + transition: width 0.3s ease; + } + .context-segment.system { + background: #ff4d4d; + } + .context-segment.skills { + background: #8b5cf6; + } + .context-segment.tools { + background: #ec4899; + } + .context-segment.files { + background: #f59e0b; + } + .context-legend { + display: flex; + flex-wrap: wrap; + gap: 16px; + margin-top: 12px; + } + .context-total { + margin-top: 10px; + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + } + .context-details { + margin-top: 12px; + border: 1px solid var(--border); + border-radius: 6px; + overflow: hidden; + } + .context-details summary { + padding: 10px 14px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + background: var(--bg); + border-bottom: 1px solid var(--border); + } + .context-details[open] summary { + border-bottom: 1px solid var(--border); + } + .context-list { + max-height: 200px; + overflow-y: auto; + } + .context-list-header { + display: flex; + justify-content: space-between; + padding: 8px 14px; + font-size: 11px; + text-transform: uppercase; + color: var(--text-muted); + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + } + .context-list-item { + display: flex; + justify-content: space-between; + padding: 8px 14px; + font-size: 12px; + border-bottom: 1px solid var(--border); + } + .context-list-item:last-child { + border-bottom: none; + } + .context-list-item .mono { + font-family: var(--font-mono); + color: var(--text); + } + .context-list-item .muted { + color: var(--text-muted); + font-family: var(--font-mono); + } + + /* ===== NO CONTEXT NOTE ===== */ + .no-context-note { + margin-top: 24px; + padding: 16px; + background: var(--bg-secondary); + border-radius: 8px; + font-size: 13px; + color: var(--text-muted); + line-height: 1.5; + } + + /* ===== TWO COLUMN LAYOUT ===== */ + .usage-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18px; + margin-top: 18px; + align-items: stretch; + } + .usage-grid-left { + display: flex; + flex-direction: column; + } + .usage-grid-right { + display: flex; + flex-direction: column; + } + + /* ===== LEFT CARD (Daily + Breakdown) ===== */ + .usage-left-card { + /* inherits background, border, shadow from .card */ + flex: 1; + display: flex; + flex-direction: column; + } + .usage-left-card .daily-chart-bars { + flex: 1; + min-height: 200px; + } + .usage-left-card .sessions-panel-title { + font-weight: 600; + font-size: 14px; + margin-bottom: 12px; + } + + /* ===== COMPACT DAILY CHART ===== */ + .daily-chart-compact { + margin-bottom: 16px; + } + .daily-chart-compact .sessions-panel-title { + margin-bottom: 8px; + } + .daily-chart-compact .daily-chart-bars { + height: 100px; + padding-bottom: 20px; + } + + /* ===== COMPACT COST BREAKDOWN ===== */ + .cost-breakdown-compact { + padding: 0; + margin: 0; + background: transparent; + border-top: 1px solid var(--border); + padding-top: 12px; + } + .cost-breakdown-compact .cost-breakdown-header { + margin-bottom: 8px; + } + .cost-breakdown-compact .cost-breakdown-legend { + gap: 12px; + } + .cost-breakdown-compact .cost-breakdown-note { + display: none; + } + + /* ===== SESSIONS CARD ===== */ + .sessions-card { + /* inherits background, border, shadow from .card */ + flex: 1; + display: flex; + flex-direction: column; + } + .sessions-card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + } + .sessions-card-title { + font-weight: 600; + font-size: 14px; + } + .sessions-card-count { + font-size: 12px; + color: var(--text-muted); + } + .sessions-card-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin: 8px 0 10px; + font-size: 12px; + color: var(--text-muted); + } + .sessions-card-stats { + display: inline-flex; + gap: 12px; + } + .sessions-sort { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--text-muted); + } + .sessions-sort select { + padding: 4px 8px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--bg); + color: var(--text); + font-size: 12px; + } + .sessions-action-btn { + height: 28px; + padding: 0 10px; + border-radius: 8px; + font-size: 12px; + line-height: 1; + } + .sessions-action-btn.icon { + width: 32px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + } + .sessions-card-hint { + font-size: 11px; + color: var(--text-muted); + margin-bottom: 8px; + } + .sessions-card .session-bars { + max-height: 280px; + background: var(--bg); + border-radius: 6px; + border: 1px solid var(--border); + margin: 0; + overflow-y: auto; + padding: 8px; + } + .sessions-card .session-bar-row { + padding: 6px 8px; + border-radius: 6px; + margin-bottom: 3px; + border: 1px solid transparent; + transition: all 0.15s; + } + .sessions-card .session-bar-row:hover { + border-color: var(--border); + background: var(--bg-hover); + } + .sessions-card .session-bar-row.selected { + border-color: var(--accent); + background: var(--accent-subtle); + box-shadow: inset 0 0 0 1px rgba(255, 77, 77, 0.15); + } + .sessions-card .session-bar-label { + flex: 1 1 auto; + min-width: 140px; + font-size: 12px; + } + .sessions-card .session-bar-value { + flex: 0 0 60px; + font-size: 11px; + font-weight: 600; + } + .sessions-card .session-bar-track { + flex: 0 0 70px; + height: 5px; + opacity: 0.5; + } + .sessions-card .session-bar-fill { + background: rgba(255, 77, 77, 0.55); + } + .sessions-clear-btn { + margin-left: auto; + } + + /* ===== EMPTY DETAIL STATE ===== */ + .session-detail-empty { + margin-top: 18px; + background: var(--bg-secondary); + border-radius: 8px; + border: 2px dashed var(--border); + padding: 32px; + text-align: center; + } + .session-detail-empty-title { + font-size: 15px; + font-weight: 600; + color: var(--text); + margin-bottom: 8px; + } + .session-detail-empty-desc { + font-size: 13px; + color: var(--text-muted); + margin-bottom: 16px; + line-height: 1.5; + } + .session-detail-empty-features { + display: flex; + justify-content: center; + gap: 24px; + flex-wrap: wrap; + } + .session-detail-empty-feature { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--text-muted); + } + .session-detail-empty-feature .icon { + font-size: 16px; + } + + /* ===== SESSION DETAIL PANEL ===== */ + .session-detail-panel { + margin-top: 12px; + /* inherits background, border-radius, shadow from .card */ + border: 2px solid var(--accent) !important; + } + .session-detail-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + border-bottom: 1px solid var(--border); + cursor: pointer; + } + .session-detail-header:hover { + background: var(--bg-hover); + } + .session-detail-title { + font-weight: 600; + font-size: 14px; + display: flex; + align-items: center; + gap: 8px; + } + .session-detail-header-left { + display: flex; + align-items: center; + gap: 8px; + } + .session-close-btn { + background: var(--bg); + border: 1px solid var(--border); + color: var(--text); + cursor: pointer; + padding: 2px 8px; + font-size: 16px; + line-height: 1; + border-radius: 4px; + transition: background 0.15s, color 0.15s; + } + .session-close-btn:hover { + background: var(--bg-hover); + color: var(--text); + border-color: var(--accent); + } + .session-detail-stats { + display: flex; + gap: 10px; + font-size: 12px; + color: var(--text-muted); + } + .session-detail-stats strong { + color: var(--text); + font-family: var(--font-mono); + } + .session-detail-content { + padding: 12px; + } + .session-summary-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 8px; + margin-bottom: 12px; + } + .session-summary-card { + border: 1px solid var(--border); + border-radius: 8px; + padding: 8px; + background: var(--bg-secondary); + } + .session-summary-title { + font-size: 11px; + color: var(--text-muted); + margin-bottom: 4px; + } + .session-summary-value { + font-size: 14px; + font-weight: 600; + } + .session-summary-meta { + font-size: 11px; + color: var(--text-muted); + margin-top: 4px; + } + .session-detail-row { + display: grid; + grid-template-columns: 1fr; + gap: 10px; + /* Separate "Usage Over Time" from the summary + Top Tools/Model Mix cards above. */ + margin-top: 12px; + margin-bottom: 10px; + } + .session-detail-bottom { + display: grid; + grid-template-columns: minmax(0, 1.8fr) minmax(0, 1fr); + gap: 10px; + align-items: stretch; + } + .session-detail-bottom .session-logs-compact { + margin: 0; + display: flex; + flex-direction: column; + } + .session-detail-bottom .session-logs-compact .session-logs-list { + flex: 1 1 auto; + max-height: none; + } + .context-details-panel { + display: flex; + flex-direction: column; + gap: 8px; + background: var(--bg); + border-radius: 6px; + border: 1px solid var(--border); + padding: 12px; + } + .context-breakdown-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 10px; + margin-top: 8px; + } + .context-breakdown-card { + border: 1px solid var(--border); + border-radius: 8px; + padding: 8px; + background: var(--bg-secondary); + } + .context-breakdown-title { + font-size: 11px; + font-weight: 600; + margin-bottom: 6px; + } + .context-breakdown-list { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 11px; + } + .context-breakdown-item { + display: flex; + justify-content: space-between; + gap: 8px; + } + .context-breakdown-more { + font-size: 10px; + color: var(--text-muted); + margin-top: 4px; + } + .context-breakdown-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + } + .context-expand-btn { + border: 1px solid var(--border); + background: var(--bg-secondary); + color: var(--text-muted); + font-size: 11px; + padding: 4px 8px; + border-radius: 999px; + cursor: pointer; + transition: all 0.15s; + } + .context-expand-btn:hover { + color: var(--text); + border-color: var(--border-strong); + background: var(--bg); + } + + /* ===== COMPACT TIMESERIES ===== */ + .session-timeseries-compact { + background: var(--bg); + border-radius: 6px; + border: 1px solid var(--border); + padding: 12px; + margin: 0; + } + .session-timeseries-compact .timeseries-header-row { + margin-bottom: 8px; + } + .session-timeseries-compact .timeseries-header { + font-size: 12px; + } + .session-timeseries-compact .timeseries-summary { + font-size: 11px; + margin-top: 8px; + } + + /* ===== COMPACT CONTEXT ===== */ + .context-weight-compact { + background: var(--bg); + border-radius: 6px; + border: 1px solid var(--border); + padding: 12px; + margin: 0; + } + .context-weight-compact .context-weight-header { + font-size: 12px; + margin-bottom: 4px; + } + .context-weight-compact .context-weight-desc { + font-size: 11px; + margin-bottom: 8px; + } + .context-weight-compact .context-stacked-bar { + height: 16px; + } + .context-weight-compact .context-legend { + font-size: 11px; + gap: 10px; + margin-top: 8px; + } + .context-weight-compact .context-total { + font-size: 11px; + margin-top: 6px; + } + .context-weight-compact .context-details { + margin-top: 8px; + } + .context-weight-compact .context-details summary { + font-size: 12px; + padding: 6px 10px; + } + + /* ===== COMPACT LOGS ===== */ + .session-logs-compact { + background: var(--bg); + border-radius: 10px; + border: 1px solid var(--border); + overflow: hidden; + margin: 0; + display: flex; + flex-direction: column; + } + .session-logs-compact .session-logs-header { + padding: 10px 12px; + font-size: 12px; + } + .session-logs-compact .session-logs-list { + max-height: none; + flex: 1 1 auto; + overflow: auto; + } + .session-logs-compact .session-log-entry { + padding: 8px 12px; + } + .session-logs-compact .session-log-content { + font-size: 12px; + max-height: 160px; + } + .session-log-tools { + margin-top: 6px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-secondary); + padding: 6px 8px; + font-size: 11px; + color: var(--text); + } + .session-log-tools summary { + cursor: pointer; + list-style: none; + display: flex; + align-items: center; + gap: 6px; + font-weight: 600; + } + .session-log-tools summary::-webkit-details-marker { + display: none; + } + .session-log-tools-list { + margin-top: 6px; + display: flex; + flex-wrap: wrap; + gap: 6px; + } + .session-log-tools-pill { + border: 1px solid var(--border); + border-radius: 999px; + padding: 2px 8px; + font-size: 10px; + background: var(--bg); + color: var(--text); + } + + /* ===== RESPONSIVE ===== */ + @media (max-width: 900px) { + .usage-grid { + grid-template-columns: 1fr; + } + .session-detail-row { + grid-template-columns: 1fr; + } + } + @media (max-width: 600px) { + .session-bar-label { + flex: 0 0 100px; + } + .cost-breakdown-legend { + gap: 10px; + } + .legend-item { + font-size: 11px; + } + .daily-chart-bars { + height: 170px; + gap: 6px; + padding-bottom: 40px; + } + .daily-bar-label { + font-size: 8px; + bottom: -30px; + transform: rotate(-45deg); + } + .usage-mosaic-grid { + grid-template-columns: 1fr; + } + .usage-hour-grid { + grid-template-columns: repeat(12, minmax(10px, 1fr)); + } + .usage-hour-cell { + height: 22px; + } + } +`; + +export type UsageSessionEntry = { + key: string; + label?: string; + sessionId?: string; + updatedAt?: number; + agentId?: string; + channel?: string; + chatType?: string; + origin?: { + label?: string; + provider?: string; + surface?: string; + chatType?: string; + from?: string; + to?: string; + accountId?: string; + threadId?: string | number; + }; + modelOverride?: string; + providerOverride?: string; + modelProvider?: string; + model?: string; + usage: { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + totalTokens: number; + totalCost: number; + inputCost?: number; + outputCost?: number; + cacheReadCost?: number; + cacheWriteCost?: number; + missingCostEntries: number; + firstActivity?: number; + lastActivity?: number; + durationMs?: number; + activityDates?: string[]; // YYYY-MM-DD dates when session had activity + dailyBreakdown?: Array<{ date: string; tokens: number; cost: number }>; // Per-day breakdown + dailyMessageCounts?: Array<{ + date: string; + total: number; + user: number; + assistant: number; + toolCalls: number; + toolResults: number; + errors: number; + }>; + dailyLatency?: Array<{ + date: string; + count: number; + avgMs: number; + p95Ms: number; + minMs: number; + maxMs: number; + }>; + dailyModelUsage?: Array<{ + date: string; + provider?: string; + model?: string; + tokens: number; + cost: number; + count: number; + }>; + messageCounts?: { + total: number; + user: number; + assistant: number; + toolCalls: number; + toolResults: number; + errors: number; + }; + toolUsage?: { + totalCalls: number; + uniqueTools: number; + tools: Array<{ name: string; count: number }>; + }; + modelUsage?: Array<{ + provider?: string; + model?: string; + count: number; + totals: UsageTotals; + }>; + latency?: { + count: number; + avgMs: number; + p95Ms: number; + minMs: number; + maxMs: number; + }; + } | null; + contextWeight?: { + systemPrompt: { chars: number; projectContextChars: number; nonProjectContextChars: number }; + skills: { promptChars: number; entries: Array<{ name: string; blockChars: number }> }; + tools: { + listChars: number; + schemaChars: number; + entries: Array<{ name: string; summaryChars: number; schemaChars: number }>; + }; + injectedWorkspaceFiles: Array<{ + name: string; + path: string; + rawChars: number; + injectedChars: number; + truncated: boolean; + }>; + } | null; +}; + +export type UsageTotals = { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + totalTokens: number; + totalCost: number; + inputCost: number; + outputCost: number; + cacheReadCost: number; + cacheWriteCost: number; + missingCostEntries: number; +}; + +export type CostDailyEntry = UsageTotals & { date: string }; + +export type UsageAggregates = { + messages: { + total: number; + user: number; + assistant: number; + toolCalls: number; + toolResults: number; + errors: number; + }; + tools: { + totalCalls: number; + uniqueTools: number; + tools: Array<{ name: string; count: number }>; + }; + byModel: Array<{ + provider?: string; + model?: string; + count: number; + totals: UsageTotals; + }>; + byProvider: Array<{ + provider?: string; + model?: string; + count: number; + totals: UsageTotals; + }>; + byAgent: Array<{ agentId: string; totals: UsageTotals }>; + byChannel: Array<{ channel: string; totals: UsageTotals }>; + latency?: { + count: number; + avgMs: number; + p95Ms: number; + minMs: number; + maxMs: number; + }; + dailyLatency?: Array<{ + date: string; + count: number; + avgMs: number; + p95Ms: number; + minMs: number; + maxMs: number; + }>; + modelDaily?: Array<{ + date: string; + provider?: string; + model?: string; + tokens: number; + cost: number; + count: number; + }>; + daily: Array<{ + date: string; + tokens: number; + cost: number; + messages: number; + toolCalls: number; + errors: number; + }>; +}; + +export type UsageColumnId = + | "channel" + | "agent" + | "provider" + | "model" + | "messages" + | "tools" + | "errors" + | "duration"; + +export type TimeSeriesPoint = { + timestamp: number; + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + totalTokens: number; + cost: number; + cumulativeTokens: number; + cumulativeCost: number; +}; + +export type UsageProps = { + loading: boolean; + error: string | null; + startDate: string; + endDate: string; + sessions: UsageSessionEntry[]; + sessionsLimitReached: boolean; // True if 1000 session cap was hit + totals: UsageTotals | null; + aggregates: UsageAggregates | null; + costDaily: CostDailyEntry[]; + selectedSessions: string[]; // Support multiple session selection + selectedDays: string[]; // Support multiple day selection + selectedHours: number[]; // Support multiple hour selection + chartMode: "tokens" | "cost"; + dailyChartMode: "total" | "by-type"; + timeSeriesMode: "cumulative" | "per-turn"; + timeSeriesBreakdownMode: "total" | "by-type"; + timeSeries: { points: TimeSeriesPoint[] } | null; + timeSeriesLoading: boolean; + sessionLogs: SessionLogEntry[] | null; + sessionLogsLoading: boolean; + sessionLogsExpanded: boolean; + logFilterRoles: SessionLogRole[]; + logFilterTools: string[]; + logFilterHasTools: boolean; + logFilterQuery: string; + query: string; + queryDraft: string; + sessionSort: "tokens" | "cost" | "recent" | "messages" | "errors"; + sessionSortDir: "asc" | "desc"; + recentSessions: string[]; + sessionsTab: "all" | "recent"; + visibleColumns: UsageColumnId[]; + timeZone: "local" | "utc"; + contextExpanded: boolean; + headerPinned: boolean; + onStartDateChange: (date: string) => void; + onEndDateChange: (date: string) => void; + onRefresh: () => void; + onTimeZoneChange: (zone: "local" | "utc") => void; + onToggleContextExpanded: () => void; + onToggleHeaderPinned: () => void; + onToggleSessionLogsExpanded: () => void; + onLogFilterRolesChange: (next: SessionLogRole[]) => void; + onLogFilterToolsChange: (next: string[]) => void; + onLogFilterHasToolsChange: (next: boolean) => void; + onLogFilterQueryChange: (next: string) => void; + onLogFilterClear: () => void; + onSelectSession: (key: string, shiftKey: boolean) => void; + onChartModeChange: (mode: "tokens" | "cost") => void; + onDailyChartModeChange: (mode: "total" | "by-type") => void; + onTimeSeriesModeChange: (mode: "cumulative" | "per-turn") => void; + onTimeSeriesBreakdownChange: (mode: "total" | "by-type") => void; + onSelectDay: (day: string, shiftKey: boolean) => void; // Support shift-click + onSelectHour: (hour: number, shiftKey: boolean) => void; + onClearDays: () => void; + onClearHours: () => void; + onClearSessions: () => void; + onClearFilters: () => void; + onQueryDraftChange: (query: string) => void; + onApplyQuery: () => void; + onClearQuery: () => void; + onSessionSortChange: (sort: "tokens" | "cost" | "recent" | "messages" | "errors") => void; + onSessionSortDirChange: (dir: "asc" | "desc") => void; + onSessionsTabChange: (tab: "all" | "recent") => void; + onToggleColumn: (column: UsageColumnId) => void; +}; + +export type SessionLogEntry = { + timestamp: number; + role: "user" | "assistant" | "tool" | "toolResult"; + content: string; + tokens?: number; + cost?: number; +}; + +export type SessionLogRole = SessionLogEntry["role"]; + +// ~4 chars per token is a rough approximation +const CHARS_PER_TOKEN = 4; + +function charsToTokens(chars: number): number { + return Math.round(chars / CHARS_PER_TOKEN); +} + +function formatTokens(n: number): string { + if (n >= 1_000_000) { + return `${(n / 1_000_000).toFixed(1)}M`; + } + if (n >= 1_000) { + return `${(n / 1_000).toFixed(1)}K`; + } + return String(n); +} + +function formatHourLabel(hour: number): string { + const date = new Date(); + date.setHours(hour, 0, 0, 0); + return date.toLocaleTimeString(undefined, { hour: "numeric" }); +} + +function buildPeakErrorHours(sessions: UsageSessionEntry[], timeZone: "local" | "utc") { + const hourErrors = Array.from({ length: 24 }, () => 0); + const hourMsgs = Array.from({ length: 24 }, () => 0); + + for (const session of sessions) { + const usage = session.usage; + if (!usage?.messageCounts || usage.messageCounts.total === 0) { + continue; + } + const start = usage.firstActivity ?? session.updatedAt; + const end = usage.lastActivity ?? session.updatedAt; + if (!start || !end) { + continue; + } + const startMs = Math.min(start, end); + const endMs = Math.max(start, end); + const durationMs = Math.max(endMs - startMs, 1); + const totalMinutes = durationMs / 60000; + + let cursor = startMs; + while (cursor < endMs) { + const date = new Date(cursor); + const hour = getZonedHour(date, timeZone); + const nextHour = setToHourEnd(date, timeZone); + const nextMs = Math.min(nextHour.getTime(), endMs); + const minutes = Math.max((nextMs - cursor) / 60000, 0); + const share = minutes / totalMinutes; + hourErrors[hour] += usage.messageCounts.errors * share; + hourMsgs[hour] += usage.messageCounts.total * share; + cursor = nextMs + 1; + } + } + + return hourMsgs + .map((msgs, hour) => { + const errors = hourErrors[hour]; + const rate = msgs > 0 ? errors / msgs : 0; + return { + hour, + rate, + errors, + msgs, + }; + }) + .filter((entry) => entry.msgs > 0 && entry.errors > 0) + .toSorted((a, b) => b.rate - a.rate) + .slice(0, 5) + .map((entry) => ({ + label: formatHourLabel(entry.hour), + value: `${(entry.rate * 100).toFixed(2)}%`, + sub: `${Math.round(entry.errors)} errors · ${Math.round(entry.msgs)} msgs`, + })); +} + +type UsageMosaicStats = { + hasData: boolean; + totalTokens: number; + hourTotals: number[]; + weekdayTotals: Array<{ label: string; tokens: number }>; +}; + +const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + +function getZonedHour(date: Date, zone: "local" | "utc"): number { + return zone === "utc" ? date.getUTCHours() : date.getHours(); +} + +function getZonedWeekday(date: Date, zone: "local" | "utc"): number { + return zone === "utc" ? date.getUTCDay() : date.getDay(); +} + +function setToHourEnd(date: Date, zone: "local" | "utc"): Date { + const next = new Date(date); + if (zone === "utc") { + next.setUTCMinutes(59, 59, 999); + } else { + next.setMinutes(59, 59, 999); + } + return next; +} + +function buildUsageMosaicStats( + sessions: UsageSessionEntry[], + timeZone: "local" | "utc", +): UsageMosaicStats { + const hourTotals = Array.from({ length: 24 }, () => 0); + const weekdayTotals = Array.from({ length: 7 }, () => 0); + let totalTokens = 0; + let hasData = false; + + for (const session of sessions) { + const usage = session.usage; + if (!usage || !usage.totalTokens || usage.totalTokens <= 0) { + continue; + } + totalTokens += usage.totalTokens; + + const start = usage.firstActivity ?? session.updatedAt; + const end = usage.lastActivity ?? session.updatedAt; + if (!start || !end) { + continue; + } + hasData = true; + + const startMs = Math.min(start, end); + const endMs = Math.max(start, end); + const durationMs = Math.max(endMs - startMs, 1); + const totalMinutes = durationMs / 60000; + + let cursor = startMs; + while (cursor < endMs) { + const date = new Date(cursor); + const hour = getZonedHour(date, timeZone); + const weekday = getZonedWeekday(date, timeZone); + const nextHour = setToHourEnd(date, timeZone); + const nextMs = Math.min(nextHour.getTime(), endMs); + const minutes = Math.max((nextMs - cursor) / 60000, 0); + const share = minutes / totalMinutes; + hourTotals[hour] += usage.totalTokens * share; + weekdayTotals[weekday] += usage.totalTokens * share; + cursor = nextMs + 1; + } + } + + const weekdayLabels = WEEKDAYS.map((label, index) => ({ + label, + tokens: weekdayTotals[index], + })); + + return { + hasData, + totalTokens, + hourTotals, + weekdayTotals: weekdayLabels, + }; +} + +function renderUsageMosaic( + sessions: UsageSessionEntry[], + timeZone: "local" | "utc", + selectedHours: number[], + onSelectHour: (hour: number, shiftKey: boolean) => void, +) { + const stats = buildUsageMosaicStats(sessions, timeZone); + if (!stats.hasData) { + return html` +
+
+
+
Activity by Time
+
Estimates require session timestamps.
+
+
${formatTokens(0)} tokens
+
+
No timeline data yet.
+
+ `; + } + + const maxHour = Math.max(...stats.hourTotals, 1); + const maxWeekday = Math.max(...stats.weekdayTotals.map((d) => d.tokens), 1); + + return html` +
+
+
+
Activity by Time
+
+ Estimated from session spans (first/last activity). Time zone: ${timeZone === "utc" ? "UTC" : "Local"}. +
+
+
${formatTokens(stats.totalTokens)} tokens
+
+
+
+
Day of Week
+
+ ${stats.weekdayTotals.map((part) => { + const intensity = Math.min(part.tokens / maxWeekday, 1); + const bg = + part.tokens > 0 ? `rgba(255, 77, 77, ${0.12 + intensity * 0.6})` : "transparent"; + return html` +
+
${part.label}
+
${formatTokens(part.tokens)}
+
+ `; + })} +
+
+
+
+ Hours + 0 → 23 +
+
+ ${stats.hourTotals.map((value, hour) => { + const intensity = Math.min(value / maxHour, 1); + const bg = value > 0 ? `rgba(255, 77, 77, ${0.08 + intensity * 0.7})` : "transparent"; + const title = `${hour}:00 · ${formatTokens(value)} tokens`; + const border = intensity > 0.7 ? "rgba(255, 77, 77, 0.6)" : "rgba(255, 77, 77, 0.2)"; + const selected = selectedHours.includes(hour); + return html` +
onSelectHour(hour, e.shiftKey)} + >
+ `; + })} +
+
+ Midnight + 4am + 8am + Noon + 4pm + 8pm +
+
+ + Low → High token density +
+
+
+
+ `; +} + +function formatCost(n: number, decimals = 2): string { + return `$${n.toFixed(decimals)}`; +} + +function formatIsoDate(date: Date): string { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; +} + +function formatDurationShort(ms?: number): string { + if (!ms || ms <= 0) { + return "0s"; + } + if (ms >= 60_000) { + return `${Math.round(ms / 60000)}m`; + } + if (ms >= 1000) { + return `${Math.round(ms / 1000)}s`; + } + return `${Math.round(ms)}ms`; +} + +function parseYmdDate(dateStr: string): Date | null { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr); + if (!match) { + return null; + } + const [, y, m, d] = match; + const date = new Date(Date.UTC(Number(y), Number(m) - 1, Number(d))); + return Number.isNaN(date.valueOf()) ? null : date; +} + +function formatDayLabel(dateStr: string): string { + const date = parseYmdDate(dateStr); + if (!date) { + return dateStr; + } + return date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); +} + +function formatFullDate(dateStr: string): string { + const date = parseYmdDate(dateStr); + if (!date) { + return dateStr; + } + return date.toLocaleDateString(undefined, { month: "long", day: "numeric", year: "numeric" }); +} + +function formatDurationMs(ms?: number): string { + if (!ms || ms <= 0) { + return "—"; + } + const totalSeconds = Math.round(ms / 1000); + const seconds = totalSeconds % 60; + const minutes = Math.floor(totalSeconds / 60) % 60; + const hours = Math.floor(totalSeconds / 3600); + if (hours > 0) { + return `${hours}h ${minutes}m`; + } + if (minutes > 0) { + return `${minutes}m ${seconds}s`; + } + return `${seconds}s`; +} + +function downloadTextFile(filename: string, content: string, type = "text/plain") { + const blob = new Blob([content], { type }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + anchor.click(); + URL.revokeObjectURL(url); +} + +function csvEscape(value: string): string { + if (value.includes('"') || value.includes(",") || value.includes("\n")) { + return `"${value.replace(/"/g, '""')}"`; + } + return value; +} + +function toCsvRow(values: Array): string { + return values + .map((val) => { + if (val === undefined || val === null) { + return ""; + } + return csvEscape(String(val)); + }) + .join(","); +} + +const emptyUsageTotals = (): UsageTotals => ({ + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + totalCost: 0, + inputCost: 0, + outputCost: 0, + cacheReadCost: 0, + cacheWriteCost: 0, + missingCostEntries: 0, +}); + +const mergeUsageTotals = (target: UsageTotals, source: Partial) => { + target.input += source.input ?? 0; + target.output += source.output ?? 0; + target.cacheRead += source.cacheRead ?? 0; + target.cacheWrite += source.cacheWrite ?? 0; + target.totalTokens += source.totalTokens ?? 0; + target.totalCost += source.totalCost ?? 0; + target.inputCost += source.inputCost ?? 0; + target.outputCost += source.outputCost ?? 0; + target.cacheReadCost += source.cacheReadCost ?? 0; + target.cacheWriteCost += source.cacheWriteCost ?? 0; + target.missingCostEntries += source.missingCostEntries ?? 0; +}; + +const buildAggregatesFromSessions = ( + sessions: UsageSessionEntry[], + fallback?: UsageAggregates | null, +): UsageAggregates => { + if (sessions.length === 0) { + return ( + fallback ?? { + messages: { total: 0, user: 0, assistant: 0, toolCalls: 0, toolResults: 0, errors: 0 }, + tools: { totalCalls: 0, uniqueTools: 0, tools: [] }, + byModel: [], + byProvider: [], + byAgent: [], + byChannel: [], + daily: [], + } + ); + } + + const messages = { total: 0, user: 0, assistant: 0, toolCalls: 0, toolResults: 0, errors: 0 }; + const toolMap = new Map(); + const modelMap = new Map< + string, + { provider?: string; model?: string; count: number; totals: UsageTotals } + >(); + const providerMap = new Map< + string, + { provider?: string; model?: string; count: number; totals: UsageTotals } + >(); + const agentMap = new Map(); + const channelMap = new Map(); + const dailyMap = new Map< + string, + { + date: string; + tokens: number; + cost: number; + messages: number; + toolCalls: number; + errors: number; + } + >(); + const dailyLatencyMap = new Map< + string, + { date: string; count: number; sum: number; min: number; max: number; p95Max: number } + >(); + const modelDailyMap = new Map< + string, + { date: string; provider?: string; model?: string; tokens: number; cost: number; count: number } + >(); + const latencyTotals = { count: 0, sum: 0, min: Number.POSITIVE_INFINITY, max: 0, p95Max: 0 }; + + for (const session of sessions) { + const usage = session.usage; + if (!usage) { + continue; + } + if (usage.messageCounts) { + messages.total += usage.messageCounts.total; + messages.user += usage.messageCounts.user; + messages.assistant += usage.messageCounts.assistant; + messages.toolCalls += usage.messageCounts.toolCalls; + messages.toolResults += usage.messageCounts.toolResults; + messages.errors += usage.messageCounts.errors; + } + + if (usage.toolUsage) { + for (const tool of usage.toolUsage.tools) { + toolMap.set(tool.name, (toolMap.get(tool.name) ?? 0) + tool.count); + } + } + + if (usage.modelUsage) { + for (const entry of usage.modelUsage) { + const modelKey = `${entry.provider ?? "unknown"}::${entry.model ?? "unknown"}`; + const modelExisting = modelMap.get(modelKey) ?? { + provider: entry.provider, + model: entry.model, + count: 0, + totals: emptyUsageTotals(), + }; + modelExisting.count += entry.count; + mergeUsageTotals(modelExisting.totals, entry.totals); + modelMap.set(modelKey, modelExisting); + + const providerKey = entry.provider ?? "unknown"; + const providerExisting = providerMap.get(providerKey) ?? { + provider: entry.provider, + model: undefined, + count: 0, + totals: emptyUsageTotals(), + }; + providerExisting.count += entry.count; + mergeUsageTotals(providerExisting.totals, entry.totals); + providerMap.set(providerKey, providerExisting); + } + } + + if (usage.latency) { + const { count, avgMs, minMs, maxMs, p95Ms } = usage.latency; + if (count > 0) { + latencyTotals.count += count; + latencyTotals.sum += avgMs * count; + latencyTotals.min = Math.min(latencyTotals.min, minMs); + latencyTotals.max = Math.max(latencyTotals.max, maxMs); + latencyTotals.p95Max = Math.max(latencyTotals.p95Max, p95Ms); + } + } + + if (session.agentId) { + const totals = agentMap.get(session.agentId) ?? emptyUsageTotals(); + mergeUsageTotals(totals, usage); + agentMap.set(session.agentId, totals); + } + if (session.channel) { + const totals = channelMap.get(session.channel) ?? emptyUsageTotals(); + mergeUsageTotals(totals, usage); + channelMap.set(session.channel, totals); + } + + for (const day of usage.dailyBreakdown ?? []) { + const daily = dailyMap.get(day.date) ?? { + date: day.date, + tokens: 0, + cost: 0, + messages: 0, + toolCalls: 0, + errors: 0, + }; + daily.tokens += day.tokens; + daily.cost += day.cost; + dailyMap.set(day.date, daily); + } + for (const day of usage.dailyMessageCounts ?? []) { + const daily = dailyMap.get(day.date) ?? { + date: day.date, + tokens: 0, + cost: 0, + messages: 0, + toolCalls: 0, + errors: 0, + }; + daily.messages += day.total; + daily.toolCalls += day.toolCalls; + daily.errors += day.errors; + dailyMap.set(day.date, daily); + } + for (const day of usage.dailyLatency ?? []) { + const existing = dailyLatencyMap.get(day.date) ?? { + date: day.date, + count: 0, + sum: 0, + min: Number.POSITIVE_INFINITY, + max: 0, + p95Max: 0, + }; + existing.count += day.count; + existing.sum += day.avgMs * day.count; + existing.min = Math.min(existing.min, day.minMs); + existing.max = Math.max(existing.max, day.maxMs); + existing.p95Max = Math.max(existing.p95Max, day.p95Ms); + dailyLatencyMap.set(day.date, existing); + } + for (const day of usage.dailyModelUsage ?? []) { + const key = `${day.date}::${day.provider ?? "unknown"}::${day.model ?? "unknown"}`; + const existing = modelDailyMap.get(key) ?? { + date: day.date, + provider: day.provider, + model: day.model, + tokens: 0, + cost: 0, + count: 0, + }; + existing.tokens += day.tokens; + existing.cost += day.cost; + existing.count += day.count; + modelDailyMap.set(key, existing); + } + } + + return { + messages, + tools: { + totalCalls: Array.from(toolMap.values()).reduce((sum, count) => sum + count, 0), + uniqueTools: toolMap.size, + tools: Array.from(toolMap.entries()) + .map(([name, count]) => ({ name, count })) + .toSorted((a, b) => b.count - a.count), + }, + byModel: Array.from(modelMap.values()).toSorted( + (a, b) => b.totals.totalCost - a.totals.totalCost, + ), + byProvider: Array.from(providerMap.values()).toSorted( + (a, b) => b.totals.totalCost - a.totals.totalCost, + ), + byAgent: Array.from(agentMap.entries()) + .map(([agentId, totals]) => ({ agentId, totals })) + .toSorted((a, b) => b.totals.totalCost - a.totals.totalCost), + byChannel: Array.from(channelMap.entries()) + .map(([channel, totals]) => ({ channel, totals })) + .toSorted((a, b) => b.totals.totalCost - a.totals.totalCost), + latency: + latencyTotals.count > 0 + ? { + count: latencyTotals.count, + avgMs: latencyTotals.sum / latencyTotals.count, + minMs: latencyTotals.min === Number.POSITIVE_INFINITY ? 0 : latencyTotals.min, + maxMs: latencyTotals.max, + p95Ms: latencyTotals.p95Max, + } + : undefined, + dailyLatency: Array.from(dailyLatencyMap.values()) + .map((entry) => ({ + date: entry.date, + count: entry.count, + avgMs: entry.count ? entry.sum / entry.count : 0, + minMs: entry.min === Number.POSITIVE_INFINITY ? 0 : entry.min, + maxMs: entry.max, + p95Ms: entry.p95Max, + })) + .toSorted((a, b) => a.date.localeCompare(b.date)), + modelDaily: Array.from(modelDailyMap.values()).toSorted( + (a, b) => a.date.localeCompare(b.date) || b.cost - a.cost, + ), + daily: Array.from(dailyMap.values()).toSorted((a, b) => a.date.localeCompare(b.date)), + }; +}; + +type UsageInsightStats = { + durationSumMs: number; + durationCount: number; + avgDurationMs: number; + throughputTokensPerMin?: number; + throughputCostPerMin?: number; + errorRate: number; + peakErrorDay?: { date: string; errors: number; messages: number; rate: number }; +}; + +const buildUsageInsightStats = ( + sessions: UsageSessionEntry[], + totals: UsageTotals | null, + aggregates: UsageAggregates, +): UsageInsightStats => { + let durationSumMs = 0; + let durationCount = 0; + for (const session of sessions) { + const duration = session.usage?.durationMs ?? 0; + if (duration > 0) { + durationSumMs += duration; + durationCount += 1; + } + } + + const avgDurationMs = durationCount ? durationSumMs / durationCount : 0; + const throughputTokensPerMin = + totals && durationSumMs > 0 ? totals.totalTokens / (durationSumMs / 60000) : undefined; + const throughputCostPerMin = + totals && durationSumMs > 0 ? totals.totalCost / (durationSumMs / 60000) : undefined; + + const errorRate = aggregates.messages.total + ? aggregates.messages.errors / aggregates.messages.total + : 0; + const peakErrorDay = aggregates.daily + .filter((day) => day.messages > 0 && day.errors > 0) + .map((day) => ({ + date: day.date, + errors: day.errors, + messages: day.messages, + rate: day.errors / day.messages, + })) + .toSorted((a, b) => b.rate - a.rate || b.errors - a.errors)[0]; + + return { + durationSumMs, + durationCount, + avgDurationMs, + throughputTokensPerMin, + throughputCostPerMin, + errorRate, + peakErrorDay, + }; +}; + +const buildSessionsCsv = (sessions: UsageSessionEntry[]): string => { + const rows = [ + toCsvRow([ + "key", + "label", + "agentId", + "channel", + "provider", + "model", + "updatedAt", + "durationMs", + "messages", + "errors", + "toolCalls", + "inputTokens", + "outputTokens", + "cacheReadTokens", + "cacheWriteTokens", + "totalTokens", + "totalCost", + ]), + ]; + + for (const session of sessions) { + const usage = session.usage; + rows.push( + toCsvRow([ + session.key, + session.label ?? "", + session.agentId ?? "", + session.channel ?? "", + session.modelProvider ?? session.providerOverride ?? "", + session.model ?? session.modelOverride ?? "", + session.updatedAt ? new Date(session.updatedAt).toISOString() : "", + usage?.durationMs ?? "", + usage?.messageCounts?.total ?? "", + usage?.messageCounts?.errors ?? "", + usage?.messageCounts?.toolCalls ?? "", + usage?.input ?? "", + usage?.output ?? "", + usage?.cacheRead ?? "", + usage?.cacheWrite ?? "", + usage?.totalTokens ?? "", + usage?.totalCost ?? "", + ]), + ); + } + + return rows.join("\n"); +}; + +const buildDailyCsv = (daily: CostDailyEntry[]): string => { + const rows = [ + toCsvRow([ + "date", + "inputTokens", + "outputTokens", + "cacheReadTokens", + "cacheWriteTokens", + "totalTokens", + "inputCost", + "outputCost", + "cacheReadCost", + "cacheWriteCost", + "totalCost", + ]), + ]; + + for (const day of daily) { + rows.push( + toCsvRow([ + day.date, + day.input, + day.output, + day.cacheRead, + day.cacheWrite, + day.totalTokens, + day.inputCost ?? "", + day.outputCost ?? "", + day.cacheReadCost ?? "", + day.cacheWriteCost ?? "", + day.totalCost, + ]), + ); + } + + return rows.join("\n"); +}; + +type QuerySuggestion = { + label: string; + value: string; +}; + +const buildQuerySuggestions = ( + query: string, + sessions: UsageSessionEntry[], + aggregates?: UsageAggregates | null, +): QuerySuggestion[] => { + const trimmed = query.trim(); + if (!trimmed) { + return []; + } + const tokens = trimmed.length ? trimmed.split(/\s+/) : []; + const lastToken = tokens.length ? tokens[tokens.length - 1] : ""; + const [rawKey, rawValue] = lastToken.includes(":") + ? [lastToken.slice(0, lastToken.indexOf(":")), lastToken.slice(lastToken.indexOf(":") + 1)] + : ["", ""]; + + const key = rawKey.toLowerCase(); + const value = rawValue.toLowerCase(); + + const unique = (items: Array): string[] => { + const set = new Set(); + for (const item of items) { + if (item) { + set.add(item); + } + } + return Array.from(set); + }; + + const agents = unique(sessions.map((s) => s.agentId)).slice(0, 6); + const channels = unique(sessions.map((s) => s.channel)).slice(0, 6); + const providers = unique([ + ...sessions.map((s) => s.modelProvider), + ...sessions.map((s) => s.providerOverride), + ...(aggregates?.byProvider.map((p) => p.provider) ?? []), + ]).slice(0, 6); + const models = unique([ + ...sessions.map((s) => s.model), + ...(aggregates?.byModel.map((m) => m.model) ?? []), + ]).slice(0, 6); + const tools = unique(aggregates?.tools.tools.map((t) => t.name) ?? []).slice(0, 6); + + if (!key) { + return [ + { label: "agent:", value: "agent:" }, + { label: "channel:", value: "channel:" }, + { label: "provider:", value: "provider:" }, + { label: "model:", value: "model:" }, + { label: "tool:", value: "tool:" }, + { label: "has:errors", value: "has:errors" }, + { label: "has:tools", value: "has:tools" }, + { label: "minTokens:", value: "minTokens:" }, + { label: "maxCost:", value: "maxCost:" }, + ]; + } + + const suggestions: QuerySuggestion[] = []; + const addValues = (prefix: string, values: string[]) => { + for (const val of values) { + if (!value || val.toLowerCase().includes(value)) { + suggestions.push({ label: `${prefix}:${val}`, value: `${prefix}:${val}` }); + } + } + }; + + switch (key) { + case "agent": + addValues("agent", agents); + break; + case "channel": + addValues("channel", channels); + break; + case "provider": + addValues("provider", providers); + break; + case "model": + addValues("model", models); + break; + case "tool": + addValues("tool", tools); + break; + case "has": + ["errors", "tools", "context", "usage", "model", "provider"].forEach((entry) => { + if (!value || entry.includes(value)) { + suggestions.push({ label: `has:${entry}`, value: `has:${entry}` }); + } + }); + break; + default: + break; + } + + return suggestions; +}; + +const applySuggestionToQuery = (query: string, suggestion: string): string => { + const trimmed = query.trim(); + if (!trimmed) { + return `${suggestion} `; + } + const tokens = trimmed.split(/\s+/); + tokens[tokens.length - 1] = suggestion; + return `${tokens.join(" ")} `; +}; + +const normalizeQueryText = (value: string): string => value.trim().toLowerCase(); + +const addQueryToken = (query: string, token: string): string => { + const trimmed = query.trim(); + if (!trimmed) { + return `${token} `; + } + const tokens = trimmed.split(/\s+/); + const last = tokens[tokens.length - 1] ?? ""; + const tokenKey = token.includes(":") ? token.split(":")[0] : null; + const lastKey = last.includes(":") ? last.split(":")[0] : null; + if (last.endsWith(":") && tokenKey && lastKey === tokenKey) { + tokens[tokens.length - 1] = token; + return `${tokens.join(" ")} `; + } + if (tokens.includes(token)) { + return `${tokens.join(" ")} `; + } + return `${tokens.join(" ")} ${token} `; +}; + +const removeQueryToken = (query: string, token: string): string => { + const tokens = query.trim().split(/\s+/).filter(Boolean); + const next = tokens.filter((entry) => entry !== token); + return next.length ? `${next.join(" ")} ` : ""; +}; + +const setQueryTokensForKey = (query: string, key: string, values: string[]): string => { + const normalizedKey = normalizeQueryText(key); + const tokens = extractQueryTerms(query) + .filter((term) => normalizeQueryText(term.key ?? "") !== normalizedKey) + .map((term) => term.raw); + const next = [...tokens, ...values.map((value) => `${key}:${value}`)]; + return next.length ? `${next.join(" ")} ` : ""; +}; + +function pct(part: number, total: number): number { + if (total === 0) { + return 0; + } + return (part / total) * 100; +} + +function getCostBreakdown(totals: UsageTotals) { + // Use actual costs from API data (already aggregated in backend) + const totalCost = totals.totalCost || 0; + + return { + input: { + tokens: totals.input, + cost: totals.inputCost || 0, + pct: pct(totals.inputCost || 0, totalCost), + }, + output: { + tokens: totals.output, + cost: totals.outputCost || 0, + pct: pct(totals.outputCost || 0, totalCost), + }, + cacheRead: { + tokens: totals.cacheRead, + cost: totals.cacheReadCost || 0, + pct: pct(totals.cacheReadCost || 0, totalCost), + }, + cacheWrite: { + tokens: totals.cacheWrite, + cost: totals.cacheWriteCost || 0, + pct: pct(totals.cacheWriteCost || 0, totalCost), + }, + totalCost, + }; +} + +function renderFilterChips( + selectedDays: string[], + selectedHours: number[], + selectedSessions: string[], + sessions: UsageSessionEntry[], + onClearDays: () => void, + onClearHours: () => void, + onClearSessions: () => void, + onClearFilters: () => void, +) { + const hasFilters = + selectedDays.length > 0 || selectedHours.length > 0 || selectedSessions.length > 0; + if (!hasFilters) { + return nothing; + } + + const selectedSession = + selectedSessions.length === 1 ? sessions.find((s) => s.key === selectedSessions[0]) : null; + const sessionsLabel = selectedSession + ? (selectedSession.label || selectedSession.key).slice(0, 20) + + ((selectedSession.label || selectedSession.key).length > 20 ? "…" : "") + : selectedSessions.length === 1 + ? selectedSessions[0].slice(0, 8) + "…" + : `${selectedSessions.length} sessions`; + const sessionsFullName = selectedSession + ? selectedSession.label || selectedSession.key + : selectedSessions.length === 1 + ? selectedSessions[0] + : selectedSessions.join(", "); + + const daysLabel = selectedDays.length === 1 ? selectedDays[0] : `${selectedDays.length} days`; + const hoursLabel = + selectedHours.length === 1 ? `${selectedHours[0]}:00` : `${selectedHours.length} hours`; + + return html` +
+ ${ + selectedDays.length > 0 + ? html` +
+ Days: ${daysLabel} + +
+ ` + : nothing + } + ${ + selectedHours.length > 0 + ? html` +
+ Hours: ${hoursLabel} + +
+ ` + : nothing + } + ${ + selectedSessions.length > 0 + ? html` +
+ Session: ${sessionsLabel} + +
+ ` + : nothing + } + ${ + (selectedDays.length > 0 || selectedHours.length > 0) && selectedSessions.length > 0 + ? html` + + ` + : nothing + } +
+ `; +} + +function renderDailyChartCompact( + daily: CostDailyEntry[], + selectedDays: string[], + chartMode: "tokens" | "cost", + dailyChartMode: "total" | "by-type", + onDailyChartModeChange: (mode: "total" | "by-type") => void, + onSelectDay: (day: string, shiftKey: boolean) => void, +) { + if (!daily.length) { + return html` +
+
Daily Usage
+
No data
+
+ `; + } + + const isTokenMode = chartMode === "tokens"; + const values = daily.map((d) => (isTokenMode ? d.totalTokens : d.totalCost)); + const maxValue = Math.max(...values, isTokenMode ? 1 : 0.0001); + + // Calculate bar width based on number of days + const barMaxWidth = daily.length > 30 ? 12 : daily.length > 20 ? 18 : daily.length > 14 ? 24 : 32; + const showTotals = daily.length <= 14; + + return html` +
+
+
+ + +
+
Daily ${isTokenMode ? "Token" : "Cost"} Usage
+
+
+
+ ${daily.map((d, idx) => { + const value = values[idx]; + const heightPct = (value / maxValue) * 100; + const isSelected = selectedDays.includes(d.date); + const label = formatDayLabel(d.date); + // Shorter label for many days (just day number) + const shortLabel = daily.length > 20 ? String(parseInt(d.date.slice(8), 10)) : label; + const labelStyle = daily.length > 20 ? "font-size: 8px" : ""; + const segments = + dailyChartMode === "by-type" + ? isTokenMode + ? [ + { value: d.output, class: "output" }, + { value: d.input, class: "input" }, + { value: d.cacheWrite, class: "cache-write" }, + { value: d.cacheRead, class: "cache-read" }, + ] + : [ + { value: d.outputCost ?? 0, class: "output" }, + { value: d.inputCost ?? 0, class: "input" }, + { value: d.cacheWriteCost ?? 0, class: "cache-write" }, + { value: d.cacheReadCost ?? 0, class: "cache-read" }, + ] + : []; + const breakdownLines = + dailyChartMode === "by-type" + ? isTokenMode + ? [ + `Output ${formatTokens(d.output)}`, + `Input ${formatTokens(d.input)}`, + `Cache write ${formatTokens(d.cacheWrite)}`, + `Cache read ${formatTokens(d.cacheRead)}`, + ] + : [ + `Output ${formatCost(d.outputCost ?? 0)}`, + `Input ${formatCost(d.inputCost ?? 0)}`, + `Cache write ${formatCost(d.cacheWriteCost ?? 0)}`, + `Cache read ${formatCost(d.cacheReadCost ?? 0)}`, + ] + : []; + const totalLabel = isTokenMode ? formatTokens(d.totalTokens) : formatCost(d.totalCost); + return html` +
onSelectDay(d.date, e.shiftKey)} + > + ${ + dailyChartMode === "by-type" + ? html` +
+ ${(() => { + const total = segments.reduce((sum, seg) => sum + seg.value, 0) || 1; + return segments.map( + (seg) => html` +
+ `, + ); + })()} +
+ ` + : html` +
+ ` + } + ${showTotals ? html`
${totalLabel}
` : nothing} +
${shortLabel}
+
+ ${formatFullDate(d.date)}
+ ${formatTokens(d.totalTokens)} tokens
+ ${formatCost(d.totalCost)} + ${ + breakdownLines.length + ? html`${breakdownLines.map((line) => html`
${line}
`)}` + : nothing + } +
+
+ `; + })} +
+
+
+ `; +} + +function renderCostBreakdownCompact(totals: UsageTotals, mode: "tokens" | "cost") { + const breakdown = getCostBreakdown(totals); + const isTokenMode = mode === "tokens"; + const totalTokens = totals.totalTokens || 1; + const tokenPcts = { + output: pct(totals.output, totalTokens), + input: pct(totals.input, totalTokens), + cacheWrite: pct(totals.cacheWrite, totalTokens), + cacheRead: pct(totals.cacheRead, totalTokens), + }; + + return html` +
+
${isTokenMode ? "Tokens" : "Cost"} by Type
+
+
+
+
+
+
+
+ Output ${isTokenMode ? formatTokens(totals.output) : formatCost(breakdown.output.cost)} + Input ${isTokenMode ? formatTokens(totals.input) : formatCost(breakdown.input.cost)} + Cache Write ${isTokenMode ? formatTokens(totals.cacheWrite) : formatCost(breakdown.cacheWrite.cost)} + Cache Read ${isTokenMode ? formatTokens(totals.cacheRead) : formatCost(breakdown.cacheRead.cost)} +
+
+ Total: ${isTokenMode ? formatTokens(totals.totalTokens) : formatCost(totals.totalCost)} +
+
+ `; +} + +function renderInsightList( + title: string, + items: Array<{ label: string; value: string; sub?: string }>, + emptyLabel: string, +) { + return html` +
+
${title}
+ ${ + items.length === 0 + ? html`
${emptyLabel}
` + : html` +
+ ${items.map( + (item) => html` +
+ ${item.label} + + ${item.value} + ${item.sub ? html`${item.sub}` : nothing} + +
+ `, + )} +
+ ` + } +
+ `; +} + +function renderPeakErrorList( + title: string, + items: Array<{ label: string; value: string; sub?: string }>, + emptyLabel: string, +) { + return html` +
+
${title}
+ ${ + items.length === 0 + ? html`
${emptyLabel}
` + : html` +
+ ${items.map( + (item) => html` +
+
${item.label}
+
${item.value}
+ ${item.sub ? html`
${item.sub}
` : nothing} +
+ `, + )} +
+ ` + } +
+ `; +} + +function renderUsageInsights( + totals: UsageTotals | null, + aggregates: UsageAggregates, + stats: UsageInsightStats, + showCostHint: boolean, + errorHours: Array<{ label: string; value: string; sub?: string }>, + sessionCount: number, + totalSessions: number, +) { + if (!totals) { + return nothing; + } + + const avgTokens = aggregates.messages.total + ? Math.round(totals.totalTokens / aggregates.messages.total) + : 0; + const avgCost = aggregates.messages.total ? totals.totalCost / aggregates.messages.total : 0; + const cacheBase = totals.input + totals.cacheRead; + const cacheHitRate = cacheBase > 0 ? totals.cacheRead / cacheBase : 0; + const cacheHitLabel = cacheBase > 0 ? `${(cacheHitRate * 100).toFixed(1)}%` : "—"; + const errorRatePct = stats.errorRate * 100; + const throughputLabel = + stats.throughputTokensPerMin !== undefined + ? `${formatTokens(Math.round(stats.throughputTokensPerMin))} tok/min` + : "—"; + const throughputCostLabel = + stats.throughputCostPerMin !== undefined + ? `${formatCost(stats.throughputCostPerMin, 4)} / min` + : "—"; + const avgDurationLabel = stats.durationCount > 0 ? formatDurationShort(stats.avgDurationMs) : "—"; + const cacheHint = "Cache hit rate = cache read / (input + cache read). Higher is better."; + const errorHint = "Error rate = errors / total messages. Lower is better."; + const throughputHint = "Throughput shows tokens per minute over active time. Higher is better."; + const tokensHint = "Average tokens per message in this range."; + const costHint = showCostHint + ? "Average cost per message when providers report costs. Cost data is missing for some or all sessions in this range." + : "Average cost per message when providers report costs."; + + const errorDays = aggregates.daily + .filter((day) => day.messages > 0 && day.errors > 0) + .map((day) => { + const rate = day.errors / day.messages; + return { + label: formatDayLabel(day.date), + value: `${(rate * 100).toFixed(2)}%`, + sub: `${day.errors} errors · ${day.messages} msgs · ${formatTokens(day.tokens)}`, + rate, + }; + }) + .toSorted((a, b) => b.rate - a.rate) + .slice(0, 5) + .map(({ rate: _rate, ...rest }) => rest); + + const topModels = aggregates.byModel.slice(0, 5).map((entry) => ({ + label: entry.model ?? "unknown", + value: formatCost(entry.totals.totalCost), + sub: `${formatTokens(entry.totals.totalTokens)} · ${entry.count} msgs`, + })); + const topProviders = aggregates.byProvider.slice(0, 5).map((entry) => ({ + label: entry.provider ?? "unknown", + value: formatCost(entry.totals.totalCost), + sub: `${formatTokens(entry.totals.totalTokens)} · ${entry.count} msgs`, + })); + const topTools = aggregates.tools.tools.slice(0, 6).map((tool) => ({ + label: tool.name, + value: `${tool.count}`, + sub: "calls", + })); + const topAgents = aggregates.byAgent.slice(0, 5).map((entry) => ({ + label: entry.agentId, + value: formatCost(entry.totals.totalCost), + sub: formatTokens(entry.totals.totalTokens), + })); + const topChannels = aggregates.byChannel.slice(0, 5).map((entry) => ({ + label: entry.channel, + value: formatCost(entry.totals.totalCost), + sub: formatTokens(entry.totals.totalTokens), + })); + + return html` +
+
Usage Overview
+
+
+
+ Messages + ? +
+
${aggregates.messages.total}
+
+ ${aggregates.messages.user} user · ${aggregates.messages.assistant} assistant +
+
+
+
+ Tool Calls + ? +
+
${aggregates.tools.totalCalls}
+
${aggregates.tools.uniqueTools} tools used
+
+
+
+ Errors + ? +
+
${aggregates.messages.errors}
+
${aggregates.messages.toolResults} tool results
+
+
+
+ Avg Tokens / Msg + ? +
+
${formatTokens(avgTokens)}
+
Across ${aggregates.messages.total || 0} messages
+
+
+
+ Avg Cost / Msg + ? +
+
${formatCost(avgCost, 4)}
+
${formatCost(totals.totalCost)} total
+
+
+
+ Sessions + ? +
+
${sessionCount}
+
of ${totalSessions} in range
+
+
+
+ Throughput + ? +
+
${throughputLabel}
+
${throughputCostLabel}
+
+
+
+ Error Rate + ? +
+
1 ? "warn" : "good"}">${errorRatePct.toFixed(2)}%
+
+ ${aggregates.messages.errors} errors · ${avgDurationLabel} avg session +
+
+
+
+ Cache Hit Rate + ? +
+
0.3 ? "warn" : "bad"}">${cacheHitLabel}
+
+ ${formatTokens(totals.cacheRead)} cached · ${formatTokens(cacheBase)} prompt +
+
+
+
+ ${renderInsightList("Top Models", topModels, "No model data")} + ${renderInsightList("Top Providers", topProviders, "No provider data")} + ${renderInsightList("Top Tools", topTools, "No tool calls")} + ${renderInsightList("Top Agents", topAgents, "No agent data")} + ${renderInsightList("Top Channels", topChannels, "No channel data")} + ${renderPeakErrorList("Peak Error Days", errorDays, "No error data")} + ${renderPeakErrorList("Peak Error Hours", errorHours, "No error data")} +
+
+ `; +} + +function renderSessionsCard( + sessions: UsageSessionEntry[], + selectedSessions: string[], + selectedDays: string[], + isTokenMode: boolean, + sessionSort: "tokens" | "cost" | "recent" | "messages" | "errors", + sessionSortDir: "asc" | "desc", + recentSessions: string[], + sessionsTab: "all" | "recent", + onSelectSession: (key: string, shiftKey: boolean) => void, + onSessionSortChange: (sort: "tokens" | "cost" | "recent" | "messages" | "errors") => void, + onSessionSortDirChange: (dir: "asc" | "desc") => void, + onSessionsTabChange: (tab: "all" | "recent") => void, + visibleColumns: UsageColumnId[], + totalSessions: number, + onClearSessions: () => void, +) { + const showColumn = (id: UsageColumnId) => visibleColumns.includes(id); + const formatSessionListLabel = (s: UsageSessionEntry): string => { + const raw = s.label || s.key; + // Agent session keys often include a token query param; remove it for readability. + if (raw.startsWith("agent:") && raw.includes("?token=")) { + return raw.slice(0, raw.indexOf("?token=")); + } + return raw; + }; + const copySessionName = async (s: UsageSessionEntry) => { + const text = formatSessionListLabel(s); + try { + await navigator.clipboard.writeText(text); + } catch { + // Best effort; clipboard can fail on insecure contexts or denied permission. + } + }; + + const buildSessionMeta = (s: UsageSessionEntry): string[] => { + const parts: string[] = []; + if (showColumn("channel") && s.channel) { + parts.push(`channel:${s.channel}`); + } + if (showColumn("agent") && s.agentId) { + parts.push(`agent:${s.agentId}`); + } + if (showColumn("provider") && (s.modelProvider || s.providerOverride)) { + parts.push(`provider:${s.modelProvider ?? s.providerOverride}`); + } + if (showColumn("model") && s.model) { + parts.push(`model:${s.model}`); + } + if (showColumn("messages") && s.usage?.messageCounts) { + parts.push(`msgs:${s.usage.messageCounts.total}`); + } + if (showColumn("tools") && s.usage?.toolUsage) { + parts.push(`tools:${s.usage.toolUsage.totalCalls}`); + } + if (showColumn("errors") && s.usage?.messageCounts) { + parts.push(`errors:${s.usage.messageCounts.errors}`); + } + if (showColumn("duration") && s.usage?.durationMs) { + parts.push(`dur:${formatDurationMs(s.usage.durationMs)}`); + } + return parts; + }; + + // Helper to get session value (filtered by days if selected) + const getSessionValue = (s: UsageSessionEntry): number => { + const usage = s.usage; + if (!usage) { + return 0; + } + + // If days are selected and session has daily breakdown, compute filtered total + if (selectedDays.length > 0 && usage.dailyBreakdown && usage.dailyBreakdown.length > 0) { + const filteredDays = usage.dailyBreakdown.filter((d) => selectedDays.includes(d.date)); + return isTokenMode + ? filteredDays.reduce((sum, d) => sum + d.tokens, 0) + : filteredDays.reduce((sum, d) => sum + d.cost, 0); + } + + // Otherwise use total + return isTokenMode ? (usage.totalTokens ?? 0) : (usage.totalCost ?? 0); + }; + + const sortedSessions = [...sessions].toSorted((a, b) => { + switch (sessionSort) { + case "recent": + return (b.updatedAt ?? 0) - (a.updatedAt ?? 0); + case "messages": + return (b.usage?.messageCounts?.total ?? 0) - (a.usage?.messageCounts?.total ?? 0); + case "errors": + return (b.usage?.messageCounts?.errors ?? 0) - (a.usage?.messageCounts?.errors ?? 0); + case "cost": + return getSessionValue(b) - getSessionValue(a); + case "tokens": + default: + return getSessionValue(b) - getSessionValue(a); + } + }); + const sortedWithDir = sessionSortDir === "asc" ? sortedSessions.toReversed() : sortedSessions; + + const totalValue = sortedWithDir.reduce((sum, session) => sum + getSessionValue(session), 0); + const avgValue = sortedWithDir.length ? totalValue / sortedWithDir.length : 0; + const totalErrors = sortedWithDir.reduce( + (sum, session) => sum + (session.usage?.messageCounts?.errors ?? 0), + 0, + ); + + const selectedSet = new Set(selectedSessions); + const selectedEntries = sortedWithDir.filter((s) => selectedSet.has(s.key)); + const selectedCount = selectedEntries.length; + const sessionMap = new Map(sortedWithDir.map((s) => [s.key, s])); + const recentEntries = recentSessions + .map((key) => sessionMap.get(key)) + .filter((entry): entry is UsageSessionEntry => Boolean(entry)); + + return html` +
+
+
Sessions
+
+ ${sessions.length} shown${totalSessions !== sessions.length ? ` · ${totalSessions} total` : ""} +
+
+
+
+ ${isTokenMode ? formatTokens(avgValue) : formatCost(avgValue)} avg + ${totalErrors} errors +
+
+ + +
+ + + ${ + selectedCount > 0 + ? html` + + ` + : nothing + } +
+ ${ + sessionsTab === "recent" + ? recentEntries.length === 0 + ? html` +
No recent sessions
+ ` + : html` +
+ ${recentEntries.map((s) => { + const value = getSessionValue(s); + const isSelected = selectedSet.has(s.key); + const displayLabel = formatSessionListLabel(s); + const meta = buildSessionMeta(s); + return html` +
onSelectSession(s.key, e.shiftKey)} + title="${s.key}" + > +
+
${displayLabel}
+ ${meta.length > 0 ? html`
${meta.join(" · ")}
` : nothing} +
+ +
+ +
${isTokenMode ? formatTokens(value) : formatCost(value)}
+
+
+ `; + })} +
+ ` + : sessions.length === 0 + ? html` +
No sessions in range
+ ` + : html` +
+ ${sortedWithDir.slice(0, 50).map((s) => { + const value = getSessionValue(s); + const isSelected = selectedSessions.includes(s.key); + const displayLabel = formatSessionListLabel(s); + const meta = buildSessionMeta(s); + + return html` +
onSelectSession(s.key, e.shiftKey)} + title="${s.key}" + > +
+
${displayLabel}
+ ${meta.length > 0 ? html`
${meta.join(" · ")}
` : nothing} +
+ +
+ +
${isTokenMode ? formatTokens(value) : formatCost(value)}
+
+
+ `; + })} + ${sessions.length > 50 ? html`
+${sessions.length - 50} more
` : nothing} +
+ ` + } + ${ + selectedCount > 1 + ? html` +
+
Selected (${selectedCount})
+
+ ${selectedEntries.map((s) => { + const value = getSessionValue(s); + const displayLabel = formatSessionListLabel(s); + const meta = buildSessionMeta(s); + return html` +
onSelectSession(s.key, e.shiftKey)} + title="${s.key}" + > +
+
${displayLabel}
+ ${meta.length > 0 ? html`
${meta.join(" · ")}
` : nothing} +
+ +
+ +
${isTokenMode ? formatTokens(value) : formatCost(value)}
+
+
+ `; + })} +
+
+ ` + : nothing + } +
+ `; +} + +function renderEmptyDetailState() { + return nothing; +} + +function renderSessionSummary(session: UsageSessionEntry) { + const usage = session.usage; + if (!usage) { + return html` +
No usage data for this session.
+ `; + } + + const formatTs = (ts?: number): string => (ts ? new Date(ts).toLocaleString() : "—"); + + const badges: string[] = []; + if (session.channel) { + badges.push(`channel:${session.channel}`); + } + if (session.agentId) { + badges.push(`agent:${session.agentId}`); + } + if (session.modelProvider || session.providerOverride) { + badges.push(`provider:${session.modelProvider ?? session.providerOverride}`); + } + if (session.model) { + badges.push(`model:${session.model}`); + } + + const toolItems = + usage.toolUsage?.tools.slice(0, 6).map((tool) => ({ + label: tool.name, + value: `${tool.count}`, + sub: "calls", + })) ?? []; + const modelItems = + usage.modelUsage?.slice(0, 6).map((entry) => ({ + label: entry.model ?? "unknown", + value: formatCost(entry.totals.totalCost), + sub: formatTokens(entry.totals.totalTokens), + })) ?? []; + + return html` + ${badges.length > 0 ? html`
${badges.map((b) => html`${b}`)}
` : nothing} +
+
+
Messages
+
${usage.messageCounts?.total ?? 0}
+
${usage.messageCounts?.user ?? 0} user · ${usage.messageCounts?.assistant ?? 0} assistant
+
+
+
Tool Calls
+
${usage.toolUsage?.totalCalls ?? 0}
+
${usage.toolUsage?.uniqueTools ?? 0} tools
+
+
+
Errors
+
${usage.messageCounts?.errors ?? 0}
+
${usage.messageCounts?.toolResults ?? 0} tool results
+
+
+
Duration
+
${formatDurationMs(usage.durationMs)}
+
${formatTs(usage.firstActivity)} → ${formatTs(usage.lastActivity)}
+
+
+
+ ${renderInsightList("Top Tools", toolItems, "No tool calls")} + ${renderInsightList("Model Mix", modelItems, "No model data")} +
+ `; +} + +function renderSessionDetailPanel( + session: UsageSessionEntry, + timeSeries: { points: TimeSeriesPoint[] } | null, + timeSeriesLoading: boolean, + timeSeriesMode: "cumulative" | "per-turn", + onTimeSeriesModeChange: (mode: "cumulative" | "per-turn") => void, + timeSeriesBreakdownMode: "total" | "by-type", + onTimeSeriesBreakdownChange: (mode: "total" | "by-type") => void, + startDate: string, + endDate: string, + selectedDays: string[], + sessionLogs: SessionLogEntry[] | null, + sessionLogsLoading: boolean, + sessionLogsExpanded: boolean, + onToggleSessionLogsExpanded: () => void, + logFilters: { + roles: SessionLogRole[]; + tools: string[]; + hasTools: boolean; + query: string; + }, + onLogFilterRolesChange: (next: SessionLogRole[]) => void, + onLogFilterToolsChange: (next: string[]) => void, + onLogFilterHasToolsChange: (next: boolean) => void, + onLogFilterQueryChange: (next: string) => void, + onLogFilterClear: () => void, + contextExpanded: boolean, + onToggleContextExpanded: () => void, + onClose: () => void, +) { + const label = session.label || session.key; + const displayLabel = label.length > 50 ? label.slice(0, 50) + "…" : label; + const usage = session.usage; + + return html` +
+
+
+
${displayLabel}
+
+
+ ${ + usage + ? html` + ${formatTokens(usage.totalTokens)} tokens + ${formatCost(usage.totalCost)} + ` + : nothing + } +
+ +
+
+ ${renderSessionSummary(session)} +
+ ${renderTimeSeriesCompact( + timeSeries, + timeSeriesLoading, + timeSeriesMode, + onTimeSeriesModeChange, + timeSeriesBreakdownMode, + onTimeSeriesBreakdownChange, + startDate, + endDate, + selectedDays, + )} +
+
+ ${renderSessionLogsCompact( + sessionLogs, + sessionLogsLoading, + sessionLogsExpanded, + onToggleSessionLogsExpanded, + logFilters, + onLogFilterRolesChange, + onLogFilterToolsChange, + onLogFilterHasToolsChange, + onLogFilterQueryChange, + onLogFilterClear, + )} + ${renderContextPanel(session.contextWeight, usage, contextExpanded, onToggleContextExpanded)} +
+
+
+ `; +} + +function renderTimeSeriesCompact( + timeSeries: { points: TimeSeriesPoint[] } | null, + loading: boolean, + mode: "cumulative" | "per-turn", + onModeChange: (mode: "cumulative" | "per-turn") => void, + breakdownMode: "total" | "by-type", + onBreakdownChange: (mode: "total" | "by-type") => void, + startDate?: string, + endDate?: string, + selectedDays?: string[], +) { + if (loading) { + return html` +
+
Loading...
+
+ `; + } + if (!timeSeries || timeSeries.points.length < 2) { + return html` +
+
No timeline data
+
+ `; + } + + // Filter and recalculate (same logic as main function) + let points = timeSeries.points; + if (startDate || endDate || (selectedDays && selectedDays.length > 0)) { + const startTs = startDate ? new Date(startDate + "T00:00:00").getTime() : 0; + const endTs = endDate ? new Date(endDate + "T23:59:59").getTime() : Infinity; + points = timeSeries.points.filter((p) => { + if (p.timestamp < startTs || p.timestamp > endTs) { + return false; + } + if (selectedDays && selectedDays.length > 0) { + const d = new Date(p.timestamp); + const dateStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + return selectedDays.includes(dateStr); + } + return true; + }); + } + if (points.length < 2) { + return html` +
+
No data in range
+
+ `; + } + let cumTokens = 0, + cumCost = 0; + let sumOutput = 0; + let sumInput = 0; + let sumCacheRead = 0; + let sumCacheWrite = 0; + points = points.map((p) => { + cumTokens += p.totalTokens; + cumCost += p.cost; + sumOutput += p.output; + sumInput += p.input; + sumCacheRead += p.cacheRead; + sumCacheWrite += p.cacheWrite; + return { ...p, cumulativeTokens: cumTokens, cumulativeCost: cumCost }; + }); + + const width = 400, + height = 80; + const padding = { top: 16, right: 10, bottom: 20, left: 40 }; + const chartWidth = width - padding.left - padding.right; + const chartHeight = height - padding.top - padding.bottom; + const isCumulative = mode === "cumulative"; + const breakdownByType = mode === "per-turn" && breakdownMode === "by-type"; + const totalTypeTokens = sumOutput + sumInput + sumCacheRead + sumCacheWrite; + const barTotals = points.map((p) => + isCumulative + ? p.cumulativeTokens + : breakdownByType + ? p.input + p.output + p.cacheRead + p.cacheWrite + : p.totalTokens, + ); + const maxValue = Math.max(...barTotals, 1); + const barWidth = Math.max(2, Math.min(8, (chartWidth / points.length) * 0.7)); + const barGap = Math.max(1, (chartWidth - barWidth * points.length) / (points.length - 1 || 1)); + + return html` +
+
+
Usage Over Time
+
+
+ + +
+ ${ + !isCumulative + ? html` +
+ + +
+ ` + : nothing + } +
+
+ + + + + + + ${formatTokens(maxValue)} + 0 + + ${ + points.length > 0 + ? svg` + ${new Date(points[0].timestamp).toLocaleDateString(undefined, { month: "short", day: "numeric" })} + ${new Date(points[points.length - 1].timestamp).toLocaleDateString(undefined, { month: "short", day: "numeric" })} + ` + : nothing + } + + ${points.map((p, i) => { + const val = barTotals[i]; + const x = padding.left + i * (barWidth + barGap); + const barHeight = (val / maxValue) * chartHeight; + const y = padding.top + chartHeight - barHeight; + const date = new Date(p.timestamp); + const tooltipLines = [ + date.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }), + `${formatTokens(val)} tokens`, + ]; + if (breakdownByType) { + tooltipLines.push(`Output ${formatTokens(p.output)}`); + tooltipLines.push(`Input ${formatTokens(p.input)}`); + tooltipLines.push(`Cache write ${formatTokens(p.cacheWrite)}`); + tooltipLines.push(`Cache read ${formatTokens(p.cacheRead)}`); + } + const tooltip = tooltipLines.join(" · "); + if (!breakdownByType) { + return svg`${tooltip}`; + } + const segments = [ + { value: p.output, class: "output" }, + { value: p.input, class: "input" }, + { value: p.cacheWrite, class: "cache-write" }, + { value: p.cacheRead, class: "cache-read" }, + ]; + let yCursor = padding.top + chartHeight; + return svg` + ${segments.map((seg) => { + if (seg.value <= 0 || val <= 0) { + return nothing; + } + const segHeight = barHeight * (seg.value / val); + yCursor -= segHeight; + return svg`${tooltip}`; + })} + `; + })} + +
${points.length} msgs · ${formatTokens(cumTokens)} · ${formatCost(cumCost)}
+ ${ + breakdownByType + ? html` +
+
Tokens by Type
+
+
+
+
+
+
+
+
+ Output ${formatTokens(sumOutput)} +
+
+ Input ${formatTokens(sumInput)} +
+
+ Cache Write ${formatTokens(sumCacheWrite)} +
+
+ Cache Read ${formatTokens(sumCacheRead)} +
+
+
Total: ${formatTokens(totalTypeTokens)}
+
+ ` + : nothing + } +
+ `; +} + +function renderContextPanel( + contextWeight: UsageSessionEntry["contextWeight"], + usage: UsageSessionEntry["usage"], + expanded: boolean, + onToggleExpanded: () => void, +) { + if (!contextWeight) { + return html` +
+
No context data
+
+ `; + } + const systemTokens = charsToTokens(contextWeight.systemPrompt.chars); + const skillsTokens = charsToTokens(contextWeight.skills.promptChars); + const toolsTokens = charsToTokens( + contextWeight.tools.listChars + contextWeight.tools.schemaChars, + ); + const filesTokens = charsToTokens( + contextWeight.injectedWorkspaceFiles.reduce((sum, f) => sum + f.injectedChars, 0), + ); + const totalContextTokens = systemTokens + skillsTokens + toolsTokens + filesTokens; + + let contextPct = ""; + if (usage && usage.totalTokens > 0) { + const inputTokens = usage.input + usage.cacheRead; + if (inputTokens > 0) { + contextPct = `~${Math.min((totalContextTokens / inputTokens) * 100, 100).toFixed(0)}% of input`; + } + } + + const skillsList = contextWeight.skills.entries.toSorted((a, b) => b.blockChars - a.blockChars); + const toolsList = contextWeight.tools.entries.toSorted( + (a, b) => b.summaryChars + b.schemaChars - (a.summaryChars + a.schemaChars), + ); + const filesList = contextWeight.injectedWorkspaceFiles.toSorted( + (a, b) => b.injectedChars - a.injectedChars, + ); + const defaultLimit = 4; + const showAll = expanded; + const skillsTop = showAll ? skillsList : skillsList.slice(0, defaultLimit); + const toolsTop = showAll ? toolsList : toolsList.slice(0, defaultLimit); + const filesTop = showAll ? filesList : filesList.slice(0, defaultLimit); + const hasMore = + skillsList.length > defaultLimit || + toolsList.length > defaultLimit || + filesList.length > defaultLimit; + + return html` +
+
+
System Prompt Breakdown
+ ${ + hasMore + ? html`` + : nothing + } +
+

${contextPct || "Base context per message"}

+
+
+
+
+
+
+
+ Sys ~${formatTokens(systemTokens)} + Skills ~${formatTokens(skillsTokens)} + Tools ~${formatTokens(toolsTokens)} + Files ~${formatTokens(filesTokens)} +
+
Total: ~${formatTokens(totalContextTokens)}
+
+ ${ + skillsList.length > 0 + ? (() => { + const more = skillsList.length - skillsTop.length; + return html` +
+
Skills (${skillsList.length})
+
+ ${skillsTop.map( + (s) => html` +
+ ${s.name} + ~${formatTokens(charsToTokens(s.blockChars))} +
+ `, + )} +
+ ${ + more > 0 + ? html`
+${more} more
` + : nothing + } +
+ `; + })() + : nothing + } + ${ + toolsList.length > 0 + ? (() => { + const more = toolsList.length - toolsTop.length; + return html` +
+
Tools (${toolsList.length})
+
+ ${toolsTop.map( + (t) => html` +
+ ${t.name} + ~${formatTokens(charsToTokens(t.summaryChars + t.schemaChars))} +
+ `, + )} +
+ ${ + more > 0 + ? html`
+${more} more
` + : nothing + } +
+ `; + })() + : nothing + } + ${ + filesList.length > 0 + ? (() => { + const more = filesList.length - filesTop.length; + return html` +
+
Files (${filesList.length})
+
+ ${filesTop.map( + (f) => html` +
+ ${f.name} + ~${formatTokens(charsToTokens(f.injectedChars))} +
+ `, + )} +
+ ${ + more > 0 + ? html`
+${more} more
` + : nothing + } +
+ `; + })() + : nothing + } +
+
+ `; +} + +function renderSessionLogsCompact( + logs: SessionLogEntry[] | null, + loading: boolean, + expandedAll: boolean, + onToggleExpandedAll: () => void, + filters: { + roles: SessionLogRole[]; + tools: string[]; + hasTools: boolean; + query: string; + }, + onFilterRolesChange: (next: SessionLogRole[]) => void, + onFilterToolsChange: (next: string[]) => void, + onFilterHasToolsChange: (next: boolean) => void, + onFilterQueryChange: (next: string) => void, + onFilterClear: () => void, +) { + if (loading) { + return html` +
+
Conversation
+
Loading...
+
+ `; + } + if (!logs || logs.length === 0) { + return html` +
+
Conversation
+
No messages
+
+ `; + } + + const normalizedQuery = filters.query.trim().toLowerCase(); + const entries = logs.map((log) => { + const toolInfo = parseToolSummary(log.content); + const cleanContent = toolInfo.cleanContent || log.content; + return { log, toolInfo, cleanContent }; + }); + const toolOptions = Array.from( + new Set(entries.flatMap((entry) => entry.toolInfo.tools.map(([name]) => name))), + ).toSorted((a, b) => a.localeCompare(b)); + const filteredEntries = entries.filter((entry) => { + if (filters.roles.length > 0 && !filters.roles.includes(entry.log.role)) { + return false; + } + if (filters.hasTools && entry.toolInfo.tools.length === 0) { + return false; + } + if (filters.tools.length > 0) { + const matchesTool = entry.toolInfo.tools.some(([name]) => filters.tools.includes(name)); + if (!matchesTool) { + return false; + } + } + if (normalizedQuery) { + const haystack = entry.cleanContent.toLowerCase(); + if (!haystack.includes(normalizedQuery)) { + return false; + } + } + return true; + }); + const displayedCount = + filters.roles.length > 0 || filters.tools.length > 0 || filters.hasTools || normalizedQuery + ? `${filteredEntries.length} of ${logs.length}` + : `${logs.length}`; + + const roleSelected = new Set(filters.roles); + const toolSelected = new Set(filters.tools); + + return html` +
+
+ Conversation (${displayedCount} messages) + +
+
+ + + + onFilterQueryChange((event.target as HTMLInputElement).value)} + /> + +
+
+ ${filteredEntries.map((entry) => { + const { log, toolInfo, cleanContent } = entry; + const roleClass = log.role === "user" ? "user" : "assistant"; + const roleLabel = + log.role === "user" ? "You" : log.role === "assistant" ? "Assistant" : "Tool"; + return html` +
+
+ ${roleLabel} + ${new Date(log.timestamp).toLocaleString()} + ${log.tokens ? html`${formatTokens(log.tokens)}` : nothing} +
+
${cleanContent}
+ ${ + toolInfo.tools.length > 0 + ? html` +
+ ${toolInfo.summary} +
+ ${toolInfo.tools.map( + ([name, count]) => html` + ${name} × ${count} + `, + )} +
+
+ ` + : nothing + } +
+ `; + })} + ${ + filteredEntries.length === 0 + ? html` +
No messages match the filters.
+ ` + : nothing + } +
+
+ `; +} + +export function renderUsage(props: UsageProps) { + // Show loading skeleton if loading and no data yet + if (props.loading && !props.totals) { + // Use inline styles since main stylesheet hasn't loaded yet on initial render + return html` + +
+
+
+
+
Token Usage
+ + + Loading + +
+
+
+
+ + to + +
+
+
+
+ `; + } + + const isTokenMode = props.chartMode === "tokens"; + const hasQuery = props.query.trim().length > 0; + const hasDraftQuery = props.queryDraft.trim().length > 0; + // (intentionally no global Clear button in the header; chips + query clear handle this) + + // Sort sessions by tokens or cost depending on mode + const sortedSessions = [...props.sessions].toSorted((a, b) => { + const valA = isTokenMode ? (a.usage?.totalTokens ?? 0) : (a.usage?.totalCost ?? 0); + const valB = isTokenMode ? (b.usage?.totalTokens ?? 0) : (b.usage?.totalCost ?? 0); + return valB - valA; + }); + + // Filter sessions by selected days + const dayFilteredSessions = + props.selectedDays.length > 0 + ? sortedSessions.filter((s) => { + if (s.usage?.activityDates?.length) { + return s.usage.activityDates.some((d) => props.selectedDays.includes(d)); + } + if (!s.updatedAt) { + return false; + } + const d = new Date(s.updatedAt); + const sessionDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + return props.selectedDays.includes(sessionDate); + }) + : sortedSessions; + + const sessionTouchesHours = (session: UsageSessionEntry, hours: number[]): boolean => { + if (hours.length === 0) { + return true; + } + const usage = session.usage; + const start = usage?.firstActivity ?? session.updatedAt; + const end = usage?.lastActivity ?? session.updatedAt; + if (!start || !end) { + return false; + } + const startMs = Math.min(start, end); + const endMs = Math.max(start, end); + let cursor = startMs; + while (cursor <= endMs) { + const date = new Date(cursor); + const hour = getZonedHour(date, props.timeZone); + if (hours.includes(hour)) { + return true; + } + const nextHour = setToHourEnd(date, props.timeZone); + const nextMs = Math.min(nextHour.getTime(), endMs); + cursor = nextMs + 1; + } + return false; + }; + + const hourFilteredSessions = + props.selectedHours.length > 0 + ? dayFilteredSessions.filter((s) => sessionTouchesHours(s, props.selectedHours)) + : dayFilteredSessions; + + // Filter sessions by query (client-side) + const queryResult = filterSessionsByQuery(hourFilteredSessions, props.query); + const filteredSessions = queryResult.sessions; + const queryWarnings = queryResult.warnings; + const querySuggestions = buildQuerySuggestions( + props.queryDraft, + sortedSessions, + props.aggregates, + ); + const queryTerms = extractQueryTerms(props.query); + const selectedValuesFor = (key: string): string[] => { + const normalized = normalizeQueryText(key); + return queryTerms + .filter((term) => normalizeQueryText(term.key ?? "") === normalized) + .map((term) => term.value) + .filter(Boolean); + }; + const unique = (items: Array) => { + const set = new Set(); + for (const item of items) { + if (item) { + set.add(item); + } + } + return Array.from(set); + }; + const agentOptions = unique(sortedSessions.map((s) => s.agentId)).slice(0, 12); + const channelOptions = unique(sortedSessions.map((s) => s.channel)).slice(0, 12); + const providerOptions = unique([ + ...sortedSessions.map((s) => s.modelProvider), + ...sortedSessions.map((s) => s.providerOverride), + ...(props.aggregates?.byProvider.map((entry) => entry.provider) ?? []), + ]).slice(0, 12); + const modelOptions = unique([ + ...sortedSessions.map((s) => s.model), + ...(props.aggregates?.byModel.map((entry) => entry.model) ?? []), + ]).slice(0, 12); + const toolOptions = unique(props.aggregates?.tools.tools.map((tool) => tool.name) ?? []).slice( + 0, + 12, + ); + + // Get first selected session for detail view (timeseries, logs) + const primarySelectedEntry = + props.selectedSessions.length === 1 + ? (props.sessions.find((s) => s.key === props.selectedSessions[0]) ?? + filteredSessions.find((s) => s.key === props.selectedSessions[0])) + : null; + + // Compute totals from sessions + const computeSessionTotals = (sessions: UsageSessionEntry[]): UsageTotals => { + return sessions.reduce( + (acc, s) => { + if (s.usage) { + acc.input += s.usage.input; + acc.output += s.usage.output; + acc.cacheRead += s.usage.cacheRead; + acc.cacheWrite += s.usage.cacheWrite; + acc.totalTokens += s.usage.totalTokens; + acc.totalCost += s.usage.totalCost; + acc.inputCost += s.usage.inputCost ?? 0; + acc.outputCost += s.usage.outputCost ?? 0; + acc.cacheReadCost += s.usage.cacheReadCost ?? 0; + acc.cacheWriteCost += s.usage.cacheWriteCost ?? 0; + acc.missingCostEntries += s.usage.missingCostEntries ?? 0; + } + return acc; + }, + { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + totalCost: 0, + inputCost: 0, + outputCost: 0, + cacheReadCost: 0, + cacheWriteCost: 0, + missingCostEntries: 0, + }, + ); + }; + + // Compute totals from daily data for selected days (more accurate than session totals) + const computeDailyTotals = (days: string[]): UsageTotals => { + const matchingDays = props.costDaily.filter((d) => days.includes(d.date)); + return matchingDays.reduce( + (acc, d) => { + acc.input += d.input; + acc.output += d.output; + acc.cacheRead += d.cacheRead; + acc.cacheWrite += d.cacheWrite; + acc.totalTokens += d.totalTokens; + acc.totalCost += d.totalCost; + acc.inputCost += d.inputCost ?? 0; + acc.outputCost += d.outputCost ?? 0; + acc.cacheReadCost += d.cacheReadCost ?? 0; + acc.cacheWriteCost += d.cacheWriteCost ?? 0; + return acc; + }, + { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + totalCost: 0, + inputCost: 0, + outputCost: 0, + cacheReadCost: 0, + cacheWriteCost: 0, + missingCostEntries: 0, + }, + ); + }; + + // Compute display totals and count based on filters + let displayTotals: UsageTotals | null; + let displaySessionCount: number; + const totalSessions = sortedSessions.length; + + if (props.selectedSessions.length > 0) { + // Sessions selected - compute totals from selected sessions + const selectedSessionEntries = filteredSessions.filter((s) => + props.selectedSessions.includes(s.key), + ); + displayTotals = computeSessionTotals(selectedSessionEntries); + displaySessionCount = selectedSessionEntries.length; + } else if (props.selectedDays.length > 0 && props.selectedHours.length === 0) { + // Days selected - use daily aggregates for accurate per-day totals + displayTotals = computeDailyTotals(props.selectedDays); + displaySessionCount = filteredSessions.length; + } else if (props.selectedHours.length > 0) { + displayTotals = computeSessionTotals(filteredSessions); + displaySessionCount = filteredSessions.length; + } else if (hasQuery) { + displayTotals = computeSessionTotals(filteredSessions); + displaySessionCount = filteredSessions.length; + } else { + // No filters - show all + displayTotals = props.totals; + displaySessionCount = totalSessions; + } + + const aggregateSessions = + props.selectedSessions.length > 0 + ? filteredSessions.filter((s) => props.selectedSessions.includes(s.key)) + : hasQuery || props.selectedHours.length > 0 + ? filteredSessions + : props.selectedDays.length > 0 + ? dayFilteredSessions + : sortedSessions; + const activeAggregates = buildAggregatesFromSessions(aggregateSessions, props.aggregates); + + // Filter daily chart data if sessions are selected + const filteredDaily = + props.selectedSessions.length > 0 + ? (() => { + const selectedEntries = filteredSessions.filter((s) => + props.selectedSessions.includes(s.key), + ); + const allActivityDates = new Set(); + for (const entry of selectedEntries) { + for (const date of entry.usage?.activityDates ?? []) { + allActivityDates.add(date); + } + } + return allActivityDates.size > 0 + ? props.costDaily.filter((d) => allActivityDates.has(d.date)) + : props.costDaily; + })() + : props.costDaily; + + const insightStats = buildUsageInsightStats(aggregateSessions, displayTotals, activeAggregates); + const isEmpty = !props.loading && !props.totals && props.sessions.length === 0; + const hasMissingCost = + (displayTotals?.missingCostEntries ?? 0) > 0 || + (displayTotals + ? displayTotals.totalTokens > 0 && + displayTotals.totalCost === 0 && + displayTotals.input + + displayTotals.output + + displayTotals.cacheRead + + displayTotals.cacheWrite > + 0 + : false); + const datePresets = [ + { label: "Today", days: 1 }, + { label: "7d", days: 7 }, + { label: "30d", days: 30 }, + ]; + const applyPreset = (days: number) => { + const end = new Date(); + const start = new Date(); + start.setDate(start.getDate() - (days - 1)); + props.onStartDateChange(formatIsoDate(start)); + props.onEndDateChange(formatIsoDate(end)); + }; + const renderFilterSelect = (key: string, label: string, options: string[]) => { + if (options.length === 0) { + return nothing; + } + const selected = selectedValuesFor(key); + const selectedSet = new Set(selected.map((value) => normalizeQueryText(value))); + const allSelected = + options.length > 0 && options.every((value) => selectedSet.has(normalizeQueryText(value))); + const selectedCount = selected.length; + return html` +
{ + const el = e.currentTarget as HTMLDetailsElement; + if (!el.open) { + return; + } + const onClick = (ev: MouseEvent) => { + const path = ev.composedPath(); + if (!path.includes(el)) { + el.open = false; + window.removeEventListener("click", onClick, true); + } + }; + window.addEventListener("click", onClick, true); + }} + > + + ${label} + ${ + selectedCount > 0 + ? html`${selectedCount}` + : html` + All + ` + } + +
+
+ + +
+
+ ${options.map((value) => { + const checked = selectedSet.has(normalizeQueryText(value)); + return html` + + `; + })} +
+
+
+ `; + }; + const exportStamp = formatIsoDate(new Date()); + + return html` + + +
+
Usage
+
See where tokens go, when sessions spike, and what drives cost.
+
+ +
+
+
+
Filters
+ ${ + props.loading + ? html` + Loading + ` + : nothing + } + ${ + isEmpty + ? html` + Select a date range and click Refresh to load usage. + ` + : nothing + } +
+
+ ${ + displayTotals + ? html` + + ${formatTokens(displayTotals.totalTokens)} tokens + + + ${formatCost(displayTotals.totalCost)} cost + + + ${displaySessionCount} + session${displaySessionCount !== 1 ? "s" : ""} + + ` + : nothing + } + +
{ + const el = e.currentTarget as HTMLDetailsElement; + if (!el.open) { + return; + } + const onClick = (ev: MouseEvent) => { + const path = ev.composedPath(); + if (!path.includes(el)) { + el.open = false; + window.removeEventListener("click", onClick, true); + } + }; + window.addEventListener("click", onClick, true); + }} + > + Export ▾ +
+
+ + + +
+
+
+
+
+
+
+ ${renderFilterChips( + props.selectedDays, + props.selectedHours, + props.selectedSessions, + props.sessions, + props.onClearDays, + props.onClearHours, + props.onClearSessions, + props.onClearFilters, + )} +
+ ${datePresets.map( + (preset) => html` + + `, + )} +
+ props.onStartDateChange((e.target as HTMLInputElement).value)} + /> + to + props.onEndDateChange((e.target as HTMLInputElement).value)} + /> + +
+ + +
+ +
+ +
+ +
+
+ props.onQueryDraftChange((e.target as HTMLInputElement).value)} + @keydown=${(e: KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + props.onApplyQuery(); + } + }} + /> +
+ + ${ + hasDraftQuery || hasQuery + ? html`` + : nothing + } + + ${ + hasQuery + ? `${filteredSessions.length} of ${totalSessions} sessions match` + : `${totalSessions} sessions in range` + } + +
+
+
+ ${renderFilterSelect("agent", "Agent", agentOptions)} + ${renderFilterSelect("channel", "Channel", channelOptions)} + ${renderFilterSelect("provider", "Provider", providerOptions)} + ${renderFilterSelect("model", "Model", modelOptions)} + ${renderFilterSelect("tool", "Tool", toolOptions)} + + Tip: use filters or click bars to filter days. + +
+ ${ + queryTerms.length > 0 + ? html` +
+ ${queryTerms.map((term) => { + const label = term.raw; + return html` + + ${label} + + + `; + })} +
+ ` + : nothing + } + ${ + querySuggestions.length > 0 + ? html` +
+ ${querySuggestions.map( + (suggestion) => html` + + `, + )} +
+ ` + : nothing + } + ${ + queryWarnings.length > 0 + ? html` +
+ ${queryWarnings.join(" · ")} +
+ ` + : nothing + } +
+ + ${ + props.error + ? html`
${props.error}
` + : nothing + } + + ${ + props.sessionsLimitReached + ? html` +
+ Showing first 1,000 sessions. Narrow date range for complete results. +
+ ` + : nothing + } +
+ + ${renderUsageInsights( + displayTotals, + activeAggregates, + insightStats, + hasMissingCost, + buildPeakErrorHours(aggregateSessions, props.timeZone), + displaySessionCount, + totalSessions, + )} + + ${renderUsageMosaic(aggregateSessions, props.timeZone, props.selectedHours, props.onSelectHour)} + + +
+
+
+ ${renderDailyChartCompact( + filteredDaily, + props.selectedDays, + props.chartMode, + props.dailyChartMode, + props.onDailyChartModeChange, + props.onSelectDay, + )} + ${displayTotals ? renderCostBreakdownCompact(displayTotals, props.chartMode) : nothing} +
+
+
+ ${renderSessionsCard( + filteredSessions, + props.selectedSessions, + props.selectedDays, + isTokenMode, + props.sessionSort, + props.sessionSortDir, + props.recentSessions, + props.sessionsTab, + props.onSelectSession, + props.onSessionSortChange, + props.onSessionSortDirChange, + props.onSessionsTabChange, + props.visibleColumns, + totalSessions, + props.onClearSessions, + )} +
+
+ + + ${ + primarySelectedEntry + ? renderSessionDetailPanel( + primarySelectedEntry, + props.timeSeries, + props.timeSeriesLoading, + props.timeSeriesMode, + props.onTimeSeriesModeChange, + props.timeSeriesBreakdownMode, + props.onTimeSeriesBreakdownChange, + props.startDate, + props.endDate, + props.selectedDays, + props.sessionLogs, + props.sessionLogsLoading, + props.sessionLogsExpanded, + props.onToggleSessionLogsExpanded, + { + roles: props.logFilterRoles, + tools: props.logFilterTools, + hasTools: props.logFilterHasTools, + query: props.logFilterQuery, + }, + props.onLogFilterRolesChange, + props.onLogFilterToolsChange, + props.onLogFilterHasToolsChange, + props.onLogFilterQueryChange, + props.onLogFilterClear, + props.contextExpanded, + props.onToggleContextExpanded, + props.onClearSessions, + ) + : renderEmptyDetailState() + } + `; +} + +// Exposed for Playwright/Vitest browser unit tests. diff --git a/ui/vitest.node.config.ts b/ui/vitest.node.config.ts new file mode 100644 index 0000000000..0522e88e03 --- /dev/null +++ b/ui/vitest.node.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +// Node-only tests for pure logic (no Playwright/browser dependency). +export default defineConfig({ + test: { + include: ["src/**/*.node.test.ts"], + environment: "node", + }, +});