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
This commit is contained in:
lzt404 committed 2026-10-04 17:31:52 +08:00
1 parent fdf56ee7eb
commit 12ad2a8e97
18 files changed
+2574 -1262

No files matched your search

+20
View File
@@ -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 キー',
+20
View File
@@ -599,6 +599,17 @@ export const en: Record<MessageKey, string> = {
'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<MessageKey, string> = {
'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<MessageKey, string> = {
'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',
+20
View File
@@ -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': '鉴权密钥',
+615
View File
@@ -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;
}
}
+817
View File
@@ -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<EventColumnKey, number> = {
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<EventColumnKey>(getAllEventColumnKeys());
const seen = new Set<EventColumnKey>();
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<EventColumnKey, number> => {
const initial: Record<EventColumnKey, number> = {} 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<HTMLDivElement | null>;
}) {
const scrollbarRef = useRef<HTMLDivElement | null>(null);
const trackRef = useRef<HTMLDivElement | null>(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 (
<div
ref={scrollbarRef}
className="usage-table-top-scrollbar"
aria-hidden="true"
>
<div ref={trackRef} style={{ height: '1px' }} />
</div>
);
}
function UsageResultCell({ record }: { record: UsageRecord }) {
const { t } = useI18n();
const state = record.canceled ? 'canceled' : record.failed ? 'failed' : 'success';
const detail = [
record.failure_status > 0 ? `HTTP ${record.failure_status}` : '',
record.failure_body.trim(),
]
.filter(Boolean)
.join(' · ');
return (
<td className="usage-result-cell align-left" title={detail || t(`usage.result.${state}`)}>
<span className={`usage-result ${state}`}>
<span className="usage-result-dot" />
{t(`usage.result.${state}`)}
</span>
{detail ? <small title={detail}>{detail}</small> : null}
</td>
);
}
function UsageEventCell({
record,
columnKey,
noRemarkLabel,
}: {
record: UsageRecord;
columnKey: EventColumnKey;
noRemarkLabel: string;
}) {
const { t, formatDate } = useI18n();
switch (columnKey) {
case 'time':
return (
<td className="usage-td-time usage-stacked-cell align-left" title={formatDate(record.timestamp)}>
<strong>{formatTime(record.timestamp)}</strong>
<small>{formatEventDate(record.timestamp)}</small>
</td>
);
case 'model':
return (
<td className="usage-stacked-cell align-left">
<strong title={record.alias || record.model}>{record.alias || record.model}</strong>
{record.alias && record.alias !== record.model ? <small title={record.model}>{record.model}</small> : null}
</td>
);
case 'effort':
return <td className="usage-stacked-cell align-left" title={record.reasoning_effort || 'auto'}><strong>{record.reasoning_effort || 'auto'}</strong></td>;
case 'request':
return <td className="usage-stacked-cell align-left" title={record.endpoint || undefined}><strong>{record.endpoint || '—'}</strong></td>;
case 'provider':
return (
<td className="usage-td-provider align-left" title={record.provider || undefined}>
<span className="usage-tag-pill">{record.provider || '—'}</span>
</td>
);
case 'source':
return (
<td className="usage-td-source align-left" title={record.source_display || record.source || undefined}>
<div className="usage-event-source">
<span className="usage-event-source-icon" aria-hidden="true"><Terminal size={13} /></span>
<span>{record.source_display || record.source || '—'}</span>
</div>
</td>
);
case 'key':
return (
<td className="usage-stacked-cell usage-td-key align-left">
<strong title={record.api_key_display || undefined}>{record.api_key_display || '—'}</strong>
<small title={record.api_key_remark}>{record.api_key_remark || noRemarkLabel}</small>
</td>
);
case 'input':
return (
<td className="usage-td-token align-left" title={`${record.tokens.input_tokens.toLocaleString()} tokens`}>
{compactNumber(record.tokens.input_tokens)}
</td>
);
case 'output':
return (
<td className="usage-td-token align-left" title={`${record.tokens.output_tokens.toLocaleString()} tokens`}>
{compactNumber(record.tokens.output_tokens)}
</td>
);
case 'reasoning':
return (
<td className="usage-td-token align-left" title={`${record.tokens.reasoning_tokens.toLocaleString()} tokens`}>
{compactNumber(record.tokens.reasoning_tokens)}
</td>
);
case 'cache':
return (
<td
className="usage-td-token usage-td-cache align-left"
title={`${t('usage.token.cacheRead')}: ${record.tokens.cache_read_tokens.toLocaleString()} tokens${
record.tokens.cache_creation_tokens > 0
? ` / ${t('usage.token.cacheCreation')}: ${record.tokens.cache_creation_tokens.toLocaleString()} tokens`
: ''
}`}
>
<strong>{formatCacheReadRate({ inputTokens: record.tokens.input_tokens, cacheReadTokens: record.tokens.cache_read_tokens })}</strong>
<div className="usage-event-metrics"><span className="tone-cache-read" title={`${t('usage.token.cacheRead')}: ${record.tokens.cache_read_tokens.toLocaleString()}`} aria-label={`${t('usage.token.cacheRead')}: ${record.tokens.cache_read_tokens}`}><Database size={11} aria-hidden="true" />{compactNumber(record.tokens.cache_read_tokens)}</span></div>
<div className="usage-event-metrics"><span className="tone-cache-write" title={`${t('usage.token.cacheCreation')}: ${record.tokens.cache_creation_tokens.toLocaleString()}`} aria-label={`${t('usage.token.cacheCreation')}: ${record.tokens.cache_creation_tokens}`}><DatabaseZap size={11} aria-hidden="true" />{compactNumber(record.tokens.cache_creation_tokens)}</span></div>
</td>
);
case 'cacheRate': {
const value = formatCacheReadRate({
inputTokens: record.tokens.input_tokens,
cacheReadTokens: record.tokens.cache_read_tokens,
});
return <td className="usage-td-cache-rate align-left" title={value === '—' ? undefined : value}>{value}</td>;
}
case 'total':
return (
<td className="usage-td-token usage-td-total align-left" title={`${record.tokens.total_tokens.toLocaleString()} tokens`}>
<strong>{compactNumber(record.tokens.total_tokens)}</strong>
<div className="usage-event-metrics"><span className="tone-input" title={`${t('usage.column.input')}: ${record.tokens.input_tokens.toLocaleString()}`} aria-label={`${t('usage.column.input')}: ${record.tokens.input_tokens}`}><ArrowUp size={11} aria-hidden="true" />{compactNumber(record.tokens.input_tokens)}</span></div>
<div className="usage-event-metrics">
<span className="tone-output" title={`${t('usage.column.output')}: ${record.tokens.output_tokens.toLocaleString()}`} aria-label={`${t('usage.column.output')}: ${record.tokens.output_tokens}`}><ArrowDown size={11} aria-hidden="true" />{compactNumber(record.tokens.output_tokens)}</span>
<span className="tone-reasoning" title={`${t('usage.column.reasoning')}: ${record.tokens.reasoning_tokens.toLocaleString()}`} aria-label={`${t('usage.column.reasoning')}: ${record.tokens.reasoning_tokens}`}><Brain size={11} aria-hidden="true" />{compactNumber(record.tokens.reasoning_tokens)}</span>
</div>
</td>
);
case 'result':
return <UsageResultCell record={record} />;
case 'latency':
return (
<td className="usage-td-latency usage-stacked-cell align-left" title={`${record.latency_ms} ms`}>
<strong>{compactDuration(record.latency_ms)}</strong>
<small title={record.ttft_ms == null ? undefined : `${record.ttft_ms} ms`}>{t('usage.column.ttft')} {record.ttft_ms == null ? '—' : compactDuration(record.ttft_ms)}</small>
</td>
);
case 'ttft':
return (
<td className="usage-td-ttft align-left" title={record.ttft_ms == null ? undefined : `${record.ttft_ms} ms`}>
{record.ttft_ms == null ? '—' : compactDuration(record.ttft_ms)}
</td>
);
case 'speed': {
const value = formatGenerationSpeed({
outputTokens: record.tokens.output_tokens,
latencyMs: record.latency_ms,
});
return <td className="usage-td-speed align-left" title={value === '—' ? undefined : value}>{value}</td>;
}
}
}
export function EventsView({
events,
pageSize,
onPage,
onPageSizeChange,
filters,
loading = false,
}: {
events: UsageEventPage;
pageSize: number;
onPage: (page: number) => void;
onPageSizeChange: (pageSize: number) => void;
filters?: ReactNode;
loading?: boolean;
}) {
const { t } = useI18n();
const [widths, setWidths] = useState<Record<EventColumnKey, number>>(getInitialColumnWidths);
const [visibleColumnKeys, setVisibleColumnKeys] = useState<EventColumnKey[]>(getInitialVisibleColumns);
const [columnSettingsOpen, setColumnSettingsOpen] = useState(false);
const [draftVisibleColumnKeys, setDraftVisibleColumnKeys] = useState<EventColumnKey[]>(visibleColumnKeys);
const [resizingCol, setResizingCol] = useState<EventColumnKey | null>(null);
const columnDialogRef = useDialogFocusTrap<HTMLElement>({
active: columnSettingsOpen,
onEscape: () => setColumnSettingsOpen(false),
});
const tableWrapRef = useRef<HTMLDivElement | null>(null);
const resizeCleanupRef = useRef<(() => void) | null>(null);
useEffect(() => () => resizeCleanupRef.current?.(), []);
const visibleColumnKeySet = new Set(visibleColumnKeys);
const visibleColumns = EVENT_COLUMNS.filter((column) => visibleColumnKeySet.has(column.key));
const isCustomized = EVENT_COLUMNS.some((col) => widths[col.key] !== col.defaultWidth);
const noRemarkLabel = t('usage.key.noRemark');
const resetAllWidths = () => {
const defaults: Record<EventColumnKey, number> = {} as any;
for (const col of EVENT_COLUMNS) {
defaults[col.key] = col.defaultWidth;
}
setWidths(defaults);
try {
localStorage.setItem(EVENT_COL_WIDTHS_STORAGE_KEY, JSON.stringify(defaults));
} catch {}
};
const openColumnSettings = () => {
setDraftVisibleColumnKeys(visibleColumnKeys);
setColumnSettingsOpen(true);
};
const toggleDraftColumn = (key: EventColumnKey) => {
setDraftVisibleColumnKeys((current) => {
if (current.includes(key)) {
return current.length > 1 ? current.filter((columnKey) => columnKey !== key) : current;
}
return EVENT_COLUMNS.filter(
(column) => current.includes(column.key) || column.key === key
).map((column) => column.key);
});
};
const applyColumnSettings = () => {
const next =
draftVisibleColumnKeys.length > 0 ? draftVisibleColumnKeys : getAllEventColumnKeys();
setVisibleColumnKeys(next);
try {
localStorage.setItem(EVENT_VISIBLE_COLS_STORAGE_KEY, JSON.stringify(next));
} catch {}
setColumnSettingsOpen(false);
};
const resetVisibleColumns = () => {
setDraftVisibleColumnKeys(getAllEventColumnKeys());
};
const resetSingleColumn = (key: EventColumnKey, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const colDef = EVENT_COLUMNS.find((c) => c.key === key);
if (!colDef) return;
setWidths((prev) => {
const next = { ...prev, [key]: colDef.defaultWidth };
try {
localStorage.setItem(EVENT_COL_WIDTHS_STORAGE_KEY, JSON.stringify(next));
} catch {}
return next;
});
};
const persistColumnWidth = (key: EventColumnKey, width: number) => {
setWidths((current) => {
const next = { ...current, [key]: width };
try {
localStorage.setItem(EVENT_COL_WIDTHS_STORAGE_KEY, JSON.stringify(next));
} catch {}
return next;
});
};
const handleResizeKeyDown = (key: EventColumnKey, event: KeyboardEvent<HTMLDivElement>) => {
const column = EVENT_COLUMNS.find((item) => item.key === key);
if (!column) return;
const current = widths[key] ?? column.defaultWidth;
const step = event.shiftKey ? 25 : 10;
const next = event.key === 'Home'
? column.defaultWidth
: event.key === 'ArrowLeft'
? Math.max(column.minWidth, current - step)
: event.key === 'ArrowRight'
? Math.min(800, current + step)
: null;
if (next === null) return;
event.preventDefault();
persistColumnWidth(key, next);
};
const handleResizeStart = (key: EventColumnKey, e: React.PointerEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
resizeCleanupRef.current?.();
const startX = e.clientX;
const startWidth =
widths[key] ?? EVENT_COLUMNS.find((c) => c.key === key)?.defaultWidth ?? 100;
const colDef = EVENT_COLUMNS.find((c) => c.key === key);
const minWidth = colDef?.minWidth ?? 50;
setResizingCol(key);
document.body.classList.add('table-col-resizing');
let currentWidth = startWidth;
const onPointerMove = (moveEvent: PointerEvent) => {
const delta = moveEvent.clientX - startX;
const nextWidth = Math.min(800, Math.max(minWidth, Math.round(startWidth + delta)));
currentWidth = nextWidth;
setWidths((prev) => ({ ...prev, [key]: nextWidth }));
};
const cleanup = () => {
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
window.removeEventListener('pointercancel', onPointerUp);
document.body.classList.remove('table-col-resizing');
resizeCleanupRef.current = null;
};
const onPointerUp = () => {
cleanup();
setResizingCol(null);
setWidths((prev) => {
const next = { ...prev, [key]: currentWidth };
try {
localStorage.setItem(EVENT_COL_WIDTHS_STORAGE_KEY, JSON.stringify(next));
} catch {}
return next;
});
};
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
window.addEventListener('pointercancel', onPointerUp);
resizeCleanupRef.current = cleanup;
};
const totalTableWidth = visibleColumns.reduce(
(sum, col) => sum + (widths[col.key] ?? col.defaultWidth),
0
);
const startRecordNum = events.total > 0 ? (events.page - 1) * pageSize + 1 : 0;
const endRecordNum = Math.min(events.page * pageSize, events.total);
const exportCurrentPage = () => {
const headers = ['id', 'row_id', 'timestamp', 'api_key_display', 'api_key_remark', 'api_key_hash', 'source', 'source_display', 'provider', 'model', 'alias', 'reasoning_effort', 'endpoint', 'failed', 'canceled', 'failure_status', 'failure_body', 'latency_ms', 'ttft_ms', 'input_tokens', 'output_tokens', 'reasoning_tokens', 'cache_read_tokens', 'cache_creation_tokens', 'total_tokens'];
const csvCell = (value: string | number | boolean | null) => {
const text = value == null ? '' : String(value);
const safe = typeof value === 'string' && /^[\s\u0000-\u001f]*[=+@-]/.test(text) ? `'${text}` : text;
return `"${safe.replace(/"/g, '""')}"`;
};
const rows = events.items.map((record) => [record.id, record.row_id, record.timestamp, record.api_key_display, record.api_key_remark, record.api_key_hash, record.source, record.source_display, record.provider, record.model, record.alias, record.reasoning_effort, record.endpoint, record.failed, record.canceled, record.failure_status, record.failure_body, record.latency_ms, record.ttft_ms, record.tokens.input_tokens, record.tokens.output_tokens, record.tokens.reasoning_tokens, record.tokens.cache_read_tokens, record.tokens.cache_creation_tokens, record.tokens.total_tokens]);
const csv = '\uFEFF' + [headers, ...rows].map((row) => row.map(csvCell).join(',')).join('\r\n');
const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8;' }));
const link = document.createElement('a');
link.href = url;
link.download = `usage-events-page-${events.page}-${new Date().toISOString().slice(0, 10)}.csv`;
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
};
return (
<section className="panel usage-events-panel usage-request-log" aria-busy={loading}>
<div className="usage-events-summary">
<div className="usage-events-heading">
<div className="usage-events-title-row">
<h2>{t('usage.events.title')}</h2>
<span className="usage-events-count-badge">{t('usage.events.total', { count: loading ? '—' : compactNumber(events.total) })}</span>
</div>
<p>{t('usage.events.description')}</p>
</div>
<div className="usage-events-summary-right">
<button
type="button"
className="usage-col-settings-btn"
onClick={openColumnSettings}
title={t('usage.events.columnSettings')}
aria-label={t('usage.events.columnSettings')}
>
<Columns3Cog size={14} />
<span>{t('usage.events.columns')}</span>
</button>
{isCustomized ? (
<button
type="button"
className="usage-col-reset-btn icon-only"
onClick={resetAllWidths}
title={t('usage.events.resetColumns')}
aria-label={t('usage.events.resetColumns')}
>
<RotateCcw size={13} />
</button>
) : null}
<button type="button" className="usage-events-export-btn" disabled={loading || events.items.length === 0} onClick={exportCurrentPage} title={t('usage.events.exportDescription')}>
<Download size={14} aria-hidden="true" /><span>{t('usage.events.exportPage')}</span>
</button>
</div>
</div>
{filters ? <div className="usage-events-filter-slot">{filters}</div> : null}
{loading ? <div className="usage-empty" role="status"><Database size={20} aria-hidden="true" /><span>{t('usage.loading')}</span></div> : events.items.length ? (
<div ref={tableWrapRef} className="usage-table-wrap" tabIndex={0} role="region" aria-label={t('usage.events.title')}>
<table
className="usage-events-table"
style={{ width: `${totalTableWidth}px` }}
>
<colgroup>
{visibleColumns.map((col) => (
<col key={col.key} style={{ width: `${widths[col.key]}px` }} />
))}
</colgroup>
<thead>
<tr>
{visibleColumns.map((col) => {
const label = t(col.labelKey);
return (
<th
key={col.key}
className={`usage-th-${col.key} align-${col.align}`}
style={{ width: `${widths[col.key]}px` }}
>
<div className="usage-th-content" title={label}>
<span>{label}</span>
</div>
<div
className={`usage-col-resizer ${resizingCol === col.key ? 'active' : ''}`}
role="separator"
tabIndex={0}
aria-label={`${label}: ${t('usage.events.resizeHint')}`}
aria-orientation="vertical"
aria-valuemin={col.minWidth}
aria-valuemax={800}
aria-valuenow={widths[col.key]}
onPointerDown={(e) => handleResizeStart(col.key, e)}
onDoubleClick={(e) => resetSingleColumn(col.key, e)}
onKeyDown={(event) => handleResizeKeyDown(col.key, event)}
title={t('usage.events.resizeHint')}
/>
</th>
);
})}
</tr>
</thead>
<tbody>
{events.items.map((record) => (
<tr key={record.row_id}>
{visibleColumns.map((column) => (
<UsageEventCell
key={column.key}
record={record}
columnKey={column.key}
noRemarkLabel={noRemarkLabel}
/>
))}
</tr>
))}
</tbody>
</table>
</div>
) : (
<UsageEmpty />
)}
{!loading && events.items.length > 0 ? <TableTopScrollbar tableWrapRef={tableWrapRef} /> : null}
<div className="usage-events-footer">
<span className="usage-pagination-summary">{t('usage.events.rangeSummary', { start: startRecordNum, end: endRecordNum, total: compactNumber(events.total) })}</span>
<div className="usage-pagination-controls">
<select className="usage-page-size-select" value={pageSize} disabled={loading} onChange={(event) => onPageSizeChange(Number(event.currentTarget.value))} aria-label={t('usage.events.pageSize', { size: pageSize })}>
{[20, 50, 100, 200].map((size) => <option key={size} value={size}>{t('usage.events.pageSize', { size })}</option>)}
</select>
<div className="usage-pagination-right">
<button type="button" className="usage-page-nav-btn" disabled={loading || events.page <= 1} onClick={() => onPage(events.page - 1)}><ChevronLeft size={14} aria-hidden="true" /><span>{t('usage.previous')}</span></button>
<span className="usage-pagination-info">{events.page} / {Math.max(1, events.totalPages)}</span>
<button type="button" className="usage-page-nav-btn" disabled={loading || events.page >= events.totalPages} onClick={() => onPage(events.page + 1)}><span>{t('usage.next')}</span><ChevronRight size={14} aria-hidden="true" /></button>
</div>
</div>
</div>
{columnSettingsOpen ? (
<div
className="config-dialog-backdrop"
onMouseDown={(event) =>
event.currentTarget === event.target && setColumnSettingsOpen(false)
}
>
<section
ref={columnDialogRef}
className="config-dialog usage-column-dialog"
role="dialog"
tabIndex={-1}
aria-modal="true"
aria-labelledby="usage-column-dialog-title"
onKeyDown={(event) => {
if (event.key === 'Escape') setColumnSettingsOpen(false);
}}
>
<div className="usage-column-dialog-heading">
<div>
<Columns3Cog size={19} aria-hidden="true" />
<h2 id="usage-column-dialog-title">{t('usage.events.columnSettings')}</h2>
</div>
<button
type="button"
className="icon-button quiet"
onClick={() => setColumnSettingsOpen(false)}
title={t('common.close')}
aria-label={t('common.close')}
>
<X size={17} />
</button>
</div>
<p className="usage-column-dialog-description">
{t('usage.events.columnSettingsDescription')}
</p>
<div className="usage-column-options">
{EVENT_COLUMNS.map((column) => {
const checked = draftVisibleColumnKeys.includes(column.key);
return (
<label key={column.key} className="usage-column-option">
<input
type="checkbox"
checked={checked}
disabled={checked && draftVisibleColumnKeys.length === 1}
onChange={() => toggleDraftColumn(column.key)}
/>
<span>{t(column.labelKey)}</span>
</label>
);
})}
</div>
<div className="usage-column-dialog-footer">
<div className="usage-column-dialog-meta">
<span>
{t('usage.events.columnsSelected', {
selected: draftVisibleColumnKeys.length,
total: EVENT_COLUMNS.length,
})}
</span>
<button
type="button"
className="usage-column-select-all"
onClick={resetVisibleColumns}
>
{t('usage.events.selectAllColumns')}
</button>
</div>
<div className="usage-column-dialog-actions">
<button
type="button"
className="secondary-button"
onClick={() => setColumnSettingsOpen(false)}
>
{t('common.cancel')}
</button>
<button
type="button"
className="primary-button"
onClick={applyColumnSettings}
>
{t('usage.events.applyColumns')}
</button>
</div>
</div>
</section>
</div>
) : null}
</section>
);
}
function UsageEmpty() {
const { t } = useI18n();
return <div className="usage-empty"><TriangleAlert size={18} /><span>{t('usage.empty')}</span></div>;
}
File diff suppressed because it is too large. Load diff
+28
View File
@@ -8,6 +8,34 @@ type CacheReadRateInput = {
cacheReadTokens: number;
};
export const calculateTokenComposition = (tokens: {
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
cacheCreationTokens: number;
}) => {
const normalizedCount = (value: number) => Number.isFinite(value) ? Math.max(0, value) : 0;
const input = normalizedCount(tokens.inputTokens);
const output = normalizedCount(tokens.outputTokens);
const cacheRead = normalizedCount(tokens.cacheReadTokens);
const cacheCreation = normalizedCount(tokens.cacheCreationTokens);
const uncachedInput = Math.max(0, input - cacheRead - cacheCreation);
const total = uncachedInput + cacheRead + cacheCreation + output;
let offset = 0;
const segments = ([
{ key: 'input', value: uncachedInput },
{ key: 'cache-read', value: cacheRead },
{ key: 'cache-creation', value: cacheCreation },
{ key: 'output', value: output },
] as const).map((segment) => {
const percent = total > 0 ? segment.value / total * 100 : 0;
const result = { ...segment, percent, offset };
offset += percent;
return result;
});
return { total, segments, cacheShare: total > 0 ? cacheRead / total * 100 : 0 };
};
export const calculateGenerationSpeed = ({
outputTokens,
latencyMs,
+378 -39
View File
@@ -8579,12 +8579,30 @@ textarea:focus-visible {
/* Overview Layout */
.usage-overview-layout {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(260px, 0.65fr);
grid-template-rows: max-content minmax(280px, 1fr);
grid-template-columns: minmax(0, 1fr);
grid-template-rows: max-content minmax(0, 1fr);
gap: 14px;
align-items: stretch;
overflow-x: hidden;
overflow-y: auto;
overflow: clip;
}
.usage-overview-panels {
position: relative;
min-width: 0;
min-height: 0;
}
.usage-overview-canvas {
position: absolute;
inset: 0 auto auto 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-rows: minmax(0, 1fr);
gap: 14px;
width: calc(100% / var(--usage-overview-scale, 1));
height: calc(100% / var(--usage-overview-scale, 1));
transform: scale(var(--usage-overview-scale, 1));
transform-origin: top left;
}
.usage-stat-grid {
@@ -8635,16 +8653,6 @@ textarea:focus-visible {
text-overflow: ellipsis;
}
.usage-stat-card-meta {
display: flex;
flex-wrap: wrap;
gap: 4px;
color: var(--theme-8a847c);
font-size: var(--font-size-caption);
font-variant-numeric: tabular-nums;
line-height: 1.35;
}
/* Trend Panel */
.usage-trend-panel,
.usage-health-panel,
@@ -8939,39 +8947,133 @@ textarea:focus-visible {
}
/* Token Breakdown Panel */
.usage-health-panel {
container: token-panel / size;
grid-template-rows: auto minmax(0, 1fr);
align-self: stretch;
height: auto;
min-height: 0;
}
.usage-token-content {
display: grid;
min-width: 0;
min-height: 0;
grid-template-rows: minmax(0, 1fr) auto auto;
gap: clamp(6px, 2cqb, 14px);
}
.usage-token-composition {
container: token-chart / size;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr);
min-height: 0;
align-items: center;
gap: clamp(10px, 3cqi, 22px);
padding: clamp(8px, 2cqb, 20px) 12px;
border: 1px solid var(--border-color);
border-radius: 20px;
}
.usage-token-donut {
container-type: inline-size;
position: relative;
width: min(100%, 100cqb, 320px);
justify-self: center;
aspect-ratio: 1;
}
.usage-token-donut svg {
display: block;
width: 100%;
height: 100%;
overflow: visible;
fill: none;
stroke-width: 34;
}
.usage-token-donut-track {
stroke: var(--bg-hover);
}
.usage-token-donut-segment {
stroke: var(--token-color);
}
.usage-token-donut-label {
position: absolute;
inset: 24%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
text-align: center;
}
.usage-token-donut-label strong {
color: var(--text-primary);
font-size: clamp(14px, 14cqi, 36px);
font-weight: var(--font-weight-semibold);
font-variant-numeric: tabular-nums;
line-height: 1.2;
letter-spacing: -0.04em;
}
.usage-token-donut-label span {
color: var(--text-tertiary);
font-size: clamp(9px, 5cqi, 12px);
line-height: 1.4;
}
.usage-token-breakdown {
display: grid;
min-height: 0;
align-content: start;
gap: 12px;
overflow: auto;
min-width: 0;
align-self: stretch;
align-content: space-evenly;
gap: clamp(4px, 3cqb, 18px);
margin: 0;
}
.usage-token-row {
display: grid;
gap: 6px;
}
.usage-token-info {
display: flex;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
justify-content: space-between;
gap: 6px 8px;
font-size: var(--font-size-caption);
}
.usage-token-measurement {
display: contents;
}
.usage-token-name {
color: var(--theme-2d2a26);
font-weight: var(--font-weight-semibold);
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
color: var(--text-secondary);
line-height: 1.4;
}
.usage-token-name i {
flex: none;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--token-color);
}
.usage-token-vals {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
gap: 6px;
margin: 0;
}
.usage-token-count {
color: var(--theme-7a746c);
color: var(--text-primary);
font-variant-numeric: tabular-nums;
font-weight: var(--font-weight-medium);
}
@@ -8986,8 +9088,9 @@ textarea:focus-visible {
}
.usage-token-bar-track {
grid-column: 1 / -1;
width: 100%;
height: 6px;
height: 4px;
background: var(--theme-eeece6);
border-radius: 999px;
overflow: hidden;
@@ -8996,27 +9099,263 @@ textarea:focus-visible {
.usage-token-bar-fill {
height: 100%;
border-radius: 999px;
background: var(--token-color);
transition: width 0.3s ease;
}
.usage-token-row.tone-input .usage-token-bar-fill {
background: #3b82f6;
.usage-token-row.tone-input,
.usage-token-donut-segment.tone-input {
--token-color: #5595f9;
}
.usage-token-row.tone-output .usage-token-bar-fill {
background: #10b981;
.usage-token-row.tone-output,
.usage-token-donut-segment.tone-output {
--token-color: #22b573;
}
.usage-token-row.tone-reasoning .usage-token-bar-fill {
background: #8b5cf6;
.usage-token-row.tone-cache-read,
.usage-token-donut-segment.tone-cache-read {
--token-color: #ed9205;
}
.usage-token-row.tone-cache-read .usage-token-bar-fill {
background: #f59e0b;
.usage-token-row.tone-cache-creation,
.usage-token-donut-segment.tone-cache-creation {
--token-color: #ee5182;
}
.usage-token-row.tone-cache-creation .usage-token-bar-fill {
background: #ec4899;
.usage-token-notes {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
padding: 4px 0;
color: var(--text-tertiary);
font-size: var(--font-size-caption);
line-height: 1.5;
}
.usage-token-notes span {
display: grid;
align-content: start;
gap: 2px;
min-width: 0;
padding-inline: 12px;
overflow-wrap: anywhere;
}
.usage-token-notes span + span {
border-left: 1px solid var(--border-color);
}
.usage-token-notes b {
color: var(--text-primary);
font-size: var(--font-size-section);
font-weight: var(--font-weight-semibold);
font-variant-numeric: tabular-nums;
}
.usage-token-context {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
align-items: stretch;
gap: 10px;
font-size: var(--font-size-caption);
line-height: 1.4;
}
.usage-token-context-group {
display: grid;
grid-template-rows: auto 1fr;
min-width: 0;
gap: 14px;
padding: 14px;
border: 1px solid var(--border-secondary);
border-radius: 12px;
background: var(--bg-hover);
}
.usage-token-context-group h3 {
display: flex;
align-items: center;
gap: 6px;
margin: 0;
color: var(--text-secondary);
font-size: var(--font-size-caption);
font-weight: var(--font-weight-semibold);
}
.usage-token-context-group h3 svg {
flex: none;
color: var(--text-quaternary);
}
.usage-token-context-metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-rows: auto auto;
align-content: start;
align-items: start;
gap: 5px 12px;
margin: 0;
}
.usage-token-results .usage-token-context-metrics {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.usage-token-context-metrics > div {
display: grid;
grid-template-rows: subgrid;
grid-row: span 2;
min-width: 0;
gap: 5px;
}
.usage-token-context-metrics dt {
color: var(--text-tertiary);
overflow-wrap: anywhere;
}
.usage-token-context-metrics dd {
margin: 0;
color: var(--text-primary);
font-size: 20px;
font-weight: var(--font-weight-semibold);
font-variant-numeric: tabular-nums;
letter-spacing: -0.025em;
line-height: 1.3;
overflow-wrap: anywhere;
}
.usage-token-context-metrics .tone-success dd {
color: #15803d;
}
.usage-token-context-metrics .tone-failed dd {
color: #b91c1c;
}
:root[data-theme='dark'] .usage-token-context-metrics .tone-success dd {
color: #4ade80;
}
:root[data-theme='dark'] .usage-token-context-metrics .tone-failed dd {
color: #f87171;
}
@container token-panel (max-width: 600px) {
.usage-token-context {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.usage-token-results {
grid-column: 1 / -1;
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
grid-template-rows: auto;
align-items: center;
gap: 6px 12px;
}
.usage-token-results .usage-token-context-metrics {
grid-column: 2;
grid-row: 1 / -1;
}
}
@container token-panel (max-width: 370px) {
.usage-token-composition {
grid-template-columns: minmax(0, 1fr);
gap: 20px;
padding: 18px 16px;
}
.usage-token-donut {
max-width: 170px;
}
.usage-token-notes {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.usage-token-notes span:nth-child(3) {
border-left: 0;
}
}
@container token-panel (max-width: 300px) {
.usage-token-context {
grid-template-columns: minmax(0, 1fr);
}
.usage-token-results {
grid-template-columns: minmax(0, 1fr);
}
.usage-token-results .usage-token-context-metrics {
grid-column: auto;
grid-row: auto;
}
}
@container token-panel (max-height: 540px) {
.usage-token-content {
gap: 8px;
}
.usage-token-notes {
font-size: 11px;
padding: 0;
}
.usage-token-notes span {
padding-inline: 6px;
}
.usage-token-notes b {
font-size: 14px;
}
.usage-token-context {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
font-size: 11px;
}
.usage-token-context-group,
.usage-token-results {
grid-column: auto;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto 1fr;
gap: 8px;
padding: 8px;
}
.usage-token-context-group h3 {
font-size: 11px;
gap: 4px;
}
.usage-token-context-metrics {
column-gap: 6px;
}
.usage-token-context-metrics dd {
font-size: 16px;
}
.usage-token-results .usage-token-context-metrics dt {
font-size: 10px;
}
.usage-token-results .usage-token-context-metrics {
grid-template-columns: 1.1fr 0.8fr 1.2fr;
column-gap: 3px;
}
.usage-token-results .usage-token-context-metrics {
grid-column: auto;
grid-row: auto;
}
}
/* Analysis Tab */
+1
View File
@@ -2964,6 +2964,7 @@
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.config-network-grid,
.config-tls-fields,
.config-management-form,
+1
View File
@@ -6,3 +6,4 @@
@import './responsive-agents.css';
@import './responsive-settings.css';
@import './config-settings.css';
@import '../pages/UsageEventsView.css';
+57 -9
View File
@@ -131,16 +131,29 @@
white-space: nowrap;
}
.usage-records-page:has(.usage-overview-layout) {
gap: clamp(8px, 1.5vh, 16px);
}
@container workspace (min-width: 600px) {
.usage-overview-layout .usage-stat-grid,
.usage-records-page:has(.usage-overview-layout) .usage-filter-group {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
}
@container workspace (max-width: 880px) {
.usage-overview-layout .usage-stat-card {
gap: 5px;
padding: 10px 8px;
}
.usage-overview-layout .usage-stat-card-value {
font-size: 24px;
}
}
@container workspace (max-width: 760px) {
.usage-overview-layout {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: max-content minmax(280px, 1fr) auto;
}
.usage-health-panel {
height: auto;
}
.usage-data-management-action {
flex-wrap: wrap;
gap: 12px;
@@ -152,6 +165,41 @@
}
}
@media (max-width: 760px) {
.usage-overview-layout {
grid-template-rows: max-content auto;
overflow: visible;
}
.usage-overview-canvas {
position: static;
width: 100%;
height: auto;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: 300px auto;
transform: none;
}
.usage-health-panel {
container-type: inline-size;
grid-template-rows: auto auto;
}
.usage-token-content {
grid-template-rows: auto auto auto;
}
.usage-token-composition {
container-type: inline-size;
min-height: 240px;
}
.usage-token-donut {
width: 100%;
max-width: 240px;
}
}
@container workspace (max-width: 540px) {
.usage-events-summary-right {
margin-left: 0;
+7 -82
View File
@@ -689,6 +689,7 @@ body, html, #root {
.usage-stat-card {
--usage-tone: #1d1d1f;
position: relative;
justify-content: flex-start;
gap: 8px;
overflow: hidden;
padding: 16px 16px 14px;
@@ -730,68 +731,13 @@ body, html, #root {
line-height: 1.02;
}
.usage-stat-meter {
display: block;
height: 5px;
overflow: hidden;
border-radius: 999px;
background: #e5e5ea;
.app-shell:has(.usage-overview-layout) .content {
padding: clamp(12px, 1.5vw, 20px) clamp(12px, 1.5vw, 20px) 16px;
}
.usage-stat-meter > span {
display: block;
height: 100%;
border-radius: inherit;
background: var(--usage-tone);
}
.usage-stat-card-meta {
display: flex;
min-height: 0;
flex-wrap: wrap;
gap: 5px;
overflow: visible;
color: var(--text-secondary);
white-space: normal;
-webkit-line-clamp: unset;
-webkit-box-orient: unset;
}
.usage-stat-chip {
display: inline-flex;
max-width: 100%;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
background: #f2f2f7;
color: #3a3a3c;
font-size: var(--font-size-caption);
font-weight: 590;
line-height: 1.45;
}
.usage-stat-chip.tone-ok {
background: rgba(52, 199, 89, 0.14);
color: #248a3d;
}
.usage-stat-chip.tone-bad {
background: rgba(255, 59, 48, 0.12);
color: #d70015;
}
.usage-stat-chip.tone-warn {
background: rgba(255, 149, 0, 0.16);
color: #c93400;
}
.usage-stat-chip.tone-note {
width: 100%;
padding: 0;
border-radius: 0;
background: transparent;
color: var(--text-secondary);
font-weight: 500;
.usage-overview-layout {
grid-template-rows: max-content minmax(0, 1fr);
align-content: stretch;
}
.usage-metrics-grid .metric-card {
@@ -1133,31 +1079,10 @@ body, html, #root {
--usage-tone: #f5f5f7;
}
:root[data-theme='dark'] .usage-stat-card-label,
:root[data-theme='dark'] .usage-stat-chip.tone-note {
:root[data-theme='dark'] .usage-stat-card-label {
color: var(--text-secondary);
}
:root[data-theme='dark'] .usage-stat-chip {
background: var(--bg-tertiary);
color: var(--text-secondary);
}
:root[data-theme='dark'] .usage-stat-chip.tone-ok {
background: rgba(16, 185, 129, 0.16);
color: #6ee7b7;
}
:root[data-theme='dark'] .usage-stat-chip.tone-bad {
background: rgba(239, 68, 68, 0.16);
color: #fca5a5;
}
:root[data-theme='dark'] .usage-stat-chip.tone-warn {
background: rgba(245, 158, 11, 0.16);
color: #fcd34d;
}
:root[data-theme='dark'] .usage-stat-card,
:root[data-theme='dark'] .usage-filter-panel,
:root[data-theme='dark'] .usage-trend-panel,
+53 -32
View File
@@ -42,6 +42,38 @@ const timeline = Array.from({ length: 4 }, (_, index) => ({
})),
}));
const records = Array.from({ length: 400 }, (_, index) => ({
id: `record-${index + 1}`,
row_id: String(index + 1),
timestamp: new Date(now.getTime() - index * 1000).toISOString(),
latency_ms: 2000,
ttft_ms: 200,
source: index % 2 ? 'secondary-source' : 'test-source',
source_display: index % 2 ? 'Secondary source' : 'Test source',
failed: index % 4 === 0,
canceled: false,
failure_status: index % 4 === 0 ? 429 : 0,
failure_body: index % 4 === 0 ? 'Rate limit exceeded' : '',
provider: 'test-provider',
model: index % 3 ? 'test-model' : 'secondary-model',
alias: '',
reasoning_effort: 'high',
endpoint: '/v1/responses',
api_key_hash: 'test-key',
api_key_display: 'sk-test',
api_key_remark: 'Test key, "local"',
tokens: {
input_tokens: 1000,
output_tokens: 200,
reasoning_tokens: 100,
cache_read_tokens: 400,
cache_creation_tokens: 50,
total_tokens: 1200,
},
}));
const category = (key: string, label = key) => ({ key, label, requests: 200, failures: 25, tokens: 24_000 });
mockIPC(async (cmd, args) => {
if (cmd === 'plugin:event|listen') return 1;
if (cmd === 'plugin:event|unlisten' || cmd === 'set_app_locale') return null;
@@ -49,43 +81,32 @@ mockIPC(async (cmd, args) => {
return { state: 'collecting', message: '', lastCollectedAt: now.toISOString(), totalRecords: 54 };
}
if (cmd === 'get_usage_analysis') {
return { models: [], providers: [], sources: [], apiKeys: [] };
return {
models: [category('test-model'), category('secondary-model')],
providers: [category('test-provider')],
sources: [category('test-source', 'Test source'), category('secondary-source', 'Secondary source')],
apiKeys: [category('test-key', 'Test key')],
};
}
if (cmd === 'get_usage_events') {
const query = args?.query as { page: number; page_size: number };
const query = args?.query as {
page: number; page_size: number; model?: string; source?: string;
provider?: string; api_key_hash?: string; failed?: boolean; canceled?: boolean;
};
const filtered = records.filter(record =>
(!query.model || record.model === query.model)
&& (!query.source || record.source === query.source)
&& (!query.provider || record.provider === query.provider)
&& (!query.api_key_hash || record.api_key_hash === query.api_key_hash)
&& (query.failed === undefined || record.failed === query.failed)
&& (query.canceled === undefined || record.canceled === query.canceled));
const start = (query.page - 1) * query.page_size;
return {
page: query.page,
pageSize: query.page_size,
total: 400,
totalPages: Math.ceil(400 / query.page_size),
items: Array.from({ length: query.page_size }, (_, index) => ({
id: `record-${query.page}-${index}`,
timestamp: now.toISOString(),
latency_ms: 2000,
ttft_ms: 200,
source: 'test-source',
source_display: 'Test source',
failed: index % 4 === 0,
canceled: false,
failure_status: index % 4 === 0 ? 429 : 0,
failure_body: index % 4 === 0 ? 'Rate limit exceeded' : '',
provider: 'test-provider',
model: 'test-model',
alias: '',
reasoning_effort: 'high',
endpoint: '/v1/responses',
api_key_hash: 'test-key',
api_key_display: 'sk-test',
api_key_remark: 'Test key',
tokens: {
input_tokens: 1000,
output_tokens: 200,
reasoning_tokens: 100,
cache_read_tokens: 400,
cache_creation_tokens: 0,
total_tokens: 1600,
},
})),
total: filtered.length,
totalPages: Math.max(1, Math.ceil(filtered.length / query.page_size)),
items: filtered.slice(start, start + query.page_size),
};
}
if (cmd === 'get_usage_overview') {
+13 -3
View File
@@ -72,26 +72,36 @@ const viewports = [
const assertRequestToolbar = async label => {
const metrics = await page.locator('.usage-events-panel').evaluate(panel => {
const summary = panel.querySelector('.usage-events-summary');
const table = panel.querySelector('.usage-table-wrap');
const scrollbar = panel.querySelector('.usage-table-top-scrollbar');
const footer = panel.querySelector('.usage-events-footer');
const pagination = panel.querySelector('.usage-pagination-controls');
const summaryRect = summary.getBoundingClientRect();
const tableRect = table.getBoundingClientRect();
const scrollbarRect = scrollbar.getBoundingClientRect();
const footerRect = footer.getBoundingClientRect();
const paginationRect = pagination.getBoundingClientRect();
const controls = [...pagination.querySelectorAll('button, select, .usage-pagination-info')];
return {
summaryBottom: summaryRect.bottom,
tableTop: tableRect.top,
tableBottom: tableRect.bottom,
scrollbarTop: scrollbarRect.top,
scrollbarBottom: scrollbarRect.bottom,
footerTop: footerRect.top,
controlsContained: controls.every(control => {
const rect = control.getBoundingClientRect();
return rect.width > 0 && rect.height > 0
&& rect.left >= paginationRect.left - 1 && rect.right <= paginationRect.right + 1
&& rect.top >= paginationRect.top - 1 && rect.bottom <= paginationRect.bottom + 1
&& rect.bottom <= summaryRect.bottom + 1;
&& rect.bottom <= footerRect.bottom + 1;
}),
};
});
assert.ok(metrics.summaryBottom <= metrics.scrollbarTop + 1, `${label}: request summary must not overlap the horizontal scrollbar`);
assert.equal(metrics.controlsContained, true, `${label}: pagination controls must fit completely inside their toolbar`);
assert.ok(metrics.summaryBottom <= metrics.tableTop + 1, `${label}: request heading and filters must remain above the table`);
assert.ok(metrics.tableBottom <= metrics.scrollbarTop + 1, `${label}: the horizontal scrollbar must remain below the table`);
assert.ok(metrics.scrollbarBottom <= metrics.footerTop + 1, `${label}: the scrollbar must not overlap the pagination footer`);
assert.equal(metrics.controlsContained, true, `${label}: pagination controls must fit completely inside their footer`);
for (const control of await page.locator('.usage-pagination-controls button, .usage-page-size-select').all()) {
await assertReachable(control, `${label} pagination`);
}
+19 -15
View File
@@ -36,16 +36,12 @@ function componentFiles(directory: string): string[] {
describe('UI localization boundaries', () => {
it('preserves the raw reasoning effort in usage text and tooltips', () => {
const file = join(sourceRoot, 'pages', 'UsageRecordsPage.tsx');
const file = join(sourceRoot, 'pages', 'UsageEventsView.tsx');
const source = ts.createSourceFile(file, readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
let effortCell: ts.JsxElement | undefined;
const findEffortCell = (node: ts.Node) => {
if (ts.isJsxElement(node) && node.openingElement.tagName.getText(source) === 'small') effortCell = node;
ts.forEachChild(node, findEffortCell);
};
let effortCase: ts.CaseClause | undefined;
const visit = (node: ts.Node) => {
if (ts.isCaseClause(node) && ts.isStringLiteral(node.expression) && node.expression.text === 'model') {
findEffortCell(node);
if (ts.isCaseClause(node) && ts.isStringLiteral(node.expression) && node.expression.text === 'effort') {
effortCase = node;
} else {
ts.forEachChild(node, visit);
}
@@ -53,13 +49,21 @@ describe('UI localization boundaries', () => {
const component = source.statements.find((node) => ts.isFunctionDeclaration(node) && node.name?.text === 'UsageEventCell');
expect(component).toBeDefined();
if (component) visit(component);
const text = effortCell?.children.find(ts.isJsxExpression);
const title = effortCell?.openingElement.attributes.properties.find((attribute) =>
ts.isJsxAttribute(attribute) && attribute.name.getText(source) === 'title',
);
expect(text?.expression?.getText(source)).toBe("record.reasoning_effort || 'auto'");
expect(title && ts.isJsxAttribute(title) && title.initializer?.getText(source))
.toBe("{record.reasoning_effort || 'auto'}");
const displayedExpressions: string[] = [];
const titleExpressions: string[] = [];
const inspectEffort = (node: ts.Node) => {
if (ts.isJsxExpression(node) && !ts.isJsxAttribute(node.parent) && node.expression) {
displayedExpressions.push(node.expression.getText(source));
}
if (ts.isJsxAttribute(node) && node.name.getText(source) === 'title' && node.initializer) {
titleExpressions.push(node.initializer.getText(source));
}
ts.forEachChild(node, inspectEffort);
};
expect(effortCase).toBeDefined();
if (effortCase) inspectEffort(effortCase);
expect(displayedExpressions).toContain("record.reasoning_effort || 'auto'");
expect(titleExpressions).toContain("{record.reasoning_effort || 'auto'}");
});
it('routes visible prose and accessibility labels through translations', () => {
+99 -11
View File
@@ -14,11 +14,22 @@ const base = 'http://127.0.0.1:1421';
const statInfo1213 = await page.evaluate(() => {
const cards = Array.from(document.querySelectorAll('.usage-stat-card'));
const metas = Array.from(document.querySelectorAll('.usage-stat-card-meta'));
const tokenPanel = document.querySelector('.usage-health-panel');
const trendPanel = document.querySelector('.usage-trend-panel');
if (!(tokenPanel instanceof HTMLElement) || !(trendPanel instanceof HTMLElement)) throw new Error('Overview panels did not render');
const tokenRect = tokenPanel.getBoundingClientRect();
const trendRect = trendPanel.getBoundingClientRect();
const tpsCard = cards.find((card) => card.querySelector('.usage-stat-card-label')?.textContent?.trim() === 'TPS');
const tpsValue = tpsCard?.querySelector('.usage-stat-card-value')?.textContent?.trim() ?? '';
return {
cardCount: cards.length,
metaCount: metas.length,
detailPanelCount: document.querySelectorAll('.usage-stat-details-panel').length,
tokensBesideTrend: tokenRect.left > trendRect.right && Math.abs(tokenRect.top - trendRect.top) < 1,
tokenCount: tokenPanel.querySelectorAll('.usage-token-row').length,
cacheShare: tokenPanel.querySelector('.usage-token-donut-label strong')?.textContent,
contextText: tokenPanel.querySelector('.usage-token-context')?.textContent,
tokenContentInsidePanel: tokenPanel.querySelector('.usage-token-context').getBoundingClientRect().bottom <= tokenRect.bottom,
cardRows: new Set(cards.map((card) => Math.round(card.getBoundingClientRect().top))).size,
tpsValue,
};
@@ -26,12 +37,19 @@ const base = 'http://127.0.0.1:1421';
assert.equal(statInfo1213.cardCount, 6, 'There are 6 stat cards');
assert.equal(statInfo1213.cardRows, 1, 'At 1213px width, all 6 cards fit into a single row');
assert.equal(statInfo1213.metaCount, 6, 'Each stat card shows its supporting detail');
assert.equal(statInfo1213.metaCount, 0, 'The top stat cards show only their labels and primary values');
assert.equal(statInfo1213.detailPanelCount, 0, 'The statistics details panel is removed');
assert.equal(statInfo1213.tokensBesideTrend, true, 'Token composition sits beside the trend without an empty slot');
assert.equal(statInfo1213.tokenCount, 4, 'Token composition separates uncached input, cache reads, cache writes and output');
assert.equal(statInfo1213.cacheShare, '21.1%', 'The ring shows cache reads as a share of input plus output without double counting');
assert.equal(statInfo1213.tokenContentInsidePanel, true, 'Supporting details remain inside the Token card');
for (const value of ['50', '4', '0', '54', 'RPM', '0.23', '2.84 s']) {
assert.ok(statInfo1213.contextText.includes(value), `The Token card retains supporting request, performance and pricing details: ${value}`);
}
assert.ok(!statInfo1213.tpsValue.includes('TPS'), `TPS value should not contain TPS unit: ${statInfo1213.tpsValue}`);
assert.ok(/^\d+(\.\d+)?$/.test(statInfo1213.tpsValue), `TPS value should be numeric: ${statInfo1213.tpsValue}`);
// Now test constrained width where cards wrap and ensure vertical scrolling is preserved
await page.setViewportSize({ width: 750, height: 600 });
await page.setViewportSize({ width: 950, height: 600 });
await page.waitForTimeout(100);
const geometry = await page.evaluate(() => {
@@ -39,11 +57,14 @@ const base = 'http://127.0.0.1:1421';
const panel = document.querySelector('.usage-trend-panel');
const plot = document.querySelector('.usage-trend-plot');
const xAxis = document.querySelector('.usage-trend-x-axis');
const tokenPanel = document.querySelector('.usage-health-panel');
const cards = Array.from(document.querySelectorAll('.usage-stat-card'));
if (!(layout instanceof HTMLElement) || !(panel instanceof HTMLElement)
|| !(plot instanceof HTMLElement) || !(xAxis instanceof HTMLElement)) throw new Error('Fixture did not render');
|| !(plot instanceof HTMLElement) || !(xAxis instanceof HTMLElement)
|| !(tokenPanel instanceof HTMLElement)) throw new Error('Fixture did not render');
const panelRect = panel.getBoundingClientRect();
const xAxisRect = xAxis.getBoundingClientRect();
const tokenRect = tokenPanel.getBoundingClientRect();
return {
layoutClientHeight: layout.clientHeight,
layoutScrollHeight: layout.scrollHeight,
@@ -51,28 +72,41 @@ const base = 'http://127.0.0.1:1421';
layoutScrollWidth: layout.scrollWidth,
plotHeight: plot.getBoundingClientRect().height,
xAxisInsidePanel: xAxisRect.bottom <= panelRect.bottom,
tokensBesideTrend: tokenRect.left > panelRect.right,
panelsAligned: Math.abs(tokenRect.top - panelRect.top) < 1 && Math.abs(tokenRect.bottom - panelRect.bottom) < 1,
scale: Number(getComputedStyle(document.querySelector('.usage-overview-panels')).getPropertyValue('--usage-overview-scale')),
tokenContentInsidePanel: tokenPanel.querySelector('.usage-token-context').getBoundingClientRect().bottom <= tokenRect.bottom,
cardRows: new Set(cards.map((card) => Math.round(card.getBoundingClientRect().top))).size,
};
});
assert.ok(geometry.cardRows >= 2, 'The constrained layout reproduces wrapped summary cards');
assert.ok(geometry.layoutScrollHeight > geometry.layoutClientHeight, 'The overview becomes vertically scrollable');
assert.equal(geometry.cardRows, 1, 'Summary cards stay compact to leave room for the charts');
assert.equal(geometry.layoutScrollHeight, geometry.layoutClientHeight, 'The overview fits vertically without adding a scrollbar');
assert.equal(geometry.layoutScrollWidth, geometry.layoutClientWidth, 'The overview does not introduce horizontal scrolling');
assert.ok(geometry.plotHeight >= 140, 'The trend plot retains its minimum drawing height');
assert.ok(geometry.scale > 0 && geometry.scale < 1, 'The panels scale down for a constrained window');
assert.ok(geometry.plotHeight >= 140 * geometry.scale - 1, 'The trend plot scales with the rest of the panels');
assert.equal(geometry.xAxisInsidePanel, true, 'The X axis remains inside the trend panel instead of being clipped');
assert.equal(geometry.tokensBesideTrend, true, 'The scaled panels remain beside each other');
assert.equal(geometry.panelsAligned, true, 'The scaled panels keep equal heights');
assert.equal(geometry.tokenContentInsidePanel, true, 'The scaled Token card contains all supporting details');
await page.locator('.usage-overview-layout').evaluate((layout) => { layout.scrollTop = layout.scrollHeight; });
assert.ok(await page.locator('.usage-trend-x-axis').evaluate((axis) => {
const layout = axis.closest('.usage-overview-layout');
if (!layout) return false;
return axis.getBoundingClientRect().bottom <= layout.getBoundingClientRect().bottom + 1;
}), 'The complete X axis is reachable by scrolling the overview');
}), 'The complete X axis is visible without scrolling the overview');
await page.locator('.usage-trend-plot').hover();
await page.locator('.usage-trend-tooltip').waitFor();
await page.locator('.usage-trend-plot').press('Home');
assert.ok(await page.locator('.usage-trend-tooltip-total').textContent(), 'The scaled chart still supports pointer and keyboard inspection');
await page.locator('.usage-trend-plot').press('Escape');
const narrowAxis = await page.evaluate(() => ({
width: document.querySelector('.usage-trend-plot')?.getBoundingClientRect().width ?? 0,
ticks: document.querySelectorAll('.usage-trend-x-axis span').length,
}));
await page.setViewportSize({ width: 1500, height: 600 });
await page.setViewportSize({ width: 2400, height: 600 });
await page.waitForFunction((previous) => {
const plot = document.querySelector('.usage-trend-plot');
const ticks = document.querySelectorAll('.usage-trend-x-axis span').length;
@@ -85,7 +119,61 @@ const base = 'http://127.0.0.1:1421';
assert.ok(wideAxis.width > narrowAxis.width, 'The trend plot follows the wider window');
assert.ok(wideAxis.ticks > narrowAxis.ticks, 'The X axis adds readable ticks when more width is available');
console.log('PASS: usage overview layout and responsive trend axis passed.');
for (const [width, height] of [[1213, 1000], [1600, 1000], [1213, 600], [1280, 540], [950, 600]]) {
await page.setViewportSize({ width, height });
await page.waitForTimeout(100);
const panels = await page.evaluate(() => {
const layout = document.querySelector('.usage-overview-layout');
const trend = document.querySelector('.usage-trend-panel').getBoundingClientRect();
const tokens = document.querySelector('.usage-health-panel').getBoundingClientRect();
const context = document.querySelector('.usage-token-context').getBoundingClientRect();
const composition = document.querySelector('.usage-token-composition').getBoundingClientRect();
const donut = document.querySelector('.usage-token-donut').getBoundingClientRect();
const breakdown = document.querySelector('.usage-token-breakdown').getBoundingClientRect();
return {
topDifference: Math.abs(trend.top - tokens.top),
bottomDifference: Math.abs(trend.bottom - tokens.bottom),
contentInside: context.bottom <= tokens.bottom,
windowBottomGap: window.innerHeight - tokens.bottom,
windowRightGap: window.innerWidth - tokens.right,
verticalOverflow: layout.scrollHeight - layout.clientHeight,
horizontalOverflow: layout.scrollWidth - layout.clientWidth,
chartInside: donut.top >= composition.top && donut.bottom <= composition.bottom
&& breakdown.top >= composition.top && breakdown.bottom <= composition.bottom
&& Array.from(document.querySelectorAll('.usage-token-row')).every((row) => {
const bounds = row.getBoundingClientRect();
return bounds.top >= composition.top && bounds.bottom <= composition.bottom;
}),
};
});
assert.ok(panels.topDifference < 1 && panels.bottomDifference < 1, `The two Token panels have equal heights at ${width}×${height}`);
assert.equal(panels.contentInside, true, 'Equal-height panels preserve all supporting details');
assert.equal(panels.chartInside, true, 'The ring and legend shrink to fit their chart area');
assert.equal(panels.verticalOverflow, 0, 'Resizing does not introduce vertical scrolling');
assert.equal(panels.horizontalOverflow, 0, 'Resizing does not introduce horizontal scrolling');
assert.ok(panels.windowBottomGap >= 0 && panels.windowBottomGap <= 20, 'The panels use the available height with a compact bottom margin');
assert.ok(panels.windowRightGap >= 0 && panels.windowRightGap <= 20, 'The overview keeps a compact margin at the window edge');
}
const filterGroup = page.locator('.usage-filter-group');
const filterBounds = await filterGroup.boundingBox();
assert.equal(await page.locator('.usage-filter-reset-btn').count(), 0, 'The overview does not show a reset filters button');
const modelFilter = page.locator('.usage-filter-item select').nth(1);
await modelFilter.selectOption('test-model');
await page.locator('.usage-trend-x-axis').waitFor();
assert.equal(await modelFilter.inputValue(), 'test-model', 'Model filtering remains available');
assert.equal(await page.locator('.usage-filter-reset-btn').count(), 0, 'Selecting a filter does not reveal a reset button');
assert.deepEqual(await filterGroup.boundingBox(), filterBounds, 'Selecting a filter does not shift or resize the filter row');
await modelFilter.selectOption('');
const rangeFilter = page.locator('.usage-filter-item select').first();
await rangeFilter.selectOption('custom');
await page.locator('.usage-custom-range').waitFor();
assert.equal(await page.locator('.usage-filter-reset-btn').count(), 0, 'Custom date filters do not reveal a reset button');
await rangeFilter.selectOption('4h');
await page.locator('.usage-trend-x-axis').waitFor();
assert.equal(await modelFilter.inputValue(), '', 'Filters can still be cleared through their own dropdowns');
console.log('PASS: usage overview layout, responsive trend axis, and stable filters without a reset button passed.');
} finally {
await browser.close();
}
+97 -1
View File
@@ -3,6 +3,26 @@ const assert = require('node:assert/strict');
const base = 'http://127.0.0.1:1421';
function parseCsv(text) {
const rows = [];
let row = [];
let field = '';
let quoted = false;
for (let index = 0; index < text.length; index += 1) {
const character = text[index];
if (character === '"') {
if (quoted && text[index + 1] === '"') { field += '"'; index += 1; }
else quoted = !quoted;
} else if (!quoted && (character === ',' || character === '\n')) {
row.push(field);
field = '';
if (character === '\n') { rows.push(row); row = []; }
} else if (quoted || character !== '\r') field += character;
}
if (field || row.length) { row.push(field); rows.push(row); }
return rows;
}
(async () => {
const browser = await chromium.launch({
channel: 'msedge', headless: true, args: ['--no-proxy-server'],
@@ -10,12 +30,28 @@ const base = 'http://127.0.0.1:1421';
});
try {
const page = await browser.newPage({ viewport: { width: 1100, height: 700 } });
const errors = [];
page.on('pageerror', error => errors.push(String(error)));
await page.route('**/*', (route) => route.request().url().startsWith(`${base}/`) ? route.continue() : route.abort());
await page.goto(`${base}/tests/fixtures/usage-layout.html?tab=events&locale=en`, { waitUntil: 'domcontentloaded' });
await page.locator('.usage-table-top-scrollbar:not(.is-hidden)').waitFor();
await page.locator('.usage-page-size-select').selectOption('200');
await page.waitForFunction(() => document.querySelectorAll('.usage-events-table tbody tr').length === 200);
assert.equal(await page.getByRole('heading', { name: 'Request Event Log' }).count(), 1);
const firstRow = page.locator('.usage-events-table tbody tr').first();
assert.equal(await firstRow.locator('.usage-td-total').getAttribute('title'), '1,200 tokens', 'The displayed total uses the recorded total instead of adding cache and reasoning again');
assert.equal(await firstRow.locator('.tone-input').getAttribute('aria-label'), 'Input: 1000');
assert.equal(await firstRow.locator('.tone-output').getAttribute('aria-label'), 'Output: 200');
assert.equal(await firstRow.locator('.tone-reasoning').getAttribute('aria-label'), 'Reasoning: 100');
assert.equal(parseFloat(await firstRow.locator('.usage-td-cache > strong').textContent()), 40);
assert.equal(await firstRow.locator('.tone-cache-read').textContent(), '400');
assert.equal(await firstRow.locator('.tone-cache-write').textContent(), '50');
assert.equal(await firstRow.locator('.usage-td-time small').count(), 1, 'The request date remains visible below its time');
assert.ok((await firstRow.locator('.usage-td-latency small').textContent()).includes('200'), 'The latency cell includes first-token latency');
assert.equal(await page.locator('.usage-events-summary .usage-page-size-select').count(), 0);
assert.equal(await page.locator('.usage-events-footer .usage-page-size-select').count(), 1, 'Pagination remains in the bottom footer');
// Multiple input updates can arrive before the next animation frame. None
// may be dropped, and queued programmatic scroll events must not echo back.
const rapidScroll = await page.evaluate(async () => {
@@ -125,7 +161,30 @@ const base = 'http://127.0.0.1:1421';
for (let index = 2; index < await checkboxes.count(); index += 1) await checkboxes.nth(index).uncheck();
await page.locator('.usage-column-dialog-actions .primary-button').click();
await page.waitForFunction(() => document.querySelector('.usage-table-top-scrollbar').classList.contains('is-hidden'));
assert.equal(await page.locator('.usage-events-table th').count(), 2, 'Column visibility applies to the header and every request row');
assert.equal(await firstRow.locator('td').count(), 2);
assert.equal(await page.locator('.usage-table-wrap').evaluate((element) => element.scrollLeft), 0);
// Export carries complete request data even when most display columns are hidden.
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Export Page CSV' }).click();
const download = await downloadPromise;
assert.match(download.suggestedFilename(), /^usage-events-page-1-\d{4}-\d{2}-\d{2}\.csv$/);
const stream = await download.createReadStream();
const chunks = [];
for await (const chunk of stream) chunks.push(chunk);
const exported = parseCsv(Buffer.concat(chunks).toString('utf8').replace(/^\uFEFF/, ''));
const [headers, ...records] = exported;
assert.equal(records.length, 200, 'CSV contains exactly the current page');
const values = Object.fromEntries(headers.map((header, index) => [header, records[0][index]]));
assert.deepEqual(Object.fromEntries(['input_tokens', 'output_tokens', 'reasoning_tokens', 'cache_read_tokens', 'cache_creation_tokens', 'total_tokens'].map(key => [key, values[key]])), {
input_tokens: '1000', output_tokens: '200', reasoning_tokens: '100', cache_read_tokens: '400', cache_creation_tokens: '50', total_tokens: '1200',
});
assert.equal(values.api_key_remark, 'Test key, "local"', 'CSV correctly escapes commas and quotes');
assert.equal(values.endpoint, '/v1/responses');
assert.equal(values.row_id, '1');
assert.equal(records.at(-1)[headers.indexOf('row_id')], '200');
await page.locator('.usage-col-settings-btn').click();
await page.locator('.usage-column-select-all').click();
await page.locator('.usage-column-dialog-actions .primary-button').click();
@@ -133,7 +192,44 @@ const base = 'http://127.0.0.1:1421';
await bar.evaluate((element) => { element.scrollLeft = 250; });
await page.waitForFunction(() => document.querySelector('.usage-table-wrap').scrollLeft === 250);
console.log('PASS: rapid two-way scrolling, delayed echoes, refresh, window/column resizing, column visibility, and native dragging stay synchronized with 200 rows.');
const timeResize = page.locator('.usage-th-time .usage-col-resizer');
await timeResize.focus();
const beforeWidth = Number(await timeResize.getAttribute('aria-valuenow'));
await page.keyboard.press('ArrowRight');
assert.equal(Number(await timeResize.getAttribute('aria-valuenow')), beforeWidth + 10, 'Keyboard resizing remains available');
await page.keyboard.press('Home');
assert.equal(Number(await timeResize.getAttribute('aria-valuenow')), 84);
await page.locator('.usage-page-size-select').selectOption('20');
await page.waitForFunction(() => document.querySelector('.usage-pagination-info').textContent.trim() === '1 / 20');
const originalTime = await firstRow.locator('.usage-td-time strong').textContent();
await page.getByRole('button', { name: 'Next', exact: true }).click();
await page.waitForFunction(() => document.querySelector('.usage-pagination-info').textContent.trim() === '2 / 20');
assert.notEqual(await firstRow.locator('.usage-td-time strong').textContent(), originalTime, 'Bottom pagination loads a different request page');
assert.equal(await page.locator('.usage-pagination-summary').textContent(), 'Showing 21 - 40 of 400');
assert.equal(await page.getByRole('button', { name: 'Reset Filters', exact: true }).count(), 1, 'The request log keeps a clear filters control visible');
assert.equal(await page.getByRole('button', { name: 'Reset Filters', exact: true }).isDisabled(), true, 'The clear filters control is disabled without active filters');
await page.getByRole('combobox', { name: 'Model', exact: true }).selectOption('test-model');
await page.waitForFunction(() => document.querySelector('.usage-pagination-summary').textContent === 'Showing 1 - 20 of 266');
assert.equal(await page.locator('.usage-events-table tbody strong[title="test-model"]').count(), 20, 'Changing a filter resets pagination and shows matching models');
await page.getByRole('combobox', { name: 'Source', exact: true }).selectOption('test-source');
await page.waitForFunction(() => document.querySelector('.usage-pagination-summary').textContent === 'Showing 1 - 20 of 133');
assert.ok((await page.locator('.usage-td-source').allTextContents()).every(text => text === 'Test source'));
await page.getByRole('combobox', { name: 'Request result', exact: true }).selectOption('failed');
await page.waitForFunction(() => document.querySelector('.usage-pagination-summary').textContent === 'Showing 1 - 20 of 66');
assert.equal(await page.locator('.usage-events-table tbody .usage-result.failed').count(), 20);
assert.equal(await page.getByRole('button', { name: 'Reset Filters', exact: true }).isDisabled(), false, 'Active filters enable the clear filters control');
await page.getByRole('combobox', { name: 'Model', exact: true }).selectOption('');
await page.getByRole('combobox', { name: 'Source', exact: true }).selectOption('');
await page.getByRole('combobox', { name: 'Request result', exact: true }).selectOption('all');
await page.waitForFunction(() => document.querySelector('.usage-pagination-summary').textContent === 'Showing 1 - 20 of 400');
assert.equal(await page.getByRole('combobox', { name: 'Model', exact: true }).inputValue(), '');
assert.equal(await page.getByRole('combobox', { name: 'Source', exact: true }).inputValue(), '');
assert.equal(await page.getByRole('combobox', { name: 'Request result', exact: true }).inputValue(), 'all');
assert.deepEqual(errors, [], 'Request log interactions do not produce runtime errors');
console.log('PASS: request values, CSV export, filters, bottom pagination, column visibility/resizing, and synchronized horizontal scrolling with 200 rows.');
} finally {
await browser.close();
}
+39
View File
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test';
import {
calculateCacheReadRate,
calculateGenerationSpeed,
calculateTokenComposition,
formatCacheReadRate,
formatGenerationSpeed,
} from '../src/services/usageMetrics';
@@ -45,3 +46,41 @@ describe('cache read rate', () => {
expect(formatCacheReadRate(input)).toBe('—');
});
});
describe('token composition', () => {
test('splits cached input without double counting cache or reasoning', () => {
const tokens = {
inputTokens: 1_000,
outputTokens: 200,
cacheReadTokens: 600,
cacheCreationTokens: 100,
reasoningTokens: 80,
};
const composition = calculateTokenComposition(tokens);
expect(composition.total).toBe(1_200);
expect(composition.segments.map((segment) => segment.value)).toEqual([300, 600, 100, 200]);
expect(composition.cacheShare).toBe(50);
expect(composition.segments.reduce((total, segment) => total + segment.percent, 0)).toBeCloseTo(100);
expect(composition.segments[1].offset).toBe(25);
});
test('keeps an empty chart finite and free of colored segments', () => {
const composition = calculateTokenComposition({ inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0 });
expect(composition.total).toBe(0);
expect(composition.cacheShare).toBe(0);
expect(composition.segments.every((segment) => segment.value === 0 && segment.percent === 0 && segment.offset === 0)).toBe(true);
});
test('avoids negative uncached input when legacy cache counts exceed input', () => {
const composition = calculateTokenComposition({ inputTokens: 100, outputTokens: 0, cacheReadTokens: 600, cacheCreationTokens: 0 });
expect(composition.total).toBe(600);
expect(composition.segments[0].value).toBe(0);
expect(composition.cacheShare).toBe(100);
});
test('normalizes invalid counts before calculating chart proportions', () => {
const composition = calculateTokenComposition({ inputTokens: Number.NaN, outputTokens: 100, cacheReadTokens: -10, cacheCreationTokens: Number.POSITIVE_INFINITY });
expect(composition.total).toBe(100);
expect(composition.segments.map((segment) => segment.percent)).toEqual([0, 0, 0, 100]);
});
});