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

This commit is contained in:
Supra4E8C
2026-02-21 17:23:21 +08:00
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';