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>
|
||||
|
||||
@@ -1029,9 +1029,15 @@
|
||||
"request_events_source": "Source",
|
||||
"request_events_auth_index": "Auth Index",
|
||||
"request_events_result": "Result",
|
||||
"time": "Time",
|
||||
"avg_time": "Avg Time",
|
||||
"total_time": "Total Time",
|
||||
"time": "Latency",
|
||||
"avg_time": "Avg Latency",
|
||||
"total_time": "Total Latency",
|
||||
"latency_unit_hint": "Durations use backend field {{field}} and are interpreted as {{unit}} before formatting.",
|
||||
"duration_unit_d": "d",
|
||||
"duration_unit_h": "h",
|
||||
"duration_unit_m": "m",
|
||||
"duration_unit_s": "s",
|
||||
"duration_unit_ms": "ms",
|
||||
"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",
|
||||
|
||||
@@ -1026,9 +1026,15 @@
|
||||
"request_events_source": "Источник",
|
||||
"request_events_auth_index": "Auth Index",
|
||||
"request_events_result": "Результат",
|
||||
"time": "Время",
|
||||
"avg_time": "Среднее время",
|
||||
"total_time": "Общее время",
|
||||
"time": "Задержка",
|
||||
"avg_time": "Средняя задержка",
|
||||
"total_time": "Суммарная задержка",
|
||||
"latency_unit_hint": "Длительность берётся из поля бэкенда {{field}} и интерпретируется как {{unit}} перед форматированием.",
|
||||
"duration_unit_d": "д",
|
||||
"duration_unit_h": "ч",
|
||||
"duration_unit_m": "мин",
|
||||
"duration_unit_s": "с",
|
||||
"duration_unit_ms": "мс",
|
||||
"request_events_empty_title": "События запросов отсутствуют",
|
||||
"request_events_empty_desc": "Нет деталей запросов для выбранного диапазона времени.",
|
||||
"request_events_no_result_title": "Совпадений не найдено",
|
||||
|
||||
@@ -1029,9 +1029,15 @@
|
||||
"request_events_source": "来源",
|
||||
"request_events_auth_index": "认证索引",
|
||||
"request_events_result": "结果",
|
||||
"time": "耗时",
|
||||
"avg_time": "平均耗时",
|
||||
"total_time": "总耗时",
|
||||
"time": "延迟",
|
||||
"avg_time": "平均延迟",
|
||||
"total_time": "总延迟",
|
||||
"latency_unit_hint": "耗时取自后端字段 {{field}},按 {{unit}} 解释后再格式化显示。",
|
||||
"duration_unit_d": "天",
|
||||
"duration_unit_h": "时",
|
||||
"duration_unit_m": "分",
|
||||
"duration_unit_s": "秒",
|
||||
"duration_unit_ms": "毫秒",
|
||||
"request_events_empty_title": "暂无请求事件",
|
||||
"request_events_empty_desc": "当前时间范围内暂无可用的请求明细数据。",
|
||||
"request_events_no_result_title": "没有匹配结果",
|
||||
|
||||
@@ -128,8 +128,7 @@
|
||||
padding: 18px;
|
||||
background:
|
||||
radial-gradient(120% 140% at 12% 0%, var(--accent-soft) 0%, rgba(0, 0, 0, 0) 62%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0)),
|
||||
var(--bg-primary);
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0)), var(--bg-primary);
|
||||
border-radius: $radius-lg;
|
||||
border: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
@@ -137,7 +136,10 @@
|
||||
gap: 10px;
|
||||
min-height: 176px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
transition: transform $transition-fast, box-shadow $transition-fast, border-color $transition-fast;
|
||||
transition:
|
||||
transform $transition-fast,
|
||||
box-shadow $transition-fast,
|
||||
border-color $transition-fast;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
@@ -350,7 +352,10 @@
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
border-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
@@ -505,6 +510,12 @@
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.detailsNote {
|
||||
padding: 0 4px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
// Table (80%比例)
|
||||
.tableWrapper {
|
||||
overflow-x: auto;
|
||||
@@ -515,7 +526,8 @@
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
|
||||
th, td {
|
||||
th,
|
||||
td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
@@ -1100,7 +1112,9 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
transition: transform 0.15s ease, opacity 0.15s ease;
|
||||
transition:
|
||||
transform 0.15s ease,
|
||||
opacity 0.15s ease;
|
||||
|
||||
.healthBlockWrapper:hover &,
|
||||
.healthBlockWrapper.healthBlockActive & {
|
||||
|
||||
+17
-156
@@ -4,8 +4,25 @@
|
||||
*/
|
||||
|
||||
import type { ScriptableContext } from 'chart.js';
|
||||
import type { LatencyAccumulator, LatencyStats } from './usage/latency';
|
||||
import {
|
||||
addLatencySample,
|
||||
calculateLatencyStatsFromDetails,
|
||||
createLatencyAccumulator,
|
||||
extractLatencyMs,
|
||||
finalizeLatencyStats,
|
||||
} from './usage/latency';
|
||||
import { maskApiKey } from './format';
|
||||
|
||||
export type { DurationFormatOptions, LatencyStats } from './usage/latency';
|
||||
export {
|
||||
LATENCY_SOURCE_FIELD,
|
||||
LATENCY_SOURCE_UNIT,
|
||||
calculateLatencyStatsFromDetails,
|
||||
extractLatencyMs,
|
||||
formatDurationMs,
|
||||
} from './usage/latency';
|
||||
|
||||
export interface KeyStatBucket {
|
||||
success: number;
|
||||
failure: number;
|
||||
@@ -29,18 +46,6 @@ 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;
|
||||
@@ -656,150 +661,6 @@ export function extractTotalTokens(detail: unknown): number {
|
||||
return inputTokens + outputTokens + reasoningTokens + cachedTokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从单条明细提取耗时(毫秒)
|
||||
*/
|
||||
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;
|
||||
}
|
||||
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,
|
||||
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 invalidText;
|
||||
}
|
||||
|
||||
if (parsed < 1000) {
|
||||
return `${Math.round(parsed)}ms`;
|
||||
}
|
||||
|
||||
const seconds = parsed / 1000;
|
||||
if (seconds < 60) {
|
||||
const secondDecimalPlaces = resolveSecondDecimalPlaces(seconds, options.secondDecimals);
|
||||
return `${trimTrailingZeros(seconds.toFixed(secondDecimalPlaces))}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 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算耗时统计
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import i18n from '@/i18n';
|
||||
|
||||
export const LATENCY_SOURCE_FIELD = 'latency_ms';
|
||||
export const LATENCY_SOURCE_UNIT = 'ms';
|
||||
|
||||
export interface LatencyStats {
|
||||
averageMs: number | null;
|
||||
totalMs: number | null;
|
||||
sampleCount: number;
|
||||
}
|
||||
|
||||
export interface DurationFormatOptions {
|
||||
maxUnits?: number;
|
||||
invalidText?: string;
|
||||
secondDecimals?: number | 'auto';
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export interface LatencyAccumulator {
|
||||
totalMs: number;
|
||||
sampleCount: number;
|
||||
}
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
const resolveDurationLocale = (locale?: string): string | undefined =>
|
||||
locale?.trim() || i18n.resolvedLanguage || i18n.language || undefined;
|
||||
|
||||
const formatDurationNumber = (
|
||||
value: number,
|
||||
locale: string | undefined,
|
||||
options: Intl.NumberFormatOptions = {}
|
||||
): string => {
|
||||
try {
|
||||
return new Intl.NumberFormat(locale, {
|
||||
useGrouping: false,
|
||||
...options,
|
||||
}).format(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
};
|
||||
|
||||
const getDurationUnitLabel = (unit: 'd' | 'h' | 'm' | 's' | 'ms'): string =>
|
||||
i18n.t(`usage_stats.duration_unit_${unit}`, { defaultValue: unit });
|
||||
|
||||
const formatDurationPart = (
|
||||
value: number,
|
||||
unit: 'd' | 'h' | 'm' | 's' | 'ms',
|
||||
locale: string | undefined,
|
||||
options: Intl.NumberFormatOptions = {}
|
||||
): string => `${formatDurationNumber(value, locale, options)}${getDurationUnitLabel(unit)}`;
|
||||
|
||||
/**
|
||||
* 从后端字段 latency_ms 提取耗时,并按毫秒解释。
|
||||
*/
|
||||
export function extractLatencyMs(detail: unknown): number | null {
|
||||
const record = isRecord(detail) ? detail : null;
|
||||
const rawValue = record?.[LATENCY_SOURCE_FIELD];
|
||||
if (
|
||||
rawValue === null ||
|
||||
rawValue === undefined ||
|
||||
(typeof rawValue === 'string' && rawValue.trim() === '')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = Number(rawValue);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export const createLatencyAccumulator = (): LatencyAccumulator => ({
|
||||
totalMs: 0,
|
||||
sampleCount: 0,
|
||||
});
|
||||
|
||||
export 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;
|
||||
};
|
||||
|
||||
export const finalizeLatencyStats = (accumulator: LatencyAccumulator): LatencyStats => ({
|
||||
averageMs: accumulator.sampleCount > 0 ? accumulator.totalMs / accumulator.sampleCount : null,
|
||||
totalMs: accumulator.sampleCount > 0 ? accumulator.totalMs : null,
|
||||
sampleCount: accumulator.sampleCount,
|
||||
});
|
||||
|
||||
/**
|
||||
* 从明细列表计算耗时统计
|
||||
*/
|
||||
export function calculateLatencyStatsFromDetails(details: Iterable<unknown>): LatencyStats {
|
||||
const accumulator = createLatencyAccumulator();
|
||||
for (const detail of details) {
|
||||
addLatencySample(accumulator, extractLatencyMs(detail));
|
||||
}
|
||||
return finalizeLatencyStats(accumulator);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按当前语言格式化耗时显示。
|
||||
*/
|
||||
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 invalidText;
|
||||
}
|
||||
|
||||
const locale = resolveDurationLocale(options.locale);
|
||||
|
||||
if (parsed < 1000) {
|
||||
return formatDurationPart(Math.round(parsed), 'ms', locale);
|
||||
}
|
||||
|
||||
const seconds = parsed / 1000;
|
||||
if (seconds < 60) {
|
||||
const secondDecimalPlaces = resolveSecondDecimalPlaces(seconds, options.secondDecimals);
|
||||
return formatDurationPart(seconds, 's', locale, {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: secondDecimalPlaces,
|
||||
});
|
||||
}
|
||||
|
||||
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 = [
|
||||
{ unit: 'd' as const, value: days },
|
||||
{ unit: 'h' as const, value: hours },
|
||||
{ unit: 'm' as const, value: minutes },
|
||||
{ unit: 's' as const, value: remainingSeconds },
|
||||
].filter((part) => part.value > 0);
|
||||
|
||||
if (!parts.length) {
|
||||
return formatDurationPart(0, 's', locale);
|
||||
}
|
||||
|
||||
return parts
|
||||
.slice(0, normalizeDurationMaxUnits(options.maxUnits))
|
||||
.map((part, index) =>
|
||||
formatDurationPart(part.value, part.unit, locale, {
|
||||
minimumIntegerDigits: index > 0 && (part.unit === 'm' || part.unit === 's') ? 2 : 1,
|
||||
maximumFractionDigits: 0,
|
||||
})
|
||||
)
|
||||
.join(' ');
|
||||
}
|
||||
Reference in New Issue
Block a user