From f7c7814e187023300bbe9e7ec3050529ae020bee Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 12 Aug 2026 20:38:08 +0900 Subject: [PATCH 1/2] fix(usage): disclose the window a truncated read actually covers The usage API reads the newest managementUsageMaxReadBytes and applies the 7d/30d/all filter afterwards, so on a busy installation both 30d and Available history summarize the same moving byte tail. A measured case had 30d omitting 73.6% of in-range requests while still labelled 30d, and cumulative totals can decrease as older rows fall out of the window. The response now reports snapshotWindowStart/End: the timestamp bounds of the rows the reader loaded, before range and surface filtering. It describes the READ rather than the query, because truncation is a property of the read. Deliberately not a completeness claim. usage.jsonl is appended when a request completes while each row carries the request start time, so a long-running request can be appended after shorter ones that started later. The oldest loaded timestamp therefore does not bound what the dropped prefix contains, and no derived rangeFullyCovered flag would be sound. The dashboard names the loaded window and downgrades the notice from ok to warn, since a total that omits in-range rows is a caveat. This is a partial mitigation, not the fix #1497 asks for: complete aggregation still needs the rollup work tracked in #1008. Refs #1497, #1008 --- gui/src/i18n/de.ts | 1 + gui/src/i18n/en.ts | 1 + gui/src/i18n/ja.ts | 1 + gui/src/i18n/ko.ts | 1 + gui/src/i18n/ru.ts | 1 + gui/src/i18n/tr.ts | 1 + gui/src/i18n/zh-TW.ts | 1 + gui/src/i18n/zh.ts | 1 + gui/src/pages/Usage.tsx | 23 +++++- gui/tests/usage-layout.test.ts | 14 ++++ src/server/management/logs-usage-routes.ts | 28 +++++++ tests/api-usage.test.ts | 86 ++++++++++++++++++++++ 12 files changed, 158 insertions(+), 1 deletion(-) diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index f59ffde6e3..350fd07959 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 Summen umfassen nur die von {start} bis {end} geladene Nutzung. Ältere Einträge überschreiten das Leselimit, 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 09290a3a91..1c47c703c8 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": "Totals cover only the usage loaded from {start} to {end}. Older entries exceed 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 d2ee008a70..5b5b8c547c 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 7a5fa9b4af..2b27763879 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 c4df1a933c..6a8d6d895c 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 de51ee0b64..e9068f20dc 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": "Toplamlar yalnızca {start} - {end} aralığında yüklenen kullanımı kapsar. Daha eski kayıtlar okuma sınırını aştığı için 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 16e99c0273..fa1f63cb8b 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 792a0029ca..80f5687663 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 5169608983..193ed18e92 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,23 @@ 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. + + {/* `!= null` rather than `!== null`: an older proxy that predates these fields + omits them entirely, and an undefined bound must fall back to the generic + wording instead of rendering "Invalid Date". */} + {data.snapshotWindowStart != null && data.snapshotWindowEnd != null + ? t("usage.historyTruncatedWindow", { + start: new Date(data.snapshotWindowStart).toLocaleString(), + end: new Date(data.snapshotWindowEnd).toLocaleString(), + }) + : t("usage.historyTruncated")} + + )} { 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,19 @@ 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"); + expect(page).toContain("data.snapshotWindowStart != null && data.snapshotWindowEnd != null"); + // A total that silently omits in-range rows is a caveat, not a status update. + expect(page).toContain(''); +}); + 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 5d5cd8bbe1..083c2ed536 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 () => { + writeFixture(Date.now()); + saveConfig({ ...baseConfig(), managementUsageMaxReadBytes: 256 }); + 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(thirty.snapshotWindowStart).toBe(all.snapshotWindowStart); + expect(thirty.snapshotWindowEnd).toBe(all.snapshotWindowEnd); + expect(claude.snapshotWindowStart).toBe(all.snapshotWindowStart); + expect(claude.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()); + 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); From d65bb0c99f2eedb075d5de7ef97c2510cd54c1ff Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 12 Aug 2026 20:56:03 +0900 Subject: [PATCH 2/2] fix(usage): state the window as observed extrema, and prove the tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings, all correct. The notice said totals cover usage 'from start to end' and that older entries exceed the read limit. Both claims overstate what the data supports, and they overstate it in exactly the way this PR exists to stop: usage.jsonl is appended on request completion while rows carry the request start time, so retained rows can have gaps and the dropped prefix can contain requests whose start times fall inside the displayed interval. The wording now describes the bounds as what they are — the range of request start times among loaded rows — in all eight locales. The filter-invariance test was vacuous. A 256-byte tail retained a single row, so the surface and range filters discarded nothing and the comparisons held trivially; on unmodified dev every compared field was undefined, which also compared equal. It now builds its own fixture where the retained set is 3 rows that the filters cut to 2 and then 1, asserts exact independently computed bounds, and asserts the fields are numeric. The cache test now asserts the second request is a cache hit. Presence-checking the bounds prevented an omitted field from rendering 'Invalid Date' but not an out-of-range one, and the reader preserves whatever a hand-edited row carries. Both bounds now have to round-trip through Date before the detailed wording is used, with a rendered test covering the unrenderable case. --- gui/src/i18n/de.ts | 2 +- gui/src/i18n/en.ts | 2 +- gui/src/i18n/ja.ts | 2 +- gui/src/i18n/ko.ts | 2 +- gui/src/i18n/ru.ts | 2 +- gui/src/i18n/tr.ts | 2 +- gui/src/i18n/zh-TW.ts | 2 +- gui/src/i18n/zh.ts | 2 +- gui/src/pages/Usage.tsx | 28 +++++++---- gui/tests/usage-layout.test.ts | 79 +++++++++++++++++++++++++++++- tests/api-usage.test.ts | 87 +++++++++++++++++++++++++++++++--- 11 files changed, 186 insertions(+), 24 deletions(-) diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 350fd07959..98c476c617 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -737,7 +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 Summen umfassen nur die von {start} bis {end} geladene Nutzung. Ältere Einträge überschreiten das Leselimit, daher kann jeder gewählte Zeitraum unvollständig sein.", + "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 1c47c703c8..77648b63ca 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -768,7 +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": "Totals cover only the usage loaded from {start} to {end}. Older entries exceed the read limit, so any selected range may be incomplete.", + "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 5b5b8c547c..4e56f55d38 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -726,7 +726,7 @@ export const ja: Record = { "usage.range.all": "すべて", "usage.range.available": "利用可能な履歴", "usage.historyTruncated": "古い利用履歴が読み込まれていないため、合計は利用可能な履歴のみを対象とします。", - "usage.historyTruncatedWindow": "合計は {start} から {end} までに読み込まれた利用量のみを対象とします。それより古い記録は読み取り上限を超えるため、選択した期間が不完全な場合があります。", + "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 2b27763879..c59cfde7fb 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -759,7 +759,7 @@ export const ko: Record = { "usage.range.all": "전체", "usage.range.available": "사용 가능한 기록", "usage.historyTruncated": "이전 사용 기록을 불러오지 않아 합계는 사용 가능한 기록만 포함합니다.", - "usage.historyTruncatedWindow": "합계는 {start}부터 {end}까지 불러온 사용량만 포함합니다. 그보다 오래된 기록은 읽기 한도를 넘어 선택한 기간이 완전하지 않을 수 있습니다.", + "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 6a8d6d895c..ed86f7eb3d 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -758,7 +758,7 @@ export const ru: Record = { "usage.range.all": "Все", "usage.range.available": "Доступная история", "usage.historyTruncated": "Итоги охватывают только доступную историю, поскольку старые данные не загружены.", - "usage.historyTruncatedWindow": "Итоги охватывают только использование, загруженное с {start} по {end}. Более старые записи превышают лимит чтения, поэтому выбранный период может быть неполным.", + "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 e9068f20dc..3aa8275305 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -765,7 +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": "Toplamlar yalnızca {start} - {end} aralığında yüklenen kullanımı kapsar. Daha eski kayıtlar okuma sınırını aştığı için seçilen aralık eksik olabilir.", + "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 fa1f63cb8b..09782ffe73 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1586,7 +1586,7 @@ export const zhTW: Record = { "provider.name.volcengineAgentPlan": "Volcengine Ark Agent Plan", "usage.range.available": "可用歷史紀錄", "usage.historyTruncated": "總計僅涵蓋可用歷史紀錄,因為較舊的用量未被載入。", - "usage.historyTruncatedWindow": "總計僅涵蓋從 {start} 到 {end} 已載入的用量。更早的紀錄超出讀取上限,因此所選期間可能不完整。", + "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 80f5687663..ede0ffcef2 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -752,7 +752,7 @@ export const zh: Record = { "usage.range.all": "全部", "usage.range.available": "可用历史", "usage.historyTruncated": "由于未加载较早的使用记录,合计仅涵盖可用历史。", - "usage.historyTruncatedWindow": "合计仅涵盖从 {start} 到 {end} 已加载的用量。更早的记录超出读取上限,因此所选时间范围可能不完整。", + "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 193ed18e92..18cf77b4f8 100644 --- a/gui/src/pages/Usage.tsx +++ b/gui/src/pages/Usage.tsx @@ -827,15 +827,16 @@ export default function Usage({ apiBase }: { apiBase: string }) { // they claim (#1497). `warn` rather than `ok` because a total that silently omits // in-range rows is a caveat, not a status update. - {/* `!= null` rather than `!== null`: an older proxy that predates these fields - omits them entirely, and an undefined bound must fall back to the generic - wording instead of rendering "Invalid Date". */} - {data.snapshotWindowStart != null && data.snapshotWindowEnd != null - ? t("usage.historyTruncatedWindow", { - start: new Date(data.snapshotWindowStart).toLocaleString(), - end: new Date(data.snapshotWindowEnd).toLocaleString(), - }) - : t("usage.historyTruncated")} + {(() => { + // 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 7375345731..99ce4086d0 100644 --- a/gui/tests/usage-layout.test.ts +++ b/gui/tests/usage-layout.test.ts @@ -152,11 +152,88 @@ test("Usage names the loaded window when history is truncated", async () => { // 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"); - expect(page).toContain("data.snapshotWindowStart != null && data.snapshotWindowEnd != null"); + // 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/tests/api-usage.test.ts b/tests/api-usage.test.ts index 268e9e7000..a7fb2ab246 100644 --- a/tests/api-usage.test.ts +++ b/tests/api-usage.test.ts @@ -163,18 +163,88 @@ describe("GET /api/usage", () => { }); test("the window describes the read, so range and surface filters do not move it", async () => { - writeFixture(Date.now()); - saveConfig({ ...baseConfig(), managementUsageMaxReadBytes: 256 }); + // 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(thirty.snapshotWindowStart).toBe(all.snapshotWindowStart); - expect(thirty.snapshotWindowEnd).toBe(all.snapshotWindowEnd); - expect(claude.snapshotWindowStart).toBe(all.snapshotWindowStart); - expect(claude.snapshotWindowEnd).toBe(all.snapshotWindowEnd); + 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); } @@ -214,6 +284,11 @@ describe("GET /api/usage", () => { 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 {