mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-06-16 21:03:58 +08:00
feat: add latency tracking and display enhancements across usage components
This commit is contained in:
@@ -2,6 +2,7 @@ import { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import {
|
||||
LATENCY_SOURCE_FIELD,
|
||||
formatCompactNumber,
|
||||
formatDurationMs,
|
||||
formatUsd,
|
||||
@@ -35,6 +36,10 @@ export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCar
|
||||
const { t } = useTranslation();
|
||||
const [sortKey, setSortKey] = useState<SortKey>('requests');
|
||||
const [sortDir, setSortDir] = useState<SortDir>('desc');
|
||||
const latencyHint = t('usage_stats.latency_unit_hint', {
|
||||
field: LATENCY_SOURCE_FIELD,
|
||||
unit: t('usage_stats.duration_unit_ms'),
|
||||
});
|
||||
|
||||
const handleSort = (key: SortKey) => {
|
||||
if (sortKey === key) {
|
||||
@@ -66,129 +71,146 @@ export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCar
|
||||
return list;
|
||||
}, [modelStats, sortKey, sortDir]);
|
||||
|
||||
const arrow = (key: SortKey) =>
|
||||
sortKey === key ? (sortDir === 'asc' ? ' ▲' : ' ▼') : '';
|
||||
const arrow = (key: SortKey) => (sortKey === key ? (sortDir === 'asc' ? ' ▲' : ' ▼') : '');
|
||||
const ariaSort = (key: SortKey): 'none' | 'ascending' | 'descending' =>
|
||||
sortKey === key ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none';
|
||||
const hasLatencyData = sorted.some((stat) => stat.latencySampleCount > 0);
|
||||
|
||||
return (
|
||||
<Card title={t('usage_stats.models')} className={styles.detailsFixedCard}>
|
||||
{loading ? (
|
||||
<div className={styles.hint}>{t('common.loading')}</div>
|
||||
) : sorted.length > 0 ? (
|
||||
<div className={styles.detailsScroll}>
|
||||
<div className={styles.tableWrapper}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={styles.sortableHeader} aria-sort={ariaSort('model')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sortHeaderButton}
|
||||
onClick={() => handleSort('model')}
|
||||
>
|
||||
{t('usage_stats.model_name')}{arrow('model')}
|
||||
</button>
|
||||
</th>
|
||||
<th className={styles.sortableHeader} aria-sort={ariaSort('requests')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sortHeaderButton}
|
||||
onClick={() => handleSort('requests')}
|
||||
>
|
||||
{t('usage_stats.requests_count')}{arrow('requests')}
|
||||
</button>
|
||||
</th>
|
||||
<th className={styles.sortableHeader} aria-sort={ariaSort('tokens')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sortHeaderButton}
|
||||
onClick={() => handleSort('tokens')}
|
||||
>
|
||||
{t('usage_stats.tokens_count')}{arrow('tokens')}
|
||||
</button>
|
||||
</th>
|
||||
<th
|
||||
className={styles.sortableHeader}
|
||||
aria-sort={ariaSort('averageLatencyMs')}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sortHeaderButton}
|
||||
onClick={() => handleSort('averageLatencyMs')}
|
||||
>
|
||||
{t('usage_stats.avg_time')}{arrow('averageLatencyMs')}
|
||||
</button>
|
||||
</th>
|
||||
<th className={styles.sortableHeader} aria-sort={ariaSort('totalLatencyMs')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sortHeaderButton}
|
||||
onClick={() => handleSort('totalLatencyMs')}
|
||||
>
|
||||
{t('usage_stats.total_time')}{arrow('totalLatencyMs')}
|
||||
</button>
|
||||
</th>
|
||||
<th className={styles.sortableHeader} aria-sort={ariaSort('successRate')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sortHeaderButton}
|
||||
onClick={() => handleSort('successRate')}
|
||||
>
|
||||
{t('usage_stats.success_rate')}{arrow('successRate')}
|
||||
</button>
|
||||
</th>
|
||||
{hasPrices && (
|
||||
<th className={styles.sortableHeader} aria-sort={ariaSort('cost')}>
|
||||
<>
|
||||
{hasLatencyData && <div className={styles.detailsNote}>{latencyHint}</div>}
|
||||
<div className={styles.detailsScroll}>
|
||||
<div className={styles.tableWrapper}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={styles.sortableHeader} aria-sort={ariaSort('model')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sortHeaderButton}
|
||||
onClick={() => handleSort('cost')}
|
||||
onClick={() => handleSort('model')}
|
||||
>
|
||||
{t('usage_stats.total_cost')}{arrow('cost')}
|
||||
{t('usage_stats.model_name')}
|
||||
{arrow('model')}
|
||||
</button>
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((stat) => (
|
||||
<tr key={stat.model}>
|
||||
<td className={styles.modelCell}>{stat.model}</td>
|
||||
<td>
|
||||
<span className={styles.requestCountCell}>
|
||||
<span>{stat.requests.toLocaleString()}</span>
|
||||
<span className={styles.requestBreakdown}>
|
||||
(<span className={styles.statSuccess}>{stat.successCount.toLocaleString()}</span>{' '}
|
||||
<span className={styles.statFailure}>{stat.failureCount.toLocaleString()}</span>)
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td>{formatCompactNumber(stat.tokens)}</td>
|
||||
<td className={styles.durationCell}>
|
||||
{formatDurationMs(stat.averageLatencyMs)}
|
||||
</td>
|
||||
<td className={styles.durationCell}>{formatDurationMs(stat.totalLatencyMs)}</td>
|
||||
<td>
|
||||
<span
|
||||
className={
|
||||
stat.successRate >= 95
|
||||
? styles.statSuccess
|
||||
: stat.successRate >= 80
|
||||
? styles.statNeutral
|
||||
: styles.statFailure
|
||||
}
|
||||
<th className={styles.sortableHeader} aria-sort={ariaSort('requests')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sortHeaderButton}
|
||||
onClick={() => handleSort('requests')}
|
||||
>
|
||||
{stat.successRate.toFixed(1)}%
|
||||
</span>
|
||||
</td>
|
||||
{hasPrices && <td>{stat.cost > 0 ? formatUsd(stat.cost) : '--'}</td>}
|
||||
{t('usage_stats.requests_count')}
|
||||
{arrow('requests')}
|
||||
</button>
|
||||
</th>
|
||||
<th className={styles.sortableHeader} aria-sort={ariaSort('tokens')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sortHeaderButton}
|
||||
onClick={() => handleSort('tokens')}
|
||||
>
|
||||
{t('usage_stats.tokens_count')}
|
||||
{arrow('tokens')}
|
||||
</button>
|
||||
</th>
|
||||
<th className={styles.sortableHeader} aria-sort={ariaSort('averageLatencyMs')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sortHeaderButton}
|
||||
onClick={() => handleSort('averageLatencyMs')}
|
||||
title={latencyHint}
|
||||
>
|
||||
{t('usage_stats.avg_time')}
|
||||
{arrow('averageLatencyMs')}
|
||||
</button>
|
||||
</th>
|
||||
<th className={styles.sortableHeader} aria-sort={ariaSort('totalLatencyMs')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sortHeaderButton}
|
||||
onClick={() => handleSort('totalLatencyMs')}
|
||||
title={latencyHint}
|
||||
>
|
||||
{t('usage_stats.total_time')}
|
||||
{arrow('totalLatencyMs')}
|
||||
</button>
|
||||
</th>
|
||||
<th className={styles.sortableHeader} aria-sort={ariaSort('successRate')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sortHeaderButton}
|
||||
onClick={() => handleSort('successRate')}
|
||||
>
|
||||
{t('usage_stats.success_rate')}
|
||||
{arrow('successRate')}
|
||||
</button>
|
||||
</th>
|
||||
{hasPrices && (
|
||||
<th className={styles.sortableHeader} aria-sort={ariaSort('cost')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sortHeaderButton}
|
||||
onClick={() => handleSort('cost')}
|
||||
>
|
||||
{t('usage_stats.total_cost')}
|
||||
{arrow('cost')}
|
||||
</button>
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((stat) => (
|
||||
<tr key={stat.model}>
|
||||
<td className={styles.modelCell}>{stat.model}</td>
|
||||
<td>
|
||||
<span className={styles.requestCountCell}>
|
||||
<span>{stat.requests.toLocaleString()}</span>
|
||||
<span className={styles.requestBreakdown}>
|
||||
(
|
||||
<span className={styles.statSuccess}>
|
||||
{stat.successCount.toLocaleString()}
|
||||
</span>{' '}
|
||||
<span className={styles.statFailure}>
|
||||
{stat.failureCount.toLocaleString()}
|
||||
</span>
|
||||
)
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td>{formatCompactNumber(stat.tokens)}</td>
|
||||
<td className={styles.durationCell}>
|
||||
{formatDurationMs(stat.averageLatencyMs)}
|
||||
</td>
|
||||
<td className={styles.durationCell}>
|
||||
{formatDurationMs(stat.totalLatencyMs)}
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className={
|
||||
stat.successRate >= 95
|
||||
? styles.statSuccess
|
||||
: stat.successRate >= 80
|
||||
? styles.statNeutral
|
||||
: styles.statFailure
|
||||
}
|
||||
>
|
||||
{stat.successRate.toFixed(1)}%
|
||||
</span>
|
||||
</td>
|
||||
{hasPrices && <td>{stat.cost > 0 ? formatUsd(stat.cost) : '--'}</td>}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
|
||||
)}
|
||||
|
||||
@@ -10,10 +10,11 @@ import type { AuthFileItem } from '@/types/authFile';
|
||||
import type { CredentialInfo } from '@/types/sourceInfo';
|
||||
import { buildSourceInfoMap, resolveSourceDisplay } from '@/utils/sourceResolver';
|
||||
import {
|
||||
extractLatencyMs,
|
||||
collectUsageDetails,
|
||||
extractLatencyMs,
|
||||
extractTotalTokens,
|
||||
formatDurationMs,
|
||||
LATENCY_SOURCE_FIELD,
|
||||
normalizeAuthIndex,
|
||||
} from '@/utils/usage';
|
||||
import { downloadBlob } from '@/utils/download';
|
||||
@@ -74,6 +75,10 @@ export function RequestEventsDetailsCard({
|
||||
openaiProviders,
|
||||
}: 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 [modelFilter, setModelFilter] = useState(ALL_FILTER);
|
||||
const [sourceFilter, setSourceFilter] = useState(ALL_FILTER);
|
||||
@@ -422,6 +427,7 @@ export function RequestEventsDetailsCard({
|
||||
<>
|
||||
<div className={styles.requestEventsMeta}>
|
||||
<span>{t('usage_stats.request_events_count', { count: filteredRows.length })}</span>
|
||||
{hasLatencyData && <span className={styles.requestEventsLimitHint}>{latencyHint}</span>}
|
||||
{filteredRows.length > MAX_RENDERED_EVENTS && (
|
||||
<span className={styles.requestEventsLimitHint}>
|
||||
{t('usage_stats.request_events_limit_hint', {
|
||||
@@ -441,7 +447,7 @@ export function RequestEventsDetailsCard({
|
||||
<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>
|
||||
{hasLatencyData && <th>{t('usage_stats.time')}</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>
|
||||
|
||||
@@ -9,12 +9,13 @@ import {
|
||||
IconTrendingUp,
|
||||
} from '@/components/ui/icons';
|
||||
import {
|
||||
LATENCY_SOURCE_FIELD,
|
||||
calculateLatencyStatsFromDetails,
|
||||
calculateCost,
|
||||
formatCompactNumber,
|
||||
formatDurationMs,
|
||||
formatPerMinuteValue,
|
||||
formatUsd,
|
||||
calculateCost,
|
||||
collectUsageDetails,
|
||||
extractTotalTokens,
|
||||
type ModelPrice,
|
||||
@@ -52,6 +53,10 @@ export interface StatCardsProps {
|
||||
|
||||
export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: StatCardsProps) {
|
||||
const { t } = useTranslation();
|
||||
const latencyHint = t('usage_stats.latency_unit_hint', {
|
||||
field: LATENCY_SOURCE_FIELD,
|
||||
unit: t('usage_stats.duration_unit_ms'),
|
||||
});
|
||||
|
||||
const hasPrices = Object.keys(modelPrices).length > 0;
|
||||
|
||||
@@ -60,7 +65,11 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
|
||||
tokenBreakdown: { cachedTokens: 0, reasoningTokens: 0 },
|
||||
rateStats: { rpm: 0, tpm: 0, windowMinutes: 30, requestCount: 0, tokenCount: 0 },
|
||||
totalCost: 0,
|
||||
latencyStats: { averageMs: null as number | null, totalMs: null as number | null, sampleCount: 0 },
|
||||
latencyStats: {
|
||||
averageMs: null as number | null,
|
||||
totalMs: null as number | null,
|
||||
sampleCount: 0,
|
||||
},
|
||||
};
|
||||
|
||||
if (!usage) return empty;
|
||||
@@ -141,7 +150,7 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
|
||||
{t('usage_stats.failed_requests')}: {loading ? '-' : (usage?.failure_count ?? 0)}
|
||||
</span>
|
||||
{latencyStats.sampleCount > 0 && (
|
||||
<span className={styles.statMetaItem}>
|
||||
<span className={styles.statMetaItem} title={latencyHint}>
|
||||
{t('usage_stats.avg_time')}:{' '}
|
||||
{loading ? '-' : formatDurationMs(latencyStats.averageMs)}
|
||||
</span>
|
||||
|
||||
Reference in New Issue
Block a user