mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-06-16 21:03:58 +08:00
feat(usage): add filterable request events table with export
This commit is contained in:
@@ -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<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 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 (
|
||||
<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={modelFilter}
|
||||
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={sourceFilter}
|
||||
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={authIndexFilter}
|
||||
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}>
|
||||
{row.source}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -38,3 +38,6 @@ export type { CostTrendChartProps } from './CostTrendChart';
|
||||
|
||||
export { ServiceHealthCard } from './ServiceHealthCard';
|
||||
export type { ServiceHealthCardProps } from './ServiceHealthCard';
|
||||
|
||||
export { RequestEventsDetailsCard } from './RequestEventsDetailsCard';
|
||||
export type { RequestEventsDetailsCardProps } from './RequestEventsDetailsCard';
|
||||
|
||||
@@ -816,9 +816,13 @@
|
||||
"range_7h": "Last 7 Hours",
|
||||
"range_24h": "Last 24 Hours",
|
||||
"range_7d": "Last 7 Days",
|
||||
"filter_all": "All",
|
||||
"clear_filters": "Clear Filters",
|
||||
"refresh": "Refresh",
|
||||
"export": "Export",
|
||||
"import": "Import",
|
||||
"export_csv": "Export CSV",
|
||||
"export_json": "Export JSON",
|
||||
"export_success": "Usage export downloaded",
|
||||
"import_success": "Import complete: added {{added}}, skipped {{skipped}}, total {{total}}, failed {{failed}}",
|
||||
"import_invalid": "Invalid usage export file",
|
||||
@@ -867,6 +871,20 @@
|
||||
"credential_stats": "Credential Statistics",
|
||||
"credential_name": "Credential",
|
||||
"token_breakdown": "Token Type Breakdown",
|
||||
"request_events_title": "Request Events",
|
||||
"request_events_filter_model": "Model",
|
||||
"request_events_filter_source": "Source",
|
||||
"request_events_filter_auth_index": "Auth Index",
|
||||
"request_events_timestamp": "Timestamp",
|
||||
"request_events_source": "Source",
|
||||
"request_events_auth_index": "Auth Index",
|
||||
"request_events_result": "Result",
|
||||
"request_events_empty_title": "No request events",
|
||||
"request_events_empty_desc": "No request details are available for the selected time range.",
|
||||
"request_events_no_result_title": "No matching events",
|
||||
"request_events_no_result_desc": "Try different filters or clear the current filters.",
|
||||
"request_events_count": "{{count}} events",
|
||||
"request_events_limit_hint": "Showing {{shown}} of {{total}} events to keep rendering responsive.",
|
||||
"input_tokens": "Input Tokens",
|
||||
"output_tokens": "Output Tokens",
|
||||
"last_updated": "Updated"
|
||||
|
||||
@@ -819,9 +819,13 @@
|
||||
"range_7h": "Последние 7 часов",
|
||||
"range_24h": "Последние 24 часа",
|
||||
"range_7d": "Последние 7 дней",
|
||||
"filter_all": "Все",
|
||||
"clear_filters": "Сбросить фильтры",
|
||||
"refresh": "Обновить",
|
||||
"export": "Экспорт",
|
||||
"import": "Импорт",
|
||||
"export_csv": "Экспорт CSV",
|
||||
"export_json": "Экспорт JSON",
|
||||
"export_success": "Экспорт использования скачан",
|
||||
"import_success": "Импорт завершён: добавлено {{added}}, пропущено {{skipped}}, всего {{total}}, ошибок {{failed}}",
|
||||
"import_invalid": "Недопустимый файл экспорта использования",
|
||||
@@ -870,6 +874,20 @@
|
||||
"credential_stats": "Статистика учётных данных",
|
||||
"credential_name": "Учётные данные",
|
||||
"token_breakdown": "Распределение типов токенов",
|
||||
"request_events_title": "События запросов",
|
||||
"request_events_filter_model": "Модель",
|
||||
"request_events_filter_source": "Источник",
|
||||
"request_events_filter_auth_index": "Auth Index",
|
||||
"request_events_timestamp": "Время",
|
||||
"request_events_source": "Источник",
|
||||
"request_events_auth_index": "Auth Index",
|
||||
"request_events_result": "Результат",
|
||||
"request_events_empty_title": "События запросов отсутствуют",
|
||||
"request_events_empty_desc": "Нет деталей запросов для выбранного диапазона времени.",
|
||||
"request_events_no_result_title": "Совпадений не найдено",
|
||||
"request_events_no_result_desc": "Попробуйте другие фильтры или очистите текущие.",
|
||||
"request_events_count": "{{count}} событий",
|
||||
"request_events_limit_hint": "Показано {{shown}} из {{total}} событий для стабильной производительности.",
|
||||
"input_tokens": "Входные токены",
|
||||
"output_tokens": "Выходные токены",
|
||||
"last_updated": "Обновлено"
|
||||
|
||||
@@ -816,9 +816,13 @@
|
||||
"range_7h": "最近7小时",
|
||||
"range_24h": "最近24小时",
|
||||
"range_7d": "最近7天",
|
||||
"filter_all": "全部",
|
||||
"clear_filters": "清空筛选",
|
||||
"refresh": "刷新",
|
||||
"export": "导出数据",
|
||||
"import": "导入数据",
|
||||
"export_csv": "导出 CSV",
|
||||
"export_json": "导出 JSON",
|
||||
"export_success": "使用统计已导出",
|
||||
"import_success": "导入完成:新增 {{added}},跳过 {{skipped}},总请求 {{total}},失败 {{failed}}",
|
||||
"import_invalid": "导入文件格式不正确",
|
||||
@@ -867,6 +871,20 @@
|
||||
"credential_stats": "凭证统计",
|
||||
"credential_name": "凭证",
|
||||
"token_breakdown": "Token 类型分布",
|
||||
"request_events_title": "请求事件明细",
|
||||
"request_events_filter_model": "模型",
|
||||
"request_events_filter_source": "来源",
|
||||
"request_events_filter_auth_index": "认证索引",
|
||||
"request_events_timestamp": "时间",
|
||||
"request_events_source": "来源",
|
||||
"request_events_auth_index": "认证索引",
|
||||
"request_events_result": "结果",
|
||||
"request_events_empty_title": "暂无请求事件",
|
||||
"request_events_empty_desc": "当前时间范围内暂无可用的请求明细数据。",
|
||||
"request_events_no_result_title": "没有匹配结果",
|
||||
"request_events_no_result_desc": "请尝试调整筛选条件,或清空当前筛选。",
|
||||
"request_events_count": "{{count}} 条事件",
|
||||
"request_events_limit_hint": "为保证渲染性能,仅展示 {{shown}} / {{total}} 条事件。",
|
||||
"input_tokens": "输入 Tokens",
|
||||
"output_tokens": "输出 Tokens",
|
||||
"last_updated": "更新于"
|
||||
|
||||
@@ -859,6 +859,98 @@
|
||||
}
|
||||
}
|
||||
|
||||
.requestEventsActions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $spacing-xs;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.requestEventsToolbar {
|
||||
display: flex;
|
||||
gap: $spacing-sm;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
margin-bottom: $spacing-sm;
|
||||
}
|
||||
|
||||
.requestEventsFilterItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.requestEventsFilterLabel {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.requestEventsSelect {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.requestEventsMeta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-sm;
|
||||
margin-bottom: $spacing-xs;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.requestEventsLimitHint {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.requestEventsTableWrapper {
|
||||
overflow: auto;
|
||||
max-height: 460px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-md;
|
||||
}
|
||||
|
||||
.requestEventsTimestamp {
|
||||
white-space: nowrap;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.requestEventsSourceCell,
|
||||
.requestEventsAuthIndex {
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
|
||||
monospace;
|
||||
font-size: 11px;
|
||||
max-width: 180px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.requestEventsResultSuccess {
|
||||
color: var(--success-badge-text, #065f46);
|
||||
background: var(--success-badge-bg, #d1fae5);
|
||||
border: 1px solid var(--success-badge-border, #6ee7b7);
|
||||
}
|
||||
|
||||
.requestEventsResultFailed {
|
||||
color: var(--failure-badge-text);
|
||||
background: var(--failure-badge-bg);
|
||||
border: 1px solid var(--failure-badge-border);
|
||||
}
|
||||
|
||||
.requestEventsResultSuccess,
|
||||
.requestEventsResultFailed {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: $radius-full;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chartLineHeader {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
ModelStatsCard,
|
||||
PriceSettingsCard,
|
||||
CredentialStatsCard,
|
||||
RequestEventsDetailsCard,
|
||||
TokenBreakdownChart,
|
||||
CostTrendChart,
|
||||
ServiceHealthCard,
|
||||
@@ -361,6 +362,8 @@ export function UsagePage() {
|
||||
<ModelStatsCard modelStats={modelStats} loading={loading} hasPrices={hasPrices} />
|
||||
</div>
|
||||
|
||||
<RequestEventsDetailsCard usage={filteredUsage} loading={loading} />
|
||||
|
||||
{/* Credential Stats */}
|
||||
<CredentialStatsCard
|
||||
usage={filteredUsage}
|
||||
|
||||
Reference in New Issue
Block a user