feat: improve usage latency formatting

This commit is contained in:
Michael
2026-04-05 11:14:46 +08:00
parent 13a0283e00
commit d84cf61915
8 changed files with 242 additions and 52 deletions
+49 -11
View File
@@ -1,17 +1,15 @@
import { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { formatCompactNumber, formatUsd } from '@/utils/usage';
import {
formatCompactNumber,
formatDurationMs,
formatUsd,
type ModelStatsSummary,
} from '@/utils/usage';
import styles from '@/pages/UsagePage.module.scss';
export interface ModelStat {
model: string;
requests: number;
successCount: number;
failureCount: number;
tokens: number;
cost: number;
}
export type ModelStat = ModelStatsSummary;
export interface ModelStatsCardProps {
modelStats: ModelStat[];
@@ -19,7 +17,14 @@ export interface ModelStatsCardProps {
hasPrices: boolean;
}
type SortKey = 'model' | 'requests' | 'tokens' | 'cost' | 'successRate';
type SortKey =
| 'model'
| 'requests'
| 'tokens'
| 'cost'
| 'successRate'
| 'averageLatencyMs'
| 'totalLatencyMs';
type SortDir = 'asc' | 'desc';
interface ModelStatWithRate extends ModelStat {
@@ -48,7 +53,15 @@ export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCar
const dir = sortDir === 'asc' ? 1 : -1;
list.sort((a, b) => {
if (sortKey === 'model') return dir * a.model.localeCompare(b.model);
return dir * ((a[sortKey] as number) - (b[sortKey] as number));
const left = a[sortKey];
const right = b[sortKey];
const leftValid = typeof left === 'number' && Number.isFinite(left);
const rightValid = typeof right === 'number' && Number.isFinite(right);
if (!leftValid && !rightValid) return 0;
if (!leftValid) return 1;
if (!rightValid) return -1;
return dir * (left - right);
});
return list;
}, [modelStats, sortKey, sortDir]);
@@ -95,6 +108,27 @@ export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCar
{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"
@@ -131,6 +165,10 @@ export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCar
</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={
@@ -10,6 +10,7 @@ import type { AuthFileItem } from '@/types/authFile';
import type { CredentialInfo } from '@/types/sourceInfo';
import { buildSourceInfoMap, resolveSourceDisplay } from '@/utils/sourceResolver';
import {
extractLatencyMs,
collectUsageDetails,
extractTotalTokens,
formatDurationMs,
@@ -153,12 +154,7 @@ export function RequestEventsDetailsCard({
toNumber(detail.tokens?.total_tokens),
extractTotalTokens(detail)
);
const latencyMs =
typeof detail.latency_ms === 'number' &&
Number.isFinite(detail.latency_ms) &&
detail.latency_ms >= 0
? detail.latency_ms
: null;
const latencyMs = extractLatencyMs(detail);
return {
id: `${timestamp}-${model}-${sourceRaw || source}-${authIndex}-${index}`,
@@ -480,7 +476,9 @@ export function RequestEventsDetailsCard({
{row.failed ? t('stats.failure') : t('stats.success')}
</span>
</td>
{hasLatencyData && <td>{formatDurationMs(row.latencyMs)}</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>
+5 -15
View File
@@ -9,6 +9,7 @@ import {
IconTrendingUp,
} from '@/components/ui/icons';
import {
calculateLatencyStatsFromDetails,
formatCompactNumber,
formatDurationMs,
formatPerMinuteValue,
@@ -59,18 +60,18 @@ 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, sampleCount: 0 },
latencyStats: { averageMs: null as number | null, totalMs: null as number | null, sampleCount: 0 },
};
if (!usage) return empty;
const details = collectUsageDetails(usage);
if (!details.length) return empty;
const latencyStats = calculateLatencyStatsFromDetails(details);
let cachedTokens = 0;
let reasoningTokens = 0;
let totalCost = 0;
let latencyTotalMs = 0;
let latencySampleCount = 0;
const now = nowMs;
const windowMinutes = 30;
@@ -88,14 +89,6 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
if (typeof tokens.reasoning_tokens === 'number') {
reasoningTokens += tokens.reasoning_tokens;
}
if (
typeof detail.latency_ms === 'number' &&
Number.isFinite(detail.latency_ms) &&
detail.latency_ms >= 0
) {
latencyTotalMs += detail.latency_ms;
latencySampleCount += 1;
}
const timestamp = detail.__timestampMs ?? 0;
if (
@@ -124,10 +117,7 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
tokenCount,
},
totalCost,
latencyStats: {
averageMs: latencySampleCount > 0 ? latencyTotalMs / latencySampleCount : null,
sampleCount: latencySampleCount,
},
latencyStats,
};
}, [hasPrices, modelPrices, nowMs, usage]);
+1
View File
@@ -1025,6 +1025,7 @@
"request_events_result": "Result",
"time": "Time",
"avg_time": "Avg Time",
"total_time": "Total Time",
"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",
+1
View File
@@ -1028,6 +1028,7 @@
"request_events_result": "Результат",
"time": "Время",
"avg_time": "Среднее время",
"total_time": "Общее время",
"request_events_empty_title": "События запросов отсутствуют",
"request_events_empty_desc": "Нет деталей запросов для выбранного диапазона времени.",
"request_events_no_result_title": "Совпадений не найдено",
+1
View File
@@ -1025,6 +1025,7 @@
"request_events_result": "结果",
"time": "耗时",
"avg_time": "平均耗时",
"total_time": "总耗时",
"request_events_empty_title": "暂无请求事件",
"request_events_empty_desc": "当前时间范围内暂无可用的请求明细数据。",
"request_events_no_result_title": "没有匹配结果",
+5
View File
@@ -596,6 +596,11 @@
white-space: nowrap;
}
.durationCell {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
// Pricing Section (80%比例)
.pricingSection {
display: flex;
+175 -19
View File
@@ -29,6 +29,18 @@ export interface RateStats {
tokenCount: number;
}
export interface LatencyStats {
averageMs: number | null;
totalMs: number | null;
sampleCount: number;
}
export interface DurationFormatOptions {
maxUnits?: number;
invalidText?: string;
secondDecimals?: number | 'auto';
}
export interface ModelPrice {
prompt: number;
completion: number;
@@ -73,6 +85,18 @@ export interface ApiStats {
>;
}
export interface ModelStatsSummary {
model: string;
requests: number;
successCount: number;
failureCount: number;
tokens: number;
cost: number;
averageLatencyMs: number | null;
totalLatencyMs: number | null;
latencySampleCount: number;
}
export type UsageTimeRange = '7h' | '24h' | '7d' | 'all';
const TOKENS_PER_PRICE_UNIT = 1_000_000;
@@ -638,6 +662,13 @@ export function extractTotalTokens(detail: unknown): number {
export function extractLatencyMs(detail: unknown): number | null {
const record = isRecord(detail) ? detail : null;
const rawValue = record?.latency_ms;
if (
rawValue === null ||
rawValue === undefined ||
(typeof rawValue === 'string' && rawValue.trim() === '')
) {
return null;
}
const parsed = Number(rawValue);
if (!Number.isFinite(parsed) || parsed < 0) {
return null;
@@ -645,27 +676,135 @@ export function extractLatencyMs(detail: unknown): number | null {
return parsed;
}
interface LatencyAccumulator {
totalMs: number;
sampleCount: number;
}
const createLatencyAccumulator = (): LatencyAccumulator => ({
totalMs: 0,
sampleCount: 0,
});
const addLatencySample = (
accumulator: LatencyAccumulator,
latencyMs: number | null | undefined
): void => {
if (latencyMs === null || latencyMs === undefined) {
return;
}
const parsed = Number(latencyMs);
if (!Number.isFinite(parsed) || parsed < 0) {
return;
}
accumulator.totalMs += parsed;
accumulator.sampleCount += 1;
};
const finalizeLatencyStats = (accumulator: LatencyAccumulator): LatencyStats => ({
averageMs:
accumulator.sampleCount > 0 ? accumulator.totalMs / accumulator.sampleCount : null,
totalMs: accumulator.sampleCount > 0 ? accumulator.totalMs : null,
sampleCount: accumulator.sampleCount,
});
const trimTrailingZeros = (value: string): string => value.replace(/\.?0+$/, '');
const normalizeDurationMaxUnits = (value: number | undefined): number => {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return 2;
}
return Math.min(Math.floor(parsed), 4);
};
const resolveSecondDecimalPlaces = (
seconds: number,
secondDecimals: number | 'auto' | undefined
): number => {
if (secondDecimals === 'auto' || secondDecimals === undefined) {
return seconds < 10 ? 2 : 1;
}
const parsed = Math.floor(Number(secondDecimals));
if (!Number.isFinite(parsed) || parsed < 0) {
return seconds < 10 ? 2 : 1;
}
return Math.min(parsed, 3);
};
/**
* 格式化耗时显示
*/
export function formatDurationMs(value: number | null | undefined): string {
export function formatDurationMs(
value: number | null | undefined,
options: DurationFormatOptions = {}
): string {
const invalidText = options.invalidText ?? '--';
if (value === null || value === undefined) {
return invalidText;
}
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0) {
return '--';
return invalidText;
}
if (parsed < 1000) {
return `${Math.round(parsed)} ms`;
return `${Math.round(parsed)}ms`;
}
const seconds = parsed / 1000;
if (seconds < 10) {
return `${seconds.toFixed(2).replace(/\.?0+$/, '')} s`;
if (seconds < 60) {
const secondDecimalPlaces = resolveSecondDecimalPlaces(seconds, options.secondDecimals);
return `${trimTrailingZeros(seconds.toFixed(secondDecimalPlaces))}s`;
}
if (seconds < 100) {
return `${seconds.toFixed(1).replace(/\.?0+$/, '')} s`;
const totalSeconds = Math.floor(seconds);
let remainingSeconds = totalSeconds;
const days = Math.floor(remainingSeconds / 86_400);
remainingSeconds -= days * 86_400;
const hours = Math.floor(remainingSeconds / 3_600);
remainingSeconds -= hours * 3_600;
const minutes = Math.floor(remainingSeconds / 60);
remainingSeconds -= minutes * 60;
const parts = [
{ label: 'd', value: days },
{ label: 'h', value: hours },
{ label: 'm', value: minutes },
{ label: 's', value: remainingSeconds },
].filter((part) => part.value > 0);
if (!parts.length) {
return '0s';
}
return `${Math.round(seconds)} s`;
return parts
.slice(0, normalizeDurationMaxUnits(options.maxUnits))
.map((part, index) => {
const shouldPad = index > 0 && (part.label === 'm' || part.label === 's');
const unitValue = shouldPad ? String(part.value).padStart(2, '0') : String(part.value);
return `${unitValue}${part.label}`;
})
.join(' ');
}
/**
* 从明细列表计算耗时统计
*/
export function calculateLatencyStatsFromDetails(details: Iterable<unknown>): LatencyStats {
const accumulator = createLatencyAccumulator();
for (const detail of details) {
addLatencySample(accumulator, extractLatencyMs(detail));
}
return finalizeLatencyStats(accumulator);
}
/**
* 计算耗时统计
*/
export function calculateLatencyStats(usageData: unknown): LatencyStats {
return calculateLatencyStatsFromDetails(collectUsageDetails(usageData));
}
/**
@@ -968,20 +1107,20 @@ export function getApiStats(
export function getModelStats(
usageData: unknown,
modelPrices: Record<string, ModelPrice>
): Array<{
model: string;
requests: number;
successCount: number;
failureCount: number;
tokens: number;
cost: number;
}> {
): ModelStatsSummary[] {
const apis = getApisRecord(usageData);
if (!apis) return [];
const modelMap = new Map<
string,
{ requests: number; successCount: number; failureCount: number; tokens: number; cost: number }
{
requests: number;
successCount: number;
failureCount: number;
tokens: number;
cost: number;
latency: LatencyAccumulator;
}
>();
Object.values(apis).forEach((apiData) => {
@@ -998,6 +1137,7 @@ export function getModelStats(
failureCount: 0,
tokens: 0,
cost: 0,
latency: createLatencyAccumulator(),
};
existing.requests += Number(modelData.total_requests) || 0;
existing.tokens += Number(modelData.total_tokens) || 0;
@@ -1013,9 +1153,10 @@ export function getModelStats(
existing.failureCount += Number(modelData.failure_count) || 0;
}
if (details.length > 0 && (!hasExplicitCounts || price)) {
if (details.length > 0) {
details.forEach((detail) => {
const detailRecord = isRecord(detail) ? detail : null;
const latencyMs = extractLatencyMs(detailRecord);
if (!hasExplicitCounts) {
if (detailRecord?.failed === true) {
existing.failureCount += 1;
@@ -1024,6 +1165,8 @@ export function getModelStats(
}
}
addLatencySample(existing.latency, latencyMs);
if (price && detailRecord) {
existing.cost += calculateCost(
{ ...(detailRecord as unknown as UsageDetail), __modelName: modelName },
@@ -1037,7 +1180,20 @@ export function getModelStats(
});
return Array.from(modelMap.entries())
.map(([model, stats]) => ({ model, ...stats }))
.map(([model, stats]) => {
const latencyStats = finalizeLatencyStats(stats.latency);
return {
model,
requests: stats.requests,
successCount: stats.successCount,
failureCount: stats.failureCount,
tokens: stats.tokens,
cost: stats.cost,
averageLatencyMs: latencyStats.averageMs,
totalLatencyMs: latencyStats.totalMs,
latencySampleCount: latencyStats.sampleCount,
};
})
.sort((a, b) => b.requests - a.requests);
}