Skip to content

Commit 74d9adc

Browse files
feat: add latency tracking and display enhancements across usage components
1 parent e33c890 commit 74d9adc

9 files changed

Lines changed: 411 additions & 282 deletions

File tree

src/components/usage/ModelStatsCard.tsx

Lines changed: 128 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useState, useMemo } from 'react';
22
import { useTranslation } from 'react-i18next';
33
import { Card } from '@/components/ui/Card';
44
import {
5+
LATENCY_SOURCE_FIELD,
56
formatCompactNumber,
67
formatDurationMs,
78
formatUsd,
@@ -35,6 +36,10 @@ export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCar
3536
const { t } = useTranslation();
3637
const [sortKey, setSortKey] = useState<SortKey>('requests');
3738
const [sortDir, setSortDir] = useState<SortDir>('desc');
39+
const latencyHint = t('usage_stats.latency_unit_hint', {
40+
field: LATENCY_SOURCE_FIELD,
41+
unit: t('usage_stats.duration_unit_ms'),
42+
});
3843

3944
const handleSort = (key: SortKey) => {
4045
if (sortKey === key) {
@@ -66,129 +71,146 @@ export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCar
6671
return list;
6772
}, [modelStats, sortKey, sortDir]);
6873

69-
const arrow = (key: SortKey) =>
70-
sortKey === key ? (sortDir === 'asc' ? ' ▲' : ' ▼') : '';
74+
const arrow = (key: SortKey) => (sortKey === key ? (sortDir === 'asc' ? ' ▲' : ' ▼') : '');
7175
const ariaSort = (key: SortKey): 'none' | 'ascending' | 'descending' =>
7276
sortKey === key ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none';
77+
const hasLatencyData = sorted.some((stat) => stat.latencySampleCount > 0);
7378

7479
return (
7580
<Card title={t('usage_stats.models')} className={styles.detailsFixedCard}>
7681
{loading ? (
7782
<div className={styles.hint}>{t('common.loading')}</div>
7883
) : sorted.length > 0 ? (
79-
<div className={styles.detailsScroll}>
80-
<div className={styles.tableWrapper}>
81-
<table className={styles.table}>
82-
<thead>
83-
<tr>
84-
<th className={styles.sortableHeader} aria-sort={ariaSort('model')}>
85-
<button
86-
type="button"
87-
className={styles.sortHeaderButton}
88-
onClick={() => handleSort('model')}
89-
>
90-
{t('usage_stats.model_name')}{arrow('model')}
91-
</button>
92-
</th>
93-
<th className={styles.sortableHeader} aria-sort={ariaSort('requests')}>
94-
<button
95-
type="button"
96-
className={styles.sortHeaderButton}
97-
onClick={() => handleSort('requests')}
98-
>
99-
{t('usage_stats.requests_count')}{arrow('requests')}
100-
</button>
101-
</th>
102-
<th className={styles.sortableHeader} aria-sort={ariaSort('tokens')}>
103-
<button
104-
type="button"
105-
className={styles.sortHeaderButton}
106-
onClick={() => handleSort('tokens')}
107-
>
108-
{t('usage_stats.tokens_count')}{arrow('tokens')}
109-
</button>
110-
</th>
111-
<th
112-
className={styles.sortableHeader}
113-
aria-sort={ariaSort('averageLatencyMs')}
114-
>
115-
<button
116-
type="button"
117-
className={styles.sortHeaderButton}
118-
onClick={() => handleSort('averageLatencyMs')}
119-
>
120-
{t('usage_stats.avg_time')}{arrow('averageLatencyMs')}
121-
</button>
122-
</th>
123-
<th className={styles.sortableHeader} aria-sort={ariaSort('totalLatencyMs')}>
124-
<button
125-
type="button"
126-
className={styles.sortHeaderButton}
127-
onClick={() => handleSort('totalLatencyMs')}
128-
>
129-
{t('usage_stats.total_time')}{arrow('totalLatencyMs')}
130-
</button>
131-
</th>
132-
<th className={styles.sortableHeader} aria-sort={ariaSort('successRate')}>
133-
<button
134-
type="button"
135-
className={styles.sortHeaderButton}
136-
onClick={() => handleSort('successRate')}
137-
>
138-
{t('usage_stats.success_rate')}{arrow('successRate')}
139-
</button>
140-
</th>
141-
{hasPrices && (
142-
<th className={styles.sortableHeader} aria-sort={ariaSort('cost')}>
84+
<>
85+
{hasLatencyData && <div className={styles.detailsNote}>{latencyHint}</div>}
86+
<div className={styles.detailsScroll}>
87+
<div className={styles.tableWrapper}>
88+
<table className={styles.table}>
89+
<thead>
90+
<tr>
91+
<th className={styles.sortableHeader} aria-sort={ariaSort('model')}>
14392
<button
14493
type="button"
14594
className={styles.sortHeaderButton}
146-
onClick={() => handleSort('cost')}
95+
onClick={() => handleSort('model')}
14796
>
148-
{t('usage_stats.total_cost')}{arrow('cost')}
97+
{t('usage_stats.model_name')}
98+
{arrow('model')}
14999
</button>
150100
</th>
151-
)}
152-
</tr>
153-
</thead>
154-
<tbody>
155-
{sorted.map((stat) => (
156-
<tr key={stat.model}>
157-
<td className={styles.modelCell}>{stat.model}</td>
158-
<td>
159-
<span className={styles.requestCountCell}>
160-
<span>{stat.requests.toLocaleString()}</span>
161-
<span className={styles.requestBreakdown}>
162-
(<span className={styles.statSuccess}>{stat.successCount.toLocaleString()}</span>{' '}
163-
<span className={styles.statFailure}>{stat.failureCount.toLocaleString()}</span>)
164-
</span>
165-
</span>
166-
</td>
167-
<td>{formatCompactNumber(stat.tokens)}</td>
168-
<td className={styles.durationCell}>
169-
{formatDurationMs(stat.averageLatencyMs)}
170-
</td>
171-
<td className={styles.durationCell}>{formatDurationMs(stat.totalLatencyMs)}</td>
172-
<td>
173-
<span
174-
className={
175-
stat.successRate >= 95
176-
? styles.statSuccess
177-
: stat.successRate >= 80
178-
? styles.statNeutral
179-
: styles.statFailure
180-
}
101+
<th className={styles.sortableHeader} aria-sort={ariaSort('requests')}>
102+
<button
103+
type="button"
104+
className={styles.sortHeaderButton}
105+
onClick={() => handleSort('requests')}
106+
>
107+
{t('usage_stats.requests_count')}
108+
{arrow('requests')}
109+
</button>
110+
</th>
111+
<th className={styles.sortableHeader} aria-sort={ariaSort('tokens')}>
112+
<button
113+
type="button"
114+
className={styles.sortHeaderButton}
115+
onClick={() => handleSort('tokens')}
116+
>
117+
{t('usage_stats.tokens_count')}
118+
{arrow('tokens')}
119+
</button>
120+
</th>
121+
<th className={styles.sortableHeader} aria-sort={ariaSort('averageLatencyMs')}>
122+
<button
123+
type="button"
124+
className={styles.sortHeaderButton}
125+
onClick={() => handleSort('averageLatencyMs')}
126+
title={latencyHint}
181127
>
182-
{stat.successRate.toFixed(1)}%
183-
</span>
184-
</td>
185-
{hasPrices && <td>{stat.cost > 0 ? formatUsd(stat.cost) : '--'}</td>}
128+
{t('usage_stats.avg_time')}
129+
{arrow('averageLatencyMs')}
130+
</button>
131+
</th>
132+
<th className={styles.sortableHeader} aria-sort={ariaSort('totalLatencyMs')}>
133+
<button
134+
type="button"
135+
className={styles.sortHeaderButton}
136+
onClick={() => handleSort('totalLatencyMs')}
137+
title={latencyHint}
138+
>
139+
{t('usage_stats.total_time')}
140+
{arrow('totalLatencyMs')}
141+
</button>
142+
</th>
143+
<th className={styles.sortableHeader} aria-sort={ariaSort('successRate')}>
144+
<button
145+
type="button"
146+
className={styles.sortHeaderButton}
147+
onClick={() => handleSort('successRate')}
148+
>
149+
{t('usage_stats.success_rate')}
150+
{arrow('successRate')}
151+
</button>
152+
</th>
153+
{hasPrices && (
154+
<th className={styles.sortableHeader} aria-sort={ariaSort('cost')}>
155+
<button
156+
type="button"
157+
className={styles.sortHeaderButton}
158+
onClick={() => handleSort('cost')}
159+
>
160+
{t('usage_stats.total_cost')}
161+
{arrow('cost')}
162+
</button>
163+
</th>
164+
)}
186165
</tr>
187-
))}
188-
</tbody>
189-
</table>
166+
</thead>
167+
<tbody>
168+
{sorted.map((stat) => (
169+
<tr key={stat.model}>
170+
<td className={styles.modelCell}>{stat.model}</td>
171+
<td>
172+
<span className={styles.requestCountCell}>
173+
<span>{stat.requests.toLocaleString()}</span>
174+
<span className={styles.requestBreakdown}>
175+
(
176+
<span className={styles.statSuccess}>
177+
{stat.successCount.toLocaleString()}
178+
</span>{' '}
179+
<span className={styles.statFailure}>
180+
{stat.failureCount.toLocaleString()}
181+
</span>
182+
)
183+
</span>
184+
</span>
185+
</td>
186+
<td>{formatCompactNumber(stat.tokens)}</td>
187+
<td className={styles.durationCell}>
188+
{formatDurationMs(stat.averageLatencyMs)}
189+
</td>
190+
<td className={styles.durationCell}>
191+
{formatDurationMs(stat.totalLatencyMs)}
192+
</td>
193+
<td>
194+
<span
195+
className={
196+
stat.successRate >= 95
197+
? styles.statSuccess
198+
: stat.successRate >= 80
199+
? styles.statNeutral
200+
: styles.statFailure
201+
}
202+
>
203+
{stat.successRate.toFixed(1)}%
204+
</span>
205+
</td>
206+
{hasPrices && <td>{stat.cost > 0 ? formatUsd(stat.cost) : '--'}</td>}
207+
</tr>
208+
))}
209+
</tbody>
210+
</table>
211+
</div>
190212
</div>
191-
</div>
213+
</>
192214
) : (
193215
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
194216
)}

src/components/usage/RequestEventsDetailsCard.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@ import type { AuthFileItem } from '@/types/authFile';
1010
import type { CredentialInfo } from '@/types/sourceInfo';
1111
import { buildSourceInfoMap, resolveSourceDisplay } from '@/utils/sourceResolver';
1212
import {
13-
extractLatencyMs,
1413
collectUsageDetails,
14+
extractLatencyMs,
1515
extractTotalTokens,
1616
formatDurationMs,
17+
LATENCY_SOURCE_FIELD,
1718
normalizeAuthIndex,
1819
} from '@/utils/usage';
1920
import { downloadBlob } from '@/utils/download';
@@ -74,6 +75,10 @@ export function RequestEventsDetailsCard({
7475
openaiProviders,
7576
}: RequestEventsDetailsCardProps) {
7677
const { t, i18n } = useTranslation();
78+
const latencyHint = t('usage_stats.latency_unit_hint', {
79+
field: LATENCY_SOURCE_FIELD,
80+
unit: t('usage_stats.duration_unit_ms'),
81+
});
7782

7883
const [modelFilter, setModelFilter] = useState(ALL_FILTER);
7984
const [sourceFilter, setSourceFilter] = useState(ALL_FILTER);
@@ -422,6 +427,7 @@ export function RequestEventsDetailsCard({
422427
<>
423428
<div className={styles.requestEventsMeta}>
424429
<span>{t('usage_stats.request_events_count', { count: filteredRows.length })}</span>
430+
{hasLatencyData && <span className={styles.requestEventsLimitHint}>{latencyHint}</span>}
425431
{filteredRows.length > MAX_RENDERED_EVENTS && (
426432
<span className={styles.requestEventsLimitHint}>
427433
{t('usage_stats.request_events_limit_hint', {
@@ -441,7 +447,7 @@ export function RequestEventsDetailsCard({
441447
<th>{t('usage_stats.request_events_source')}</th>
442448
<th>{t('usage_stats.request_events_auth_index')}</th>
443449
<th>{t('usage_stats.request_events_result')}</th>
444-
{hasLatencyData && <th>{t('usage_stats.time')}</th>}
450+
{hasLatencyData && <th title={latencyHint}>{t('usage_stats.time')}</th>}
445451
<th>{t('usage_stats.input_tokens')}</th>
446452
<th>{t('usage_stats.output_tokens')}</th>
447453
<th>{t('usage_stats.reasoning_tokens')}</th>

src/components/usage/StatCards.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,13 @@ import {
99
IconTrendingUp,
1010
} from '@/components/ui/icons';
1111
import {
12+
LATENCY_SOURCE_FIELD,
1213
calculateLatencyStatsFromDetails,
14+
calculateCost,
1315
formatCompactNumber,
1416
formatDurationMs,
1517
formatPerMinuteValue,
1618
formatUsd,
17-
calculateCost,
1819
collectUsageDetails,
1920
extractTotalTokens,
2021
type ModelPrice,
@@ -52,6 +53,10 @@ export interface StatCardsProps {
5253

5354
export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: StatCardsProps) {
5455
const { t } = useTranslation();
56+
const latencyHint = t('usage_stats.latency_unit_hint', {
57+
field: LATENCY_SOURCE_FIELD,
58+
unit: t('usage_stats.duration_unit_ms'),
59+
});
5560

5661
const hasPrices = Object.keys(modelPrices).length > 0;
5762

@@ -60,7 +65,11 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
6065
tokenBreakdown: { cachedTokens: 0, reasoningTokens: 0 },
6166
rateStats: { rpm: 0, tpm: 0, windowMinutes: 30, requestCount: 0, tokenCount: 0 },
6267
totalCost: 0,
63-
latencyStats: { averageMs: null as number | null, totalMs: null as number | null, sampleCount: 0 },
68+
latencyStats: {
69+
averageMs: null as number | null,
70+
totalMs: null as number | null,
71+
sampleCount: 0,
72+
},
6473
};
6574

6675
if (!usage) return empty;
@@ -141,7 +150,7 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
141150
{t('usage_stats.failed_requests')}: {loading ? '-' : (usage?.failure_count ?? 0)}
142151
</span>
143152
{latencyStats.sampleCount > 0 && (
144-
<span className={styles.statMetaItem}>
153+
<span className={styles.statMetaItem} title={latencyHint}>
145154
{t('usage_stats.avg_time')}:{' '}
146155
{loading ? '-' : formatDurationMs(latencyStats.averageMs)}
147156
</span>

src/i18n/locales/en.json

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1029,9 +1029,15 @@
10291029
"request_events_source": "Source",
10301030
"request_events_auth_index": "Auth Index",
10311031
"request_events_result": "Result",
1032-
"time": "Time",
1033-
"avg_time": "Avg Time",
1034-
"total_time": "Total Time",
1032+
"time": "Latency",
1033+
"avg_time": "Avg Latency",
1034+
"total_time": "Total Latency",
1035+
"latency_unit_hint": "Durations use backend field {{field}} and are interpreted as {{unit}} before formatting.",
1036+
"duration_unit_d": "d",
1037+
"duration_unit_h": "h",
1038+
"duration_unit_m": "m",
1039+
"duration_unit_s": "s",
1040+
"duration_unit_ms": "ms",
10351041
"request_events_empty_title": "No request events",
10361042
"request_events_empty_desc": "No request details are available for the selected time range.",
10371043
"request_events_no_result_title": "No matching events",

src/i18n/locales/ru.json

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1026,9 +1026,15 @@
10261026
"request_events_source": "Источник",
10271027
"request_events_auth_index": "Auth Index",
10281028
"request_events_result": "Результат",
1029-
"time": "Время",
1030-
"avg_time": "Среднее время",
1031-
"total_time": "Общее время",
1029+
"time": "Задержка",
1030+
"avg_time": "Средняя задержка",
1031+
"total_time": "Суммарная задержка",
1032+
"latency_unit_hint": "Длительность берётся из поля бэкенда {{field}} и интерпретируется как {{unit}} перед форматированием.",
1033+
"duration_unit_d": "д",
1034+
"duration_unit_h": "ч",
1035+
"duration_unit_m": "мин",
1036+
"duration_unit_s": "с",
1037+
"duration_unit_ms": "мс",
10321038
"request_events_empty_title": "События запросов отсутствуют",
10331039
"request_events_empty_desc": "Нет деталей запросов для выбранного диапазона времени.",
10341040
"request_events_no_result_title": "Совпадений не найдено",

0 commit comments

Comments
 (0)