dsh-token-total 1.0.2 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +535 -223
- package/lib/index.js +47 -4
- package/lib/types/client/format.d.ts +8 -4
- package/lib/types/client/locales.d.ts +1 -1
- package/lib/types/client/stats-icons.d.ts +4 -0
- package/package.json +1 -1
- package/src/client/ContextUsageEntry.module.css +130 -20
- package/src/client/ContextUsageEntry.tsx +11 -7
- package/src/client/StatsLineEnhanced.module.css +15 -24
- package/src/client/StatsLineEnhanced.tsx +33 -49
- package/src/client/TokenTotalPanel.module.css +259 -99
- package/src/client/TokenTotalPanel.tsx +197 -62
- package/src/client/format.ts +9 -7
- package/src/client/locales.ts +76 -0
- package/src/client/stats-icons.tsx +83 -0
- package/src/query.ts +53 -2
|
@@ -73,6 +73,9 @@ export function TokenTotalPanel({ t }: { t: Translator }): JSX.Element {
|
|
|
73
73
|
const [stats, setStats] = useState<TokenTotalRange | null>(null)
|
|
74
74
|
const [loading, setLoading] = useState(true)
|
|
75
75
|
const [error, setError] = useState('')
|
|
76
|
+
// When the last successful fetch happened — the "as of" stamp shows a
|
|
77
|
+
// concrete clock time, not just the range's date.
|
|
78
|
+
const [loadedAt, setLoadedAt] = useState<number | null>(null)
|
|
76
79
|
const generationRef = useRef(0)
|
|
77
80
|
// The panel bounds the chart tooltips: a tip must never leave the panel
|
|
78
81
|
// even when the hovered cell sits near its edge.
|
|
@@ -116,6 +119,7 @@ export function TokenTotalPanel({ t }: { t: Translator }): JSX.Element {
|
|
|
116
119
|
const res = await fetchRange(req)
|
|
117
120
|
if (generationRef.current !== generation) return // stale response
|
|
118
121
|
setStats(res)
|
|
122
|
+
setLoadedAt(Date.now())
|
|
119
123
|
// One trigger chain for both surfaces. The heatmap window does not
|
|
120
124
|
// follow `range`, so binding its fetch to the mount effect alone meant a
|
|
121
125
|
// single failed request left the grid blank until the user pressed
|
|
@@ -233,15 +237,22 @@ export function TokenTotalPanel({ t }: { t: Translator }): JSX.Element {
|
|
|
233
237
|
/>
|
|
234
238
|
</div>
|
|
235
239
|
)}
|
|
236
|
-
<
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
240
|
+
<div className={css.toolbarEnd}>
|
|
241
|
+
{stats && (
|
|
242
|
+
<span className={css.asOf}>
|
|
243
|
+
{t('asOf')} {asOfStamp(stats.to, loadedAt)}
|
|
244
|
+
</span>
|
|
245
|
+
)}
|
|
246
|
+
<Button
|
|
247
|
+
size="sm"
|
|
248
|
+
variant="outline"
|
|
249
|
+
className={css.refresh}
|
|
250
|
+
onClick={() => { void load() }}
|
|
251
|
+
disabled={loading}
|
|
252
|
+
>
|
|
253
|
+
{t('refresh')}
|
|
254
|
+
</Button>
|
|
255
|
+
</div>
|
|
245
256
|
</div>
|
|
246
257
|
|
|
247
258
|
{error && <div className={css.errorBanner}>{error}</div>}
|
|
@@ -252,8 +263,13 @@ export function TokenTotalPanel({ t }: { t: Translator }): JSX.Element {
|
|
|
252
263
|
{stats && (
|
|
253
264
|
<>
|
|
254
265
|
<StatCards stats={stats} t={t} />
|
|
255
|
-
|
|
256
|
-
|
|
266
|
+
{/* Heatmap and the token trend share ONE row —one card each, left
|
|
267
|
+
and right (热力图左 / 趋势右). The duo grid stretches both cards
|
|
268
|
+
to a common height and folds to one column on a narrow pane. */}
|
|
269
|
+
<div className={css.duo}>
|
|
270
|
+
<Heatmap daily={heatDaily} from={heatWindow.from} to={heatWindow.to} t={t} panelRef={panelRef} />
|
|
271
|
+
<DailyTrend models={trendModels} daily={trendDaily} granularity={stats?.granularity ?? 'day'} t={t} colorForModel={colorForModel} panelRef={panelRef} />
|
|
272
|
+
</div>
|
|
257
273
|
<UsageBreakdown
|
|
258
274
|
models={stats.models}
|
|
259
275
|
providers={stats.providers}
|
|
@@ -261,12 +277,8 @@ export function TokenTotalPanel({ t }: { t: Translator }): JSX.Element {
|
|
|
261
277
|
providerNames={stats.providerNames}
|
|
262
278
|
t={t}
|
|
263
279
|
panelRef={panelRef}
|
|
280
|
+
colorForModel={colorForModel}
|
|
264
281
|
/>
|
|
265
|
-
{stats.to && (
|
|
266
|
-
<div className={css.foot}>
|
|
267
|
-
{t('asOf')} {stats.to}
|
|
268
|
-
</div>
|
|
269
|
-
)}
|
|
270
282
|
</>
|
|
271
283
|
)}
|
|
272
284
|
{!error && stats && isEmptyRange(stats) && (
|
|
@@ -278,6 +290,15 @@ export function TokenTotalPanel({ t }: { t: Translator }): JSX.Element {
|
|
|
278
290
|
|
|
279
291
|
// ── Section 2+3: numeric cards ────────────────────────────────────────────
|
|
280
292
|
|
|
293
|
+
/** "As of" stamp for a loaded range: the fetch clock time when the range
|
|
294
|
+
* reaches today (the data is live up to that refresh), else the day's end —
|
|
295
|
+
* the wire carries only dates, and a past day's data is complete. */
|
|
296
|
+
function asOfStamp(to: string, fetchedAt: number | null): string {
|
|
297
|
+
if (to !== localDay(0)) return `${to} 23:59`
|
|
298
|
+
const d = new Date(fetchedAt ?? Date.now())
|
|
299
|
+
return `${to} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
|
300
|
+
}
|
|
301
|
+
|
|
281
302
|
/** A range is empty only when NOTHING was recorded in it: a pure-cache day
|
|
282
303
|
* (zero input/output but real cache hits) or a failed-call day (requests
|
|
283
304
|
* but no tokens) both carry real usage and must not read as "no data". */
|
|
@@ -286,28 +307,31 @@ function isEmptyRange(stats: TokenTotalRange): boolean {
|
|
|
286
307
|
}
|
|
287
308
|
|
|
288
309
|
function StatCards({ stats, t }: { stats: TokenTotalRange; t: Translator }) {
|
|
289
|
-
|
|
310
|
+
// Each card wears one accent hue (tone 1..6 → --dsw-tone-1..6): the accent
|
|
311
|
+
// drives the left bar, the icon badge tint, and the corner wash, so the
|
|
312
|
+
// overview row reads in colour instead of one gray register.
|
|
313
|
+
const cards: Array<{ icon: typeof Coins; label: string; value: string; hint?: string; sub?: string; tone: number }> = [
|
|
290
314
|
// 总tokens —the provider-inclusive headline (uncached input + output +
|
|
291
315
|
// cache reads/writes): the number a provider dashboard reports. Compact
|
|
292
316
|
// K/M/B on the headline, the exact count underneath.
|
|
293
|
-
{ icon: Coins, label: t('tokens'), value: formatCompact(stats.tokens), hint: t('tokensHint'), sub: `${formatTokens(stats.tokens)} ${t('tokensUnit')}
|
|
317
|
+
{ icon: Coins, label: t('tokens'), value: formatCompact(stats.tokens), hint: t('tokensHint'), sub: `${formatTokens(stats.tokens)} ${t('tokensUnit')}`, tone: 1 },
|
|
294
318
|
// 输入 tokens —uncached prompt tokens the provider billed as new input.
|
|
295
|
-
{ icon: ArrowDownToLine, label: t('inputTokens'), value: formatCompact(stats.inputTokens), sub: `${formatTokens(stats.inputTokens)} ${t('tokensUnit')}
|
|
319
|
+
{ icon: ArrowDownToLine, label: t('inputTokens'), value: formatCompact(stats.inputTokens), sub: `${formatTokens(stats.inputTokens)} ${t('tokensUnit')}`, tone: 2 },
|
|
296
320
|
// 输出 tokens —completion tokens.
|
|
297
|
-
{ icon: ArrowUpFromLine, label: t('outputTokens'), value: formatCompact(stats.outputTokens), sub: `${formatTokens(stats.outputTokens)} ${t('tokensUnit')}
|
|
321
|
+
{ icon: ArrowUpFromLine, label: t('outputTokens'), value: formatCompact(stats.outputTokens), sub: `${formatTokens(stats.outputTokens)} ${t('tokensUnit')}`, tone: 3 },
|
|
298
322
|
// 缓存 tokens —cached prompt tokens (cache reads + cache writes).
|
|
299
|
-
{ icon: Database, label: t('cacheTokens'), value: formatCompact(stats.cacheTokens), sub: `${formatTokens(stats.cacheTokens)} ${t('tokensUnit')}
|
|
323
|
+
{ icon: Database, label: t('cacheTokens'), value: formatCompact(stats.cacheTokens), sub: `${formatTokens(stats.cacheTokens)} ${t('tokensUnit')}`, tone: 4 },
|
|
300
324
|
// 缓存命中率 —cached share of all prompt tokens in the range.
|
|
301
|
-
{ icon: Activity, label: t('cacheRate'), value: cacheRateText(stats.cacheHit, stats.cacheMiss), hint: t('cacheRateHint'), sub: `${formatCompact(stats.cacheHit)} ${t('cachedTokens')}
|
|
325
|
+
{ icon: Activity, label: t('cacheRate'), value: cacheRateText(stats.cacheHit, stats.cacheMiss), hint: t('cacheRateHint'), sub: `${formatCompact(stats.cacheHit)} ${t('cachedTokens')}`, tone: 5 },
|
|
302
326
|
// 请求数量 —provider calls (tokens or not). The failed share sits
|
|
303
327
|
// underneath, and only when there were any: a healthy range stays a
|
|
304
328
|
// single line. Failed calls are a SUBSET of requests, never additive.
|
|
305
|
-
{ icon: MessagesSquare, label: t('requests'), value: String(stats.requests), sub: stats.failures > 0 ? `${t('requestsFailed')} ${stats.failures} · ${formatPercent((stats.failures / Math.max(stats.requests, 1)) * 100)}` : undefined },
|
|
329
|
+
{ icon: MessagesSquare, label: t('requests'), value: String(stats.requests), sub: stats.failures > 0 ? `${t('requestsFailed')} ${stats.failures} · ${formatPercent((stats.failures / Math.max(stats.requests, 1)) * 100)}` : undefined, tone: 6 },
|
|
306
330
|
]
|
|
307
331
|
return (
|
|
308
332
|
<div className={css.cards}>
|
|
309
333
|
{cards.map((c) => (
|
|
310
|
-
<div className={css.card} data-dsh-usage-glass="card" key={c.label} title={c.hint}>
|
|
334
|
+
<div className={clsx(css.card, css[`cardTone${c.tone}`])} data-dsh-usage-glass="card" key={c.label} title={c.hint}>
|
|
311
335
|
<div className={css.cardHead}>
|
|
312
336
|
<c.icon className={css.cardIcon} size={14} strokeWidth={2} aria-hidden="true" />
|
|
313
337
|
<span className={css.cardLabel}>{c.label}</span>
|
|
@@ -315,7 +339,9 @@ function StatCards({ stats, t }: { stats: TokenTotalRange; t: Translator }) {
|
|
|
315
339
|
{c.sub !== undefined ? (
|
|
316
340
|
<div className={clsx(css.cardValue, css.cardModelLines)}>
|
|
317
341
|
<FitText text={c.value} className={css.cardValue} maxSize={22} />
|
|
318
|
-
|
|
342
|
+
{/* The exact-count line is the longest text in the card: fit it
|
|
343
|
+
too (down to 8px) instead of letting it clip. */}
|
|
344
|
+
<FitText text={c.sub} className={css.cardSub} maxSize={11} minSize={8} />
|
|
319
345
|
</div>
|
|
320
346
|
) : (
|
|
321
347
|
<FitText text={c.value} className={css.cardValue} maxSize={22} />
|
|
@@ -327,8 +353,9 @@ function StatCards({ stats, t }: { stats: TokenTotalRange; t: Translator }) {
|
|
|
327
353
|
}
|
|
328
354
|
|
|
329
355
|
// FitText renders `text` on a single line, shrinking the font until it fits
|
|
330
|
-
// the card width (long token numbers never overflow or wrap).
|
|
331
|
-
|
|
356
|
+
// the card width (long token numbers never overflow or wrap). `minSize` is
|
|
357
|
+
// the floor: secondary lines may shrink further than headline values.
|
|
358
|
+
function FitText({ text, className, maxSize, minSize = 11 }: { text: string; className?: string; maxSize: number; minSize?: number }) {
|
|
332
359
|
const ref = useRef<HTMLDivElement>(null)
|
|
333
360
|
const [size, setSize] = useState(maxSize)
|
|
334
361
|
|
|
@@ -338,7 +365,7 @@ function FitText({ text, className, maxSize }: { text: string; className?: strin
|
|
|
338
365
|
const fit = () => {
|
|
339
366
|
let s = maxSize
|
|
340
367
|
el.style.fontSize = `${s}px`
|
|
341
|
-
while (el.scrollWidth > el.clientWidth + 1 && s >
|
|
368
|
+
while (el.scrollWidth > el.clientWidth + 1 && s > minSize) {
|
|
342
369
|
s -= 0.5
|
|
343
370
|
el.style.fontSize = `${s}px`
|
|
344
371
|
}
|
|
@@ -348,7 +375,7 @@ function FitText({ text, className, maxSize }: { text: string; className?: strin
|
|
|
348
375
|
const ro = new ResizeObserver(fit)
|
|
349
376
|
ro.observe(el)
|
|
350
377
|
return () => ro.disconnect()
|
|
351
|
-
}, [text, maxSize])
|
|
378
|
+
}, [text, maxSize, minSize])
|
|
352
379
|
|
|
353
380
|
return (
|
|
354
381
|
<div ref={ref} className={className} style={{ fontSize: size }}>
|
|
@@ -359,13 +386,26 @@ function FitText({ text, className, maxSize }: { text: string; className?: strin
|
|
|
359
386
|
|
|
360
387
|
// ── Section 4: GitHub-style activity heatmap ──────────────────────────────
|
|
361
388
|
|
|
362
|
-
// Cell/gap ratio follows
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
|
|
367
|
-
const
|
|
368
|
-
const
|
|
389
|
+
// Cell/gap ratio follows the reference chart (a GitHub-style green heatmap):
|
|
390
|
+
// roughly a third of the cell in gap, so the tiles read as separate rounded
|
|
391
|
+
// squares instead of a wash. The gap also equals the legend's own swatch
|
|
392
|
+
// spacing, and the fixed rx matches the legend swatch border-radius, so the
|
|
393
|
+
// key renders at exactly the chart's geometry.
|
|
394
|
+
const HEAT_BASE = 12 // cell size at which column trimming starts
|
|
395
|
+
const HEAT_GAP = 4 // breathing gap between cells; mirrors the legend swatch gap
|
|
396
|
+
const HEAT_RX = 2 // cell corner radius; matches the legend swatch (css .heatCell)
|
|
397
|
+
|
|
398
|
+
// ── heatmap axis gutters ──────────────────────────────────────────────────
|
|
399
|
+
// The chart now carries a weekday column on the left and month labels along
|
|
400
|
+
// the bottom (matching the GitHub-style reference). Both are drawn INSIDE the
|
|
401
|
+
// same SVG so a label sits on the exact cell track it names; the geometry
|
|
402
|
+
// solver only ever sees the width left over after these gutters are reserved.
|
|
403
|
+
const HEAT_AXIS_LEFT = 30 // px reserved left of the first cell column (weekday labels)
|
|
404
|
+
const HEAT_AXIS_BOTTOM = 18 // px reserved under the last row (month labels)
|
|
405
|
+
|
|
406
|
+
/** 0 = Monday … 6 = Sunday: row order of the heatmap, and the label keys. */
|
|
407
|
+
const DOW_KEYS = ['dow.mon', 'dow.tue', 'dow.wed', 'dow.thu', 'dow.fri', 'dow.sat', 'dow.sun'] as const
|
|
408
|
+
const MONTH_KEYS = ['month.jan', 'month.feb', 'month.mar', 'month.apr', 'month.may', 'month.jun', 'month.jul', 'month.aug', 'month.sep', 'month.oct', 'month.nov', 'month.dec'] as const
|
|
369
409
|
|
|
370
410
|
/** One heatmap frame's geometry. */
|
|
371
411
|
export interface HeatGeometry {
|
|
@@ -439,7 +479,9 @@ function Heatmap({ daily, from, to, t, panelRef }: { daily: DailyTokenUsage[]; f
|
|
|
439
479
|
const el = wrapRef.current
|
|
440
480
|
if (!el) return
|
|
441
481
|
const update = () => {
|
|
442
|
-
|
|
482
|
+
// Reserve the weekday gutter so the cells solve against the space they
|
|
483
|
+
// actually get; the SVG width below re-adds it.
|
|
484
|
+
const avail = Math.max(1, el.clientWidth - 2 - HEAT_AXIS_LEFT)
|
|
443
485
|
const next = heatGeometry(avail, (indexOfDay(from) + 1) % 7)
|
|
444
486
|
// Only commit when the geometry actually changed —the heatmap SVG
|
|
445
487
|
// width follows `geom.size`, and committing an identical value on
|
|
@@ -463,12 +505,38 @@ function Heatmap({ daily, from, to, t, panelRef }: { daily: DailyTokenUsage[]; f
|
|
|
463
505
|
// partial first week, so the SVG width follows it directly.
|
|
464
506
|
const weeks = geom.totalWeeks
|
|
465
507
|
|
|
508
|
+
// Month labels: name a column only where its month differs from the
|
|
509
|
+
// previous column's, so a run of weeks in one month gets a single label
|
|
510
|
+
// sitting over its first week (the GitHub axis convention). The reading is
|
|
511
|
+
// taken from the column's own top cell (row 0), which is the earliest day
|
|
512
|
+
// that column holds.
|
|
513
|
+
// The window is a whole number of weeks, so it can wrap back into the month
|
|
514
|
+
// it started in; that trailing label would sit in the last column and be
|
|
515
|
+
// clipped by the wrap's overflow. Drop it — it repeats the first label.
|
|
516
|
+
const monthLabels: Array<{ col: number; month: number }> = []
|
|
517
|
+
let lastMonth = -1
|
|
518
|
+
for (let i = 0; i < days.length; i++) {
|
|
519
|
+
const col = Math.floor((i + startOffset) / 7)
|
|
520
|
+
if ((i + startOffset) % 7 !== 0) continue // only the column's first day
|
|
521
|
+
const m = new Date(days[i]! + 'T00:00:00').getMonth()
|
|
522
|
+
if (m !== lastMonth) {
|
|
523
|
+
monthLabels.push({ col, month: m })
|
|
524
|
+
lastMonth = m
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
while (monthLabels.length > 1 && monthLabels[0]!.month === monthLabels[monthLabels.length - 1]!.month) monthLabels.pop()
|
|
528
|
+
|
|
529
|
+
const cellW = geom.size + HEAT_GAP
|
|
530
|
+
const svgW = HEAT_AXIS_LEFT + weeks * cellW + HEAT_GAP
|
|
531
|
+
const svgH = rows * cellW + HEAT_GAP + HEAT_AXIS_BOTTOM
|
|
532
|
+
|
|
466
533
|
return (
|
|
467
534
|
<section className={css.section}>
|
|
468
535
|
<div className={css.sectionHead}>
|
|
469
536
|
<h3 className={css.sectionTitle}>{t('heatmap')}</h3>
|
|
470
537
|
<div className={css.heatLegend}>
|
|
471
538
|
<span>{t('heatLess')}</span>
|
|
539
|
+
<i className={clsx(css.heatCell, css.heatLevel0)} style={{ width: geom.size, height: geom.size }} />
|
|
472
540
|
<i className={clsx(css.heatCell, css.heatLevel1)} style={{ width: geom.size, height: geom.size }} />
|
|
473
541
|
<i className={clsx(css.heatCell, css.heatLevel2)} style={{ width: geom.size, height: geom.size }} />
|
|
474
542
|
<i className={clsx(css.heatCell, css.heatLevel3)} style={{ width: geom.size, height: geom.size }} />
|
|
@@ -478,15 +546,28 @@ function Heatmap({ daily, from, to, t, panelRef }: { daily: DailyTokenUsage[]; f
|
|
|
478
546
|
</div>
|
|
479
547
|
</div>
|
|
480
548
|
<div className={css.heatWrap} ref={wrapRef}>
|
|
481
|
-
<svg className={css.heatmap} width={
|
|
549
|
+
<svg className={css.heatmap} width={svgW} height={svgH} role="img" aria-label={t('heatmap')}>
|
|
550
|
+
{/* Weekday gutter: every row is named Mon…Sun so a bar reads by day. */}
|
|
551
|
+
{DOW_KEYS.map((key, row) => (
|
|
552
|
+
<text
|
|
553
|
+
key={key}
|
|
554
|
+
className={css.heatAxis}
|
|
555
|
+
x={HEAT_AXIS_LEFT - 8}
|
|
556
|
+
y={HEAT_GAP + row * cellW + geom.size / 2}
|
|
557
|
+
textAnchor="end"
|
|
558
|
+
dominantBaseline="central"
|
|
559
|
+
>
|
|
560
|
+
{t(key)}
|
|
561
|
+
</text>
|
|
562
|
+
))}
|
|
482
563
|
{days.map((day, i) => {
|
|
483
564
|
const col = Math.floor((i + startOffset) / 7)
|
|
484
565
|
const row = (i + startOffset) % 7
|
|
485
566
|
const rec = byDay.get(day)
|
|
486
567
|
const tokens = rec?.total ?? 0
|
|
487
568
|
const level = tokens === 0 ? 0 : 1 + Math.floor((tokens / max) * 4)
|
|
488
|
-
const x = HEAT_GAP + col *
|
|
489
|
-
const y = HEAT_GAP + row *
|
|
569
|
+
const x = HEAT_AXIS_LEFT + HEAT_GAP + col * cellW
|
|
570
|
+
const y = HEAT_GAP + row * cellW
|
|
490
571
|
return (
|
|
491
572
|
<rect
|
|
492
573
|
key={day}
|
|
@@ -505,6 +586,19 @@ function Heatmap({ daily, from, to, t, panelRef }: { daily: DailyTokenUsage[]; f
|
|
|
505
586
|
/>
|
|
506
587
|
)
|
|
507
588
|
})}
|
|
589
|
+
{/* Month gutter: one label per month, over the column it starts in. */}
|
|
590
|
+
{monthLabels.map(({ col, month }) => (
|
|
591
|
+
<text
|
|
592
|
+
key={`${col}-${month}`}
|
|
593
|
+
className={css.heatAxis}
|
|
594
|
+
x={HEAT_AXIS_LEFT + HEAT_GAP + col * cellW}
|
|
595
|
+
y={rows * cellW + HEAT_GAP + 3}
|
|
596
|
+
textAnchor="start"
|
|
597
|
+
dominantBaseline="hanging"
|
|
598
|
+
>
|
|
599
|
+
{t(MONTH_KEYS[month]!)}
|
|
600
|
+
</text>
|
|
601
|
+
))}
|
|
508
602
|
</svg>
|
|
509
603
|
{tip && (
|
|
510
604
|
<ChartTip anchor={tip.anchor} panelRef={panelRef}>
|
|
@@ -575,10 +669,34 @@ function DailyTrend({ models, daily, granularity, t, colorForModel, panelRef }:
|
|
|
575
669
|
const plotWUsed = view.avail
|
|
576
670
|
const maxTotal = Math.max(1, ...visible.map((d) => d.total))
|
|
577
671
|
const ticks = niceTicks(maxTotal, 4)
|
|
578
|
-
// X
|
|
579
|
-
// day
|
|
580
|
-
//
|
|
581
|
-
|
|
672
|
+
// What the X axis reads as depends on the WINDOW, not just the granularity:
|
|
673
|
+
// a multi-day hourly span (3天) is a DATE axis — one short date per day, at
|
|
674
|
+
// the day's first bucket, times would be noise across three day-clusters;
|
|
675
|
+
// a single-day hourly span (今日) is a CLOCK axis — the date is constant and
|
|
676
|
+
// would only repeat. Daily ranges stay date labels.
|
|
677
|
+
const dayCount = new Set(visible.map((d) => d.day.slice(0, 10))).size
|
|
678
|
+
const multiDayHourly = granularity === 'hour' && dayCount > 1
|
|
679
|
+
const axisMode: 'day' | 'hour' = multiDayHourly ? 'day' : granularity
|
|
680
|
+
// Label buckets: day starts on multi-day hourly, every bucket otherwise.
|
|
681
|
+
const labelCandidates: number[] = []
|
|
682
|
+
for (let i = 0; i < n; i++) {
|
|
683
|
+
if (!multiDayHourly || i === 0 || visible[i].day.slice(0, 10) !== visible[i - 1].day.slice(0, 10)) labelCandidates.push(i)
|
|
684
|
+
}
|
|
685
|
+
// Candidates are then thinned by POSITION, walking from the right edge: the
|
|
686
|
+
// rightmost candidate always keeps its label, an earlier one is kept only
|
|
687
|
+
// when it clears the previously kept label by the label width. (A fixed
|
|
688
|
+
// "every Nth" count plus a forced last label drew two labels on top of
|
|
689
|
+
// each other — the "18:00/23:00" overlap.) Both label shapes are ~30px.
|
|
690
|
+
const labelMinGap = 42
|
|
691
|
+
const labeledIdx = new Set<number>()
|
|
692
|
+
let lastLabelX = Number.POSITIVE_INFINITY
|
|
693
|
+
for (let k = labelCandidates.length - 1; k >= 0; k--) {
|
|
694
|
+
const x = padL + barHalf + labelCandidates[k] * step
|
|
695
|
+
if (lastLabelX - x >= labelMinGap) {
|
|
696
|
+
labeledIdx.add(labelCandidates[k])
|
|
697
|
+
lastLabelX = x
|
|
698
|
+
}
|
|
699
|
+
}
|
|
582
700
|
|
|
583
701
|
const trendPoints: Array<{ x: number; y: number }> = []
|
|
584
702
|
const trendPointByDay = new Map<string, { x: number; y: number }>()
|
|
@@ -603,7 +721,10 @@ function DailyTrend({ models, daily, granularity, t, colorForModel, panelRef }:
|
|
|
603
721
|
<section className={css.section}>
|
|
604
722
|
<div className={css.sectionHead}>
|
|
605
723
|
<h3 className={css.sectionTitle}>{t(granularity === 'hour' ? 'hourlyTrend' : 'dailyTrend')}</h3>
|
|
606
|
-
|
|
724
|
+
<div className={css.headMeta}>
|
|
725
|
+
{trendCapped && <span className={css.trendNote}>{t('trendLimited').replace('{n}', String(view.trimN))}</span>}
|
|
726
|
+
<span className={css.legendItem}><i className={css.legendTrend} aria-hidden="true" />{t('cacheHitRate')}</span>
|
|
727
|
+
</div>
|
|
607
728
|
</div>
|
|
608
729
|
<div className={css.chartWrap} ref={wrapRef}>
|
|
609
730
|
<svg className={css.chart} width="100%" height={H} viewBox={`0 0 ${plotWUsed} ${H}`} onMouseLeave={() => { setTip(null); setHover(null) }}>
|
|
@@ -655,8 +776,8 @@ function DailyTrend({ models, daily, granularity, t, colorForModel, panelRef }:
|
|
|
655
776
|
}}
|
|
656
777
|
onMouseLeave={() => setTip(null)}
|
|
657
778
|
/>
|
|
658
|
-
{(i
|
|
659
|
-
<text className={css.axis} x={padL + barHalf + i * step} y={H - 8} textAnchor="middle">{bucketAxisLabel(d.day,
|
|
779
|
+
{labeledIdx.has(i) && (
|
|
780
|
+
<text className={css.axis} x={padL + barHalf + i * step} y={H - 8} textAnchor="middle">{bucketAxisLabel(d.day, axisMode)}</text>
|
|
660
781
|
)}
|
|
661
782
|
</g>
|
|
662
783
|
)
|
|
@@ -750,18 +871,16 @@ function providerColor(index: number): string {
|
|
|
750
871
|
return index < 5 ? `var(--dsw-provider-${index + 1})` : 'var(--dsw-provider-other)'
|
|
751
872
|
}
|
|
752
873
|
|
|
753
|
-
|
|
754
|
-
function modelColor(index: number): string {
|
|
755
|
-
return index < TOP_MODELS ? `var(--dsw-chart-${index + 1})` : OTHER_COLOR
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
function UsageBreakdown({ models, providers, total, providerNames, t, panelRef }: {
|
|
874
|
+
function UsageBreakdown({ models, providers, total, providerNames, t, panelRef, colorForModel }: {
|
|
759
875
|
models: ModelTokenUsage[]
|
|
760
876
|
providers: ProviderTokenUsage[]
|
|
761
877
|
total: number
|
|
762
878
|
providerNames?: Record<string, string>
|
|
763
879
|
t: Translator
|
|
764
880
|
panelRef: RefObject<HTMLDivElement | null>
|
|
881
|
+
/** The trend's model palette (rank in `models`), so a model wears the same
|
|
882
|
+
* hue in the bars, the tooltips and both rankings. */
|
|
883
|
+
colorForModel: (model: string) => string
|
|
765
884
|
}) {
|
|
766
885
|
const [tip, setTip] = useState<RankTip | null>(null)
|
|
767
886
|
|
|
@@ -776,6 +895,15 @@ function UsageBreakdown({ models, providers, total, providerNames, t, panelRef }
|
|
|
776
895
|
|
|
777
896
|
const shareText = (n: number): string => (total > 0 ? formatPercent((n / total) * 100) : '—')
|
|
778
897
|
|
|
898
|
+
// One provider, one hue EVERYWHERE: the palette indexes the provider
|
|
899
|
+
// ranking, so a provider keeps its colour as a card row AND as another
|
|
900
|
+
// row's breakdown child. Per-child ranks used to renumber the same
|
|
901
|
+
// provider differently in every row.
|
|
902
|
+
const providerColorOf = useCallback((provider: string): string => {
|
|
903
|
+
const slot = providers.findIndex((p) => p.provider === provider)
|
|
904
|
+
return slot < 0 ? 'var(--dsw-provider-other)' : providerColor(slot)
|
|
905
|
+
}, [providers])
|
|
906
|
+
|
|
779
907
|
const modelRows = useMemo<BreakdownRow[]>(() => {
|
|
780
908
|
const byName = new Map<string, {
|
|
781
909
|
tokens: number
|
|
@@ -801,36 +929,36 @@ function UsageBreakdown({ models, providers, total, providerNames, t, panelRef }
|
|
|
801
929
|
return [...byName.entries()]
|
|
802
930
|
.sort((a, b) => b[1].tokens - a[1].tokens)
|
|
803
931
|
.slice(0, TOP_BREAKDOWN)
|
|
804
|
-
.map(([name, acc]
|
|
932
|
+
.map(([name, acc]) => {
|
|
805
933
|
const split: RankChild[] = [...acc.split.entries()]
|
|
806
934
|
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
807
|
-
.map(([provider, tokens]
|
|
935
|
+
.map(([provider, tokens]) => ({ key: provider, name: providerLabel(provider), tokens, color: providerColorOf(provider) }))
|
|
808
936
|
// A payload from before the split still names its sources; list them
|
|
809
937
|
// without a volume rather than inventing a number.
|
|
810
938
|
const known = new Set(split.map((child) => child.key))
|
|
811
939
|
const named: RankChild[] = [...acc.sources]
|
|
812
940
|
.filter((provider) => !known.has(provider))
|
|
813
941
|
.sort()
|
|
814
|
-
.map((provider
|
|
942
|
+
.map((provider) => ({ key: provider, name: providerLabel(provider), tokens: null, color: providerColorOf(provider) }))
|
|
815
943
|
return {
|
|
816
944
|
key: name,
|
|
817
945
|
name,
|
|
818
946
|
tokens: acc.tokens,
|
|
819
947
|
cacheHit: acc.cacheHit,
|
|
820
948
|
cacheMiss: acc.cacheMiss,
|
|
821
|
-
color:
|
|
949
|
+
color: colorForModel(name),
|
|
822
950
|
breakdownLabel: t('byProvider'),
|
|
823
951
|
breakdown: [...split, ...named],
|
|
824
952
|
}
|
|
825
953
|
})
|
|
826
|
-
}, [models, providerLabel, t])
|
|
954
|
+
}, [models, providerLabel, t, colorForModel, providerColorOf])
|
|
827
955
|
|
|
828
956
|
const providerRows = useMemo<BreakdownRow[]>(() => {
|
|
829
957
|
return [...providers]
|
|
830
958
|
.filter((p) => p.tokens > 0)
|
|
831
959
|
.sort((a, b) => b.tokens - a.tokens)
|
|
832
960
|
.slice(0, TOP_BREAKDOWN)
|
|
833
|
-
.map((p
|
|
961
|
+
.map((p) => {
|
|
834
962
|
const label = providerLabel(p.provider)
|
|
835
963
|
return {
|
|
836
964
|
key: p.provider,
|
|
@@ -840,14 +968,14 @@ function UsageBreakdown({ models, providers, total, providerNames, t, panelRef }
|
|
|
840
968
|
tokens: p.tokens,
|
|
841
969
|
cacheHit: p.cacheHit,
|
|
842
970
|
cacheMiss: p.cacheMiss,
|
|
843
|
-
color:
|
|
971
|
+
color: providerColorOf(p.provider),
|
|
844
972
|
breakdownLabel: t('byModel'),
|
|
845
973
|
breakdown: [...(p.modelTokens ?? [])]
|
|
846
974
|
.sort((a, b) => b.tokens - a.tokens || a.model.localeCompare(b.model))
|
|
847
|
-
.map((m
|
|
975
|
+
.map((m) => ({ key: m.model, name: m.model, tokens: m.tokens, color: colorForModel(m.model) })),
|
|
848
976
|
}
|
|
849
977
|
})
|
|
850
|
-
}, [providers, providerLabel, t])
|
|
978
|
+
}, [providers, providerLabel, t, colorForModel, providerColorOf])
|
|
851
979
|
|
|
852
980
|
return (
|
|
853
981
|
<section className={css.section}>
|
|
@@ -929,6 +1057,13 @@ function RankCard({ title, rows, total, t, onTip }: {
|
|
|
929
1057
|
{/* The share keeps its own line: the hover detail never
|
|
930
1058
|
displaces it (悬浮详情不占用百分比的位置). */}
|
|
931
1059
|
<span className={css.modelProvider}>{share(r.tokens)}</span>
|
|
1060
|
+
{/* Share bar: the row's slice of the total, in its own colour. */}
|
|
1061
|
+
<div className={css.rowTrack} aria-hidden="true">
|
|
1062
|
+
<i
|
|
1063
|
+
className={css.rowFill}
|
|
1064
|
+
style={{ width: total > 0 ? `${Math.min(100, (r.tokens / total) * 100)}%` : '0%', background: r.color }}
|
|
1065
|
+
/>
|
|
1066
|
+
</div>
|
|
932
1067
|
</div>
|
|
933
1068
|
<div className={css.modelValues}>
|
|
934
1069
|
<span className={css.modelTokens}>{formatCompact(r.tokens)}</span>
|
package/src/client/format.ts
CHANGED
|
@@ -68,13 +68,15 @@ export function shortDay(day: string): string {
|
|
|
68
68
|
return `${Number(m)}/${Number(d)}`
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
/** X-axis label for a trend bucket key.
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
71
|
+
/** X-axis label for a trend bucket key. `mode` is what the axis READS AS,
|
|
72
|
+
* chosen by the caller from the window it covers:
|
|
73
|
+
* - 'hour' — a single-day hourly span (今日): the date is constant, so the
|
|
74
|
+
* axis shows the bare clock ("8:00");
|
|
75
|
+
* - 'day' — anything spanning several days (3天 hourly, any daily range):
|
|
76
|
+
* the axis shows the short date ("10/22"); on multi-day hourly spans one
|
|
77
|
+
* label marks each day. The tooltip still carries the full date + hour. */
|
|
78
|
+
export function bucketAxisLabel(key: string, mode: 'day' | 'hour'): string {
|
|
79
|
+
return mode === 'hour' ? `${Number(key.slice(11, 13))}:00` : shortDay(key)
|
|
78
80
|
}
|
|
79
81
|
|
|
80
82
|
/** Tooltip title for a trend bucket key: the date in day mode, date + hour
|
package/src/client/locales.ts
CHANGED
|
@@ -43,6 +43,25 @@ export type TokenTotalKey =
|
|
|
43
43
|
| 'heatmap'
|
|
44
44
|
| 'heatLess'
|
|
45
45
|
| 'heatMore'
|
|
46
|
+
| 'dow.mon'
|
|
47
|
+
| 'dow.tue'
|
|
48
|
+
| 'dow.wed'
|
|
49
|
+
| 'dow.thu'
|
|
50
|
+
| 'dow.fri'
|
|
51
|
+
| 'dow.sat'
|
|
52
|
+
| 'dow.sun'
|
|
53
|
+
| 'month.jan'
|
|
54
|
+
| 'month.feb'
|
|
55
|
+
| 'month.mar'
|
|
56
|
+
| 'month.apr'
|
|
57
|
+
| 'month.may'
|
|
58
|
+
| 'month.jun'
|
|
59
|
+
| 'month.jul'
|
|
60
|
+
| 'month.aug'
|
|
61
|
+
| 'month.sep'
|
|
62
|
+
| 'month.oct'
|
|
63
|
+
| 'month.nov'
|
|
64
|
+
| 'month.dec'
|
|
46
65
|
| 'dailyTrend'
|
|
47
66
|
| 'hourlyTrend'
|
|
48
67
|
| 'trendLimited'
|
|
@@ -112,6 +131,25 @@ export const en: Record<TokenTotalKey, string> = {
|
|
|
112
131
|
heatmap: 'Activity heatmap',
|
|
113
132
|
heatLess: 'Less',
|
|
114
133
|
heatMore: 'More',
|
|
134
|
+
'dow.mon': 'Mon',
|
|
135
|
+
'dow.tue': 'Tue',
|
|
136
|
+
'dow.wed': 'Wed',
|
|
137
|
+
'dow.thu': 'Thu',
|
|
138
|
+
'dow.fri': 'Fri',
|
|
139
|
+
'dow.sat': 'Sat',
|
|
140
|
+
'dow.sun': 'Sun',
|
|
141
|
+
'month.jan': 'Jan',
|
|
142
|
+
'month.feb': 'Feb',
|
|
143
|
+
'month.mar': 'Mar',
|
|
144
|
+
'month.apr': 'Apr',
|
|
145
|
+
'month.may': 'May',
|
|
146
|
+
'month.jun': 'Jun',
|
|
147
|
+
'month.jul': 'Jul',
|
|
148
|
+
'month.aug': 'Aug',
|
|
149
|
+
'month.sep': 'Sep',
|
|
150
|
+
'month.oct': 'Oct',
|
|
151
|
+
'month.nov': 'Nov',
|
|
152
|
+
'month.dec': 'Dec',
|
|
115
153
|
dailyTrend: 'Daily token trend',
|
|
116
154
|
hourlyTrend: 'Hourly token trend',
|
|
117
155
|
trendLimited: 'Showing the latest {n} days',
|
|
@@ -182,6 +220,25 @@ export const zh: Record<TokenTotalKey, string> = {
|
|
|
182
220
|
heatmap: '活跃热力图',
|
|
183
221
|
heatLess: '较少',
|
|
184
222
|
heatMore: '较多',
|
|
223
|
+
'dow.mon': '周一',
|
|
224
|
+
'dow.tue': '周二',
|
|
225
|
+
'dow.wed': '周三',
|
|
226
|
+
'dow.thu': '周四',
|
|
227
|
+
'dow.fri': '周五',
|
|
228
|
+
'dow.sat': '周六',
|
|
229
|
+
'dow.sun': '周日',
|
|
230
|
+
'month.jan': '1月',
|
|
231
|
+
'month.feb': '2月',
|
|
232
|
+
'month.mar': '3月',
|
|
233
|
+
'month.apr': '4月',
|
|
234
|
+
'month.may': '5月',
|
|
235
|
+
'month.jun': '6月',
|
|
236
|
+
'month.jul': '7月',
|
|
237
|
+
'month.aug': '8月',
|
|
238
|
+
'month.sep': '9月',
|
|
239
|
+
'month.oct': '10月',
|
|
240
|
+
'month.nov': '11月',
|
|
241
|
+
'month.dec': '12月',
|
|
185
242
|
dailyTrend: '按天 Token 趋势',
|
|
186
243
|
hourlyTrend: '按小时 Token 趋势',
|
|
187
244
|
trendLimited: '仅显示最近 {n} 天',
|
|
@@ -252,6 +309,25 @@ export const zhTW: Record<TokenTotalKey, string> = {
|
|
|
252
309
|
heatmap: '活躍熱力圖',
|
|
253
310
|
heatLess: '較少',
|
|
254
311
|
heatMore: '較多',
|
|
312
|
+
'dow.mon': '週一',
|
|
313
|
+
'dow.tue': '週二',
|
|
314
|
+
'dow.wed': '週三',
|
|
315
|
+
'dow.thu': '週四',
|
|
316
|
+
'dow.fri': '週五',
|
|
317
|
+
'dow.sat': '週六',
|
|
318
|
+
'dow.sun': '週日',
|
|
319
|
+
'month.jan': '1月',
|
|
320
|
+
'month.feb': '2月',
|
|
321
|
+
'month.mar': '3月',
|
|
322
|
+
'month.apr': '4月',
|
|
323
|
+
'month.may': '5月',
|
|
324
|
+
'month.jun': '6月',
|
|
325
|
+
'month.jul': '7月',
|
|
326
|
+
'month.aug': '8月',
|
|
327
|
+
'month.sep': '9月',
|
|
328
|
+
'month.oct': '10月',
|
|
329
|
+
'month.nov': '11月',
|
|
330
|
+
'month.dec': '12月',
|
|
255
331
|
dailyTrend: '每日 Token 趨勢',
|
|
256
332
|
hourlyTrend: '每小時 Token 趨勢',
|
|
257
333
|
trendLimited: '僅顯示最近 {n} 天',
|