mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-06-16 21:03:58 +08:00
feat: improve usage latency formatting
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Совпадений не найдено",
|
||||
|
||||
@@ -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": "没有匹配结果",
|
||||
|
||||
@@ -596,6 +596,11 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.durationCell {
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// Pricing Section (80%比例)
|
||||
.pricingSection {
|
||||
display: flex;
|
||||
|
||||
+175
-19
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user