mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-06-16 21:03:58 +08:00
feat(usage): add filterable request events table with export
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user