From 69b45b0781ba76e17a1f0b69758bc07d7c5ffe4e Mon Sep 17 00:00:00 2001 From: Ethan Date: Sun, 30 Aug 2026 06:11:07 +0800 Subject: [PATCH 1/7] feat(cli): enhance usage analytics with flexible time-range, offline fallback, and markdown export --- src/cli/observe.ts | 50 +++++-- src/cli/usage-report.ts | 26 ++++ src/usage/summary.ts | 69 +++++++++- src/usage/time-range.ts | 176 ++++++++++++++++++++++++ tests/usage-time-range-enhanced.test.ts | 119 ++++++++++++++++ 5 files changed, 423 insertions(+), 17 deletions(-) create mode 100644 src/usage/time-range.ts create mode 100644 tests/usage-time-range-enhanced.test.ts diff --git a/src/cli/observe.ts b/src/cli/observe.ts index e89eea0de3..714035b9fc 100644 --- a/src/cli/observe.ts +++ b/src/cli/observe.ts @@ -10,7 +10,9 @@ import { takeOption, type RuntimeApiDeps, } from "./runtime-api"; -import { formatUsageReport } from "./usage-report"; +import { formatUsageReport, formatUsageMarkdownReport } from "./usage-report"; +import { summarizeUsageFromLogFile } from "../usage/summary"; +import { resolveTimeRange } from "../usage/time-range"; import { USAGE_RANGES, USAGE_SURFACES } from "../usage/summary"; const USAGE = `Usage: @@ -140,25 +142,51 @@ async function indexStatus(argv: string[], deps: RuntimeApiDeps): Promise async function usage(argv: string[], deps: RuntimeApiDeps): Promise { const args = [...argv]; const wantsJson = takeFlag(args, "--json"); - const range = takeOption(args, "--range") ?? "30d"; + const wantsOffline = takeFlag(args, "--offline"); + const formatOpt = takeOption(args, "--format") || (wantsJson ? "json" : "table"); + const sinceOpt = takeOption(args, "--since"); + const untilOpt = takeOption(args, "--until"); + const range = takeOption(args, "--range") || (sinceOpt || untilOpt ? "custom" : "30d"); const surface = takeOption(args, "--surface") ?? "all"; const provider = takeOption(args, "--provider"); const model = takeOption(args, "--model"); // `1d` is accepted here as well as server-side so the CLI does not reject an // alias the API would have understood. - const ranges = [...USAGE_RANGES, "1d"]; - if (!ranges.includes(range)) throw new CliUsageError(`--range must be one of ${USAGE_RANGES.join(", ")} (1d aliases today)`, USAGE); + const ranges = [...USAGE_RANGES, "1d", "custom"]; + if (!ranges.includes(range as any) && !sinceOpt && !untilOpt) { + throw new CliUsageError(`--range must be one of ${USAGE_RANGES.join(", ")} (1d aliases today)`, USAGE); + } if (!USAGE_SURFACES.includes(surface as (typeof USAGE_SURFACES)[number])) { throw new CliUsageError(`--surface must be one of ${USAGE_SURFACES.join(", ")}`, USAGE); } rejectArgs(args, USAGE); - const result = await runtimeRequest(`/api/usage${query({ range, surface, provider, model })}`, {}, deps); - // Built only when it will be printed: JavaScript evaluates arguments before - // the call, so passing formatUsageReport(...) inline would run the human - // renderer during --json and let its assumptions affect a path that is meant - // to bypass it entirely. - if (wantsJson) printData(result, true); - else printData(result, false, formatUsageReport(result as Parameters[0])); + let result: unknown; + let isOffline = wantsOffline; + if (!wantsOffline) { + try { + const qp: Record = { range, surface, provider, model }; + if (sinceOpt) qp.since = sinceOpt; + if (untilOpt) qp.until = untilOpt; + result = await runtimeRequest(`/api/usage${query(qp)}`, {}, deps); + } catch (err) { + isOffline = true; + } + } + if (isOffline) { + result = summarizeUsageFromLogFile({ + range: range === "custom" ? "all" : (range as any), + since: sinceOpt, + until: untilOpt, + surface: surface as any, + }); + } + if (formatOpt === "json" || wantsJson) { + printData(result, true); + } else if (formatOpt === "markdown") { + printData(result, false, formatUsageMarkdownReport(result as any)); + } else { + printData(result, false, formatUsageReport(result as any)); + } } async function simple(path: string, argv: string[], deps: RuntimeApiDeps): Promise { diff --git a/src/cli/usage-report.ts b/src/cli/usage-report.ts index e9f92f442d..3cd7971b2e 100644 --- a/src/cli/usage-report.ts +++ b/src/cli/usage-report.ts @@ -22,8 +22,10 @@ interface CostRow { interface UsageReportInput { range?: string; + rangeLabel?: string; surface?: string; since?: number | null; + until?: number | null; summary?: { requests?: number; totalTokens?: number; @@ -182,3 +184,27 @@ export function formatUsageReport(data: UsageReportInput): string[] { lines.push("Not a billing receipt. Subscription usage or provider credits may apply instead."); return lines; } + +export function formatUsageMarkdownReport(data: UsageReportInput): string[] { + const summary = data.summary ?? {}; + const label = data.rangeLabel || data.range || "custom"; + const lines = [ + "### OpenCodex Usage Report (" + label + ")", + "", + "- **Requests**: " + count(summary.requests), + "- **Total Tokens**: " + count(summary.totalTokens), + " - Input Tokens: " + count(summary.inputTokens) + " (Cached: " + count(summary.cachedInputTokens) + ")", + " - Output Tokens: " + count(summary.outputTokens), + "- **Estimated API Cost**: " + usd(summary.estimatedCostUsd), + "" + ]; + const models = (data.models ?? []).filter(r => r.requests > 0); + if (models.length > 0) { + lines.push("| Model | Provider | Requests | Total Tokens | Est. Cost |"); + lines.push("| :--- | :--- | :--- | :--- | :--- |"); + for (const r of models) { + lines.push("| " + (r.model || "-") + " | " + r.provider + " | " + count(r.requests) + " | " + count(r.totalTokens) + " | " + usd(r.estimatedCostUsd) + " |"); + } + } + return lines; +} diff --git a/src/usage/summary.ts b/src/usage/summary.ts index cc3161aa16..9122893b88 100644 --- a/src/usage/summary.ts +++ b/src/usage/summary.ts @@ -1,8 +1,11 @@ +import path from "node:path"; import { baseProviderLabel } from "../providers/label"; import { canonicalAntigravityUsageModel } from "../providers/antigravity-models"; import { usageDisplayTotalTokens } from "./totals"; import { isCodexUsageAccountLogLabel, type PersistedUsageEntry, type UsageStatus } from "./log"; import { type AttemptCostEstimate, type CostEstimate, estimateAttemptCost, estimateRequestCost, serviceTierContext, type ServiceTierContext } from "./cost"; +import { resolveTimeRange } from "./time-range"; +import { readFileSync, existsSync } from "node:fs"; /** * Canonical range members. The warm-up loop in the management usage route @@ -36,6 +39,8 @@ export interface UsageSummaryTotals { * Sums per-request estimateRequestCost / per-attempt combo costs; requests whose * price is unmatched are excluded from the sum and counted separately. */ estimatedCostUsd: number; + uncachedCostUsd?: number; + savedCostUsd?: number; pricedRequests: number; /** Requests with usage but no matched price anywhere (excluded from the sum). */ unpricedRequests: number; @@ -136,8 +141,10 @@ export interface UsageAccount { export interface UsageSummary { range: UsageRange; + rangeLabel?: string; surface: UsageSurface; since: number | null; + until?: number | null; generatedAt: number; summary: UsageSummaryTotals; days: UsageDay[]; @@ -265,23 +272,29 @@ function startOfLocalDay(ts: number): number { return d.getTime(); } -export function rangeWindow(range: UsageRange, now: number): { since: number | null; days: number } { +export function rangeWindow(range: UsageRange, now: number, customSince?: number | null, customUntil?: number | null): { since: number | null; until: number | null; days: number } { + if ((customSince !== undefined && customSince !== null) || (customUntil !== undefined && customUntil !== null)) { + const since = customSince ?? null; + const until = customUntil ?? null; + const dayDiff = since ? Math.max(1, Math.ceil(((until ?? now) - since) / 86400000)) : 0; + return { since, until, days: dayDiff }; + } // Handled before the others because the fallthrough below is the `all` // window: a range that reaches it is silently reported as all-time history, // which for a cost surface is a plausible-looking wrong answer rather than a // visible failure. - if (range === "today") return { since: startOfLocalDay(now), days: 1 }; + if (range === "today") return { since: startOfLocalDay(now), until: null, days: 1 }; if (range === "7d") { const start = new Date(startOfLocalDay(now)); start.setDate(start.getDate() - 6); - return { since: start.getTime(), days: 7 }; + return { since: start.getTime(), until: null, days: 7 }; } if (range === "30d") { const start = new Date(startOfLocalDay(now)); start.setDate(start.getDate() - 29); - return { since: start.getTime(), days: 30 }; + return { since: start.getTime(), until: null, days: 30 }; } - return { since: null, days: 0 }; + return { since: null, until: null, days: 0 }; } function localDateKey(ts: number): string { @@ -1087,10 +1100,14 @@ export function summarizeUsage( range: UsageRange, now: number, surface: UsageSurface = "all", + customSince?: number | null, + customUntil?: number | null, + rangeLabel?: string, ): UsageSummary { - const { since } = rangeWindow(range, now); + const { since, until } = rangeWindow(range, now, customSince, customUntil); const filteredEntries = entries.filter(entry => { if (since !== null && entry.timestamp < since) return false; + if (until !== null && entry.timestamp > until) return false; if (surface === "claude") return entry.surface === "claude" || entry.surface === "claude-desktop"; if (surface === "grok") return entry.surface === "grok"; // Codex = the historical unlabelled bucket. Before the grok tag existed every @@ -1113,8 +1130,10 @@ export function summarizeUsage( finalizeCoverage(totals); return { range, + rangeLabel: rangeLabel ?? range, surface, since, + until, generatedAt: now, summary: totals, days: buildDayGrid(range, since, now, filteredEntries, costMap), @@ -1198,3 +1217,41 @@ export function projectUsageSummary( filter: { provider, model, matched, comboOverlap }, }; } + + +export function summarizeUsageFromLogFile(options: { + filePath?: string; + range?: UsageRange; + since?: string | number | null; + until?: string | number | null; + surface?: UsageSurface; + now?: number; +}): UsageSummary { + const now = options.now ?? Date.now(); + const resolved = resolveTimeRange({ + range: options.range, + since: options.since, + until: options.until, + now, + }); + + const homeDir = process.env.USERPROFILE || process.env.HOME || ""; + const logPath = options.filePath ? path.resolve(options.filePath) : path.join(homeDir, ".opencodex", "usage.jsonl"); + + if (!existsSync(logPath)) { + return summarizeUsage([], (options.range ?? "30d") as UsageRange, now, options.surface ?? "all", resolved.since, resolved.until, resolved.rangeLabel); + } + + const content = readFileSync(logPath, "utf-8"); + const lines = content.split(/\r?\n/); + const entries: PersistedUsageEntry[] = []; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + entries.push(JSON.parse(trimmed)); + } catch {} + } + + return summarizeUsage(entries, (options.range ?? "30d") as UsageRange, now, options.surface ?? "all", resolved.since, resolved.until, resolved.rangeLabel); +} diff --git a/src/usage/time-range.ts b/src/usage/time-range.ts new file mode 100644 index 0000000000..aab2b324a3 --- /dev/null +++ b/src/usage/time-range.ts @@ -0,0 +1,176 @@ +/** + * Flexible time parsing and range window resolution for OpenCodex usage analytics. + */ + +export interface TimeRangeResolution { + since: number | null; + until: number | null; + rangeLabel: string; + isCustom: boolean; +} + +const RELATIVE_TIME_RE = /^(\d+)\s*(m|min|minute|minutes|h|hr|hour|hours|d|day|days|w|week|weeks)\s*(?:ago)?$/i; +const TIME_OF_DAY_RE = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/; + +function startOfLocalDay(ts: number, dayOffset = 0): number { + const d = new Date(ts); + d.setHours(0, 0, 0, 0); + if (dayOffset !== 0) d.setDate(d.getDate() + dayOffset); + return d.getTime(); +} + +function endOfLocalDay(ts: number, dayOffset = 0): number { + const d = new Date(ts); + d.setHours(23, 59, 59, 999); + if (dayOffset !== 0) d.setDate(d.getDate() + dayOffset); + return d.getTime(); +} + +/** + * Parse a date/time string, timestamp, or natural language expression into epoch milliseconds. + */ +export function parseTimeBoundary(input: string | number | null | undefined, now = Date.now(), isEndOfWindow = false): number | null { + if (input === null || input === undefined) return null; + if (typeof input === "number") { + if (!Number.isFinite(input)) return null; + // Detect 10-digit seconds timestamp + return input < 1e11 ? input * 1000 : input; + } + + const raw = String(input).trim(); + if (!raw) return null; + + // Check pure numeric timestamp + if (/^\d{10,13}$/.test(raw)) { + const num = Number(raw); + return raw.length <= 10 ? num * 1000 : num; + } + + const lower = raw.toLowerCase(); + if (lower === "now") return now; + + // "today" or "today 14:30" + if (lower === "today" || lower.startsWith("today ")) { + const base = startOfLocalDay(now, 0); + const rest = raw.slice(5).trim(); + if (!rest) return isEndOfWindow ? endOfLocalDay(now, 0) : base; + const match = rest.match(TIME_OF_DAY_RE); + if (match) { + const [_, h, m, s] = match; + const d = new Date(base); + d.setHours(Number(h), Number(m), s ? Number(s) : (isEndOfWindow ? 59 : 0), isEndOfWindow ? 999 : 0); + return d.getTime(); + } + } + + // "yesterday" or "yesterday 09:17" + if (lower === "yesterday" || lower.startsWith("yesterday ")) { + const base = startOfLocalDay(now, -1); + const rest = raw.slice(9).trim(); + if (!rest) return isEndOfWindow ? endOfLocalDay(now, -1) : base; + const match = rest.match(TIME_OF_DAY_RE); + if (match) { + const [_, h, m, s] = match; + const d = new Date(base); + d.setHours(Number(h), Number(m), s ? Number(s) : (isEndOfWindow ? 59 : 0), isEndOfWindow ? 999 : 0); + return d.getTime(); + } + } + + // Relative duration like "2h ago", "3d", "30m" + const relMatch = lower.match(RELATIVE_TIME_RE); + if (relMatch) { + const count = Number(relMatch[1]); + const unit = relMatch[2].toLowerCase(); + let multiplier = 1000; + if (unit.startsWith("m")) multiplier = 60 * 1000; + else if (unit.startsWith("h")) multiplier = 3600 * 1000; + else if (unit.startsWith("d")) multiplier = 86400 * 1000; + else if (unit.startsWith("w")) multiplier = 7 * 86400 * 1000; + return now - count * multiplier; + } + + // Date string with space, e.g. "2026-08-29 09:17:00" -> convert space to T for ISO parsing + let normalized = raw; + if (/^\d{4}-\d{2}-\d{2}\s+\d{1,2}:\d{2}/.test(raw)) { + normalized = raw.replace(/\s+/, "T"); + } + + const parsed = Date.parse(normalized); + if (!Number.isNaN(parsed)) { + // If input was date-only like "2026-08-29" and isEndOfWindow is true, extend to end of day + if (/^\d{4}-\d{2}-\d{2}$/.test(raw) && isEndOfWindow) { + return endOfLocalDay(parsed, 0); + } + return parsed; + } + + return null; +} + +/** + * Resolve window bounds from canonical range and/or custom since/until flags. + */ +export function resolveTimeRange(options: { + range?: string | null; + since?: string | number | null; + until?: string | number | null; + now?: number; +}): TimeRangeResolution { + const now = options.now ?? Date.now(); + const customSince = parseTimeBoundary(options.since, now, false); + const customUntil = parseTimeBoundary(options.until, now, true); + + if (customSince !== null || customUntil !== null) { + const sinceStr = typeof options.since === "string" ? options.since : (customSince ? new Date(customSince).toISOString().replace("T", " ").slice(0, 19) : "*"); + const untilStr = typeof options.until === "string" ? options.until : (customUntil ? new Date(customUntil).toISOString().replace("T", " ").slice(0, 19) : "*"); + return { + since: customSince, + until: customUntil, + rangeLabel: `${sinceStr} ~ ${untilStr}`, + isCustom: true, + }; + } + + const range = (options.range ?? "30d").toLowerCase(); + if (range === "today" || range === "1d") { + return { + since: startOfLocalDay(now, 0), + until: null, + rangeLabel: "today", + isCustom: false, + }; + } + if (range === "7d") { + return { + since: startOfLocalDay(now, -6), + until: null, + rangeLabel: "7d", + isCustom: false, + }; + } + if (range === "30d") { + return { + since: startOfLocalDay(now, -29), + until: null, + rangeLabel: "30d", + isCustom: false, + }; + } + if (range === "all") { + return { + since: null, + until: null, + rangeLabel: "all", + isCustom: false, + }; + } + + // Fallback to 30d + return { + since: startOfLocalDay(now, -29), + until: null, + rangeLabel: "30d", + isCustom: false, + }; +} diff --git a/tests/usage-time-range-enhanced.test.ts b/tests/usage-time-range-enhanced.test.ts new file mode 100644 index 0000000000..ec84fd9fe6 --- /dev/null +++ b/tests/usage-time-range-enhanced.test.ts @@ -0,0 +1,119 @@ + +import { describe, expect, it } from "bun:test"; +import { parseTimeBoundary, resolveTimeRange } from "../src/usage/time-range"; +import { summarizeUsage, summarizeUsageFromLogFile } from "../src/usage/summary"; +import { formatUsageMarkdownReport } from "../src/cli/usage-report"; +import { writeFileSync, mkdtempSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +describe("Time Range Parsing & Resolution", () => { + const fixedNow = new Date("2026-08-30T12:00:00+08:00").getTime(); + + it("parses numeric epoch timestamps", () => { + expect(parseTimeBoundary(1787966220000, fixedNow)).toBe(1787966220000); + expect(parseTimeBoundary("1787966220000", fixedNow)).toBe(1787966220000); + // 10-digit seconds timestamp + expect(parseTimeBoundary(1787966220, fixedNow)).toBe(1787966220000); + }); + + it("parses ISO and space-separated date times", () => { + const ts = parseTimeBoundary("2026-08-29 09:17:00", fixedNow); + expect(ts).toBeNumber(); + const d = new Date(ts!); + expect(d.getFullYear()).toBe(2026); + expect(d.getMonth()).toBe(7); // August (0-indexed) + expect(d.getDate()).toBe(29); + }); + + it("parses relative natural expressions (today, yesterday, Xh ago)", () => { + const yesterdayTs = parseTimeBoundary("yesterday 09:17", fixedNow); + expect(yesterdayTs).toBeNumber(); + const d = new Date(yesterdayTs!); + expect(d.getDate()).toBe(29); + expect(d.getHours()).toBe(9); + expect(d.getMinutes()).toBe(17); + + const twoHoursAgo = parseTimeBoundary("2h ago", fixedNow); + expect(twoHoursAgo).toBe(fixedNow - 2 * 3600 * 1000); + }); + + it("resolves time range window bounds correctly", () => { + const custom = resolveTimeRange({ + since: "2026-08-29 09:17", + until: "2026-08-30 04:23", + now: fixedNow, + }); + expect(custom.isCustom).toBe(true); + expect(custom.since).toBeNumber(); + expect(custom.until).toBeNumber(); + expect(custom.until!).toBeGreaterThan(custom.since!); + }); +}); + +describe("Usage Summarization and Markdown Output", () => { + it("formats markdown report properly", () => { + const md = formatUsageMarkdownReport({ + range: "custom", + rangeLabel: "2026-08-29 09:17 ~ 2026-08-30 04:23", + summary: { + requests: 100, + totalTokens: 50000, + inputTokens: 45000, + cachedInputTokens: 40000, + outputTokens: 5000, + estimatedCostUsd: 1.25, + }, + models: [ + { model: "gpt-5.6-sol", provider: "openai", requests: 80, totalTokens: 40000, estimatedCostUsd: 1.10 }, + { model: "gpt-5.6-luna", provider: "openai", requests: 20, totalTokens: 10000, estimatedCostUsd: 0.15 }, + ], + }); + + const text = md.join("\n"); + expect(text).toContain("### OpenCodex Usage Report"); + expect(text).toContain("| gpt-5.6-sol | openai | 80 | 40,000 |"); + expect(text).toContain("Estimated API Cost"); + }); + + it("can summarize usage from offline jsonl file", () => { + const tmpDir = mkdtempSync(join(tmpdir(), "ocx-test-")); + const tmpFile = join(tmpDir, "test-usage.jsonl"); + + const sample = [ + JSON.stringify({ + requestId: "req-1", + timestamp: 1787966300000, + provider: "openai", + model: "gpt-5.6-sol", + usageStatus: "reported", + usage: { inputTokens: 1000, outputTokens: 100, cachedInputTokens: 800, totalTokens: 1100 }, + totalTokens: 1100, + }), + JSON.stringify({ + requestId: "req-2", + timestamp: 1787966400000, + provider: "openai", + model: "gpt-5.6-luna", + usageStatus: "reported", + usage: { inputTokens: 2000, outputTokens: 200, cachedInputTokens: 1500, totalTokens: 2200 }, + totalTokens: 2200, + }), + ].join("\n"); + + writeFileSync(tmpFile, sample, "utf-8"); + + const summary = summarizeUsageFromLogFile({ + filePath: tmpFile, + since: 1787966200000, + until: 1787966500000, + }); + + expect(summary.summary.requests).toBe(2); + expect(summary.summary.totalTokens).toBe(3300); + expect(summary.models.length).toBe(2); + + rmSync(tmpDir, { recursive: true, force: true }); + }); +}); + From 0afa991440b141666f1e7910e23c0afd227ae643 Mon Sep 17 00:00:00 2001 From: Ethan Date: Sun, 30 Aug 2026 06:19:49 +0800 Subject: [PATCH 2/7] feat(gui): add interactive date-time range filter and custom presets to usage workspace --- gui/src/i18n/de.ts | 9 +++ gui/src/i18n/en.ts | 9 +++ gui/src/i18n/fr.ts | 9 +++ gui/src/i18n/ja.ts | 9 +++ gui/src/i18n/ko.ts | 9 +++ gui/src/i18n/ru.ts | 9 +++ gui/src/i18n/tr.ts | 9 +++ gui/src/i18n/zh-TW.ts | 9 +++ gui/src/i18n/zh.ts | 9 +++ gui/src/pages/Usage.tsx | 169 ++++++++++++++++++++++++++++------------ 10 files changed, 200 insertions(+), 50 deletions(-) diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index d2f1830a1e..ce4da73b95 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -794,6 +794,15 @@ export const de: Record = { "usage.range.available": "Verfügbarer Verlauf", "usage.historyTruncated": "Die Summen beziehen sich nur auf den verfügbaren Verlauf, da ältere Nutzungsdaten nicht geladen wurden.", "usage.historyTruncatedWindow": "Die geladenen Zeilen haben Anfragestartzeiten zwischen {start} und {end}. Frühere Dateieinträge wurden durch das Leselimit ausgelassen, daher kann jeder gewählte Zeitraum unvollständig sein.", + "usage.range.today": "Today", + "usage.range.yesterday": "Yesterday", + "usage.range.custom": "Custom...", + "usage.custom.since": "Since", + "usage.custom.until": "Until", + "usage.custom.apply": "Apply", + "usage.custom.placeholder": "YYYY-MM-DD HH:mm", + "usage.card.savedCost": "Est. Cache Savings", + "usage.card.reasoningTokens": "Reasoning Tokens", "usage.range.30d": "30d", "usage.range.7d": "7d", "usage.card.requests": "Anfragen", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 2d4c55b139..38f628c079 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -831,6 +831,15 @@ export const en = { "usage.range.available": "Available history", "usage.historyTruncated": "Totals cover available history only because older usage was not loaded.", "usage.historyTruncatedWindow": "Loaded rows have request start times ranging from {start} to {end}. Earlier file entries were omitted by the read limit, so any selected range may be incomplete.", + "usage.range.today": "Today", + "usage.range.yesterday": "Yesterday", + "usage.range.custom": "Custom...", + "usage.custom.since": "Since", + "usage.custom.until": "Until", + "usage.custom.apply": "Apply", + "usage.custom.placeholder": "YYYY-MM-DD HH:mm", + "usage.card.savedCost": "Est. Cache Savings", + "usage.card.reasoningTokens": "Reasoning Tokens", "usage.range.30d": "30d", "usage.range.7d": "7d", "usage.card.requests": "Requests", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 99d64ee4e3..c2c4c248da 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -808,6 +808,15 @@ export const fr: Record = { "usage.range.available": "Historique disponible", "usage.historyTruncated": "Les totaux couvrent uniquement l’historique disponible, car les données d’utilisation plus anciennes n’ont pas été chargées.", "usage.historyTruncatedWindow": "Les heures de début des requêtes dans les lignes chargées vont de {start} à {end}. Les entrées antérieures du fichier ont été omises en raison de la limite de lecture ; toute période sélectionnée peut donc être incomplète.", + "usage.range.today": "Today", + "usage.range.yesterday": "Yesterday", + "usage.range.custom": "Custom...", + "usage.custom.since": "Since", + "usage.custom.until": "Until", + "usage.custom.apply": "Apply", + "usage.custom.placeholder": "YYYY-MM-DD HH:mm", + "usage.card.savedCost": "Est. Cache Savings", + "usage.card.reasoningTokens": "Reasoning Tokens", "usage.range.30d": "30 j", "usage.range.7d": "7 j", "usage.card.requests": "Requêtes", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 4788f0ef80..62baa9bef6 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -774,6 +774,15 @@ export const ja: Record = { "usage.range.available": "利用可能な履歴", "usage.historyTruncated": "古い利用履歴が読み込まれていないため、合計は利用可能な履歴のみを対象とします。", "usage.historyTruncatedWindow": "読み込まれた行のリクエスト開始時刻は {start} から {end} の範囲です。読み取り上限によりファイル前方の記録が除外されているため、選択した期間は不完全な場合があります。", + "usage.range.today": "Today", + "usage.range.yesterday": "Yesterday", + "usage.range.custom": "Custom...", + "usage.custom.since": "Since", + "usage.custom.until": "Until", + "usage.custom.apply": "Apply", + "usage.custom.placeholder": "YYYY-MM-DD HH:mm", + "usage.card.savedCost": "Est. Cache Savings", + "usage.card.reasoningTokens": "Reasoning Tokens", "usage.range.30d": "30日", "usage.range.7d": "7日", "usage.card.requests": "リクエスト", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 33c4452c14..75b157b51d 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -816,6 +816,15 @@ export const ko: Record = { "usage.range.available": "사용 가능한 기록", "usage.historyTruncated": "이전 사용 기록을 불러오지 않아 합계는 사용 가능한 기록만 포함합니다.", "usage.historyTruncatedWindow": "불러온 기록의 요청 시작 시각은 {start}부터 {end} 사이입니다. 읽기 한도 때문에 파일 앞부분의 기록이 빠졌으므로 선택한 기간이 완전하지 않을 수 있습니다.", + "usage.range.today": "Today", + "usage.range.yesterday": "Yesterday", + "usage.range.custom": "Custom...", + "usage.custom.since": "Since", + "usage.custom.until": "Until", + "usage.custom.apply": "Apply", + "usage.custom.placeholder": "YYYY-MM-DD HH:mm", + "usage.card.savedCost": "Est. Cache Savings", + "usage.card.reasoningTokens": "Reasoning Tokens", "usage.range.30d": "30일", "usage.range.7d": "7일", "usage.card.requests": "요청", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 08bf01b65b..aa6531d9e5 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -815,6 +815,15 @@ export const ru: Record = { "usage.range.available": "Доступная история", "usage.historyTruncated": "Итоги охватывают только доступную историю, поскольку старые данные не загружены.", "usage.historyTruncatedWindow": "У загруженных записей время начала запроса находится в диапазоне от {start} до {end}. Более ранние записи файла пропущены из-за лимита чтения, поэтому выбранный период может быть неполным.", + "usage.range.today": "Today", + "usage.range.yesterday": "Yesterday", + "usage.range.custom": "Custom...", + "usage.custom.since": "Since", + "usage.custom.until": "Until", + "usage.custom.apply": "Apply", + "usage.custom.placeholder": "YYYY-MM-DD HH:mm", + "usage.card.savedCost": "Est. Cache Savings", + "usage.card.reasoningTokens": "Reasoning Tokens", "usage.range.30d": "30 дн.", "usage.range.7d": "7 дн.", "usage.card.requests": "Запросы", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index a7e001271b..4458c96871 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -822,6 +822,15 @@ export const tr: Record = { "usage.range.available": "Mevcut geçmiş", "usage.historyTruncated": "Toplamlar yalnızca mevcut geçmişi kapsar.", "usage.historyTruncatedWindow": "Yüklenen satırların istek başlangıç zamanları {start} ile {end} arasındadır. Dosyanın önceki kayıtları okuma sınırı nedeniyle atlandı, bu yüzden seçilen aralık eksik olabilir.", + "usage.range.today": "Today", + "usage.range.yesterday": "Yesterday", + "usage.range.custom": "Custom...", + "usage.custom.since": "Since", + "usage.custom.until": "Until", + "usage.custom.apply": "Apply", + "usage.custom.placeholder": "YYYY-MM-DD HH:mm", + "usage.card.savedCost": "Est. Cache Savings", + "usage.card.reasoningTokens": "Reasoning Tokens", "usage.range.30d": "30 gün", "usage.range.7d": "7 gün", "usage.card.requests": "İstekler", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index c2eb37f8d2..678878d953 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -641,6 +641,15 @@ export const zhTW: Record = { "usage.empty": "尚無用量紀錄。透過代理傳送請求後將在此顯示。", "usage.loadError": "無法載入用量資料。", "usage.range.all": "全部", + "usage.range.today": "今天", + "usage.range.yesterday": "昨天", + "usage.range.custom": "自訂...", + "usage.custom.since": "起始時間", + "usage.custom.until": "截止時間", + "usage.custom.apply": "篩選", + "usage.custom.placeholder": "YYYY-MM-DD HH:mm", + "usage.card.savedCost": "快取節省預估", + "usage.card.reasoningTokens": "推理思考 Token", "usage.range.30d": "30 天", "usage.range.7d": "7 天", "usage.card.requests": "請求數", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index bc36d0f752..542f09006e 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -809,6 +809,15 @@ export const zh: Record = { "usage.range.available": "可用历史", "usage.historyTruncated": "由于未加载较早的使用记录,合计仅涵盖可用历史。", "usage.historyTruncatedWindow": "已加载记录的请求开始时间介于 {start} 到 {end} 之间。受读取上限限制,文件较前的条目已被省略,所选时间范围可能不完整。", + "usage.range.today": "今天", + "usage.range.yesterday": "昨天", + "usage.range.custom": "自定义...", + "usage.custom.since": "起始时间", + "usage.custom.until": "截止时间", + "usage.custom.apply": "筛选", + "usage.custom.placeholder": "YYYY-MM-DD HH:mm", + "usage.card.savedCost": "缓存节省预估", + "usage.card.reasoningTokens": "推理思考 Token", "usage.range.30d": "30 天", "usage.range.7d": "7 天", "usage.card.requests": "请求数", diff --git a/gui/src/pages/Usage.tsx b/gui/src/pages/Usage.tsx index 18cf77b4f8..b47de913b1 100644 --- a/gui/src/pages/Usage.tsx +++ b/gui/src/pages/Usage.tsx @@ -11,7 +11,7 @@ import { DataSurfaceSkeleton } from "../components/data-surface"; import { SectionTabs } from "../components/section-tabs"; import { sectionAnchorId } from "../section-anchors"; -type Range = "all" | "30d" | "7d"; +type Range = "all" | "30d" | "7d" | "today" | "yesterday" | "custom"; type UsageSurface = "all" | "codex" | "claude" | "grok"; interface UsageSummaryTotals { @@ -209,63 +209,113 @@ function buildHeatmap(days: UsageDay[]): { weeks: HeatmapCell[][]; months: { lab function UsageFilters({ surface, range, + customSince, + customUntil, onSurface, onRange, + onApplyCustom, t, }: { surface: UsageSurface; range: Range; + customSince: string; + customUntil: string; onSurface: (surface: UsageSurface) => void; onRange: (range: Range) => void; + onApplyCustom: (since: string, until: string) => void; t: TFn; }) { + const [showCustom, setShowCustom] = useState(range === "custom"); + const [sinceVal, setSinceVal] = useState(customSince); + const [untilVal, setUntilVal] = useState(customUntil); + + useEffect(() => { + setShowCustom(range === "custom"); + }, [range]); + return ( -
-
- {(["all", "codex", "claude", "grok"] as UsageSurface[]).map(choice => { - const label = t(`logs.filter.surface.${choice}`); - return ( - + ); + })} +
+
+ {(["today", "yesterday", "7d", "30d", "all", "custom"] as Range[]).map(choice => { + const label = choice === "all" ? t("usage.range.available") : (t(`usage.range.${choice}` as any) || choice); + return ( + - ); - })} -
-
- {(["all", "30d", "7d"] as Range[]).map(choice => { - const label = choice === "all" ? t("usage.range.available") : t(`usage.range.${choice}`); - return ( - - ); - })} + + ); + })} +
+ {showCustom && ( +
+ + setSinceVal(e.target.value)} + /> + + setUntilVal(e.target.value)} + /> + +
+ )} ); } @@ -759,22 +809,29 @@ export default function Usage({ apiBase }: { apiBase: string }) { const [range, setRange] = useState("30d"); const [surface, setSurface] = useState("all"); const [modelQuery, setModelQuery] = useState(""); + const [customSince, setCustomSince] = useState(""); + const [customUntil, setCustomUntil] = useState(""); const loadUsage = useCallback(async (signal: AbortSignal): Promise => { - const response = await fetch(`${apiBase}/api/usage?range=${range}&surface=${surface}`, { signal }); + let url = `${apiBase}/api/usage?range=${range === "custom" ? "all" : range}&surface=${surface}`; + if (range === "custom") { + if (customSince) url += `&since=${encodeURIComponent(customSince)}`; + if (customUntil) url += `&until=${encodeURIComponent(customUntil)}`; + } + const response = await fetch(url, { signal }); if (!response.ok) throw new Error(`${response.status} ${response.statusText}`.trim()); const next = await response.json() as UsageResponse; writeHeldUsage(apiBase, range, surface, next); return next; - }, [apiBase, range, surface]); + }, [apiBase, range, surface, customSince, customUntil]); - const resourceKey = usageCacheKey(apiBase, range, surface); + const resourceKey = `${usageCacheKey(apiBase, range, surface)}:${customSince}:${customUntil}`; const cached = readHeldUsage(apiBase, range, surface); // Range and surface identify different reports, so the key changes with both. That prevents // a force-loading dependency revalidation from ever showing a previous report as this one. const resource = useDataSurface( resourceKey, - [apiBase, range, surface], + [apiBase, range, surface, customSince, customUntil], loadUsage, { isEmpty: () => false, initialData: cached ?? undefined }, ); @@ -805,7 +862,19 @@ export default function Usage({ apiBase }: { apiBase: string }) { <>

{t("usage.title")}

- + { + setCustomSince(s); + setCustomUntil(u); + }} + t={t} + />

{t("usage.subtitle")}

From 6c811374f977c64f2cf268f381a30189c8b29e0d Mon Sep 17 00:00:00 2001 From: Ethan Date: Sun, 30 Aug 2026 06:38:47 +0800 Subject: [PATCH 3/7] fix(usage): wire custom windows and filters end to end --- .../content/docs/fr/reference/cli/agents.md | 2 +- .../content/docs/ja/reference/cli/agents.md | 2 +- .../content/docs/ko/reference/cli/agents.md | 2 +- .../src/content/docs/reference/cli/agents.md | 12 +- .../content/docs/reference/management-api.md | 8 +- .../content/docs/ru/reference/cli/agents.md | 2 +- .../content/docs/tr/reference/cli/agents.md | 2 +- .../docs/zh-cn/reference/cli/agents.md | 2 +- .../docs/zh-tw/reference/cli/agents.md | 2 +- gui/src/i18n/de.ts | 15 +-- gui/src/i18n/en.ts | 3 - gui/src/i18n/fr.ts | 15 +-- gui/src/i18n/ja.ts | 15 +-- gui/src/i18n/ko.ts | 15 +-- gui/src/i18n/ru.ts | 15 +-- gui/src/i18n/tr.ts | 15 +-- gui/src/i18n/zh-TW.ts | 3 - gui/src/i18n/zh.ts | 3 - gui/src/pages/Usage.tsx | 63 ++++++----- gui/tests/usage-time-filter.test.tsx | 104 ++++++++++++++++++ .../ocx/references/01_management_surface.md | 6 +- src/cli/capabilities.ts | 12 +- src/cli/observe.ts | 28 ++++- src/server/management/logs-usage-routes.ts | 23 +++- src/usage/summary.ts | 77 ++++++++----- tests/api-usage.test.ts | 73 ++++++++++++ tests/cli-usage-report.test.ts | 26 +++++ tests/usage-time-range-enhanced.test.ts | 37 ++++++- 28 files changed, 436 insertions(+), 146 deletions(-) create mode 100644 gui/tests/usage-time-filter.test.tsx diff --git a/docs-site/src/content/docs/fr/reference/cli/agents.md b/docs-site/src/content/docs/fr/reference/cli/agents.md index 91db38efea..a6d5454f84 100644 --- a/docs-site/src/content/docs/fr/reference/cli/agents.md +++ b/docs-site/src/content/docs/fr/reference/cli/agents.md @@ -90,7 +90,7 @@ Inspectez les requêtes de proxy, l’utilisation, le stockage, la mémoire et l | Alias ​​| Ressource équivalente | | --- | --- | | `ocx logs [filters] [--follow] [--json\|--jsonl]` | `ocx observe logs` | -| `ocx usage [--range ] [--surface ] [--provider ] [--model ] [--json]` | `ocx observe usage` | +| `ocx usage [--range ] [--since