Skip to content

Commit 4c9845c

Browse files
committed
feat(usage): add filtered chunk and traffic trends
Show sparkline trends for chunk and traffic summary cards and make them follow the selected time range. Also widen the model stats panel to reduce horizontal scrolling in the details area.
1 parent a1c61be commit 4c9845c

7 files changed

Lines changed: 127 additions & 31 deletions

File tree

src/components/usage/StatCards.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ export interface StatCardsProps {
5151
rpm: SparklineBundle | null;
5252
tpm: SparklineBundle | null;
5353
cost: SparklineBundle | null;
54+
chunks: SparklineBundle | null;
55+
traffic: SparklineBundle | null;
5456
};
5557
}
5658

@@ -209,7 +211,7 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
209211
</span>
210212
</>
211213
),
212-
trend: null,
214+
trend: sparklines.chunks,
213215
},
214216
{
215217
key: 'traffic',
@@ -229,7 +231,7 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
229231
</span>
230232
</>
231233
),
232-
trend: null,
234+
trend: sparklines.traffic,
233235
},
234236
{
235237
key: 'tokens',

src/components/usage/hooks/useSparklines.ts

Lines changed: 98 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -33,44 +33,102 @@ export interface UseSparklinesReturn {
3333
rpmSparkline: SparklineBundle | null;
3434
tpmSparkline: SparklineBundle | null;
3535
costSparkline: SparklineBundle | null;
36+
chunksSparkline: SparklineBundle | null;
37+
trafficSparkline: SparklineBundle | null;
3638
}
3739

3840
export function useSparklines({ usage, loading, nowMs }: UseSparklinesOptions): UseSparklinesReturn {
3941
const lastHourSeries = useMemo(() => {
40-
if (!usage) return { labels: [], requests: [], tokens: [] };
41-
if (!Number.isFinite(nowMs) || nowMs <= 0) {
42-
return { labels: [], requests: [], tokens: [] };
42+
if (!usage) {
43+
return {
44+
labels: [],
45+
requests: [],
46+
tokens: [],
47+
chunks: [],
48+
traffic: []
49+
};
4350
}
4451
const details = collectUsageDetails(usage);
45-
if (!details.length) return { labels: [], requests: [], tokens: [] };
52+
if (!details.length) {
53+
return {
54+
labels: [],
55+
requests: [],
56+
tokens: [],
57+
chunks: [],
58+
traffic: []
59+
};
60+
}
61+
62+
const minTimestamp = details.reduce((min, detail) => {
63+
const timestamp = detail.__timestampMs ?? 0;
64+
return Number.isFinite(timestamp) && timestamp > 0 ? Math.min(min, timestamp) : min;
65+
}, Number.POSITIVE_INFINITY);
66+
const maxTimestamp = details.reduce((max, detail) => {
67+
const timestamp = detail.__timestampMs ?? 0;
68+
return Number.isFinite(timestamp) && timestamp > 0 ? Math.max(max, timestamp) : max;
69+
}, 0);
70+
const fallbackNow = Number.isFinite(nowMs) && nowMs > 0 ? nowMs : 0;
71+
const windowEnd = maxTimestamp > 0 ? maxTimestamp : fallbackNow;
72+
if (!Number.isFinite(minTimestamp) || minTimestamp <= 0 || !Number.isFinite(windowEnd) || windowEnd <= 0) {
73+
return {
74+
labels: [],
75+
requests: [],
76+
tokens: [],
77+
chunks: [],
78+
traffic: []
79+
};
80+
}
4681

47-
const windowMinutes = 60;
48-
const now = nowMs;
49-
const windowStart = now - windowMinutes * 60 * 1000;
50-
const requestBuckets = new Array(windowMinutes).fill(0);
51-
const tokenBuckets = new Array(windowMinutes).fill(0);
82+
const maxBuckets = 60;
83+
const spanMs = Math.max(1, windowEnd - minTimestamp + 1);
84+
const bucketMs = Math.max(60000, Math.ceil(spanMs / maxBuckets));
85+
const bucketCount = Math.max(1, Math.min(maxBuckets, Math.ceil(spanMs / bucketMs)));
86+
const windowStart = windowEnd - bucketMs * bucketCount;
87+
const requestBuckets = new Array(bucketCount).fill(0);
88+
const tokenBuckets = new Array(bucketCount).fill(0);
89+
const chunkBuckets = new Array(bucketCount).fill(0);
90+
const trafficBuckets = new Array(bucketCount).fill(0);
5291

5392
details.forEach((detail) => {
5493
const timestamp = detail.__timestampMs ?? 0;
55-
if (!Number.isFinite(timestamp) || timestamp < windowStart || timestamp > now) {
94+
if (!Number.isFinite(timestamp) || timestamp < windowStart || timestamp > windowEnd) {
5695
return;
5796
}
58-
const minuteIndex = Math.min(
59-
windowMinutes - 1,
60-
Math.floor((timestamp - windowStart) / 60000)
97+
const bucketIndex = Math.min(
98+
bucketCount - 1,
99+
Math.max(0, Math.floor((timestamp - windowStart) / bucketMs))
61100
);
62-
requestBuckets[minuteIndex] += 1;
63-
tokenBuckets[minuteIndex] += extractTotalTokens(detail);
101+
requestBuckets[bucketIndex] += 1;
102+
tokenBuckets[bucketIndex] += extractTotalTokens(detail);
103+
chunkBuckets[bucketIndex] +=
104+
typeof detail.chunk_count === 'number' && Number.isFinite(detail.chunk_count)
105+
? Math.max(detail.chunk_count, 0)
106+
: 0;
107+
const responseBytes =
108+
typeof detail.response_bytes === 'number' && Number.isFinite(detail.response_bytes)
109+
? Math.max(detail.response_bytes, 0)
110+
: 0;
111+
const apiResponseBytes =
112+
typeof detail.api_response_bytes === 'number' && Number.isFinite(detail.api_response_bytes)
113+
? Math.max(detail.api_response_bytes, 0)
114+
: 0;
115+
trafficBuckets[bucketIndex] += responseBytes + apiResponseBytes;
64116
});
65117

66118
const labels = requestBuckets.map((_, idx) => {
67-
const date = new Date(windowStart + (idx + 1) * 60000);
119+
const date = new Date(windowStart + (idx + 1) * bucketMs);
68120
const h = date.getHours().toString().padStart(2, '0');
69121
const m = date.getMinutes().toString().padStart(2, '0');
70122
return `${h}:${m}`;
71123
});
72124

73-
return { labels, requests: requestBuckets, tokens: tokenBuckets };
125+
return {
126+
labels,
127+
requests: requestBuckets,
128+
tokens: tokenBuckets,
129+
chunks: chunkBuckets,
130+
traffic: trafficBuckets
131+
};
74132
}, [nowMs, usage]);
75133

76134
const buildSparkline = useCallback(
@@ -155,11 +213,33 @@ export function useSparklines({ usage, loading, nowMs }: UseSparklinesOptions):
155213
[buildSparkline, lastHourSeries.labels, lastHourSeries.tokens]
156214
);
157215

216+
const chunksSparkline = useMemo(
217+
() =>
218+
buildSparkline(
219+
{ labels: lastHourSeries.labels, data: lastHourSeries.chunks },
220+
'#0ea5e9',
221+
'rgba(14, 165, 233, 0.18)'
222+
),
223+
[buildSparkline, lastHourSeries.chunks, lastHourSeries.labels]
224+
);
225+
226+
const trafficSparkline = useMemo(
227+
() =>
228+
buildSparkline(
229+
{ labels: lastHourSeries.labels, data: lastHourSeries.traffic },
230+
'#14b8a6',
231+
'rgba(20, 184, 166, 0.18)'
232+
),
233+
[buildSparkline, lastHourSeries.labels, lastHourSeries.traffic]
234+
);
235+
158236
return {
159237
requestsSparkline,
160238
tokensSparkline,
161239
rpmSparkline,
162240
tpmSparkline,
163-
costSparkline
241+
costSparkline,
242+
chunksSparkline,
243+
trafficSparkline
164244
};
165245
}

src/i18n/locales/en.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1063,7 +1063,7 @@
10631063
"request_events_limit_hint": "Showing {{shown}} of {{total}} events to keep rendering responsive.",
10641064
"chunk_count": "Chunk Count",
10651065
"total_chunk_count": "Total Chunks",
1066-
"avg_chunk_per_request": "Avg Chunks / Request",
1066+
"avg_chunk_per_request": "Avg Chunks",
10671067
"traffic_stats": "Traffic",
10681068
"response_bytes": "Response Bytes",
10691069
"api_response_bytes": "Upstream Response Bytes",

src/i18n/locales/ru.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1060,7 +1060,7 @@
10601060
"request_events_limit_hint": "Показано {{shown}} из {{total}} событий для стабильной производительности.",
10611061
"chunk_count": "Количество chunk",
10621062
"total_chunk_count": "Всего chunk",
1063-
"avg_chunk_per_request": "Среднее chunk на запрос",
1063+
"avg_chunk_per_request": "Среднее chunk",
10641064
"traffic_stats": "Трафик",
10651065
"response_bytes": "Байты ответа",
10661066
"api_response_bytes": "Байты upstream-ответа",

src/i18n/locales/zh-CN.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1063,7 +1063,7 @@
10631063
"request_events_limit_hint": "为保证渲染性能,仅展示 {{shown}} / {{total}} 条事件。",
10641064
"chunk_count": "Chunk 数",
10651065
"total_chunk_count": "Chunk 总数",
1066-
"avg_chunk_per_request": "平均每请求 Chunk",
1066+
"avg_chunk_per_request": "平均 Chunk",
10671067
"traffic_stats": "流量统计",
10681068
"response_bytes": "下游响应字节",
10691069
"api_response_bytes": "上游响应字节",

src/pages/UsagePage.module.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -873,7 +873,7 @@
873873
}
874874

875875
@include desktop {
876-
grid-template-columns: repeat(2, minmax(0, 1fr));
876+
grid-template-columns: minmax(280px, 0.85fr) minmax(0, 1.15fr);
877877
}
878878
}
879879

src/pages/UsagePage.tsx

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
getApiStats,
3939
getModelStats,
4040
filterUsageByTimeRange,
41+
collectUsageDetails,
4142
type UsageTimeRange
4243
} from '@/utils/usage';
4344
import styles from './UsagePage.module.scss';
@@ -154,9 +155,21 @@ export function UsagePage() {
154155
[t]
155156
);
156157

158+
const nowMs = lastRefreshedAt?.getTime() ?? 0;
159+
const usageLatestTimestampMs = useMemo(() => {
160+
if (!usage) {
161+
return 0;
162+
}
163+
return collectUsageDetails(usage).reduce((max, detail) => {
164+
const timestamp = detail.__timestampMs ?? 0;
165+
return Number.isFinite(timestamp) && timestamp > 0 ? Math.max(max, timestamp) : max;
166+
}, 0);
167+
}, [usage]);
168+
const filterReferenceMs = nowMs > 0 ? nowMs : usageLatestTimestampMs;
169+
157170
const filteredUsage = useMemo(
158-
() => (usage ? filterUsageByTimeRange(usage, timeRange) : null),
159-
[usage, timeRange]
171+
() => (usage ? filterUsageByTimeRange(usage, timeRange, filterReferenceMs) : null),
172+
[filterReferenceMs, usage, timeRange]
160173
);
161174
const hourWindowHours =
162175
timeRange === 'all' ? undefined : HOUR_WINDOW_BY_TIME_RANGE[timeRange];
@@ -187,15 +200,14 @@ export function UsagePage() {
187200
}
188201
}, [timeRange]);
189202

190-
const nowMs = lastRefreshedAt?.getTime() ?? 0;
191-
192-
// Sparklines hook
193203
const {
194204
requestsSparkline,
195205
tokensSparkline,
196206
rpmSparkline,
197207
tpmSparkline,
198-
costSparkline
208+
costSparkline,
209+
chunksSparkline,
210+
trafficSparkline
199211
} = useSparklines({ usage: filteredUsage, loading, nowMs });
200212

201213
// Chart data hook
@@ -301,7 +313,9 @@ export function UsagePage() {
301313
tokens: tokensSparkline,
302314
rpm: rpmSparkline,
303315
tpm: tpmSparkline,
304-
cost: costSparkline
316+
cost: costSparkline,
317+
chunks: chunksSparkline,
318+
traffic: trafficSparkline
305319
}}
306320
/>
307321

0 commit comments

Comments
 (0)