diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index f59ffde6e..98c476c61 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -737,6 +737,7 @@ export const de: Record = { "usage.range.all": "Alle", "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.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 09290a3a9..77648b63c 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -768,6 +768,7 @@ export const en = { "usage.range.all": "All", "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.30d": "30d", "usage.range.7d": "7d", "usage.card.requests": "Requests", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index d2ee008a7..4e56f55d3 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -726,6 +726,7 @@ export const ja: Record = { "usage.range.all": "すべて", "usage.range.available": "利用可能な履歴", "usage.historyTruncated": "古い利用履歴が読み込まれていないため、合計は利用可能な履歴のみを対象とします。", + "usage.historyTruncatedWindow": "読み込まれた行のリクエスト開始時刻は {start} から {end} の範囲です。読み取り上限によりファイル前方の記録が除外されているため、選択した期間は不完全な場合があります。", "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 7a5fa9b4a..c59cfde7f 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -759,6 +759,7 @@ export const ko: Record = { "usage.range.all": "전체", "usage.range.available": "사용 가능한 기록", "usage.historyTruncated": "이전 사용 기록을 불러오지 않아 합계는 사용 가능한 기록만 포함합니다.", + "usage.historyTruncatedWindow": "불러온 기록의 요청 시작 시각은 {start}부터 {end} 사이입니다. 읽기 한도 때문에 파일 앞부분의 기록이 빠졌으므로 선택한 기간이 완전하지 않을 수 있습니다.", "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 c4df1a933..ed86f7eb3 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -758,6 +758,7 @@ export const ru: Record = { "usage.range.all": "Все", "usage.range.available": "Доступная история", "usage.historyTruncated": "Итоги охватывают только доступную историю, поскольку старые данные не загружены.", + "usage.historyTruncatedWindow": "У загруженных записей время начала запроса находится в диапазоне от {start} до {end}. Более ранние записи файла пропущены из-за лимита чтения, поэтому выбранный период может быть неполным.", "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 de51ee0b6..3aa827530 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -765,6 +765,7 @@ export const tr: Record = { "usage.range.all": "Tümü", "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.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 16e99c027..09782ffe7 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1586,6 +1586,7 @@ export const zhTW: Record = { "provider.name.volcengineAgentPlan": "Volcengine Ark Agent Plan", "usage.range.available": "可用歷史紀錄", "usage.historyTruncated": "總計僅涵蓋可用歷史紀錄,因為較舊的用量未被載入。", + "usage.historyTruncatedWindow": "已載入紀錄的請求開始時間介於 {start} 到 {end} 之間。受讀取上限限制,檔案較前的項目已被略過,所選期間可能不完整。", "codexAuth.autoSwitchQuotaDesc": "配額:使用率達 {threshold}% 或以上時,下一個請求可能移至用量較低的合格帳號,包括已綁定的任務;Go/Free 僅使用 30 天。", "codexAuth.autoSwitchQuotaOffDesc": "基於用量的主動切換已關閉。新增/未綁定分派與故障恢復仍然適用。", "codexAuth.autoSwitchRoundRobinDesc": "輪詢分派不使用此閾值;它會繼續輪換新增/未綁定的任務。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 792a0029c..ede0ffcef 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -752,6 +752,7 @@ export const zh: Record = { "usage.range.all": "全部", "usage.range.available": "可用历史", "usage.historyTruncated": "由于未加载较早的使用记录,合计仅涵盖可用历史。", + "usage.historyTruncatedWindow": "已加载记录的请求开始时间介于 {start} 到 {end} 之间。受读取上限限制,文件较前的条目已被省略,所选时间范围可能不完整。", "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 516960898..18cf77b4f 100644 --- a/gui/src/pages/Usage.tsx +++ b/gui/src/pages/Usage.tsx @@ -88,6 +88,11 @@ interface UsageResponse { truncatedPrefixBytes: number; entriesTruncated: boolean; entriesDropped: number; + // Bounds of the rows the bounded reader loaded, before any range or surface filtering. + // Describes the read, not the query, and is never a completeness claim (#1497). + // Optional because a dashboard can talk to a proxy that predates these fields. + snapshotWindowStart?: number | null; + snapshotWindowEnd?: number | null; error?: string; } @@ -816,7 +821,24 @@ export default function Usage({ apiBase }: { apiBase: string }) { ) : ( <> {state.showError && {t("usage.loadError")}} - {data?.historyTruncated && {t("usage.historyTruncated")}} + {data?.historyTruncated && ( + // Naming the loaded window is the point: without it, `30d` and "Available history" + // look identical on a busy installation even though both may cover far less than + // they claim (#1497). `warn` rather than `ok` because a total that silently omits + // in-range rows is a caveat, not a status update. + + {(() => { + // Both bounds must be renderable before the detailed wording is used: an older + // proxy omits the fields entirely, and a hand-edited row can carry a timestamp + // outside Date's range. Either way the generic string is the honest fallback. + const start = renderableInstant(data.snapshotWindowStart); + const end = renderableInstant(data.snapshotWindowEnd); + return start !== null && end !== null + ? t("usage.historyTruncatedWindow", { start, end }) + : t("usage.historyTruncated"); + })()} + + )} ); } +function renderableInstant(value: number | null | undefined): string | null { + // The reader preserves whatever timestamp a row carries, including hand-edited values far + // outside Date's supported range. A presence check alone would then render the literal + // string "Invalid Date" in a notice whose whole job is to be trustworthy, so the bound is + // only used once it round-trips through Date. + if (typeof value !== "number" || !Number.isFinite(value)) return null; + const at = new Date(value); + return Number.isFinite(at.getTime()) ? at.toLocaleString() : null; +} diff --git a/gui/tests/usage-layout.test.ts b/gui/tests/usage-layout.test.ts index 5c029e580..99ce4086d 100644 --- a/gui/tests/usage-layout.test.ts +++ b/gui/tests/usage-layout.test.ts @@ -68,6 +68,7 @@ test("usage workspace i18n keys exist in every locale", async () => { expect(dict).toContain('"usage.workspace.report":'); expect(dict).toContain('"usage.range.available":'); expect(dict).toContain('"usage.historyTruncated":'); + expect(dict).toContain('"usage.historyTruncatedWindow":'); expect(dict).toContain('"api.attribution.totalRequestsAvailable":'); } }); @@ -143,6 +144,96 @@ test("Usage renders Available history and a persistent qualification when histor } }); +test("Usage names the loaded window when history is truncated", async () => { + const page = await Bun.file(new URL("../src/pages/Usage.tsx", import.meta.url)).text(); + + // #1497: when the proxy reports the window it actually loaded, the notice must name that + // window instead of the generic wording — otherwise `30d` and "Available history" stay + // indistinguishable on a busy installation. `!= null` keeps an older proxy that omits the + // fields on the generic string rather than rendering "Invalid Date". + expect(page).toContain("usage.historyTruncatedWindow"); + // Presence alone is not enough: a hand-edited row can carry a timestamp outside Date's + // range, so both bounds must round-trip through Date before the detailed wording is used. + expect(page).toContain("function renderableInstant"); + expect(page).toContain("Number.isFinite(at.getTime())"); + // A total that silently omits in-range rows is a caveat, not a status update. + expect(page).toContain(''); +}); + +test("Usage falls back to the generic caveat when a reported bound is unrenderable", async () => { + const globalKeys = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; + const previous = Object.fromEntries(globalKeys.map(key => [key, Reflect.get(globalThis, key)])); + const originalFetch = globalThis.fetch; + const testWindow = new Window({ url: "http://localhost/" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + clearClientResourceStoresForTests(); + // A timestamp beyond Date's supported range. Rendering it would put the literal string + // "Invalid Date" inside a notice whose entire purpose is to be trustworthy. + globalThis.fetch = (async () => Response.json({ + range: "30d", + surface: "all", + since: null, + generatedAt: Date.now(), + summary: { + requests: 0, + measuredRequests: 0, + reportedRequests: 0, + unreportedRequests: 0, + unsupportedRequests: 0, + estimatedRequests: 0, + inputTokens: 0, + outputTokens: 0, + cachedInputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 0, + coverageRatio: 1, + }, + days: [], + models: [], + providers: [], + historyTruncated: true, + truncatedPrefixBytes: 1, + entriesTruncated: false, + entriesDropped: 0, + snapshotWindowStart: 1e18, + snapshotWindowEnd: 1e18, + })) as typeof fetch; + + const container = document.createElement("div"); + document.body.append(container); + const { createRoot } = await import("react-dom/client"); + const root = createRoot(container); + try { + await act(async () => { + root.render(createElement(LanguageProvider, null, createElement(Usage, { apiBase: "http://usage-invalid-window-test" }))); + }); + const deadline = Date.now() + 1_000; + while (!(container.textContent ?? "").includes("Totals cover available history only")) { + if (Date.now() >= deadline) throw new Error("Usage fallback qualification did not render"); + await act(async () => { + await new Promise(resolve => testWindow.setTimeout(resolve, 10)); + }); + } + expect(container.textContent).not.toContain("Invalid Date"); + expect(container.textContent).not.toContain("request start times ranging"); + } finally { + await act(async () => { root.unmount(); }); + container.remove(); + globalThis.fetch = originalFetch; + clearClientResourceStoresForTests(); + testWindow.close(); + for (const key of globalKeys) { + Object.defineProperty(globalThis, key, { configurable: true, value: previous[key] }); + } + } +}); + test("Usage source marks keep brand colors and invert only the monochrome Grok mark", async () => { const page = await Bun.file(new URL("../src/pages/Usage.tsx", import.meta.url)).text(); const css = await Bun.file(new URL("../src/styles.css", import.meta.url)).text(); diff --git a/src/server/management/logs-usage-routes.ts b/src/server/management/logs-usage-routes.ts index 5d5cd8bbe..083c2ed53 100644 --- a/src/server/management/logs-usage-routes.ts +++ b/src/server/management/logs-usage-routes.ts @@ -121,6 +121,29 @@ function refreshedUsageSummary end) end = at; + } + return { start, end }; +} + export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise { const { req, url, config, deps, syncClaudeAgentDefsBestEffort } = ctx; @@ -209,12 +232,15 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise 0 || snapshot.entriesTruncated, truncatedPrefixBytes: snapshot.truncatedPrefixBytes, entriesTruncated: snapshot.entriesTruncated, entriesDropped: snapshot.entriesDropped, + snapshotWindowStart: window.start, + snapshotWindowEnd: window.end, }; if (userCostOverlayVersion() !== overlayVersion) { // The overlay changed while the summary was being computed, so this @@ -266,6 +292,8 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise { } }); + // #1497: on a busy installation the newest `managementUsageMaxReadBytes` can cover far less + // than the selected range, so `30d` and "Available history" summarize the same moving tail. + // The response now names the window the reader actually loaded. It describes the READ, not + // the query — usage.jsonl is appended on request completion while rows carry the request + // start time, so the oldest loaded row does not bound what the dropped prefix contains, and + // no field here may be read as a completeness claim. + describe("snapshot window disclosure (#1497)", () => { + test("a truncated read reports the loaded window, and it matches the rows that survived", async () => { + const now = Date.now(); + writeFixture(now); + saveConfig({ ...baseConfig(), managementUsageMaxReadBytes: 256 }); + const server = startServer(0); + try { + const body = await fetch(new URL("/api/usage?range=30d", server.url)).then(r => r.json()); + expect(body.historyTruncated).toBe(true); + expect(typeof body.snapshotWindowStart).toBe("number"); + expect(typeof body.snapshotWindowEnd).toBe("number"); + expect(body.snapshotWindowStart).toBeLessThanOrEqual(body.snapshotWindowEnd); + // The dropped prefix is the OLDEST part of the file, so a truncated read cannot still + // start at the fixture's oldest row. + expect(body.snapshotWindowStart).toBeGreaterThan(now - 10 * 86_400_000); + } finally { + await server.stop(true); + } + }); + + test("the window describes the read, so range and surface filters do not move it", async () => { + // A tail small enough to truncate but large enough to retain rows the filters will + // actually discard. Retaining a single row would make every filter a no-op and the + // assertions vacuous, which is exactly what an earlier version of this test did. + const now = Date.now(); + const oldest = now - 200 * 86_400_000; + const rows = [ + // Dropped by the byte limit: only here to make the read truncated. + ...Array.from({ length: 40 }, (_, i) => ({ + requestId: `ocx-prefix-${i}`, + timestamp: oldest, + provider: "openai", + model: "gpt-5.5", + status: 200, + durationMs: 5, + usageStatus: "reported" as const, + usage: { inputTokens: 1, outputTokens: 1 }, + totalTokens: 2, + })), + // Retained, and deliberately outside a 30d window so the range filter discards it. + { + requestId: "ocx-window-old", + timestamp: now - 90 * 86_400_000, + provider: "openai", + model: "gpt-5.5", + status: 200, + durationMs: 5, + usageStatus: "reported" as const, + usage: { inputTokens: 10, outputTokens: 5 }, + totalTokens: 15, + }, + // Retained and inside 30d, but a Codex surface so the claude filter discards it. + { + requestId: "ocx-window-codex", + timestamp: now - 2 * 86_400_000, + provider: "openai", + model: "gpt-5.5", + status: 200, + durationMs: 5, + usageStatus: "reported" as const, + usage: { inputTokens: 10, outputTokens: 5 }, + totalTokens: 15, + }, + // Retained, inside 30d, and a claude surface: survives every filter. + { + requestId: "ocx-window-claude", + timestamp: now - 1 * 86_400_000, + provider: "anthropic", + model: "claude-x", + surface: "claude", + status: 200, + durationMs: 5, + usageStatus: "reported" as const, + usage: { inputTokens: 10, outputTokens: 5 }, + totalTokens: 15, + }, + ]; + writeFileSync(join(testDir, "usage.jsonl"), `${rows.map(r => JSON.stringify(r)).join("\n")}\n`); + // Sized to keep the last three rows and drop the 40-row prefix. + const tailBytes = rows.slice(-3).reduce((sum, r) => sum + Buffer.byteLength(`${JSON.stringify(r)}\n`), 0); + saveConfig({ ...baseConfig(), managementUsageMaxReadBytes: tailBytes + 8 }); + const server = startServer(0); + try { + const all = await fetch(new URL("/api/usage?range=all", server.url)).then(r => r.json()); + const thirty = await fetch(new URL("/api/usage?range=30d", server.url)).then(r => r.json()); + const claude = await fetch(new URL("/api/usage?range=all&surface=claude", server.url)).then(r => r.json()); + + expect(all.historyTruncated).toBe(true); + // The retained set really is what the filters will cut down. + expect(all.summary.requests).toBe(3); + expect(thirty.summary.requests).toBe(2); + expect(claude.summary.requests).toBe(1); + + // Exact bounds, computed independently of the reader. + expect(all.snapshotWindowStart).toBe(now - 90 * 86_400_000); + expect(all.snapshotWindowEnd).toBe(now - 1 * 86_400_000); + + for (const body of [thirty, claude]) { + expect(typeof body.snapshotWindowStart).toBe("number"); + expect(typeof body.snapshotWindowEnd).toBe("number"); + expect(body.snapshotWindowStart).toBe(all.snapshotWindowStart); + expect(body.snapshotWindowEnd).toBe(all.snapshotWindowEnd); + } + } finally { + await server.stop(true); + } + }); + + test("an untruncated read spans the whole fixture and reports no truncation", async () => { + const now = Date.now(); + writeFixture(now); + const server = startServer(0); + try { + const body = await fetch(new URL("/api/usage?range=all", server.url)).then(r => r.json()); + expect(body.historyTruncated).toBe(false); + // The oldest fixture row is 10 days back; an unbounded read must include it. + expect(body.snapshotWindowStart).toBeLessThanOrEqual(now - 10 * 86_400_000 + 1000); + expect(body.snapshotWindowEnd).toBeGreaterThanOrEqual(body.snapshotWindowStart); + } finally { + await server.stop(true); + } + }); + + test("an empty ledger reports null bounds rather than NaN or Infinity", async () => { + writeFileSync(join(testDir, "usage.jsonl"), ""); + const server = startServer(0); + try { + const body = await fetch(new URL("/api/usage?range=all", server.url)).then(r => r.json()); + expect(body.snapshotWindowStart).toBeNull(); + expect(body.snapshotWindowEnd).toBeNull(); + } finally { + await server.stop(true); + } + }); + + test("a cached response carries the window through unchanged", async () => { + writeFixture(Date.now()); + saveConfig({ ...baseConfig(), managementUsageMaxReadBytes: 256 }); + const server = startServer(0); + try { + const first = await fetch(new URL("/api/usage?range=all", server.url)).then(r => r.json()); + const second = await fetch(new URL("/api/usage?range=all", server.url)).then(r => r.json()); + // Prove the second response is a cache hit rather than a second full read; otherwise + // this asserts nothing about the cache path. + expect(usageReadCacheStatsForTests().fullReads).toBe(1); + expect(typeof first.snapshotWindowStart).toBe("number"); + expect(typeof first.snapshotWindowEnd).toBe("number"); + expect(second.snapshotWindowStart).toBe(first.snapshotWindowStart); + expect(second.snapshotWindowEnd).toBe(first.snapshotWindowEnd); + } finally { + await server.stop(true); + } + }); + }); + test("reuses only a compact summary for an unchanged revision", async () => { writeFixture(Date.now()); const server = startServer(0);