Spaces:
Running
Running
| 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 ( | |
| <div className={styles.sectionTitleBlock}> | |
| <span className={styles.sectionEyebrow}>{eyebrow}</span> | |
| <h3 className={styles.sectionTitle}>{title}</h3> | |
| <p className={styles.sectionSubtitle}>{subtitle}</p> | |
| </div> | |
| ); | |
| } | |
| 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<RequestEventRow[]>(() => { | |
| 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 ( | |
| <Card | |
| title={ | |
| <RequestEventsTitle | |
| eyebrow={t('usage_stats.request_events_eyebrow')} | |
| title={t('usage_stats.request_events_title')} | |
| subtitle={t('usage_stats.request_events_subtitle')} | |
| /> | |
| } | |
| extra={ | |
| <div className={styles.requestEventsActions}> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={handleClearFilters} | |
| disabled={!hasActiveFilters} | |
| > | |
| {t('usage_stats.clear_filters')} | |
| </Button> | |
| </div> | |
| } | |
| > | |
| <div className={styles.requestEventsToolbar}> | |
| <div className={styles.requestEventsFiltersGroup}> | |
| <label className={styles.requestEventsFilterItem}> | |
| <span className={styles.requestEventsFilterLabel}> | |
| {t('usage_stats.request_events_filter_model')} | |
| </span> | |
| <Select | |
| value={effectiveModelFilter} | |
| options={modelOptions} | |
| onChange={onModelFilterChange} | |
| className={styles.requestEventsSelect} | |
| ariaLabel={t('usage_stats.request_events_filter_model')} | |
| fullWidth={false} | |
| /> | |
| </label> | |
| <label className={styles.requestEventsFilterItem}> | |
| <span className={styles.requestEventsFilterLabel}> | |
| {t('usage_stats.request_events_filter_source')} | |
| </span> | |
| <Select | |
| value={effectiveSourceFilter} | |
| options={sourceOptions} | |
| onChange={onSourceFilterChange} | |
| className={styles.requestEventsSelect} | |
| ariaLabel={t('usage_stats.request_events_filter_source')} | |
| fullWidth={false} | |
| /> | |
| </label> | |
| <label className={styles.requestEventsFilterItem}> | |
| <span className={styles.requestEventsFilterLabel}> | |
| {t('usage_stats.request_events_filter_result')} | |
| </span> | |
| <Select | |
| value={effectiveResultFilter} | |
| options={resultOptions} | |
| onChange={onResultFilterChange} | |
| className={styles.requestEventsResultSelect} | |
| ariaLabel={t('usage_stats.request_events_filter_result')} | |
| fullWidth={false} | |
| /> | |
| </label> | |
| </div> | |
| <div className={styles.requestEventsPaginationControls}> | |
| <div className={styles.requestEventsPaginationItem}> | |
| <span className={styles.requestEventsFilterLabel}>{t('usage_stats.request_events_rows_per_page')}</span> | |
| <Select | |
| value={String(pageSize)} | |
| options={pageSizeSelectOptions} | |
| onChange={(value) => onPageSizeChange(Number(value))} | |
| className={`${styles.requestEventsPageSizeSelect} ${styles.requestEventsPageSizeSelectCompact}`} | |
| ariaLabel={`${t('usage_stats.request_events_rows_per_page')}: ${pageSizeOptions.join(', ')}`} | |
| fullWidth={false} | |
| disabled={loading} | |
| /> | |
| </div> | |
| <div className={styles.requestEventsPaginationItem}> | |
| <span className={styles.requestEventsFilterLabel}>{pageLabel}</span> | |
| <div className={styles.requestEventsPagerControls}> | |
| <button | |
| type="button" | |
| className={styles.requestEventsPagerButton} | |
| onClick={() => onPageChange(page - 1)} | |
| disabled={loading || safePage <= 1} | |
| > | |
| {t('usage_stats.request_events_previous_page')} | |
| </button> | |
| <button | |
| type="button" | |
| className={styles.requestEventsPagerButton} | |
| onClick={() => onPageChange(page + 1)} | |
| disabled={loading || safeTotalPages === 0 || safePage >= safeTotalPages} | |
| > | |
| {t('usage_stats.request_events_next_page')} | |
| </button> | |
| </div> | |
| </div> | |
| </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')} | |
| /> | |
| ) : ( | |
| <> | |
| <div className={styles.requestEventsTableMeta}> | |
| <div className={styles.requestEventsCountGroup}> | |
| <span>{t('usage_stats.request_events_count', { count: rows.length })}</span> | |
| <span>{t('usage_stats.request_events_total_count', { count: totalCount })}</span> | |
| </div> | |
| {hasLatencyData && <span className={styles.requestEventsLimitHint}>{latencyHint}</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_result')}</th> | |
| {hasLatencyData && <th title={latencyHint}>{t('usage_stats.time')}</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> | |
| {rows.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}> | |
| <span>{row.source}</span> | |
| {row.sourceType && ( | |
| <span className={styles.credentialType}>{row.sourceType}</span> | |
| )} | |
| </td> | |
| <td> | |
| <span | |
| className={ | |
| row.failed | |
| ? styles.requestEventsResultFailed | |
| : styles.requestEventsResultSuccess | |
| } | |
| > | |
| {row.failed ? t('usage_stats.failure') : t('usage_stats.success')} | |
| </span> | |
| </td> | |
| {hasLatencyData && ( | |
| <td className={styles.durationCell}>{formatDurationMs(row.latencyMs)}</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> | |
| ); | |
| } | |