feat(usage): add filterable request events table with export

This commit is contained in:
Supra4E8C
2026-02-21 17:23:21 +08:00
Unverified
parent 6ae85cf715
commit 8861384bcc
7 changed files with 547 additions and 0 deletions
@@ -0,0 +1,395 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { EmptyState } from '@/components/ui/EmptyState';
import { Select } from '@/components/ui/Select';
import { collectUsageDetails, extractTotalTokens } from '@/utils/usage';
import styles from '@/pages/UsagePage.module.scss';
const ALL_FILTER = '__all__';
const MAX_RENDERED_EVENTS = 500;
type RequestEventRow = {
id: string;
timestamp: string;
timestampMs: number;
timestampLabel: string;
model: string;
source: string;
authIndex: string;
failed: boolean;
inputTokens: number;
outputTokens: number;
reasoningTokens: number;
cachedTokens: number;
totalTokens: number;
};
export interface RequestEventsDetailsCardProps {
usage: unknown;
loading: boolean;
}
const toNumber = (value: unknown): number => {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return 0;
return parsed;
};
const encodeCsv = (value: string | number): string => {
const text = String(value ?? '');
return `"${text.replace(/"/g, '""')}"`;
};
const downloadFile = (filename: string, content: string, mimeType: string) => {
const blob = new Blob([content], { type: mimeType });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
window.URL.revokeObjectURL(url);
};
export function RequestEventsDetailsCard({ usage, loading }: RequestEventsDetailsCardProps) {
const { t, i18n } = useTranslation();
const [modelFilter, setModelFilter] = useState(ALL_FILTER);
const [sourceFilter, setSourceFilter] = useState(ALL_FILTER);
const [authIndexFilter, setAuthIndexFilter] = useState(ALL_FILTER);
const rows = useMemo<RequestEventRow[]>(() => {
const details = collectUsageDetails(usage);
return details
.map((detail, index) => {
const timestamp = typeof detail.timestamp === 'string' ? detail.timestamp : '';
const timestampMs = Date.parse(timestamp);
const date = Number.isNaN(timestampMs) ? null : new Date(timestampMs);
const source = String(detail.source ?? '').trim() || '-';
const model = String(detail.__modelName ?? '').trim() || '-';
const authIndexRaw = detail.auth_index as unknown;
const authIndex =
authIndexRaw === null || authIndexRaw === undefined || authIndexRaw === ''
? '-'
: String(authIndexRaw);
const inputTokens = Math.max(toNumber(detail.tokens?.input_tokens), 0);
const outputTokens = Math.max(toNumber(detail.tokens?.output_tokens), 0);
const reasoningTokens = Math.max(toNumber(detail.tokens?.reasoning_tokens), 0);
const cachedTokens = Math.max(
Math.max(toNumber(detail.tokens?.cached_tokens), 0),
Math.max(toNumber(detail.tokens?.cache_tokens), 0)
);
const totalTokens = Math.max(
toNumber(detail.tokens?.total_tokens),
extractTotalTokens(detail)
);
return {
id: `${timestamp}-${model}-${source}-${authIndex}-${index}`,
timestamp,
timestampMs: Number.isNaN(timestampMs) ? 0 : timestampMs,
timestampLabel: date ? date.toLocaleString(i18n.language) : timestamp || '-',
model,
source,
authIndex,
failed: detail.failed === true,
inputTokens,
outputTokens,
reasoningTokens,
cachedTokens,
totalTokens
};
})
.sort((a, b) => b.timestampMs - a.timestampMs);
}, [i18n.language, usage]);
const modelOptions = useMemo(
() => [
{ value: ALL_FILTER, label: t('usage_stats.filter_all') },
...Array.from(new Set(rows.map((row) => row.model))).map((model) => ({
value: model,
label: model
}))
],
[rows, t]
);
const sourceOptions = useMemo(
() => [
{ value: ALL_FILTER, label: t('usage_stats.filter_all') },
...Array.from(new Set(rows.map((row) => row.source))).map((source) => ({
value: source,
label: source
}))
],
[rows, t]
);
const authIndexOptions = useMemo(
() => [
{ value: ALL_FILTER, label: t('usage_stats.filter_all') },
...Array.from(new Set(rows.map((row) => row.authIndex))).map((authIndex) => ({
value: authIndex,
label: authIndex
}))
],
[rows, t]
);
useEffect(() => {
if (!modelOptions.some((option) => option.value === modelFilter)) {
setModelFilter(ALL_FILTER);
}
}, [modelFilter, modelOptions]);
useEffect(() => {
if (!sourceOptions.some((option) => option.value === sourceFilter)) {
setSourceFilter(ALL_FILTER);
}
}, [sourceFilter, sourceOptions]);
useEffect(() => {
if (!authIndexOptions.some((option) => option.value === authIndexFilter)) {
setAuthIndexFilter(ALL_FILTER);
}
}, [authIndexFilter, authIndexOptions]);
const filteredRows = useMemo(
() =>
rows.filter((row) => {
const modelMatched = modelFilter === ALL_FILTER || row.model === modelFilter;
const sourceMatched = sourceFilter === ALL_FILTER || row.source === sourceFilter;
const authIndexMatched = authIndexFilter === ALL_FILTER || row.authIndex === authIndexFilter;
return modelMatched && sourceMatched && authIndexMatched;
}),
[authIndexFilter, modelFilter, rows, sourceFilter]
);
const renderedRows = useMemo(
() => filteredRows.slice(0, MAX_RENDERED_EVENTS),
[filteredRows]
);
const hasActiveFilters =
modelFilter !== ALL_FILTER || sourceFilter !== ALL_FILTER || authIndexFilter !== ALL_FILTER;
const handleClearFilters = () => {
setModelFilter(ALL_FILTER);
setSourceFilter(ALL_FILTER);
setAuthIndexFilter(ALL_FILTER);
};
const handleExportCsv = () => {
if (!filteredRows.length) return;
const csvHeader = [
'timestamp',
'model',
'source',
'auth_index',
'result',
'input_tokens',
'output_tokens',
'reasoning_tokens',
'cached_tokens',
'total_tokens'
];
const csvRows = filteredRows.map((row) =>
[
row.timestamp,
row.model,
row.source,
row.authIndex,
row.failed ? 'failed' : 'success',
row.inputTokens,
row.outputTokens,
row.reasoningTokens,
row.cachedTokens,
row.totalTokens
]
.map((value) => encodeCsv(value))
.join(',')
);
const content = [csvHeader.join(','), ...csvRows].join('\n');
const fileTime = new Date().toISOString().replace(/[:.]/g, '-');
downloadFile(`usage-events-${fileTime}.csv`, content, 'text/csv;charset=utf-8');
};
const handleExportJson = () => {
if (!filteredRows.length) return;
const payload = filteredRows.map((row) => ({
timestamp: row.timestamp,
model: row.model,
source: row.source,
auth_index: row.authIndex,
failed: row.failed,
tokens: {
input_tokens: row.inputTokens,
output_tokens: row.outputTokens,
reasoning_tokens: row.reasoningTokens,
cached_tokens: row.cachedTokens,
total_tokens: row.totalTokens
}
}));
const content = JSON.stringify(payload, null, 2);
const fileTime = new Date().toISOString().replace(/[:.]/g, '-');
downloadFile(`usage-events-${fileTime}.json`, content, 'application/json;charset=utf-8');
};
return (
<Card
title={t('usage_stats.request_events_title')}
extra={
<div className={styles.requestEventsActions}>
<Button
variant="ghost"
size="sm"
onClick={handleClearFilters}
disabled={!hasActiveFilters}
>
{t('usage_stats.clear_filters')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleExportCsv}
disabled={filteredRows.length === 0}
>
{t('usage_stats.export_csv')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleExportJson}
disabled={filteredRows.length === 0}
>
{t('usage_stats.export_json')}
</Button>
</div>
}
>
<div className={styles.requestEventsToolbar}>
<div className={styles.requestEventsFilterItem}>
<span className={styles.requestEventsFilterLabel}>
{t('usage_stats.request_events_filter_model')}
</span>
<Select
value={modelFilter}
options={modelOptions}
onChange={setModelFilter}
className={styles.requestEventsSelect}
ariaLabel={t('usage_stats.request_events_filter_model')}
fullWidth={false}
/>
</div>
<div className={styles.requestEventsFilterItem}>
<span className={styles.requestEventsFilterLabel}>
{t('usage_stats.request_events_filter_source')}
</span>
<Select
value={sourceFilter}
options={sourceOptions}
onChange={setSourceFilter}
className={styles.requestEventsSelect}
ariaLabel={t('usage_stats.request_events_filter_source')}
fullWidth={false}
/>
</div>
<div className={styles.requestEventsFilterItem}>
<span className={styles.requestEventsFilterLabel}>
{t('usage_stats.request_events_filter_auth_index')}
</span>
<Select
value={authIndexFilter}
options={authIndexOptions}
onChange={setAuthIndexFilter}
className={styles.requestEventsSelect}
ariaLabel={t('usage_stats.request_events_filter_auth_index')}
fullWidth={false}
/>
</div>
</div>
{loading && rows.length === 0 ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : rows.length === 0 ? (
<EmptyState
title={t('usage_stats.request_events_empty_title')}
description={t('usage_stats.request_events_empty_desc')}
/>
) : filteredRows.length === 0 ? (
<EmptyState
title={t('usage_stats.request_events_no_result_title')}
description={t('usage_stats.request_events_no_result_desc')}
/>
) : (
<>
<div className={styles.requestEventsMeta}>
<span>{t('usage_stats.request_events_count', { count: filteredRows.length })}</span>
{filteredRows.length > MAX_RENDERED_EVENTS && (
<span className={styles.requestEventsLimitHint}>
{t('usage_stats.request_events_limit_hint', {
shown: MAX_RENDERED_EVENTS,
total: filteredRows.length
})}
</span>
)}
</div>
<div className={styles.requestEventsTableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th>{t('usage_stats.request_events_timestamp')}</th>
<th>{t('usage_stats.model_name')}</th>
<th>{t('usage_stats.request_events_source')}</th>
<th>{t('usage_stats.request_events_auth_index')}</th>
<th>{t('usage_stats.request_events_result')}</th>
<th>{t('usage_stats.input_tokens')}</th>
<th>{t('usage_stats.output_tokens')}</th>
<th>{t('usage_stats.reasoning_tokens')}</th>
<th>{t('usage_stats.cached_tokens')}</th>
<th>{t('usage_stats.total_tokens')}</th>
</tr>
</thead>
<tbody>
{renderedRows.map((row) => (
<tr key={row.id}>
<td title={row.timestamp} className={styles.requestEventsTimestamp}>
{row.timestampLabel}
</td>
<td className={styles.modelCell}>{row.model}</td>
<td className={styles.requestEventsSourceCell} title={row.source}>
{row.source}
</td>
<td className={styles.requestEventsAuthIndex} title={row.authIndex}>
{row.authIndex}
</td>
<td>
<span
className={row.failed ? styles.requestEventsResultFailed : styles.requestEventsResultSuccess}
>
{row.failed ? t('stats.failure') : t('stats.success')}
</span>
</td>
<td>{row.inputTokens.toLocaleString()}</td>
<td>{row.outputTokens.toLocaleString()}</td>
<td>{row.reasoningTokens.toLocaleString()}</td>
<td>{row.cachedTokens.toLocaleString()}</td>
<td>{row.totalTokens.toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</Card>
);
}
+3
View File
@@ -38,3 +38,6 @@ export type { CostTrendChartProps } from './CostTrendChart';
export { ServiceHealthCard } from './ServiceHealthCard';
export type { ServiceHealthCardProps } from './ServiceHealthCard';
export { RequestEventsDetailsCard } from './RequestEventsDetailsCard';
export type { RequestEventsDetailsCardProps } from './RequestEventsDetailsCard';
+18
View File
@@ -816,9 +816,13 @@
"range_7h": "Last 7 Hours",
"range_24h": "Last 24 Hours",
"range_7d": "Last 7 Days",
"filter_all": "All",
"clear_filters": "Clear Filters",
"refresh": "Refresh",
"export": "Export",
"import": "Import",
"export_csv": "Export CSV",
"export_json": "Export JSON",
"export_success": "Usage export downloaded",
"import_success": "Import complete: added {{added}}, skipped {{skipped}}, total {{total}}, failed {{failed}}",
"import_invalid": "Invalid usage export file",
@@ -867,6 +871,20 @@
"credential_stats": "Credential Statistics",
"credential_name": "Credential",
"token_breakdown": "Token Type Breakdown",
"request_events_title": "Request Events",
"request_events_filter_model": "Model",
"request_events_filter_source": "Source",
"request_events_filter_auth_index": "Auth Index",
"request_events_timestamp": "Timestamp",
"request_events_source": "Source",
"request_events_auth_index": "Auth Index",
"request_events_result": "Result",
"request_events_empty_title": "No request events",
"request_events_empty_desc": "No request details are available for the selected time range.",
"request_events_no_result_title": "No matching events",
"request_events_no_result_desc": "Try different filters or clear the current filters.",
"request_events_count": "{{count}} events",
"request_events_limit_hint": "Showing {{shown}} of {{total}} events to keep rendering responsive.",
"input_tokens": "Input Tokens",
"output_tokens": "Output Tokens",
"last_updated": "Updated"
+18
View File
@@ -819,9 +819,13 @@
"range_7h": "Последние 7 часов",
"range_24h": "Последние 24 часа",
"range_7d": "Последние 7 дней",
"filter_all": "Все",
"clear_filters": "Сбросить фильтры",
"refresh": "Обновить",
"export": "Экспорт",
"import": "Импорт",
"export_csv": "Экспорт CSV",
"export_json": "Экспорт JSON",
"export_success": "Экспорт использования скачан",
"import_success": "Импорт завершён: добавлено {{added}}, пропущено {{skipped}}, всего {{total}}, ошибок {{failed}}",
"import_invalid": "Недопустимый файл экспорта использования",
@@ -870,6 +874,20 @@
"credential_stats": "Статистика учётных данных",
"credential_name": "Учётные данные",
"token_breakdown": "Распределение типов токенов",
"request_events_title": "События запросов",
"request_events_filter_model": "Модель",
"request_events_filter_source": "Источник",
"request_events_filter_auth_index": "Auth Index",
"request_events_timestamp": "Время",
"request_events_source": "Источник",
"request_events_auth_index": "Auth Index",
"request_events_result": "Результат",
"request_events_empty_title": "События запросов отсутствуют",
"request_events_empty_desc": "Нет деталей запросов для выбранного диапазона времени.",
"request_events_no_result_title": "Совпадений не найдено",
"request_events_no_result_desc": "Попробуйте другие фильтры или очистите текущие.",
"request_events_count": "{{count}} событий",
"request_events_limit_hint": "Показано {{shown}} из {{total}} событий для стабильной производительности.",
"input_tokens": "Входные токены",
"output_tokens": "Выходные токены",
"last_updated": "Обновлено"
+18
View File
@@ -816,9 +816,13 @@
"range_7h": "最近7小时",
"range_24h": "最近24小时",
"range_7d": "最近7天",
"filter_all": "全部",
"clear_filters": "清空筛选",
"refresh": "刷新",
"export": "导出数据",
"import": "导入数据",
"export_csv": "导出 CSV",
"export_json": "导出 JSON",
"export_success": "使用统计已导出",
"import_success": "导入完成:新增 {{added}},跳过 {{skipped}},总请求 {{total}},失败 {{failed}}",
"import_invalid": "导入文件格式不正确",
@@ -867,6 +871,20 @@
"credential_stats": "凭证统计",
"credential_name": "凭证",
"token_breakdown": "Token 类型分布",
"request_events_title": "请求事件明细",
"request_events_filter_model": "模型",
"request_events_filter_source": "来源",
"request_events_filter_auth_index": "认证索引",
"request_events_timestamp": "时间",
"request_events_source": "来源",
"request_events_auth_index": "认证索引",
"request_events_result": "结果",
"request_events_empty_title": "暂无请求事件",
"request_events_empty_desc": "当前时间范围内暂无可用的请求明细数据。",
"request_events_no_result_title": "没有匹配结果",
"request_events_no_result_desc": "请尝试调整筛选条件,或清空当前筛选。",
"request_events_count": "{{count}} 条事件",
"request_events_limit_hint": "为保证渲染性能,仅展示 {{shown}} / {{total}} 条事件。",
"input_tokens": "输入 Tokens",
"output_tokens": "输出 Tokens",
"last_updated": "更新于"
+92
View File
@@ -859,6 +859,98 @@
}
}
.requestEventsActions {
display: inline-flex;
align-items: center;
gap: $spacing-xs;
flex-wrap: wrap;
}
.requestEventsToolbar {
display: flex;
gap: $spacing-sm;
flex-wrap: wrap;
align-items: flex-end;
margin-bottom: $spacing-sm;
}
.requestEventsFilterItem {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 180px;
}
.requestEventsFilterLabel {
font-size: 12px;
color: var(--text-secondary);
font-weight: 600;
}
.requestEventsSelect {
min-width: 180px;
}
.requestEventsMeta {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-sm;
margin-bottom: $spacing-xs;
font-size: 12px;
color: var(--text-secondary);
flex-wrap: wrap;
}
.requestEventsLimitHint {
color: var(--text-tertiary);
}
.requestEventsTableWrapper {
overflow: auto;
max-height: 460px;
border: 1px solid var(--border-color);
border-radius: $radius-md;
}
.requestEventsTimestamp {
white-space: nowrap;
min-width: 160px;
}
.requestEventsSourceCell,
.requestEventsAuthIndex {
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace;
font-size: 11px;
max-width: 180px;
word-break: break-all;
}
.requestEventsResultSuccess {
color: var(--success-badge-text, #065f46);
background: var(--success-badge-bg, #d1fae5);
border: 1px solid var(--success-badge-border, #6ee7b7);
}
.requestEventsResultFailed {
color: var(--failure-badge-text);
background: var(--failure-badge-bg);
border: 1px solid var(--failure-badge-border);
}
.requestEventsResultSuccess,
.requestEventsResultFailed {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: $radius-full;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
.chartLineHeader {
display: inline-flex;
align-items: center;
+3
View File
@@ -25,6 +25,7 @@ import {
ModelStatsCard,
PriceSettingsCard,
CredentialStatsCard,
RequestEventsDetailsCard,
TokenBreakdownChart,
CostTrendChart,
ServiceHealthCard,
@@ -361,6 +362,8 @@ export function UsagePage() {
<ModelStatsCard modelStats={modelStats} loading={loading} hasPrices={hasPrices} />
</div>
<RequestEventsDetailsCard usage={filteredUsage} loading={loading} />
{/* Credential Stats */}
<CredentialStatsCard
usage={filteredUsage}