daili-usage-keeper / web /src /components /usage /RequestEventsDetailsCard.tsx
pjpjq's picture
fix: build usage keeper from source
b034029 verified
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>
);
}