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={
} >
{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}}
{hasLatencyData && } {rows.map((row) => ( {hasLatencyData && ( )} ))}
{t('usage_stats.request_events_timestamp')} {t('usage_stats.model_name')} {t('usage_stats.request_events_source')} {t('usage_stats.request_events_result')}{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')}
{row.timestampLabel} {row.model} {row.source} {row.sourceType && ( {row.sourceType} )} {row.failed ? t('usage_stats.failure') : t('usage_stats.success')} {formatDurationMs(row.latencyMs)}{row.inputTokens.toLocaleString()} {row.outputTokens.toLocaleString()} {row.reasoningTokens.toLocaleString()} {row.cachedTokens.toLocaleString()} {row.totalTokens.toLocaleString()}
)}
); }