mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-06-16 21:03:58 +08:00
472 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|