diff --git a/src/components/usage/RequestEventsDetailsCard.tsx b/src/components/usage/RequestEventsDetailsCard.tsx new file mode 100644 index 0000000..b01181c --- /dev/null +++ b/src/components/usage/RequestEventsDetailsCard.tsx @@ -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(() => { + 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 ( + + + + + + } + > +
+
+ + {t('usage_stats.request_events_filter_model')} + + +
+
+ + {t('usage_stats.request_events_filter_auth_index')} + +