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:
1 parent
fdf56ee7eb
commit
12ad2a8e97
18 files changed
+2574
-1262
No files matched your search
@@ -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 キー',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '鉴权密钥',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
+290
-1070
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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 */
|
||||
|
||||
@@ -2964,6 +2964,7 @@
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
|
||||
.config-network-grid,
|
||||
.config-tls-fields,
|
||||
.config-management-form,
|
||||
|
||||
@@ -6,3 +6,4 @@
|
||||
@import './responsive-agents.css';
|
||||
@import './responsive-settings.css';
|
||||
@import './config-settings.css';
|
||||
@import '../pages/UsageEventsView.css';
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Vendored
+53
-32
@@ -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') {
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user