Skip to content

Commit a1c61be

Browse files
committed
feat(usage): move traffic metrics into summary panels
1 parent 7da9db9 commit a1c61be

7 files changed

Lines changed: 224 additions & 20 deletions

File tree

src/components/usage/ModelStatsCard.tsx

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
33
import { Card } from '@/components/ui/Card';
44
import {
55
LATENCY_SOURCE_FIELD,
6+
formatBytes,
67
formatCompactNumber,
78
formatDurationMs,
89
formatUsd,
@@ -25,13 +26,32 @@ type SortKey =
2526
| 'cost'
2627
| 'successRate'
2728
| 'averageLatencyMs'
28-
| 'totalLatencyMs';
29+
| 'totalLatencyMs'
30+
| 'averageChunkCount'
31+
| 'averageResponseBytes'
32+
| 'averageAPIResponseBytes';
2933
type SortDir = 'asc' | 'desc';
3034

3135
interface ModelStatWithRate extends ModelStat {
3236
successRate: number;
3337
}
3438

39+
const formatAverageCount = (value: number | null): string => {
40+
if (value === null || !Number.isFinite(value)) {
41+
return '--';
42+
}
43+
if (value >= 1000) {
44+
return formatCompactNumber(value);
45+
}
46+
if (value >= 100) {
47+
return value.toFixed(0);
48+
}
49+
if (value >= 10) {
50+
return value.toFixed(1);
51+
}
52+
return value.toFixed(2);
53+
};
54+
3555
export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCardProps) {
3656
const { t } = useTranslation();
3757
const [sortKey, setSortKey] = useState<SortKey>('requests');
@@ -75,6 +95,12 @@ export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCar
7595
const ariaSort = (key: SortKey): 'none' | 'ascending' | 'descending' =>
7696
sortKey === key ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none';
7797
const hasLatencyData = sorted.some((stat) => stat.latencySampleCount > 0);
98+
const hasRequestMetricData = sorted.some(
99+
(stat) =>
100+
stat.averageChunkCount !== null ||
101+
stat.averageResponseBytes !== null ||
102+
stat.averageAPIResponseBytes !== null
103+
);
78104

79105
return (
80106
<Card title={t('usage_stats.models')} className={styles.detailsFixedCard}>
@@ -140,6 +166,40 @@ export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCar
140166
{arrow('totalLatencyMs')}
141167
</button>
142168
</th>
169+
{hasRequestMetricData && (
170+
<>
171+
<th className={styles.sortableHeader} aria-sort={ariaSort('averageChunkCount')}>
172+
<button
173+
type="button"
174+
className={styles.sortHeaderButton}
175+
onClick={() => handleSort('averageChunkCount')}
176+
>
177+
{t('usage_stats.avg_chunk_per_request')}
178+
{arrow('averageChunkCount')}
179+
</button>
180+
</th>
181+
<th className={styles.sortableHeader} aria-sort={ariaSort('averageResponseBytes')}>
182+
<button
183+
type="button"
184+
className={styles.sortHeaderButton}
185+
onClick={() => handleSort('averageResponseBytes')}
186+
>
187+
{t('usage_stats.avg_response_bytes')}
188+
{arrow('averageResponseBytes')}
189+
</button>
190+
</th>
191+
<th className={styles.sortableHeader} aria-sort={ariaSort('averageAPIResponseBytes')}>
192+
<button
193+
type="button"
194+
className={styles.sortHeaderButton}
195+
onClick={() => handleSort('averageAPIResponseBytes')}
196+
>
197+
{t('usage_stats.avg_api_response_bytes')}
198+
{arrow('averageAPIResponseBytes')}
199+
</button>
200+
</th>
201+
</>
202+
)}
143203
<th className={styles.sortableHeader} aria-sort={ariaSort('successRate')}>
144204
<button
145205
type="button"
@@ -190,6 +250,21 @@ export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCar
190250
<td className={styles.durationCell}>
191251
{formatDurationMs(stat.totalLatencyMs)}
192252
</td>
253+
{hasRequestMetricData && (
254+
<>
255+
<td>{formatAverageCount(stat.averageChunkCount)}</td>
256+
<td>
257+
{stat.averageResponseBytes !== null
258+
? formatBytes(stat.averageResponseBytes)
259+
: '--'}
260+
</td>
261+
<td>
262+
{stat.averageAPIResponseBytes !== null
263+
? formatBytes(stat.averageAPIResponseBytes)
264+
: '--'}
265+
</td>
266+
</>
267+
)}
193268
<td>
194269
<span
195270
className={

src/components/usage/RequestEventsDetailsCard.tsx

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -524,9 +524,6 @@ export function RequestEventsDetailsCard({
524524
<th title={latencyHint}>{t('usage_stats.first_byte_latency')}</th>
525525
)}
526526
{hasLatencyData && <th title={latencyHint}>{t('usage_stats.time')}</th>}
527-
<th>{t('usage_stats.chunk_count')}</th>
528-
<th>{t('usage_stats.response_bytes')}</th>
529-
<th>{t('usage_stats.api_response_bytes')}</th>
530527
<th>{t('usage_stats.input_tokens')}</th>
531528
<th>{t('usage_stats.output_tokens')}</th>
532529
<th>{t('usage_stats.reasoning_tokens')}</th>
@@ -570,9 +567,6 @@ export function RequestEventsDetailsCard({
570567
{hasLatencyData && (
571568
<td className={styles.durationCell}>{formatDurationMs(row.latencyMs)}</td>
572569
)}
573-
<td>{row.chunkCount !== null ? row.chunkCount.toLocaleString() : '--'}</td>
574-
<td>{row.responseBytes !== null ? row.responseBytes.toLocaleString() : '--'}</td>
575-
<td>{row.apiResponseBytes !== null ? row.apiResponseBytes.toLocaleString() : '--'}</td>
576570
<td>{row.inputTokens.toLocaleString()}</td>
577571
<td>{row.outputTokens.toLocaleString()}</td>
578572
<td>{row.reasoningTokens.toLocaleString()}</td>

src/components/usage/StatCards.tsx

Lines changed: 80 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,14 @@ import {
77
IconSatellite,
88
IconTimer,
99
IconTrendingUp,
10+
IconChartLine,
11+
IconDownload,
1012
} from '@/components/ui/icons';
1113
import {
1214
LATENCY_SOURCE_FIELD,
1315
calculateLatencyStatsFromDetails,
1416
calculateCost,
17+
formatBytes,
1518
formatCompactNumber,
1619
formatDurationMs,
1720
formatPerMinuteValue,
@@ -60,7 +63,7 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
6063

6164
const hasPrices = Object.keys(modelPrices).length > 0;
6265

63-
const { tokenBreakdown, rateStats, totalCost, latencyStats } = useMemo(() => {
66+
const { tokenBreakdown, rateStats, totalCost, latencyStats, requestMetrics } = useMemo(() => {
6467
const empty = {
6568
tokenBreakdown: { cachedTokens: 0, reasoningTokens: 0 },
6669
rateStats: { rpm: 0, tpm: 0, windowMinutes: 30, requestCount: 0, tokenCount: 0 },
@@ -70,6 +73,11 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
7073
totalMs: null as number | null,
7174
sampleCount: 0,
7275
},
76+
requestMetrics: {
77+
chunkCount: 0,
78+
responseBytes: 0,
79+
apiResponseBytes: 0,
80+
},
7381
};
7482

7583
if (!usage) return empty;
@@ -81,6 +89,9 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
8189
let cachedTokens = 0;
8290
let reasoningTokens = 0;
8391
let totalCost = 0;
92+
let chunkCount = 0;
93+
let responseBytes = 0;
94+
let apiResponseBytes = 0;
8495

8596
const now = nowMs;
8697
const windowMinutes = 30;
@@ -98,6 +109,18 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
98109
if (typeof tokens.reasoning_tokens === 'number') {
99110
reasoningTokens += tokens.reasoning_tokens;
100111
}
112+
chunkCount +=
113+
typeof detail.chunk_count === 'number' && Number.isFinite(detail.chunk_count)
114+
? Math.max(detail.chunk_count, 0)
115+
: 0;
116+
responseBytes +=
117+
typeof detail.response_bytes === 'number' && Number.isFinite(detail.response_bytes)
118+
? Math.max(detail.response_bytes, 0)
119+
: 0;
120+
apiResponseBytes +=
121+
typeof detail.api_response_bytes === 'number' && Number.isFinite(detail.api_response_bytes)
122+
? Math.max(detail.api_response_bytes, 0)
123+
: 0;
101124

102125
const timestamp = detail.__timestampMs ?? 0;
103126
if (
@@ -127,6 +150,11 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
127150
},
128151
totalCost,
129152
latencyStats,
153+
requestMetrics: {
154+
chunkCount,
155+
responseBytes,
156+
apiResponseBytes,
157+
},
130158
};
131159
}, [hasPrices, modelPrices, nowMs, usage]);
132160

@@ -151,14 +179,58 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
151179
</span>
152180
{latencyStats.sampleCount > 0 && (
153181
<span className={styles.statMetaItem} title={latencyHint}>
154-
{t('usage_stats.avg_time')}:{' '}
155-
{loading ? '-' : formatDurationMs(latencyStats.averageMs)}
182+
{t('usage_stats.avg_time')}: {loading ? '-' : formatDurationMs(latencyStats.averageMs)}
156183
</span>
157184
)}
158185
</>
159186
),
160187
trend: sparklines.requests,
161188
},
189+
{
190+
key: 'chunks',
191+
label: t('usage_stats.total_chunk_count'),
192+
icon: <IconChartLine size={16} />,
193+
accent: '#0ea5e9',
194+
accentSoft: 'rgba(14, 165, 233, 0.18)',
195+
accentBorder: 'rgba(14, 165, 233, 0.32)',
196+
value: loading ? '-' : formatCompactNumber(requestMetrics.chunkCount),
197+
meta: (
198+
<>
199+
<span className={styles.statMetaItem}>
200+
{t('usage_stats.total_requests')}: {loading ? '-' : (usage?.total_requests ?? 0).toLocaleString()}
201+
</span>
202+
<span className={styles.statMetaItem}>
203+
{t('usage_stats.avg_chunk_per_request')}:{' '}
204+
{loading
205+
? '-'
206+
: (usage?.total_requests ?? 0) > 0
207+
? (requestMetrics.chunkCount / Math.max(usage?.total_requests ?? 0, 1)).toFixed(1)
208+
: '0.0'}
209+
</span>
210+
</>
211+
),
212+
trend: null,
213+
},
214+
{
215+
key: 'traffic',
216+
label: t('usage_stats.traffic_stats'),
217+
icon: <IconDownload size={16} />,
218+
accent: '#14b8a6',
219+
accentSoft: 'rgba(20, 184, 166, 0.18)',
220+
accentBorder: 'rgba(20, 184, 166, 0.32)',
221+
value: loading ? '-' : formatBytes(requestMetrics.responseBytes + requestMetrics.apiResponseBytes),
222+
meta: (
223+
<>
224+
<span className={styles.statMetaItem}>
225+
{t('usage_stats.response_bytes')}: {loading ? '-' : formatBytes(requestMetrics.responseBytes)}
226+
</span>
227+
<span className={styles.statMetaItem}>
228+
{t('usage_stats.api_response_bytes')}: {loading ? '-' : formatBytes(requestMetrics.apiResponseBytes)}
229+
</span>
230+
</>
231+
),
232+
trend: null,
233+
},
162234
{
163235
key: 'tokens',
164236
label: t('usage_stats.total_tokens'),
@@ -170,12 +242,10 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
170242
meta: (
171243
<>
172244
<span className={styles.statMetaItem}>
173-
{t('usage_stats.cached_tokens')}:{' '}
174-
{loading ? '-' : formatCompactNumber(tokenBreakdown.cachedTokens)}
245+
{t('usage_stats.cached_tokens')}: {loading ? '-' : formatCompactNumber(tokenBreakdown.cachedTokens)}
175246
</span>
176247
<span className={styles.statMetaItem}>
177-
{t('usage_stats.reasoning_tokens')}:{' '}
178-
{loading ? '-' : formatCompactNumber(tokenBreakdown.reasoningTokens)}
248+
{t('usage_stats.reasoning_tokens')}: {loading ? '-' : formatCompactNumber(tokenBreakdown.reasoningTokens)}
179249
</span>
180250
</>
181251
),
@@ -191,8 +261,7 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
191261
value: loading ? '-' : formatPerMinuteValue(rateStats.rpm),
192262
meta: (
193263
<span className={styles.statMetaItem}>
194-
{t('usage_stats.total_requests')}:{' '}
195-
{loading ? '-' : rateStats.requestCount.toLocaleString()}
264+
{t('usage_stats.total_requests')}: {loading ? '-' : rateStats.requestCount.toLocaleString()}
196265
</span>
197266
),
198267
trend: sparklines.rpm,
@@ -207,8 +276,7 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
207276
value: loading ? '-' : formatPerMinuteValue(rateStats.tpm),
208277
meta: (
209278
<span className={styles.statMetaItem}>
210-
{t('usage_stats.total_tokens')}:{' '}
211-
{loading ? '-' : formatCompactNumber(rateStats.tokenCount)}
279+
{t('usage_stats.total_tokens')}: {loading ? '-' : formatCompactNumber(rateStats.tokenCount)}
212280
</span>
213281
),
214282
trend: sparklines.tpm,
@@ -224,8 +292,7 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
224292
meta: (
225293
<>
226294
<span className={styles.statMetaItem}>
227-
{t('usage_stats.total_tokens')}:{' '}
228-
{loading ? '-' : formatCompactNumber(usage?.total_tokens ?? 0)}
295+
{t('usage_stats.total_tokens')}: {loading ? '-' : formatCompactNumber(usage?.total_tokens ?? 0)}
229296
</span>
230297
{!hasPrices && (
231298
<span className={`${styles.statMetaItem} ${styles.statSubtle}`}>

src/i18n/locales/en.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1062,8 +1062,13 @@
10621062
"request_events_count": "{{count}} events",
10631063
"request_events_limit_hint": "Showing {{shown}} of {{total}} events to keep rendering responsive.",
10641064
"chunk_count": "Chunk Count",
1065+
"total_chunk_count": "Total Chunks",
1066+
"avg_chunk_per_request": "Avg Chunks / Request",
1067+
"traffic_stats": "Traffic",
10651068
"response_bytes": "Response Bytes",
10661069
"api_response_bytes": "Upstream Response Bytes",
1070+
"avg_response_bytes": "Avg Downstream Traffic",
1071+
"avg_api_response_bytes": "Avg Upstream Traffic",
10671072
"input_tokens": "Input Tokens",
10681073
"output_tokens": "Output Tokens",
10691074
"last_updated": "Updated"

src/i18n/locales/ru.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1059,8 +1059,13 @@
10591059
"request_events_count": "{{count}} событий",
10601060
"request_events_limit_hint": "Показано {{shown}} из {{total}} событий для стабильной производительности.",
10611061
"chunk_count": "Количество chunk",
1062+
"total_chunk_count": "Всего chunk",
1063+
"avg_chunk_per_request": "Среднее chunk на запрос",
1064+
"traffic_stats": "Трафик",
10621065
"response_bytes": "Байты ответа",
10631066
"api_response_bytes": "Байты upstream-ответа",
1067+
"avg_response_bytes": "Средний downstream-трафик",
1068+
"avg_api_response_bytes": "Средний upstream-трафик",
10641069
"input_tokens": "Входные токены",
10651070
"output_tokens": "Выходные токены",
10661071
"last_updated": "Обновлено"

src/i18n/locales/zh-CN.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1062,8 +1062,13 @@
10621062
"request_events_count": "{{count}} 条事件",
10631063
"request_events_limit_hint": "为保证渲染性能,仅展示 {{shown}} / {{total}} 条事件。",
10641064
"chunk_count": "Chunk 数",
1065+
"total_chunk_count": "Chunk 总数",
1066+
"avg_chunk_per_request": "平均每请求 Chunk 数",
1067+
"traffic_stats": "流量统计",
10651068
"response_bytes": "下游响应字节",
10661069
"api_response_bytes": "上游响应字节",
1070+
"avg_response_bytes": "平均下游响应流量",
1071+
"avg_api_response_bytes": "平均上游响应流量",
10671072
"input_tokens": "输入 Tokens",
10681073
"output_tokens": "输出 Tokens",
10691074
"last_updated": "更新于"

0 commit comments

Comments
 (0)