import React, { useMemo } 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 type { UsageEvent, UsageSourceFilterOption } from '@/lib/types';
import { formatDurationMs, LATENCY_SOURCE_FIELD, normalizeAuthIndex } from '@/utils/usage';
import { downloadBlob } from '@/utils/download';
import styles from '@/pages/UsagePage.module.scss';
const ALL_FILTER = '__all__';
type SelectOption = { value: string; label: string };
const appendSelectedOption = (
options: SelectOption[],
selectedValue: string,
selectedLabel = selectedValue
) => {
if (selectedValue === ALL_FILTER || options.some((option) => option.value === selectedValue)) {
return options;
}
return [...options, { value: selectedValue, label: selectedLabel }];
};
type RequestEventRow = {
id: string;
timestamp: string;
timestampMs: number;
timestampLabel: string;
model: string;
sourceRaw: string;
source: string;
sourceType: string;
authIndex: string;
failed: boolean;
latencyMs: number | null;
inputTokens: number;
outputTokens: number;
reasoningTokens: number;
cachedTokens: number;
totalTokens: number;
};
export interface RequestEventsDetailsCardProps {
events: UsageEvent[];
loading: boolean;
page: number;
pageSize: number;
pageSizeOptions: readonly number[];
totalCount: number;
totalPages: number;
modelOptions: string[];
sourceOptions: UsageSourceFilterOption[];
modelFilter: string;
sourceFilter: string;
resultFilter: string;
onPageChange: (page: number) => void;
onPageSizeChange: (pageSize: number) => void;
onModelFilterChange: (model: string) => void;
onSourceFilterChange: (source: string) => void;
onResultFilterChange: (result: string) => void;
}
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 ?? '');
const trimmedLeft = text.replace(/^\s+/, '');
const safeText = trimmedLeft && /^[=+\-@]/.test(trimmedLeft) ? `'${text}` : text;
return `"${safeText.replace(/"/g, '""')}"`;
};
function RequestEventsTitle({ title, subtitle, eyebrow }: { title: string; subtitle: string; eyebrow: string }) {
return (
{eyebrow}
{title}
{subtitle}
);
}
export function RequestEventsDetailsCard({
events,
loading,
page,
pageSize,
pageSizeOptions,
totalCount,
totalPages,
modelOptions: backendModelOptions,
sourceOptions: backendSourceOptions,
modelFilter,
sourceFilter,
resultFilter,
onPageChange,
onPageSizeChange,
onModelFilterChange,
onSourceFilterChange,
onResultFilterChange,
}: RequestEventsDetailsCardProps) {
const { t, i18n } = useTranslation();
const latencyHint = t('usage_stats.latency_unit_hint', {
field: LATENCY_SOURCE_FIELD,
unit: t('usage_stats.duration_unit_ms'),
});
const rows = useMemo(() => {
return events.map((event, index) => {
const timestamp = event.timestamp;
const timestampMs = Date.parse(timestamp);
const date = Number.isNaN(timestampMs) ? null : new Date(timestampMs);
const sourceRaw = String(event.source_raw ?? '').trim() || String(event.source ?? '').trim();
const authIndexRaw = event.auth_index as unknown;
const authIndex =
authIndexRaw === null || authIndexRaw === undefined || authIndexRaw === ''
? '-'
: normalizeAuthIndex(authIndexRaw) || '-';
const source = String(event.source ?? '').trim() || '-';
const sourceType = String(event.source_type ?? '').trim();
const model = String(event.model ?? '').trim() || '-';
const inputTokens = Math.max(toNumber(event.tokens?.input_tokens), 0);
const outputTokens = Math.max(toNumber(event.tokens?.output_tokens), 0);
const reasoningTokens = Math.max(toNumber(event.tokens?.reasoning_tokens), 0);
const cachedTokens = Math.max(toNumber(event.tokens?.cached_tokens), 0);
const totalTokens = Math.max(toNumber(event.tokens?.total_tokens), 0);
const latencyMs = Number.isFinite(event.latency_ms) ? event.latency_ms : null;
return {
id: event.id ? String(event.id) : `${timestamp}-${model}-${sourceRaw || source}-${authIndex}-${index}`,
timestamp,
timestampMs: Number.isNaN(timestampMs) ? 0 : timestampMs,
timestampLabel: date ? date.toLocaleString(i18n.language) : timestamp || '-',
model,
sourceRaw: sourceRaw || '-',
source,
sourceType,
authIndex,
failed: event.failed === true,
latencyMs,
inputTokens,
outputTokens,
reasoningTokens,
cachedTokens,
totalTokens,
};
});
}, [events, i18n.language]);
const hasLatencyData = useMemo(() => rows.some((row) => row.latencyMs !== null), [rows]);
const modelOptions = useMemo(() => {
const options = [
{ value: ALL_FILTER, label: t('usage_stats.filter_all') },
...backendModelOptions.map((model) => ({ value: model, label: model })),
];
return appendSelectedOption(options, modelFilter);
}, [backendModelOptions, modelFilter, t]);
const sourceOptions = useMemo(() => {
const options = [
{ value: ALL_FILTER, label: t('usage_stats.filter_all') },
...backendSourceOptions.map((source) => ({ value: source.value, label: source.label || source.value })),
];
const selectedLabel = backendSourceOptions.find((source) => source.value === sourceFilter)?.label;
return appendSelectedOption(options, sourceFilter, selectedLabel || sourceFilter);
}, [backendSourceOptions, sourceFilter, t]);
const resultOptions = useMemo(
() => [
{ value: ALL_FILTER, label: t('usage_stats.filter_all') },
{ value: 'success', label: t('usage_stats.success') },
{ value: 'failed', label: t('usage_stats.failure') },
],
[t]
);
const modelOptionSet = useMemo(
() => new Set(modelOptions.map((option) => option.value)),
[modelOptions]
);
const sourceOptionSet = useMemo(
() => new Set(sourceOptions.map((option) => option.value)),
[sourceOptions]
);
const resultOptionSet = useMemo(
() => new Set(resultOptions.map((option) => option.value)),
[resultOptions]
);
const effectiveModelFilter = modelOptionSet.has(modelFilter) ? modelFilter : ALL_FILTER;
const effectiveSourceFilter = sourceOptionSet.has(sourceFilter) ? sourceFilter : ALL_FILTER;
const effectiveResultFilter = resultOptionSet.has(resultFilter) ? resultFilter : ALL_FILTER;
const hasActiveFilters =
modelFilter !== ALL_FILTER ||
sourceFilter !== ALL_FILTER ||
resultFilter !== ALL_FILTER;
const pageSizeSelectOptions = useMemo(
() => pageSizeOptions.map((option) => ({ value: String(option), label: String(option) })),
[pageSizeOptions]
);
const computedTotalPages = pageSize > 0 ? Math.ceil(totalCount / pageSize) : 0;
const safeTotalPages = Math.max(totalPages, computedTotalPages, rows.length > 0 ? 1 : 0);
const safePage = safeTotalPages > 0 ? Math.min(Math.max(page, 1), safeTotalPages) : 0;
const pageLabel = safeTotalPages > 0
? t('usage_stats.request_events_page_control', { page: safePage, totalPages: safeTotalPages })
: t('usage_stats.request_events_page_empty');
const handleClearFilters = () => {
onModelFilterChange(ALL_FILTER);
onSourceFilterChange(ALL_FILTER);
onResultFilterChange(ALL_FILTER);
};
const handleExportCsv = () => {
if (!rows.length) return;
const csvHeader = [
'timestamp',
'model',
'source',
'source_raw',
'auth_index',
'result',
...(hasLatencyData ? ['latency_ms'] : []),
'input_tokens',
'output_tokens',
'reasoning_tokens',
'cached_tokens',
'total_tokens',
];
const csvRows = rows.map((row) =>
[
row.timestamp,
row.model,
row.source,
row.sourceRaw,
row.authIndex,
row.failed ? 'failed' : 'success',
...(hasLatencyData ? [row.latencyMs ?? ''] : []),
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, '-');
downloadBlob({
filename: `usage-events-${fileTime}.csv`,
blob: new Blob([content], { type: 'text/csv;charset=utf-8' }),
});
};
const handleExportJson = () => {
if (!rows.length) return;
const payload = rows.map((row) => ({
timestamp: row.timestamp,
model: row.model,
source: row.source,
source_raw: row.sourceRaw,
auth_index: row.authIndex,
failed: row.failed,
...(hasLatencyData && row.latencyMs !== null ? { latency_ms: row.latencyMs } : {}),
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, '-');
downloadBlob({
filename: `usage-events-${fileTime}.json`,
blob: new Blob([content], { type: 'application/json;charset=utf-8' }),
});
};
void handleExportCsv;
void handleExportJson;
return (
}
extra={
}
>
{t('usage_stats.request_events_rows_per_page')}
{pageLabel}
{loading && rows.length === 0 ? (
{t('common.loading')}
) : rows.length === 0 ? (
) : (
<>
{t('usage_stats.request_events_count', { count: rows.length })}
{t('usage_stats.request_events_total_count', { count: totalCount })}
{hasLatencyData &&
{latencyHint}}
| {t('usage_stats.request_events_timestamp')} |
{t('usage_stats.model_name')} |
{t('usage_stats.request_events_source')} |
{t('usage_stats.request_events_result')} |
{hasLatencyData && {t('usage_stats.time')} | }
{t('usage_stats.input_tokens')} |
{t('usage_stats.output_tokens')} |
{t('usage_stats.reasoning_tokens')} |
{t('usage_stats.cached_tokens')} |
{t('usage_stats.total_tokens')} |
{rows.map((row) => (
|
{row.timestampLabel}
|
{row.model} |
{row.source}
{row.sourceType && (
{row.sourceType}
)}
|
{row.failed ? t('usage_stats.failure') : t('usage_stats.success')}
|
{hasLatencyData && (
{formatDurationMs(row.latencyMs)} |
)}
{row.inputTokens.toLocaleString()} |
{row.outputTokens.toLocaleString()} |
{row.reasoningTokens.toLocaleString()} |
{row.cachedTokens.toLocaleString()} |
{row.totalTokens.toLocaleString()} |
))}
>
)}
);
}