Files
Cli-Proxy-API-Management-Ce…/src/components/usage/RequestEventsDetailsCard.tsx
T

472 lines
16 KiB
TypeScript

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 { authFilesApi } from '@/services/api/authFiles';
import type { GeminiKeyConfig, ProviderKeyConfig, OpenAIProviderConfig } from '@/types';
import type { AuthFileItem } from '@/types/authFile';
import type { CredentialInfo } from '@/types/sourceInfo';
import { buildSourceInfoMap, resolveSourceDisplay } from '@/utils/sourceResolver';
import {
collectUsageDetails,
extractTotalTokens,
normalizeAuthIndex
} from '@/utils/usage';
import { downloadBlob } from '@/utils/download';
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;
sourceRaw: string;
source: string;
sourceType: string;
authIndex: string;
failed: boolean;
inputTokens: number;
outputTokens: number;
reasoningTokens: number;
cachedTokens: number;
totalTokens: number;
};
export interface RequestEventsDetailsCardProps {
usage: unknown;
loading: boolean;
geminiKeys: GeminiKeyConfig[];
claudeConfigs: ProviderKeyConfig[];
codexConfigs: ProviderKeyConfig[];
vertexConfigs: ProviderKeyConfig[];
openaiProviders: OpenAIProviderConfig[];
}
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, '""')}"`;
};
export function RequestEventsDetailsCard({
usage,
loading,
geminiKeys,
claudeConfigs,
codexConfigs,
vertexConfigs,
openaiProviders
}: RequestEventsDetailsCardProps) {
const { t, i18n } = useTranslation();
const [modelFilter, setModelFilter] = useState(ALL_FILTER);
const [sourceFilter, setSourceFilter] = useState(ALL_FILTER);
const [authIndexFilter, setAuthIndexFilter] = useState(ALL_FILTER);
const [authFileMap, setAuthFileMap] = useState<Map<string, CredentialInfo>>(new Map());
useEffect(() => {
let cancelled = false;
authFilesApi
.list()
.then((res) => {
if (cancelled) return;
const files = Array.isArray(res) ? res : (res as { files?: AuthFileItem[] })?.files;
if (!Array.isArray(files)) return;
const map = new Map<string, CredentialInfo>();
files.forEach((file) => {
const key = normalizeAuthIndex(file['auth_index'] ?? file.authIndex);
if (!key) return;
map.set(key, {
name: file.name || key,
type: (file.type || file.provider || '').toString()
});
});
setAuthFileMap(map);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, []);
const sourceInfoMap = useMemo(
() =>
buildSourceInfoMap({
geminiApiKeys: geminiKeys,
claudeApiKeys: claudeConfigs,
codexApiKeys: codexConfigs,
vertexApiKeys: vertexConfigs,
openaiCompatibility: openaiProviders,
}),
[claudeConfigs, codexConfigs, geminiKeys, openaiProviders, vertexConfigs]
);
const rows = useMemo<RequestEventRow[]>(() => {
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 sourceRaw = String(detail.source ?? '').trim();
const authIndexRaw = detail.auth_index as unknown;
const authIndex =
authIndexRaw === null || authIndexRaw === undefined || authIndexRaw === ''
? '-'
: String(authIndexRaw);
const sourceInfo = resolveSourceDisplay(sourceRaw, authIndexRaw, sourceInfoMap, authFileMap);
const source = sourceInfo.displayName;
const sourceType = sourceInfo.type;
const model = String(detail.__modelName ?? '').trim() || '-';
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}-${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: detail.failed === true,
inputTokens,
outputTokens,
reasoningTokens,
cachedTokens,
totalTokens
};
})
.sort((a, b) => b.timestampMs - a.timestampMs);
}, [authFileMap, i18n.language, sourceInfoMap, 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]
);
const modelOptionSet = useMemo(
() => new Set(modelOptions.map((option) => option.value)),
[modelOptions]
);
const sourceOptionSet = useMemo(
() => new Set(sourceOptions.map((option) => option.value)),
[sourceOptions]
);
const authIndexOptionSet = useMemo(
() => new Set(authIndexOptions.map((option) => option.value)),
[authIndexOptions]
);
const effectiveModelFilter = modelOptionSet.has(modelFilter) ? modelFilter : ALL_FILTER;
const effectiveSourceFilter = sourceOptionSet.has(sourceFilter) ? sourceFilter : ALL_FILTER;
const effectiveAuthIndexFilter = authIndexOptionSet.has(authIndexFilter)
? authIndexFilter
: ALL_FILTER;
const filteredRows = useMemo(
() =>
rows.filter((row) => {
const modelMatched = effectiveModelFilter === ALL_FILTER || row.model === effectiveModelFilter;
const sourceMatched = effectiveSourceFilter === ALL_FILTER || row.source === effectiveSourceFilter;
const authIndexMatched =
effectiveAuthIndexFilter === ALL_FILTER || row.authIndex === effectiveAuthIndexFilter;
return modelMatched && sourceMatched && authIndexMatched;
}),
[effectiveAuthIndexFilter, effectiveModelFilter, effectiveSourceFilter, rows]
);
const renderedRows = useMemo(
() => filteredRows.slice(0, MAX_RENDERED_EVENTS),
[filteredRows]
);
const hasActiveFilters =
effectiveModelFilter !== ALL_FILTER ||
effectiveSourceFilter !== ALL_FILTER ||
effectiveAuthIndexFilter !== ALL_FILTER;
const handleClearFilters = () => {
setModelFilter(ALL_FILTER);
setSourceFilter(ALL_FILTER);
setAuthIndexFilter(ALL_FILTER);
};
const handleExportCsv = () => {
if (!filteredRows.length) return;
const csvHeader = [
'timestamp',
'model',
'source',
'source_raw',
'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.sourceRaw,
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, '-');
downloadBlob({
filename: `usage-events-${fileTime}.csv`,
blob: new Blob([content], { type: '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,
source_raw: row.sourceRaw,
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, '-');
downloadBlob({
filename: `usage-events-${fileTime}.json`,
blob: new Blob([content], { type: 'application/json;charset=utf-8' })
});
};
return (
<Card
title={t('usage_stats.request_events_title')}
extra={
<div className={styles.requestEventsActions}>
<Button
variant="ghost"
size="sm"
onClick={handleClearFilters}
disabled={!hasActiveFilters}
>
{t('usage_stats.clear_filters')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleExportCsv}
disabled={filteredRows.length === 0}
>
{t('usage_stats.export_csv')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleExportJson}
disabled={filteredRows.length === 0}
>
{t('usage_stats.export_json')}
</Button>
</div>
}
>
<div className={styles.requestEventsToolbar}>
<div className={styles.requestEventsFilterItem}>
<span className={styles.requestEventsFilterLabel}>
{t('usage_stats.request_events_filter_model')}
</span>
<Select
value={effectiveModelFilter}
options={modelOptions}
onChange={setModelFilter}
className={styles.requestEventsSelect}
ariaLabel={t('usage_stats.request_events_filter_model')}
fullWidth={false}
/>
</div>
<div className={styles.requestEventsFilterItem}>
<span className={styles.requestEventsFilterLabel}>
{t('usage_stats.request_events_filter_source')}
</span>
<Select
value={effectiveSourceFilter}
options={sourceOptions}
onChange={setSourceFilter}
className={styles.requestEventsSelect}
ariaLabel={t('usage_stats.request_events_filter_source')}
fullWidth={false}
/>
</div>
<div className={styles.requestEventsFilterItem}>
<span className={styles.requestEventsFilterLabel}>
{t('usage_stats.request_events_filter_auth_index')}
</span>
<Select
value={effectiveAuthIndexFilter}
options={authIndexOptions}
onChange={setAuthIndexFilter}
className={styles.requestEventsSelect}
ariaLabel={t('usage_stats.request_events_filter_auth_index')}
fullWidth={false}
/>
</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')}
/>
) : filteredRows.length === 0 ? (
<EmptyState
title={t('usage_stats.request_events_no_result_title')}
description={t('usage_stats.request_events_no_result_desc')}
/>
) : (
<>
<div className={styles.requestEventsMeta}>
<span>{t('usage_stats.request_events_count', { count: filteredRows.length })}</span>
{filteredRows.length > MAX_RENDERED_EVENTS && (
<span className={styles.requestEventsLimitHint}>
{t('usage_stats.request_events_limit_hint', {
shown: MAX_RENDERED_EVENTS,
total: filteredRows.length
})}
</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_auth_index')}</th>
<th>{t('usage_stats.request_events_result')}</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>
{renderedRows.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 className={styles.requestEventsAuthIndex} title={row.authIndex}>
{row.authIndex}
</td>
<td>
<span
className={row.failed ? styles.requestEventsResultFailed : styles.requestEventsResultSuccess}
>
{row.failed ? t('stats.failure') : t('stats.success')}
</span>
</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>
);
}