From 12ad2a8e9735745ac6097daa230da1112e14d936 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Sat, 3 Oct 2026 16:21:09 +0800 Subject: [PATCH] feat(usage): redesign request details as a compact event log - Add grouped token, cache, and latency details - Tighten column spacing and preserve custom widths - Move pagination to the footer and add CSV export - Support dark mode and responsive layouts --- src/i18n/ja.ts | 20 + src/i18n/locales/en.ts | 20 + src/i18n/locales/zh-CN.ts | 20 + src/pages/UsageEventsView.css | 615 +++++++++++ src/pages/UsageEventsView.tsx | 817 +++++++++++++++ src/pages/UsageRecordsPage.tsx | 1360 ++++++------------------- src/services/usageMetrics.ts | 28 + src/styles.css | 417 +++++++- src/styles/feature-pages.css | 1 + src/styles/index.css | 1 + src/styles/responsive-shell-usage.css | 66 +- src/styles/ui-refinements.css | 89 +- tests/fixtures/usage-layout.tsx | 85 +- tests/responsive-layout-ui.cjs | 16 +- tests/uiLocalization.test.ts | 34 +- tests/usage-layout-ui.cjs | 110 +- tests/usage-scroll-ui.cjs | 98 +- tests/usageMetrics.test.ts | 39 + 18 files changed, 2574 insertions(+), 1262 deletions(-) create mode 100644 src/pages/UsageEventsView.css create mode 100644 src/pages/UsageEventsView.tsx diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 50c2a6f..d49e8f5 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -611,6 +611,17 @@ export const jaOverrides = { 'usage.dataManagement.success': '履歴データを処理しました。{repaired} 件を修復し、誤った記録を {deleted} 件削除しました。', 'usage.trend.title': 'Token の推移', 'usage.token.title': 'Token の内訳', + 'usage.token.uncachedInput': '非キャッシュ入力', + 'usage.token.cacheWrite': 'キャッシュ書込', + 'usage.token.cacheShare': 'キャッシュ読取比率', + 'usage.token.reasoningNote': '推論 Token は出力に含まれるため、グラフには重複して計上しません。', + 'usage.token.requestStatus': 'リクエスト結果', + 'usage.token.performance': 'パフォーマンス', + 'usage.token.pricing': '料金カバー率', + 'usage.token.pricingNeedsUpdate': '料金カバー率(価格統計ページで価格を更新してください)', + 'usage.token.priced': '計算済み', + 'usage.token.unpriced': '未計算', + 'usage.token.samples': '有効サンプル {count} 件', 'usage.token.description': 'キャッシュ、推論、生成の消費量', 'usage.token.input': '入力', 'usage.token.output': '出力', @@ -633,6 +644,12 @@ export const jaOverrides = { 'usage.analysis.hours': '時間帯分布', 'usage.analysis.sortedByTokens': 'Token 順', 'usage.analysis.itemMeta': '{requests} 回 · {percent}% · {tokens} Token', + 'usage.events.title': 'リクエストイベントログ', + 'usage.events.description': '選択した期間のリクエストイベントを絞り込んで確認します。', + 'usage.events.columns': '列', + 'usage.events.exportPage': 'このページを CSV 出力', + 'usage.events.exportDescription': '現在のページの全リクエスト項目を CSV ファイルに出力します', + 'usage.events.loaded': '読み込み済み {count} / {total}', 'usage.events.total': '合計 {count} 件', 'usage.events.page': '{page} / {total} ページ', 'usage.events.resetColumns': '列幅をリセット', @@ -647,6 +664,9 @@ export const jaOverrides = { 'usage.events.rangeSummary': '{total} 件中 {start} - {end} 件を表示', 'usage.column.time': '日時', 'usage.column.model': 'モデル', + 'usage.column.effort': '推論レベル', + 'usage.column.request': 'リクエスト', + 'usage.column.tokens': 'Token', 'usage.column.provider': 'プロバイダー', 'usage.column.source': 'ソース', 'usage.column.key': 'API キー', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 2c1629e..06b5a09 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -599,6 +599,17 @@ export const en: Record = { 'usage.dataManagement.success': 'Historical data processed: {repaired} repaired and {deleted} invalid records deleted.', 'usage.trend.title': 'Token Trend', 'usage.token.title': 'Token Breakdown', + 'usage.token.uncachedInput': 'Uncached Input', + 'usage.token.cacheWrite': 'Cache Write', + 'usage.token.cacheShare': 'Cache read share', + 'usage.token.reasoningNote': 'Reasoning tokens are included in output and are not counted again in the chart.', + 'usage.token.requestStatus': 'Request results', + 'usage.token.performance': 'Performance', + 'usage.token.pricing': 'Price coverage', + 'usage.token.pricingNeedsUpdate': 'Price coverage (update prices on the Price Statistics page)', + 'usage.token.priced': 'Priced', + 'usage.token.unpriced': 'Unpriced', + 'usage.token.samples': '{count} valid samples', 'usage.token.description': 'Cache, reasoning, and generation usage', 'usage.token.input': 'Input', 'usage.token.output': 'Output', @@ -621,6 +632,12 @@ export const en: Record = { 'usage.analysis.hours': 'Hourly Distribution', 'usage.analysis.sortedByTokens': 'Sorted by tokens', 'usage.analysis.itemMeta': '{requests} requests · {percent}% · {tokens} tokens', + 'usage.events.title': 'Request Event Log', + 'usage.events.description': 'Filter and inspect request events in the selected time range.', + 'usage.events.columns': 'Columns', + 'usage.events.exportPage': 'Export Page CSV', + 'usage.events.exportDescription': 'Export all request fields on the current page as a CSV file', + 'usage.events.loaded': 'Loaded {count} / {total}', 'usage.events.total': '{count} records', 'usage.events.page': 'Page {page} of {total}', 'usage.events.resetColumns': 'Reset Widths', @@ -635,6 +652,9 @@ export const en: Record = { 'usage.events.rangeSummary': 'Showing {start} - {end} of {total}', 'usage.column.time': 'Time', 'usage.column.model': 'Model', + 'usage.column.effort': 'Reasoning Effort', + 'usage.column.request': 'Request', + 'usage.column.tokens': 'Token', 'usage.column.provider': 'Provider', 'usage.column.source': 'Source', 'usage.column.key': 'API Key', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index d6fcde5..69edba9 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -598,6 +598,17 @@ export const zhCN = { 'usage.dataManagement.success': '历史数据处理完成:修复 {repaired} 条,删除 {deleted} 条误记录。', 'usage.trend.title': 'Token 趋势', 'usage.token.title': 'Token 构成', + 'usage.token.uncachedInput': '未缓存输入', + 'usage.token.cacheWrite': '缓存写入', + 'usage.token.cacheShare': '缓存读取占比', + 'usage.token.reasoningNote': '思考 Token 已包含在输出中,不重复计入环形图。', + 'usage.token.requestStatus': '请求结果', + 'usage.token.performance': '请求性能', + 'usage.token.pricing': '计价覆盖', + 'usage.token.pricingNeedsUpdate': '计价覆盖(请在价格统计页面更新价格)', + 'usage.token.priced': '已计价', + 'usage.token.unpriced': '未计价', + 'usage.token.samples': '{count} 条有效样本', 'usage.token.description': '缓存、思考与生成消耗', 'usage.token.input': '输入', 'usage.token.output': '输出', @@ -620,6 +631,12 @@ export const zhCN = { 'usage.analysis.hours': '时段分布', 'usage.analysis.sortedByTokens': '按 Token 排序', 'usage.analysis.itemMeta': '{requests} 次 · {percent}% · {tokens} Token', + 'usage.events.title': '请求事件日志', + 'usage.events.description': '筛选并查看所选时间范围内的请求事件。', + 'usage.events.columns': '列', + 'usage.events.exportPage': '导出本页 CSV', + 'usage.events.exportDescription': '导出当前页的全部请求字段为 CSV 文件', + 'usage.events.loaded': '已加载 {count} / {total}', 'usage.events.total': '共 {count} 条记录', 'usage.events.page': '第 {page} / {total} 页', 'usage.events.resetColumns': '重置列宽', @@ -634,6 +651,9 @@ export const zhCN = { 'usage.events.rangeSummary': '显示第 {start} - {end} 条,共 {total} 条', 'usage.column.time': '时间', 'usage.column.model': '模型', + 'usage.column.effort': '推理强度', + 'usage.column.request': '请求', + 'usage.column.tokens': 'Token', 'usage.column.provider': '提供商', 'usage.column.source': '来源', 'usage.column.key': '鉴权密钥', diff --git a/src/pages/UsageEventsView.css b/src/pages/UsageEventsView.css new file mode 100644 index 0000000..72d7fdf --- /dev/null +++ b/src/pages/UsageEventsView.css @@ -0,0 +1,615 @@ +/* The request log is deliberately scoped away from the other usage tabs. */ +.usage-records-page .usage-request-log { + --usage-log-surface: var(--bg-card); + --usage-log-text: #292724; + --usage-log-muted: #77736d; + --usage-log-line: #e5e4e2; + --usage-log-hover: #fafaf9; + --usage-log-soft: #f7f7f6; + --usage-log-input: #0076b5; + --usage-log-output: #15803d; + --usage-log-reasoning: #7c3aed; + --usage-log-cache-read: #b45309; + --usage-log-cache-write: #be185d; + display: flex; + min-width: 0; + min-height: 0; + padding: 0; + gap: 0; + border: 1px solid var(--usage-log-line); + border-radius: 16px; + background: var(--usage-log-surface); + color: var(--usage-log-text); + box-shadow: 0 2px 10px rgb(0 0 0 / 2%); + overflow: hidden; +} + +.usage-request-log .usage-events-summary { + min-height: 88px; + flex: none; + align-items: flex-start; + flex-wrap: wrap; + gap: 16px; + padding: 19px 20px 17px; + border-bottom: 1px solid var(--usage-log-line); + background: var(--usage-log-surface); + color: var(--usage-log-text); +} + +.usage-request-log .usage-events-heading { + min-width: 0; + flex: 1 1 240px; +} + +.usage-request-log .usage-events-title-row { + display: flex; + min-width: 0; + align-items: center; + flex-wrap: wrap; + gap: 10px; +} + +.usage-request-log .usage-events-title-row h2 { + margin: 0; + color: var(--usage-log-text); + font-size: 18px; + font-weight: 650; + line-height: 1.4; + letter-spacing: -.02em; +} + +.usage-request-log .usage-events-heading p { + margin: 9px 0 0; + color: var(--usage-log-muted); + font-size: 12px; + line-height: 1.5; +} + +.usage-request-log .usage-events-count-badge { + padding: 3px 9px; + border: 1px solid var(--usage-log-line); + border-radius: 999px; + background: var(--usage-log-soft); + color: var(--usage-log-text); + font-size: 11px; + font-weight: 600; + line-height: 1.45; +} + +.usage-request-log .usage-events-summary-right { + width: auto; + margin-left: auto; + justify-content: flex-end; + gap: 10px; +} + +.usage-request-log .usage-col-settings-btn, +.usage-request-log .usage-col-reset-btn, +.usage-request-log .usage-events-export-btn { + display: inline-flex; + height: 40px; + align-items: center; + justify-content: center; + gap: 7px; + padding: 0 14px; + border: 1px solid var(--usage-log-line); + border-radius: 999px; + background: var(--usage-log-surface); + color: var(--usage-log-text); + box-shadow: 0 2px 3px rgb(0 0 0 / 3%), 0 7px 16px rgb(0 0 0 / 4%); + font-size: 12px; + font-weight: 600; + white-space: nowrap; + cursor: pointer; + transition: background .15s ease, border-color .15s ease; +} + +.usage-request-log .usage-col-reset-btn.icon-only { + width: 40px; + padding: 0; +} + +.usage-request-log .usage-col-settings-btn:hover, +.usage-request-log .usage-col-reset-btn:hover, +.usage-request-log .usage-events-export-btn:hover { + border-color: var(--border-hover); + background: var(--usage-log-soft); + color: var(--usage-log-text); +} + +.usage-request-log .usage-events-export-btn:disabled, +.usage-request-log .usage-col-settings-btn:disabled, +.usage-request-log .usage-col-reset-btn:disabled { + opacity: .48; + cursor: default; + box-shadow: none; +} + +.usage-request-log .usage-col-settings-btn:focus-visible, +.usage-request-log .usage-col-reset-btn:focus-visible, +.usage-request-log .usage-events-export-btn:focus-visible, +.usage-request-log .usage-page-nav-btn:focus-visible { + outline: 2px solid var(--ui-accent); + outline-offset: 3px; +} + +.usage-request-log .usage-events-filter-slot { + min-width: 0; + flex: none; +} + +.usage-records-page .usage-request-log .usage-filter-panel { + flex: none; + gap: 12px; + padding: 16px 20px; + border: 0; + border-bottom: 1px solid var(--usage-log-line); + border-radius: 0; + background: var(--usage-log-surface); + box-shadow: none; +} + +.usage-records-page .usage-request-log .usage-filter-row { + align-items: flex-end; + justify-content: flex-start; + gap: 12px; +} + +.usage-records-page .usage-request-log .usage-filter-group { + display: flex; + flex: 0 1 auto; + align-items: flex-end; + gap: 10px; + flex-wrap: wrap; +} + +.usage-records-page .usage-request-log .usage-filter-item { + display: flex; + width: 160px; + min-width: 130px; + height: auto; + padding: 0; + flex: 0 1 160px; + flex-direction: column; + align-items: stretch; + gap: 5px; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; +} + +.usage-records-page .usage-request-log .usage-filter-label { + display: block; + padding: 0; + margin: 0; + border: 0; + color: var(--usage-log-muted); + font-size: 11px; + font-weight: 500; + line-height: 1.4; +} + +.usage-records-page .usage-request-log .usage-filter-item select, +.usage-records-page .usage-request-log .usage-filter-item input { + width: 100%; + height: 39px; + padding: 0 28px 0 13px; + border: 1px solid var(--usage-log-line); + border-radius: 999px; + background-color: var(--usage-log-surface); + color: var(--usage-log-text); + font-size: 12px; + box-shadow: none; +} + +.usage-records-page .usage-request-log .usage-filter-item select:hover, +.usage-records-page .usage-request-log .usage-filter-item input:hover { + border-color: var(--border-hover); +} + +.usage-records-page .usage-request-log .usage-filter-item select:focus, +.usage-records-page .usage-request-log .usage-filter-item input:focus { + border-color: var(--ui-accent); + box-shadow: 0 0 0 3px var(--ui-focus-ring); +} + +.usage-records-page .usage-request-log .usage-filter-item:focus-within { + border: 0; + box-shadow: none; +} + +.usage-records-page .usage-request-log .usage-filter-reset-btn { + align-self: flex-end; + height: 36px; + margin-bottom: 1px; + padding: 0 12px; + border: 1px solid transparent; + border-radius: 999px; + background: var(--usage-log-soft); + color: var(--usage-log-muted); + font-size: 12px; + font-weight: 500; +} + +.usage-records-page .usage-request-log .usage-filter-reset-btn:hover { + border-color: var(--usage-log-line); + background: var(--bg-hover); + color: var(--usage-log-text); +} + +.usage-records-page .usage-request-log .usage-filter-reset-btn:disabled { + opacity: .46; + cursor: default; +} + +.usage-request-log .usage-custom-range { + flex-wrap: wrap; + padding-top: 10px; + border-color: var(--usage-log-line); + color: var(--usage-log-muted); +} + +.usage-request-log .usage-custom-range input { + border-color: var(--usage-log-line); + border-radius: 8px; + background: var(--usage-log-surface); + color: var(--usage-log-text); +} + +.usage-request-log .usage-table-wrap { + min-height: 0; + flex: 1 1 auto; + background: var(--usage-log-surface); + scrollbar-gutter: auto; +} + +.usage-request-log .usage-events-table { + font-size: 12px; + font-variant-numeric: tabular-nums; +} + +.usage-request-log .usage-events-table th, +.usage-request-log .usage-events-table td { + text-align: left; + border-bottom-color: var(--usage-log-line); +} + +.usage-request-log .usage-events-table th { + height: 36px; + padding: 0; + background: var(--usage-log-surface); + color: var(--usage-log-muted); + box-shadow: none; + font-size: 10px; + font-weight: 500; +} + +.usage-request-log .usage-events-table th .usage-th-content { + height: 36px; + justify-content: flex-start; + padding: 0 8px; + text-align: left; +} + +.usage-request-log .usage-events-table td { + height: 76px; + padding: 12px 8px; + color: var(--usage-log-text); + line-height: 1.45; +} + +.usage-request-log .usage-events-table tbody tr:hover td { + background: var(--usage-log-hover) !important; +} + +.usage-request-log .usage-events-table td strong { + color: var(--usage-log-text); + font-weight: 600; +} + +.usage-request-log .usage-events-table td small { + color: var(--usage-log-muted); + font-size: 10px; + line-height: 1.6; +} + +.usage-request-log .usage-events-table .usage-stacked-cell > small { + margin-top: 2px; +} + +.usage-request-log .usage-events-table .usage-td-key > strong { + font-family: var(--font-family-mono); + font-size: 11px; + font-weight: 500; +} + +.usage-request-log .usage-events-table .usage-td-time, +.usage-request-log .usage-events-table .usage-td-speed { + color: var(--usage-log-text); + font-weight: 600; +} + +.usage-request-log .usage-event-source { + display: flex; + min-width: 0; + align-items: center; + gap: 6px; +} + +.usage-request-log .usage-event-source > span:last-child { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.usage-request-log .usage-event-source-icon { + display: inline-flex; + width: 25px; + height: 25px; + flex: 0 0 25px; + align-items: center; + justify-content: center; + border: 3px solid var(--usage-log-surface); + border-radius: 50%; + background: linear-gradient(145deg, #a5a1ff, #6557f4); + color: #fff; + box-shadow: 0 0 0 1px var(--usage-log-line); +} + +.usage-request-log .usage-event-source-icon svg { + width: 13px; + height: 13px; +} + +.usage-request-log .usage-event-metrics { + display: flex; + align-items: center; + gap: 9px; + margin-top: 3px; + font-size: 10px; + font-weight: 600; + line-height: 1.45; + white-space: nowrap; +} + +.usage-request-log .usage-event-metrics > span { + display: inline-flex; + align-items: center; + gap: 5px; +} + +.usage-request-log .usage-event-metrics svg { + width: 11px; + height: 11px; + flex: none; + stroke-width: 1.8; +} + +.usage-request-log .tone-input { color: var(--usage-log-input); } +.usage-request-log .tone-output { color: var(--usage-log-output); } +.usage-request-log .tone-reasoning { color: var(--usage-log-reasoning); } +.usage-request-log .tone-cache-read { color: var(--usage-log-cache-read); } +.usage-request-log .tone-cache-write { color: var(--usage-log-cache-write); } + +.usage-request-log .usage-tag-pill { + padding: 0; + border-radius: 0; + background: transparent; + color: var(--usage-log-text); + font-size: 12px; + font-weight: 500; +} + +.usage-request-log .usage-result { + min-width: 48px; + justify-content: center; + padding: 3px 11px; + border: 1px solid transparent; + border-radius: 999px; + font-size: 10px; + font-weight: 600; + line-height: 1.35; +} + +.usage-request-log .usage-result-dot { display: none; } + +.usage-request-log .usage-result.success { + border-color: #6ee7b7; + background: #d1fae5; + color: #047857; +} + +.usage-request-log .usage-result.failed { + border-color: var(--failure-badge-border); + background: var(--failure-badge-bg); + color: var(--failure-badge-text); +} + +.usage-request-log .usage-result.canceled { + border-color: var(--usage-log-line); + background: var(--usage-log-soft); + color: var(--usage-log-muted); +} + +.usage-request-log .usage-result-cell > small { + display: -webkit-box; + margin-top: 5px; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.usage-request-log .usage-col-resizer::after { + height: 50%; + background: var(--usage-log-line); + opacity: 0; +} + +.usage-request-log .usage-events-table th:hover .usage-col-resizer::after, +.usage-request-log .usage-col-resizer:focus-visible::after { + opacity: 1; + background: var(--usage-log-muted); +} + +.usage-request-log .usage-table-top-scrollbar { + flex: 0 0 12px; + height: 12px; + min-height: 12px; + border: 0; + background: var(--usage-log-surface); +} + +.usage-request-log .usage-table-top-scrollbar::-webkit-scrollbar { height: 12px; } + +.usage-request-log .usage-table-top-scrollbar::-webkit-scrollbar-thumb { + border: 2px solid transparent; + background-color: #d6d5d2; +} + +.usage-request-log .usage-table-top-scrollbar::-webkit-scrollbar-thumb:hover, +.usage-request-log .usage-table-top-scrollbar::-webkit-scrollbar-thumb:active { + border-width: 2px; + background-color: #b4b1ac; +} + +.usage-request-log .usage-events-footer { + display: flex; + min-height: 62px; + flex: none; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 12px; + padding: 12px 18px; + border-top: 1px solid var(--usage-log-line); + background: var(--usage-log-surface); +} + +.usage-request-log .usage-pagination-summary, +.usage-request-log .usage-pagination-info { + color: var(--usage-log-muted); + font-size: 11px; +} + +.usage-request-log .usage-pagination-controls { margin-left: auto; } + +.usage-request-log .usage-page-size-select, +.usage-request-log .usage-page-nav-btn { + height: 32px; + padding: 0 11px; + border: 1px solid var(--usage-log-line); + border-radius: 999px; + background: var(--usage-log-surface); + color: var(--usage-log-text); + font-size: 11px; +} + +.usage-request-log .usage-page-nav-btn:hover:not(:disabled) { + border-color: var(--border-hover); + background: var(--usage-log-soft); + color: var(--usage-log-text); +} + +.usage-request-log > .usage-empty { + min-height: 220px; + color: var(--usage-log-muted); +} + +:root[data-theme='dark'] .usage-records-page .usage-request-log { + --usage-log-text: #edece9; + --usage-log-muted: #aaa8a4; + --usage-log-line: #34363b; + --usage-log-hover: #26282c; + --usage-log-soft: #292b30; + --usage-log-input: #38bdf8; + --usage-log-output: #4ade80; + --usage-log-reasoning: #b69cff; + --usage-log-cache-read: #fbbf24; + --usage-log-cache-write: #f472b6; + border-color: var(--usage-log-line); + background: var(--usage-log-surface); + box-shadow: none; +} + +:root[data-theme='dark'] .usage-request-log .usage-result.success { + border-color: #17664e; + background: #123c30; + color: #6ee7b7; +} + +:root[data-theme='dark'] .usage-request-log .usage-table-top-scrollbar::-webkit-scrollbar-thumb { + background-color: #505258; +} + +:root[data-theme='dark'] .usage-records-page .usage-request-log .usage-filter-item select, +:root[data-theme='dark'] .usage-records-page .usage-request-log .usage-filter-item input { + background: var(--usage-log-surface); + color: var(--usage-log-text); +} + +@container workspace (max-width: 760px) { + .usage-request-log .usage-events-summary { + padding: 17px 16px; + } + + .usage-records-page .usage-request-log .usage-filter-panel { + padding: 14px 16px; + } + + .usage-records-page .usage-request-log .usage-filter-group { + flex: 1 1 100%; + } + + .usage-records-page .usage-request-log .usage-filter-item { + flex: 1 1 140px; + max-width: none; + } + + .usage-request-log .usage-events-footer { + padding: 12px 14px; + } +} + +@media (max-width: 760px) { + .usage-request-log .usage-table-wrap { + height: min(65vh, 580px); + min-height: 260px; + flex: none; + } +} + +@container workspace (max-width: 480px) { + .usage-request-log .usage-events-summary { + gap: 13px; + } + + .usage-request-log .usage-events-heading { + flex-basis: 100%; + } + + .usage-request-log .usage-events-summary-right { + width: auto; + margin-left: 0; + justify-content: flex-start; + } + + .usage-request-log .usage-col-settings-btn, + .usage-request-log .usage-events-export-btn { + height: 34px; + } + + .usage-request-log .usage-events-footer { + justify-content: flex-start; + } + + .usage-request-log .usage-pagination-controls { + width: 100%; + margin-left: 0; + } + + .usage-request-log .usage-pagination-right { + margin-left: auto; + } +} diff --git a/src/pages/UsageEventsView.tsx b/src/pages/UsageEventsView.tsx new file mode 100644 index 0000000..6859664 --- /dev/null +++ b/src/pages/UsageEventsView.tsx @@ -0,0 +1,817 @@ +import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'; +import { ArrowDown, ArrowUp, Brain, ChevronLeft, ChevronRight, Columns3Cog, Database, DatabaseZap, Download, RotateCcw, Terminal, TriangleAlert, X } from 'lucide-react'; +import { getCurrentLocale, useI18n } from '../i18n'; +import type { MessageKey } from '../i18n/resources'; +import { formatCacheReadRate, formatGenerationSpeed } from '../services/usageMetrics'; +import { formatDuration, formatUsageNumber } from '../services/usageNumber'; +import { useDialogFocusTrap } from '../components/useDialogFocusTrap'; + +const compactNumber = (value: number) => formatUsageNumber(value, getCurrentLocale()); +const compactDuration = (value: number) => formatDuration(value, getCurrentLocale()); +const formatTime = (value: string) => { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? value : new Intl.DateTimeFormat(getCurrentLocale(), { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).format(date); +}; +const formatEventDate = (value: string) => { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? '' : new Intl.DateTimeFormat(getCurrentLocale(), { year: 'numeric', month: '2-digit', day: '2-digit' }).format(date); +}; +export type UsageRecord = { + id: string; + row_id: string; + timestamp: string; + latency_ms: number; + ttft_ms: number | null; + source: string; + source_display: string; + failed: boolean; + canceled: boolean; + failure_status: number; + failure_body: string; + provider: string; + model: string; + alias: string; + reasoning_effort: string; + endpoint: string; + api_key_hash: string; + api_key_display: string; + api_key_remark: string; + tokens: { + input_tokens: number; + output_tokens: number; + reasoning_tokens: number; + cache_read_tokens: number; + cache_creation_tokens: number; + total_tokens: number; + }; +}; + +export type UsageEventPage = { + items: UsageRecord[]; + total: number; + page: number; + pageSize: number; + totalPages: number; +}; + +type EventColumnKey = + | 'time' + | 'model' + | 'provider' + | 'source' + | 'key' + | 'input' + | 'output' + | 'reasoning' + | 'cache' + | 'total' + | 'result' + | 'latency' + | 'ttft' + | 'speed' + | 'cacheRate' + | 'effort' + | 'request'; + +type EventColumnDef = { + key: EventColumnKey; + labelKey: MessageKey; + defaultWidth: number; + minWidth: number; + align: 'left' | 'center' | 'right'; +}; + +const EVENT_COLUMNS: readonly EventColumnDef[] = [ + { key: 'time', labelKey: 'usage.column.time', defaultWidth: 84, minWidth: 76, align: 'left' }, + { key: 'key', labelKey: 'usage.column.key', defaultWidth: 120, minWidth: 96, align: 'left' }, + { key: 'source', labelKey: 'usage.column.source', defaultWidth: 180, minWidth: 120, align: 'left' }, + { key: 'model', labelKey: 'usage.column.model', defaultWidth: 132, minWidth: 104, align: 'left' }, + { key: 'effort', labelKey: 'usage.column.effort', defaultWidth: 76, minWidth: 64, align: 'left' }, + { key: 'result', labelKey: 'usage.column.result', defaultWidth: 80, minWidth: 68, align: 'left' }, + { key: 'request', labelKey: 'usage.column.request', defaultWidth: 112, minWidth: 88, align: 'left' }, + { key: 'latency', labelKey: 'usage.column.latency', defaultWidth: 112, minWidth: 96, align: 'left' }, + { key: 'speed', labelKey: 'usage.column.speed', defaultWidth: 88, minWidth: 72, align: 'left' }, + { key: 'total', labelKey: 'usage.column.tokens', defaultWidth: 120, minWidth: 112, align: 'left' }, + { key: 'cache', labelKey: 'usage.column.cache', defaultWidth: 104, minWidth: 88, align: 'left' }, + { key: 'provider', labelKey: 'usage.column.provider', defaultWidth: 108, minWidth: 88, align: 'left' }, + { key: 'input', labelKey: 'usage.column.input', defaultWidth: 76, minWidth: 60, align: 'left' }, + { key: 'output', labelKey: 'usage.column.output', defaultWidth: 76, minWidth: 60, align: 'left' }, + { key: 'reasoning', labelKey: 'usage.column.reasoning', defaultWidth: 76, minWidth: 60, align: 'left' }, + { key: 'cacheRate', labelKey: 'usage.column.cacheRate', defaultWidth: 80, minWidth: 68, align: 'left' }, + { key: 'ttft', labelKey: 'usage.column.ttft', defaultWidth: 92, minWidth: 76, align: 'left' }, +] as const; + +const DEFAULT_EVENT_VISIBLE_COLUMNS: readonly EventColumnKey[] = [ + 'time', 'key', 'source', 'model', 'effort', 'result', 'request', 'latency', 'speed', 'total', 'cache', 'provider', +]; +const LEGACY_DEFAULT_EVENT_VISIBLE_COLUMNS: readonly EventColumnKey[] = [ + 'time', 'model', 'input', 'output', 'cache', 'cacheRate', 'total', 'speed', 'ttft', 'latency', 'result', 'provider', 'source', +]; + +const EVENT_COL_WIDTHS_STORAGE_KEY = 'cpa-gui.usage-events-col-widths.v2'; +const LEGACY_EVENT_COL_WIDTHS_STORAGE_KEY = 'cpa-gui.usage-events-col-widths.v1'; +const PREVIOUS_EVENT_COLUMN_WIDTHS: Record = { + time: 105, key: 150, source: 205, model: 150, effort: 100, result: 95, + request: 145, latency: 125, speed: 110, total: 145, cache: 135, provider: 135, + input: 90, output: 90, reasoning: 90, cacheRate: 95, ttft: 110, +}; +const EVENT_VISIBLE_COLS_STORAGE_KEY = 'cpa-gui.usage-events-visible-cols.v3'; +const LEGACY_EVENT_VISIBLE_COLS_STORAGE_KEY = 'cpa-gui.usage-events-visible-cols.v2'; + +const getAllEventColumnKeys = () => EVENT_COLUMNS.map((column) => column.key); + +const getInitialVisibleColumns = (): EventColumnKey[] => { + try { + const currentRaw = localStorage.getItem(EVENT_VISIBLE_COLS_STORAGE_KEY); + const raw = currentRaw ?? localStorage.getItem(LEGACY_EVENT_VISIBLE_COLS_STORAGE_KEY); + if (raw) { + const parsed: unknown = JSON.parse(raw); + if (Array.isArray(parsed)) { + const knownKeys = new Set(getAllEventColumnKeys()); + const seen = new Set(); + const savedKeys = parsed.filter((key): key is EventColumnKey => { + if (typeof key !== 'string' || !knownKeys.has(key as EventColumnKey) || seen.has(key as EventColumnKey)) { + return false; + } + seen.add(key as EventColumnKey); + return true; + }); + if (savedKeys.length > 0) { + const wasLegacyDefault = currentRaw === null + && savedKeys.length === LEGACY_DEFAULT_EVENT_VISIBLE_COLUMNS.length + && LEGACY_DEFAULT_EVENT_VISIBLE_COLUMNS.every((key) => savedKeys.includes(key)); + return wasLegacyDefault ? [...DEFAULT_EVENT_VISIBLE_COLUMNS] : savedKeys; + } + } + } + } catch { + } + return [...DEFAULT_EVENT_VISIBLE_COLUMNS]; +}; + +const getInitialColumnWidths = (): Record => { + const initial: Record = {} as any; + for (const col of EVENT_COLUMNS) { + initial[col.key] = col.defaultWidth; + } + try { + const currentRaw = localStorage.getItem(EVENT_COL_WIDTHS_STORAGE_KEY); + const raw = currentRaw ?? localStorage.getItem(LEGACY_EVENT_COL_WIDTHS_STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object') { + for (const col of EVENT_COLUMNS) { + if ( + typeof parsed[col.key] === 'number' && + Number.isFinite(parsed[col.key]) && + parsed[col.key] >= col.minWidth + ) { + // Adopt tighter defaults while retaining columns the user resized. + const wasDefault = currentRaw === null && parsed[col.key] === PREVIOUS_EVENT_COLUMN_WIDTHS[col.key]; + if (!wasDefault) initial[col.key] = Math.min(800, Math.round(parsed[col.key])); + } + } + } + } + } catch { + } + return initial; +}; + +function TableTopScrollbar({ + tableWrapRef, +}: { + tableWrapRef: React.RefObject; +}) { + const scrollbarRef = useRef(null); + const trackRef = useRef(null); + + useEffect(() => { + const scrollbar = scrollbarRef.current; + const track = trackRef.current; + const tableWrap = tableWrapRef.current; + if (!scrollbar || !track || !tableWrap) return; + + // Remember the positions we applied, rather than locking a whole frame. + // This ignores delayed programmatic/vertical scroll events without dropping + // newer drag or trackpad input on either surface. + let lastScrollbarLeft = scrollbar.scrollLeft; + let lastTableLeft = tableWrap.scrollLeft; + + const syncTable = () => { + const left = scrollbar.scrollLeft; + if (left === lastScrollbarLeft) return; + lastScrollbarLeft = left; + tableWrap.scrollLeft = left; + lastTableLeft = tableWrap.scrollLeft; + }; + + const syncScrollbar = () => { + const left = tableWrap.scrollLeft; + if (left === lastTableLeft) return; + lastTableLeft = left; + scrollbar.scrollLeft = left; + lastScrollbarLeft = scrollbar.scrollLeft; + }; + + const updateLayout = () => { + const clientWidth = tableWrap.clientWidth; + const maxScroll = Math.max(0, tableWrap.scrollWidth - clientWidth); + const left = Math.min(tableWrap.scrollLeft, maxScroll); + + // Commit the range before the position. A deferred React width update can + // clamp the thumb to its old range and then rewind the table via scroll. + scrollbar.classList.toggle('is-hidden', maxScroll <= 1); + track.style.width = `${(scrollbar.clientWidth || clientWidth) + maxScroll}px`; + tableWrap.scrollLeft = left; + scrollbar.scrollLeft = left; + lastTableLeft = tableWrap.scrollLeft; + lastScrollbarLeft = scrollbar.scrollLeft; + }; + + updateLayout(); + scrollbar.addEventListener('scroll', syncTable, { passive: true }); + tableWrap.addEventListener('scroll', syncScrollbar, { passive: true }); + + const resizeObserver = new ResizeObserver(updateLayout); + resizeObserver.observe(tableWrap); + resizeObserver.observe(scrollbar); + // Column resizing changes the table's width without resizing its viewport. + if (tableWrap.firstElementChild) resizeObserver.observe(tableWrap.firstElementChild); + + return () => { + scrollbar.removeEventListener('scroll', syncTable); + tableWrap.removeEventListener('scroll', syncScrollbar); + resizeObserver.disconnect(); + }; + }, [tableWrapRef]); + + return ( + + ); +} + +function UsageOverviewPanels({ children }: { children: ReactNode }) { + const containerRef = useRef(null); + const [scale, setScale] = useState(1); + + useLayoutEffect(() => { + const container = containerRef.current; + if (!container) return; + let frame = 0; + const measure = () => { + const { width, height } = container.getBoundingClientRect(); + if (width <= 0 || height <= 0) return; + const nextScale = Math.min(1, width / 900, height / 420); + setScale((current) => Math.abs(current - nextScale) < 0.001 ? current : nextScale); + }; + const scheduleMeasure = () => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(measure); + }; + measure(); + const observer = new ResizeObserver(scheduleMeasure); + observer.observe(container); + window.addEventListener('resize', scheduleMeasure); + return () => { + cancelAnimationFrame(frame); + observer.disconnect(); + window.removeEventListener('resize', scheduleMeasure); + }; + }, []); + + return ( +
+
{children}
); } @@ -1302,7 +1176,7 @@ function UsageTrend({ measure(); const observer = typeof ResizeObserver === 'undefined' ? null - : new ResizeObserver(([entry]) => updateWidth(entry.contentRect.width)); + : new ResizeObserver(measure); observer?.observe(plot); window.addEventListener('resize', measure); return () => { @@ -1600,31 +1474,100 @@ function UsageTrend({ ); } -function TokenMetric({ - label, - value, - total, - tone, -}: { - label: string; - value: number; - total: number; - tone: 'input' | 'output' | 'reasoning' | 'cache-read' | 'cache-creation'; -}) { - const percent = total ? Math.min((value * 100) / total, 100) : 0; +function TokenComposition({ overview }: { overview: UsageOverview }) { + const { t, locale } = useI18n(); + const composition = calculateTokenComposition(overview); + const unpricedRequests = Math.max(overview.totalRequests - overview.pricedRequests, 0); + const pricingLabel = unpricedRequests > 0 + ? t('usage.token.pricingNeedsUpdate') + : t('usage.token.pricing'); + const labels = { + input: t('usage.token.uncachedInput'), + 'cache-read': t('usage.token.cacheRead'), + 'cache-creation': t('usage.token.cacheWrite'), + output: t('usage.token.output'), + }; return ( -
-
- {label} -
- {compactNumber(value)} - {percent.toFixed(1)}% +
+
+ {t('usage.token.title')} +
+
+
+
+ +
+ {composition.cacheShare.toFixed(1)}% + {t('usage.token.cacheShare')} +
+
+
+ {composition.segments.map(({ key, value, percent }) => ( +
+