From 844d84a7f53ba985653d56ac33533cf91faeedf6 Mon Sep 17 00:00:00 2001 From: Harold Hunt Date: Fri, 20 Feb 2026 21:09:03 -0500 Subject: [PATCH] Issue 17774 - Usage - Local - Show data from midnight to midnight of selected dates for browser time zone (AI assisted) (openclaw#19357) thanks @huntharo Verified: - pnpm install --frozen-lockfile - pnpm build - pnpm check - pnpm test:macmini (override approved by Tak for this run; local baseline failures outside PR scope) Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- CHANGELOG.md | 1 + .../OpenClawProtocol/GatewayModels.swift | 8 + .../OpenClawProtocol/GatewayModels.swift | 8 + src/gateway/protocol/schema/sessions.ts | 6 + src/gateway/server-methods/usage.test.ts | 88 ++++++- src/gateway/server-methods/usage.ts | 133 ++++++++-- ui/src/ui/app-render-usage-tab.ts | 4 + ui/src/ui/controllers/usage.node.test.ts | 190 ++++++++++++++ ui/src/ui/controllers/usage.ts | 237 ++++++++++++++++-- 9 files changed, 635 insertions(+), 40 deletions(-) create mode 100644 ui/src/ui/controllers/usage.node.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bbe61a3301b..bd6665e7a1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -107,6 +107,7 @@ Docs: https://docs.openclaw.ai - Agents/Streaming: keep assistant partial streaming active during reasoning streams, handle native `thinking_*` stream events consistently, dedupe mixed reasoning-end signals, and clear stale mutating tool errors after same-target retry success. (#20635) Thanks @obviyus. - iOS/Chat: use a dedicated iOS chat session key for ChatSheet routing to avoid cross-client session collisions with main-session traffic. (#21139) thanks @mbelinky. - iOS/Chat: auto-resync chat history after reconnect sequence gaps, clear stale pending runs, and avoid dead-end manual refresh errors after transient disconnects. (#21135) thanks @mbelinky. +- UI/Usage: reload usage data immediately when timezone changes so Local/UTC toggles apply the selected date range without requiring a manual refresh. (#17774) - iOS/Screen: move `WKWebView` lifecycle ownership into `ScreenWebView` coordinator and explicit attach/detach flow to reduce gesture/lifecycle crash risk (`__NSArrayM insertObject:atIndex:` paths) during screen tab updates. (#20366) Thanks @ngutman. - iOS/Onboarding: prevent pairing-status flicker during auto-resume by keeping resumed state transitions stable. (#20310) Thanks @mbelinky. - iOS/Onboarding: stabilize pairing and reconnect behavior by resetting stale pairing request state on manual retry, disconnecting both operator and node gateways on operator failure, and avoiding duplicate pairing loops from operator transport identity attachment. (#20056) Thanks @mbelinky. diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift index 661d5dc11fd..19f3f774fa7 100644 --- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift @@ -1226,6 +1226,8 @@ public struct SessionsUsageParams: Codable, Sendable { public let key: String? public let startdate: String? public let enddate: String? + public let mode: AnyCodable? + public let utcoffset: String? public let limit: Int? public let includecontextweight: Bool? @@ -1233,12 +1235,16 @@ public struct SessionsUsageParams: Codable, Sendable { key: String?, startdate: String?, enddate: String?, + mode: AnyCodable?, + utcoffset: String?, limit: Int?, includecontextweight: Bool? ) { self.key = key self.startdate = startdate self.enddate = enddate + self.mode = mode + self.utcoffset = utcoffset self.limit = limit self.includecontextweight = includecontextweight } @@ -1246,6 +1252,8 @@ public struct SessionsUsageParams: Codable, Sendable { case key case startdate = "startDate" case enddate = "endDate" + case mode + case utcoffset = "utcOffset" case limit case includecontextweight = "includeContextWeight" } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 661d5dc11fd..19f3f774fa7 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -1226,6 +1226,8 @@ public struct SessionsUsageParams: Codable, Sendable { public let key: String? public let startdate: String? public let enddate: String? + public let mode: AnyCodable? + public let utcoffset: String? public let limit: Int? public let includecontextweight: Bool? @@ -1233,12 +1235,16 @@ public struct SessionsUsageParams: Codable, Sendable { key: String?, startdate: String?, enddate: String?, + mode: AnyCodable?, + utcoffset: String?, limit: Int?, includecontextweight: Bool? ) { self.key = key self.startdate = startdate self.enddate = enddate + self.mode = mode + self.utcoffset = utcoffset self.limit = limit self.includecontextweight = includecontextweight } @@ -1246,6 +1252,8 @@ public struct SessionsUsageParams: Codable, Sendable { case key case startdate = "startDate" case enddate = "endDate" + case mode + case utcoffset = "utcOffset" case limit case includecontextweight = "includeContextWeight" } diff --git a/src/gateway/protocol/schema/sessions.ts b/src/gateway/protocol/schema/sessions.ts index 0b32ef86212..663cf9776a0 100644 --- a/src/gateway/protocol/schema/sessions.ts +++ b/src/gateway/protocol/schema/sessions.ts @@ -114,6 +114,12 @@ export const SessionsUsageParamsSchema = Type.Object( 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}$" })), + /** How start/end dates should be interpreted. Defaults to UTC when omitted. */ + mode: Type.Optional( + Type.Union([Type.Literal("utc"), Type.Literal("gateway"), Type.Literal("specific")]), + ), + /** UTC offset to use when mode is `specific` (for example, UTC-4 or UTC+5:30). */ + utcOffset: Type.Optional(Type.String({ pattern: "^UTC[+-]\\d{1,2}(?::[0-5]\\d)?$" })), /** Maximum sessions to return (default 50). */ limit: Type.Optional(Type.Integer({ minimum: 1 })), /** Include context weight breakdown (systemPromptReport). */ diff --git a/src/gateway/server-methods/usage.test.ts b/src/gateway/server-methods/usage.test.ts index 5f9bae6299e..3f2186046d2 100644 --- a/src/gateway/server-methods/usage.test.ts +++ b/src/gateway/server-methods/usage.test.ts @@ -21,6 +21,8 @@ import { loadCostUsageSummary } from "../../infra/session-cost-usage.js"; import { __test } from "./usage.js"; describe("gateway usage helpers", () => { + const dayMs = 24 * 60 * 60 * 1000; + beforeEach(() => { __test.costUsageCache.clear(); vi.useRealTimers(); @@ -35,6 +37,20 @@ describe("gateway usage helpers", () => { expect(__test.parseDateToMs(undefined)).toBeUndefined(); }); + it("parseUtcOffsetToMinutes supports whole-hour and half-hour offsets", () => { + expect(__test.parseUtcOffsetToMinutes("UTC-4")).toBe(-240); + expect(__test.parseUtcOffsetToMinutes("UTC+5:30")).toBe(330); + expect(__test.parseUtcOffsetToMinutes(" UTC+14 ")).toBe(14 * 60); + }); + + it("parseUtcOffsetToMinutes rejects invalid offsets", () => { + expect(__test.parseUtcOffsetToMinutes("UTC+14:30")).toBeUndefined(); + expect(__test.parseUtcOffsetToMinutes("UTC+5:99")).toBeUndefined(); + expect(__test.parseUtcOffsetToMinutes("UTC+25")).toBeUndefined(); + expect(__test.parseUtcOffsetToMinutes("GMT+5")).toBeUndefined(); + expect(__test.parseUtcOffsetToMinutes(undefined)).toBeUndefined(); + }); + it("parseDays coerces strings/numbers to integers", () => { expect(__test.parseDays(7.9)).toBe(7); expect(__test.parseDays("30")).toBe(30); @@ -42,22 +58,84 @@ describe("gateway usage helpers", () => { expect(__test.parseDays("nope")).toBeUndefined(); }); - it("parseDateRange uses explicit start/end (inclusive end of day)", () => { + it("parseDateRange uses explicit start/end as UTC when mode is missing (backward compatible)", () => { 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); + expect(range.endMs).toBe(Date.UTC(2026, 1, 2) + dayMs - 1); + }); + + it("parseDateRange uses explicit UTC mode", () => { + const range = __test.parseDateRange({ + startDate: "2026-02-01", + endDate: "2026-02-02", + mode: "utc", + }); + expect(range.startMs).toBe(Date.UTC(2026, 1, 1)); + expect(range.endMs).toBe(Date.UTC(2026, 1, 2) + dayMs - 1); + }); + + it("parseDateRange uses specific UTC offset for explicit dates", () => { + const range = __test.parseDateRange({ + startDate: "2026-02-01", + endDate: "2026-02-02", + mode: "specific", + utcOffset: "UTC+5:30", + }); + const start = Date.UTC(2026, 1, 1) - 5.5 * 60 * 60 * 1000; + const endStart = Date.UTC(2026, 1, 2) - 5.5 * 60 * 60 * 1000; + expect(range.startMs).toBe(start); + expect(range.endMs).toBe(endStart + dayMs - 1); + }); + + it("parseDateRange falls back to UTC when specific mode offset is missing or invalid", () => { + const missingOffset = __test.parseDateRange({ + startDate: "2026-02-01", + endDate: "2026-02-02", + mode: "specific", + }); + const invalidOffset = __test.parseDateRange({ + startDate: "2026-02-01", + endDate: "2026-02-02", + mode: "specific", + utcOffset: "bad-value", + }); + expect(missingOffset.startMs).toBe(Date.UTC(2026, 1, 1)); + expect(missingOffset.endMs).toBe(Date.UTC(2026, 1, 2) + dayMs - 1); + expect(invalidOffset.startMs).toBe(Date.UTC(2026, 1, 1)); + expect(invalidOffset.endMs).toBe(Date.UTC(2026, 1, 2) + dayMs - 1); + }); + + it("parseDateRange uses specific offset for today/day math after UTC midnight", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-02-17T03:57:00.000Z")); + const range = __test.parseDateRange({ + days: 1, + mode: "specific", + utcOffset: "UTC-5", + }); + expect(range.startMs).toBe(Date.UTC(2026, 1, 16, 5, 0, 0, 0)); + expect(range.endMs).toBe(Date.UTC(2026, 1, 17, 4, 59, 59, 999)); + }); + + it("parseDateRange uses gateway local day boundaries in gateway mode", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-02-05T12:34:56.000Z")); + const range = __test.parseDateRange({ days: 1, mode: "gateway" }); + const expectedStart = new Date(2026, 1, 5).getTime(); + expect(range.startMs).toBe(expectedStart); + expect(range.endMs).toBe(expectedStart + dayMs - 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.endMs).toBe(Date.UTC(2026, 1, 5) + dayMs - 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); + expect(def.endMs).toBe(Date.UTC(2026, 1, 5) + dayMs - 1); + expect(def.startMs).toBe(Date.UTC(2026, 1, 5) - 29 * dayMs); }); it("loadCostUsageSummaryCached caches within TTL", async () => { diff --git a/src/gateway/server-methods/usage.ts b/src/gateway/server-methods/usage.ts index 740d562fa73..e40af58f5fe 100644 --- a/src/gateway/server-methods/usage.ts +++ b/src/gateway/server-methods/usage.ts @@ -39,8 +39,12 @@ import { import type { GatewayRequestHandlers, RespondFn } from "./types.js"; const COST_USAGE_CACHE_TTL_MS = 30_000; +const DAY_MS = 24 * 60 * 60 * 1000; type DateRange = { startMs: number; endMs: number }; +type DateInterpretation = + | { mode: "utc" | "gateway" } + | { mode: "specific"; utcOffsetMinutes: number }; type CostUsageCacheEntry = { summary?: CostUsageSummary; @@ -84,11 +88,9 @@ function resolveSessionUsageFileOrRespond( return { config, entry, agentId, sessionId, sessionFile }; } -/** - * Parse a date string (YYYY-MM-DD) to start of day timestamp in UTC. - * Returns undefined if invalid. - */ -const parseDateToMs = (raw: unknown): number | undefined => { +const parseDateParts = ( + raw: unknown, +): { year: number; monthIndex: number; day: number } | undefined => { if (typeof raw !== "string" || !raw.trim()) { return undefined; } @@ -96,13 +98,98 @@ const parseDateToMs = (raw: unknown): number | undefined => { 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)) { + const [, yearStr, monthStr, dayStr] = match; + const year = Number(yearStr); + const monthIndex = Number(monthStr) - 1; + const day = Number(dayStr); + if (!Number.isFinite(year) || !Number.isFinite(monthIndex) || !Number.isFinite(day)) { return undefined; } - return ms; + return { year, monthIndex, day }; +}; + +/** + * Parse a UTC offset string in the format UTC+H, UTC-H, UTC+HH, UTC-HH, UTC+H:MM, UTC-HH:MM. + * Returns the UTC offset in minutes (east-positive), or undefined if invalid. + */ +const parseUtcOffsetToMinutes = (raw: unknown): number | undefined => { + if (typeof raw !== "string" || !raw.trim()) { + return undefined; + } + const match = /^UTC([+-])(\d{1,2})(?::([0-5]\d))?$/.exec(raw.trim()); + if (!match) { + return undefined; + } + const sign = match[1] === "+" ? 1 : -1; + const hours = Number(match[2]); + const minutes = Number(match[3] ?? "0"); + if (!Number.isInteger(hours) || !Number.isInteger(minutes)) { + return undefined; + } + if (hours > 14 || (hours === 14 && minutes !== 0)) { + return undefined; + } + const totalMinutes = sign * (hours * 60 + minutes); + if (totalMinutes < -12 * 60 || totalMinutes > 14 * 60) { + return undefined; + } + return totalMinutes; +}; + +const resolveDateInterpretation = (params: { + mode?: unknown; + utcOffset?: unknown; +}): DateInterpretation => { + if (params.mode === "gateway") { + return { mode: "gateway" }; + } + if (params.mode === "specific") { + const utcOffsetMinutes = parseUtcOffsetToMinutes(params.utcOffset); + if (utcOffsetMinutes !== undefined) { + return { mode: "specific", utcOffsetMinutes }; + } + } + // Backward compatibility: when mode is missing (or invalid), keep current UTC interpretation. + return { mode: "utc" }; +}; + +/** + * Parse a date string (YYYY-MM-DD) to start-of-day timestamp based on interpretation mode. + * Returns undefined if invalid. + */ +const parseDateToMs = ( + raw: unknown, + interpretation: DateInterpretation = { mode: "utc" }, +): number | undefined => { + const parts = parseDateParts(raw); + if (!parts) { + return undefined; + } + const { year, monthIndex, day } = parts; + if (interpretation.mode === "gateway") { + const ms = new Date(year, monthIndex, day).getTime(); + return Number.isNaN(ms) ? undefined : ms; + } + if (interpretation.mode === "specific") { + const ms = Date.UTC(year, monthIndex, day) - interpretation.utcOffsetMinutes * 60 * 1000; + return Number.isNaN(ms) ? undefined : ms; + } + const ms = Date.UTC(year, monthIndex, day); + return Number.isNaN(ms) ? undefined : ms; +}; + +const getTodayStartMs = (now: Date, interpretation: DateInterpretation): number => { + if (interpretation.mode === "gateway") { + return new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); + } + if (interpretation.mode === "specific") { + const shifted = new Date(now.getTime() + interpretation.utcOffsetMinutes * 60 * 1000); + return ( + Date.UTC(shifted.getUTCFullYear(), shifted.getUTCMonth(), shifted.getUTCDate()) - + interpretation.utcOffsetMinutes * 60 * 1000 + ); + } + return Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); }; const parseDays = (raw: unknown): number | undefined => { @@ -126,29 +213,31 @@ const parseDateRange = (params: { startDate?: unknown; endDate?: unknown; days?: unknown; + mode?: unknown; + utcOffset?: 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 interpretation = resolveDateInterpretation(params); + const todayStartMs = getTodayStartMs(now, interpretation); + const todayEndMs = todayStartMs + DAY_MS - 1; - const startMs = parseDateToMs(params.startDate); - const endMs = parseDateToMs(params.endDate); + const startMs = parseDateToMs(params.startDate, interpretation); + const endMs = parseDateToMs(params.endDate, interpretation); if (startMs !== undefined && endMs !== undefined) { // endMs should be end of day - return { startMs, endMs: endMs + 24 * 60 * 60 * 1000 - 1 }; + return { startMs, endMs: endMs + DAY_MS - 1 }; } const days = parseDays(params.days); if (days !== undefined) { const clampedDays = Math.max(1, days); - const start = todayStartMs - (clampedDays - 1) * 24 * 60 * 60 * 1000; + const start = todayStartMs - (clampedDays - 1) * DAY_MS; return { startMs: start, endMs: todayEndMs }; } // Default to last 30 days - const defaultStartMs = todayStartMs - 29 * 24 * 60 * 60 * 1000; + const defaultStartMs = todayStartMs - 29 * DAY_MS; return { startMs: defaultStartMs, endMs: todayEndMs }; }; @@ -239,7 +328,11 @@ async function loadCostUsageSummaryCached(params: { // Exposed for unit tests (kept as a single export to avoid widening the public API surface). export const __test = { + parseDateParts, + parseUtcOffsetToMinutes, + resolveDateInterpretation, parseDateToMs, + getTodayStartMs, parseDays, parseDateRange, discoverAllSessionsForUsage, @@ -313,6 +406,8 @@ export const usageHandlers: GatewayRequestHandlers = { startDate: params?.startDate, endDate: params?.endDate, days: params?.days, + mode: params?.mode, + utcOffset: params?.utcOffset, }); const summary = await loadCostUsageSummaryCached({ startMs, endMs, config }); respond(true, summary, undefined); @@ -335,6 +430,8 @@ export const usageHandlers: GatewayRequestHandlers = { const { startMs, endMs } = parseDateRange({ startDate: p.startDate, endDate: p.endDate, + mode: p.mode, + utcOffset: p.utcOffset, }); const limit = typeof p.limit === "number" && Number.isFinite(p.limit) ? p.limit : 50; const includeContextWeight = p.includeContextWeight ?? false; diff --git a/ui/src/ui/app-render-usage-tab.ts b/ui/src/ui/app-render-usage-tab.ts index 349abd9e279..93b427ab392 100644 --- a/ui/src/ui/app-render-usage-tab.ts +++ b/ui/src/ui/app-render-usage-tab.ts @@ -73,6 +73,10 @@ export function renderUsageTab(state: AppViewState) { onRefresh: () => loadUsage(state), onTimeZoneChange: (zone) => { state.usageTimeZone = zone; + state.usageSelectedDays = []; + state.usageSelectedHours = []; + state.usageSelectedSessions = []; + void loadUsage(state); }, onToggleContextExpanded: () => { state.usageContextExpanded = !state.usageContextExpanded; diff --git a/ui/src/ui/controllers/usage.node.test.ts b/ui/src/ui/controllers/usage.node.test.ts new file mode 100644 index 00000000000..61c3c84e6c9 --- /dev/null +++ b/ui/src/ui/controllers/usage.node.test.ts @@ -0,0 +1,190 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { __test, loadUsage, type UsageState } from "./usage.ts"; + +type RequestFn = (method: string, params?: unknown) => Promise; + +function createState(request: RequestFn, overrides: Partial = {}): UsageState { + return { + client: { request } as unknown as UsageState["client"], + connected: true, + usageLoading: false, + usageResult: null, + usageCostSummary: null, + usageError: null, + usageStartDate: "2026-02-16", + usageEndDate: "2026-02-16", + usageSelectedSessions: [], + usageSelectedDays: [], + usageTimeSeries: null, + usageTimeSeriesLoading: false, + usageTimeSeriesCursorStart: null, + usageTimeSeriesCursorEnd: null, + usageSessionLogs: null, + usageSessionLogsLoading: false, + usageTimeZone: "local", + ...overrides, + }; +} + +describe("usage controller date interpretation params", () => { + beforeEach(() => { + __test.resetLegacyUsageDateParamsCache(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("formats UTC offsets for whole and half-hour timezones", () => { + expect(__test.formatUtcOffset(240)).toBe("UTC-4"); + expect(__test.formatUtcOffset(-330)).toBe("UTC+5:30"); + expect(__test.formatUtcOffset(0)).toBe("UTC+0"); + }); + + it("sends specific mode with browser offset when usage timezone is local", async () => { + const request = vi.fn(async () => ({})); + const state = createState(request, { usageTimeZone: "local" }); + vi.spyOn(Date.prototype, "getTimezoneOffset").mockReturnValue(-330); + + await loadUsage(state); + + expect(request).toHaveBeenNthCalledWith(1, "sessions.usage", { + startDate: "2026-02-16", + endDate: "2026-02-16", + mode: "specific", + utcOffset: "UTC+5:30", + limit: 1000, + includeContextWeight: true, + }); + expect(request).toHaveBeenNthCalledWith(2, "usage.cost", { + startDate: "2026-02-16", + endDate: "2026-02-16", + mode: "specific", + utcOffset: "UTC+5:30", + }); + }); + + it("sends utc mode without offset when usage timezone is utc", async () => { + const request = vi.fn(async () => ({})); + const state = createState(request, { usageTimeZone: "utc" }); + + await loadUsage(state); + + expect(request).toHaveBeenNthCalledWith(1, "sessions.usage", { + startDate: "2026-02-16", + endDate: "2026-02-16", + mode: "utc", + limit: 1000, + includeContextWeight: true, + }); + expect(request).toHaveBeenNthCalledWith(2, "usage.cost", { + startDate: "2026-02-16", + endDate: "2026-02-16", + mode: "utc", + }); + }); + + it("captures useful error strings in loadUsage", async () => { + const request = vi.fn(async () => { + throw new Error("request failed"); + }); + const state = createState(request); + + await loadUsage(state); + + expect(state.usageError).toBe("request failed"); + }); + + it("serializes non-Error objects without object-to-string coercion", () => { + expect(__test.toErrorMessage({ reason: "nope" })).toBe('{"reason":"nope"}'); + }); + + it("falls back and remembers compatibility when sessions.usage rejects mode/utcOffset", async () => { + const storage = createStorageMock(); + vi.stubGlobal("localStorage", storage as unknown as Storage); + vi.spyOn(Date.prototype, "getTimezoneOffset").mockReturnValue(-330); + + const request = vi.fn(async (method: string, params?: unknown) => { + if (method === "sessions.usage") { + const record = (params ?? {}) as Record; + if ("mode" in record || "utcOffset" in record) { + throw new Error( + "invalid sessions.usage params: at root: unexpected property 'mode'; at root: unexpected property 'utcOffset'", + ); + } + return { sessions: [] }; + } + return {}; + }); + + const state = createState(request, { + usageTimeZone: "local", + settings: { gatewayUrl: "ws://127.0.0.1:18789" }, + }); + + await loadUsage(state); + + expect(request).toHaveBeenNthCalledWith(1, "sessions.usage", { + startDate: "2026-02-16", + endDate: "2026-02-16", + mode: "specific", + utcOffset: "UTC+5:30", + limit: 1000, + includeContextWeight: true, + }); + expect(request).toHaveBeenNthCalledWith(2, "usage.cost", { + startDate: "2026-02-16", + endDate: "2026-02-16", + mode: "specific", + utcOffset: "UTC+5:30", + }); + expect(request).toHaveBeenNthCalledWith(3, "sessions.usage", { + startDate: "2026-02-16", + endDate: "2026-02-16", + limit: 1000, + includeContextWeight: true, + }); + expect(request).toHaveBeenNthCalledWith(4, "usage.cost", { + startDate: "2026-02-16", + endDate: "2026-02-16", + }); + + // Subsequent loads for the same gateway should skip mode/utcOffset immediately. + await loadUsage(state); + + expect(request).toHaveBeenNthCalledWith(5, "sessions.usage", { + startDate: "2026-02-16", + endDate: "2026-02-16", + limit: 1000, + includeContextWeight: true, + }); + expect(request).toHaveBeenNthCalledWith(6, "usage.cost", { + startDate: "2026-02-16", + endDate: "2026-02-16", + }); + + // Persisted flag should survive cache resets (simulating app reload). + __test.resetLegacyUsageDateParamsCache(); + expect(__test.shouldSendLegacyDateInterpretation(state)).toBe(false); + + vi.unstubAllGlobals(); + }); +}); + +function createStorageMock() { + const store = new Map(); + return { + getItem(key: string) { + return store.get(key) ?? null; + }, + setItem(key: string, value: string) { + store.set(key, String(value)); + }, + removeItem(key: string) { + store.delete(key); + }, + clear() { + store.clear(); + }, + }; +} diff --git a/ui/src/ui/controllers/usage.ts b/ui/src/ui/controllers/usage.ts index 44e223d3f3e..0fe257ae8e7 100644 --- a/ui/src/ui/controllers/usage.ts +++ b/ui/src/ui/controllers/usage.ts @@ -19,8 +19,169 @@ export type UsageState = { usageTimeSeriesCursorEnd: number | null; usageSessionLogs: SessionLogEntry[] | null; usageSessionLogsLoading: boolean; + usageTimeZone: "local" | "utc"; + settings?: { gatewayUrl?: string }; }; +type DateInterpretationMode = "utc" | "gateway" | "specific"; + +type UsageDateInterpretationParams = { + mode: DateInterpretationMode; + utcOffset?: string; +}; + +const LEGACY_USAGE_DATE_PARAMS_STORAGE_KEY = "openclaw.control.usage.date-params.v1"; +const LEGACY_USAGE_DATE_PARAMS_DEFAULT_GATEWAY_KEY = "__default__"; +const LEGACY_USAGE_DATE_PARAMS_MODE_RE = /unexpected property ['"]mode['"]/i; +const LEGACY_USAGE_DATE_PARAMS_OFFSET_RE = /unexpected property ['"]utcoffset['"]/i; +const LEGACY_USAGE_DATE_PARAMS_INVALID_RE = /invalid sessions\.usage params/i; + +let legacyUsageDateParamsCache: Set | null = null; + +function getLocalStorage(): Storage | null { + // Support browser runtime and node tests (when localStorage is stubbed globally). + if (typeof window !== "undefined" && window.localStorage) { + return window.localStorage; + } + if (typeof localStorage !== "undefined") { + return localStorage; + } + return null; +} + +function loadLegacyUsageDateParamsCache(): Set { + const storage = getLocalStorage(); + if (!storage) { + return new Set(); + } + try { + const raw = storage.getItem(LEGACY_USAGE_DATE_PARAMS_STORAGE_KEY); + if (!raw) { + return new Set(); + } + const parsed = JSON.parse(raw) as { unsupportedGatewayKeys?: unknown } | null; + if (!parsed || !Array.isArray(parsed.unsupportedGatewayKeys)) { + return new Set(); + } + return new Set( + parsed.unsupportedGatewayKeys + .filter((entry): entry is string => typeof entry === "string") + .map((entry) => entry.trim()) + .filter(Boolean), + ); + } catch { + return new Set(); + } +} + +function persistLegacyUsageDateParamsCache(cache: Set) { + const storage = getLocalStorage(); + if (!storage) { + return; + } + try { + storage.setItem( + LEGACY_USAGE_DATE_PARAMS_STORAGE_KEY, + JSON.stringify({ unsupportedGatewayKeys: Array.from(cache) }), + ); + } catch { + // ignore quota/private-mode failures + } +} + +function getLegacyUsageDateParamsCache(): Set { + if (!legacyUsageDateParamsCache) { + legacyUsageDateParamsCache = loadLegacyUsageDateParamsCache(); + } + return legacyUsageDateParamsCache; +} + +function normalizeGatewayCompatibilityKey(gatewayUrl?: string): string { + const trimmed = gatewayUrl?.trim(); + if (!trimmed) { + return LEGACY_USAGE_DATE_PARAMS_DEFAULT_GATEWAY_KEY; + } + try { + const parsed = new URL(trimmed); + const pathname = parsed.pathname === "/" ? "" : parsed.pathname; + return `${parsed.protocol}//${parsed.host}${pathname}`.toLowerCase(); + } catch { + return trimmed.toLowerCase(); + } +} + +function resolveGatewayCompatibilityKey(state: UsageState): string { + return normalizeGatewayCompatibilityKey(state.settings?.gatewayUrl); +} + +function shouldSendLegacyDateInterpretation(state: UsageState): boolean { + return !getLegacyUsageDateParamsCache().has(resolveGatewayCompatibilityKey(state)); +} + +function rememberLegacyDateInterpretation(state: UsageState) { + const cache = getLegacyUsageDateParamsCache(); + cache.add(resolveGatewayCompatibilityKey(state)); + persistLegacyUsageDateParamsCache(cache); +} + +function isLegacyDateInterpretationUnsupportedError(err: unknown): boolean { + const message = toErrorMessage(err); + return ( + LEGACY_USAGE_DATE_PARAMS_INVALID_RE.test(message) && + (LEGACY_USAGE_DATE_PARAMS_MODE_RE.test(message) || + LEGACY_USAGE_DATE_PARAMS_OFFSET_RE.test(message)) + ); +} + +const formatUtcOffset = (timezoneOffsetMinutes: number): string => { + // `Date#getTimezoneOffset()` is minutes to add to local time to reach UTC. + // Convert to UTC±H[:MM] where positive means east of UTC. + const offsetFromUtcMinutes = -timezoneOffsetMinutes; + const sign = offsetFromUtcMinutes >= 0 ? "+" : "-"; + const absMinutes = Math.abs(offsetFromUtcMinutes); + const hours = Math.floor(absMinutes / 60); + const minutes = absMinutes % 60; + return minutes === 0 + ? `UTC${sign}${hours}` + : `UTC${sign}${hours}:${minutes.toString().padStart(2, "0")}`; +}; + +const buildDateInterpretationParams = ( + timeZone: "local" | "utc", + includeDateInterpretation: boolean, +): UsageDateInterpretationParams | undefined => { + if (!includeDateInterpretation) { + return undefined; + } + if (timeZone === "utc") { + return { mode: "utc" }; + } + return { + mode: "specific", + utcOffset: formatUtcOffset(new Date().getTimezoneOffset()), + }; +}; + +function toErrorMessage(err: unknown): string { + if (typeof err === "string") { + return err; + } + if (err instanceof Error && typeof err.message === "string" && err.message.trim()) { + return err.message; + } + if (err && typeof err === "object") { + try { + const serialized = JSON.stringify(err); + if (serialized) { + return serialized; + } + } catch { + // ignore + } + } + return "request failed"; +} + export async function loadUsage( state: UsageState, overrides?: { @@ -28,7 +189,9 @@ export async function loadUsage( endDate?: string; }, ) { - if (!state.client || !state.connected) { + // Capture client for TS18047 work around on it being possibly null + const client = state.client; + if (!client || !state.connected) { return; } if (state.usageLoading) { @@ -39,31 +202,71 @@ export async function loadUsage( try { const startDate = overrides?.startDate ?? state.usageStartDate; const endDate = overrides?.endDate ?? state.usageEndDate; + const runUsageRequests = async (includeDateInterpretation: boolean) => { + const dateInterpretation = buildDateInterpretationParams( + state.usageTimeZone, + includeDateInterpretation, + ); + return await Promise.all([ + client.request("sessions.usage", { + startDate, + endDate, + ...dateInterpretation, + limit: 1000, // Cap at 1000 sessions + includeContextWeight: true, + }), + client.request("usage.cost", { + startDate, + endDate, + ...dateInterpretation, + }), + ]); + }; - // 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 }), - ]); + const applyUsageResults = (sessionsRes: unknown, costRes: unknown) => { + if (sessionsRes) { + state.usageResult = sessionsRes as SessionsUsageResult; + } + if (costRes) { + state.usageCostSummary = costRes as CostUsageSummary; + } + }; - if (sessionsRes) { - state.usageResult = sessionsRes as SessionsUsageResult; - } - if (costRes) { - state.usageCostSummary = costRes as CostUsageSummary; + const includeDateInterpretation = shouldSendLegacyDateInterpretation(state); + try { + const [sessionsRes, costRes] = await runUsageRequests(includeDateInterpretation); + applyUsageResults(sessionsRes, costRes); + } catch (err) { + if (includeDateInterpretation && isLegacyDateInterpretationUnsupportedError(err)) { + // Older gateways reject `mode`/`utcOffset` in `sessions.usage`. + // Remember this per gateway and retry once without those fields. + rememberLegacyDateInterpretation(state); + const [sessionsRes, costRes] = await runUsageRequests(false); + applyUsageResults(sessionsRes, costRes); + } else { + throw err; + } } } catch (err) { - state.usageError = String(err); + state.usageError = toErrorMessage(err); } finally { state.usageLoading = false; } } +export const __test = { + formatUtcOffset, + buildDateInterpretationParams, + toErrorMessage, + isLegacyDateInterpretationUnsupportedError, + normalizeGatewayCompatibilityKey, + shouldSendLegacyDateInterpretation, + rememberLegacyDateInterpretation, + resetLegacyUsageDateParamsCache: () => { + legacyUsageDateParamsCache = null; + }, +}; + export async function loadSessionTimeSeries(state: UsageState, sessionKey: string) { if (!state.client || !state.connected) { return;