dsh-token-total 1.0.3 → 1.0.5
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/README.md +16 -2
- package/lib/client.js +1318 -176
- package/lib/index.js +434 -4
- package/lib/types/client/SessionUsageView.d.ts +14 -0
- package/lib/types/client/api.d.ts +3 -1
- package/lib/types/client/format.d.ts +9 -5
- package/lib/types/client/index.d.ts +2 -0
- package/lib/types/client/locales.d.ts +1 -1
- package/lib/types/client/session-usage-locales.d.ts +62 -0
- package/lib/types/client/use-session-detail.d.ts +8 -0
- package/lib/types/routes.d.ts +2 -2
- package/lib/types/session-detail.d.ts +3 -0
- package/lib/types/wire.d.ts +55 -0
- package/package.json +1 -1
- package/src/client/SessionUsageView.module.css +117 -0
- package/src/client/SessionUsageView.tsx +217 -0
- package/src/client/TokenTotalPanel.module.css +259 -99
- package/src/client/TokenTotalPanel.tsx +210 -63
- package/src/client/api.ts +8 -2
- package/src/client/format.ts +13 -9
- package/src/client/index.tsx +20 -1
- package/src/client/locales.ts +76 -0
- package/src/client/session-usage-locales.ts +50 -0
- package/src/client/use-session-detail.ts +50 -0
- package/src/query.ts +53 -2
- package/src/routes.ts +11 -2
- package/src/session-detail.ts +182 -0
- package/src/wire.ts +56 -0
|
@@ -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,40 @@ 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,
|
|
674
|
+
// centred over that day's bar cluster, times would be noise across three
|
|
675
|
+
// day-clusters; a single-day hourly span (今日) is a CLOCK axis — the date is
|
|
676
|
+
// constant and 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 groups: on a multi-day hourly axis each DAY is one group whose label
|
|
681
|
+
// sits at the MIDDLE of that day's bars (centred on the day, not left-
|
|
682
|
+
// aligned to its first bar); otherwise every bucket is its own group and
|
|
683
|
+
// the label sits on its bar.
|
|
684
|
+
const labelGroups: Array<{ start: number; end: number }> = []
|
|
685
|
+
for (let i = 0; i < n; i++) {
|
|
686
|
+
const last = labelGroups[labelGroups.length - 1]
|
|
687
|
+
if (multiDayHourly && last && visible[last.end].day.slice(0, 10) === visible[i].day.slice(0, 10)) last.end = i
|
|
688
|
+
else labelGroups.push({ start: i, end: i })
|
|
689
|
+
}
|
|
690
|
+
// Groups are then thinned by POSITION, walking from the right edge: the
|
|
691
|
+
// rightmost group always keeps its label, an earlier one is kept only when
|
|
692
|
+
// it clears the previously kept label by the label width. (A fixed "every
|
|
693
|
+
// Nth" count plus a forced last label drew two labels on top of each other
|
|
694
|
+
// — the "18:00/23:00" overlap.) Both label shapes are ~30px.
|
|
695
|
+
const labelMinGap = 42
|
|
696
|
+
const labeledGroups = new Set<number>()
|
|
697
|
+
let lastLabelX = Number.POSITIVE_INFINITY
|
|
698
|
+
for (let k = labelGroups.length - 1; k >= 0; k--) {
|
|
699
|
+
const g = labelGroups[k]!
|
|
700
|
+
const x = padL + barHalf + ((g.start + g.end) / 2) * step
|
|
701
|
+
if (lastLabelX - x >= labelMinGap) {
|
|
702
|
+
labeledGroups.add(k)
|
|
703
|
+
lastLabelX = x
|
|
704
|
+
}
|
|
705
|
+
}
|
|
582
706
|
|
|
583
707
|
const trendPoints: Array<{ x: number; y: number }> = []
|
|
584
708
|
const trendPointByDay = new Map<string, { x: number; y: number }>()
|
|
@@ -603,7 +727,10 @@ function DailyTrend({ models, daily, granularity, t, colorForModel, panelRef }:
|
|
|
603
727
|
<section className={css.section}>
|
|
604
728
|
<div className={css.sectionHead}>
|
|
605
729
|
<h3 className={css.sectionTitle}>{t(granularity === 'hour' ? 'hourlyTrend' : 'dailyTrend')}</h3>
|
|
606
|
-
|
|
730
|
+
<div className={css.headMeta}>
|
|
731
|
+
{trendCapped && <span className={css.trendNote}>{t('trendLimited').replace('{n}', String(view.trimN))}</span>}
|
|
732
|
+
<span className={css.legendItem}><i className={css.legendTrend} aria-hidden="true" />{t('cacheHitRate')}</span>
|
|
733
|
+
</div>
|
|
607
734
|
</div>
|
|
608
735
|
<div className={css.chartWrap} ref={wrapRef}>
|
|
609
736
|
<svg className={css.chart} width="100%" height={H} viewBox={`0 0 ${plotWUsed} ${H}`} onMouseLeave={() => { setTip(null); setHover(null) }}>
|
|
@@ -655,12 +782,18 @@ function DailyTrend({ models, daily, granularity, t, colorForModel, panelRef }:
|
|
|
655
782
|
}}
|
|
656
783
|
onMouseLeave={() => setTip(null)}
|
|
657
784
|
/>
|
|
658
|
-
{(i % labelEvery === 0 || i === n - 1) && (
|
|
659
|
-
<text className={css.axis} x={padL + barHalf + i * step} y={H - 8} textAnchor="middle">{bucketAxisLabel(d.day, granularity)}</text>
|
|
660
|
-
)}
|
|
661
785
|
</g>
|
|
662
786
|
)
|
|
663
787
|
})}
|
|
788
|
+
{labelGroups.map((g, k) => labeledGroups.has(k) && (
|
|
789
|
+
<text
|
|
790
|
+
key={`axis-${k}`}
|
|
791
|
+
className={css.axis}
|
|
792
|
+
x={padL + barHalf + ((g.start + g.end) / 2) * step}
|
|
793
|
+
y={H - 8}
|
|
794
|
+
textAnchor="middle"
|
|
795
|
+
>{bucketAxisLabel(visible[g.start].day, axisMode)}</text>
|
|
796
|
+
))}
|
|
664
797
|
<path className={css.trend} d={trendPath} fill="none" strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
|
|
665
798
|
{trendTipPt && (
|
|
666
799
|
<circle className={css.trendDot} cx={trendTipPt.x} cy={trendTipPt.y} r={4} />
|
|
@@ -750,18 +883,16 @@ function providerColor(index: number): string {
|
|
|
750
883
|
return index < 5 ? `var(--dsw-provider-${index + 1})` : 'var(--dsw-provider-other)'
|
|
751
884
|
}
|
|
752
885
|
|
|
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 }: {
|
|
886
|
+
function UsageBreakdown({ models, providers, total, providerNames, t, panelRef, colorForModel }: {
|
|
759
887
|
models: ModelTokenUsage[]
|
|
760
888
|
providers: ProviderTokenUsage[]
|
|
761
889
|
total: number
|
|
762
890
|
providerNames?: Record<string, string>
|
|
763
891
|
t: Translator
|
|
764
892
|
panelRef: RefObject<HTMLDivElement | null>
|
|
893
|
+
/** The trend's model palette (rank in `models`), so a model wears the same
|
|
894
|
+
* hue in the bars, the tooltips and both rankings. */
|
|
895
|
+
colorForModel: (model: string) => string
|
|
765
896
|
}) {
|
|
766
897
|
const [tip, setTip] = useState<RankTip | null>(null)
|
|
767
898
|
|
|
@@ -776,6 +907,15 @@ function UsageBreakdown({ models, providers, total, providerNames, t, panelRef }
|
|
|
776
907
|
|
|
777
908
|
const shareText = (n: number): string => (total > 0 ? formatPercent((n / total) * 100) : '—')
|
|
778
909
|
|
|
910
|
+
// One provider, one hue EVERYWHERE: the palette indexes the provider
|
|
911
|
+
// ranking, so a provider keeps its colour as a card row AND as another
|
|
912
|
+
// row's breakdown child. Per-child ranks used to renumber the same
|
|
913
|
+
// provider differently in every row.
|
|
914
|
+
const providerColorOf = useCallback((provider: string): string => {
|
|
915
|
+
const slot = providers.findIndex((p) => p.provider === provider)
|
|
916
|
+
return slot < 0 ? 'var(--dsw-provider-other)' : providerColor(slot)
|
|
917
|
+
}, [providers])
|
|
918
|
+
|
|
779
919
|
const modelRows = useMemo<BreakdownRow[]>(() => {
|
|
780
920
|
const byName = new Map<string, {
|
|
781
921
|
tokens: number
|
|
@@ -801,36 +941,36 @@ function UsageBreakdown({ models, providers, total, providerNames, t, panelRef }
|
|
|
801
941
|
return [...byName.entries()]
|
|
802
942
|
.sort((a, b) => b[1].tokens - a[1].tokens)
|
|
803
943
|
.slice(0, TOP_BREAKDOWN)
|
|
804
|
-
.map(([name, acc]
|
|
944
|
+
.map(([name, acc]) => {
|
|
805
945
|
const split: RankChild[] = [...acc.split.entries()]
|
|
806
946
|
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
807
|
-
.map(([provider, tokens]
|
|
947
|
+
.map(([provider, tokens]) => ({ key: provider, name: providerLabel(provider), tokens, color: providerColorOf(provider) }))
|
|
808
948
|
// A payload from before the split still names its sources; list them
|
|
809
949
|
// without a volume rather than inventing a number.
|
|
810
950
|
const known = new Set(split.map((child) => child.key))
|
|
811
951
|
const named: RankChild[] = [...acc.sources]
|
|
812
952
|
.filter((provider) => !known.has(provider))
|
|
813
953
|
.sort()
|
|
814
|
-
.map((provider
|
|
954
|
+
.map((provider) => ({ key: provider, name: providerLabel(provider), tokens: null, color: providerColorOf(provider) }))
|
|
815
955
|
return {
|
|
816
956
|
key: name,
|
|
817
957
|
name,
|
|
818
958
|
tokens: acc.tokens,
|
|
819
959
|
cacheHit: acc.cacheHit,
|
|
820
960
|
cacheMiss: acc.cacheMiss,
|
|
821
|
-
color:
|
|
961
|
+
color: colorForModel(name),
|
|
822
962
|
breakdownLabel: t('byProvider'),
|
|
823
963
|
breakdown: [...split, ...named],
|
|
824
964
|
}
|
|
825
965
|
})
|
|
826
|
-
}, [models, providerLabel, t])
|
|
966
|
+
}, [models, providerLabel, t, colorForModel, providerColorOf])
|
|
827
967
|
|
|
828
968
|
const providerRows = useMemo<BreakdownRow[]>(() => {
|
|
829
969
|
return [...providers]
|
|
830
970
|
.filter((p) => p.tokens > 0)
|
|
831
971
|
.sort((a, b) => b.tokens - a.tokens)
|
|
832
972
|
.slice(0, TOP_BREAKDOWN)
|
|
833
|
-
.map((p
|
|
973
|
+
.map((p) => {
|
|
834
974
|
const label = providerLabel(p.provider)
|
|
835
975
|
return {
|
|
836
976
|
key: p.provider,
|
|
@@ -840,14 +980,14 @@ function UsageBreakdown({ models, providers, total, providerNames, t, panelRef }
|
|
|
840
980
|
tokens: p.tokens,
|
|
841
981
|
cacheHit: p.cacheHit,
|
|
842
982
|
cacheMiss: p.cacheMiss,
|
|
843
|
-
color:
|
|
983
|
+
color: providerColorOf(p.provider),
|
|
844
984
|
breakdownLabel: t('byModel'),
|
|
845
985
|
breakdown: [...(p.modelTokens ?? [])]
|
|
846
986
|
.sort((a, b) => b.tokens - a.tokens || a.model.localeCompare(b.model))
|
|
847
|
-
.map((m
|
|
987
|
+
.map((m) => ({ key: m.model, name: m.model, tokens: m.tokens, color: colorForModel(m.model) })),
|
|
848
988
|
}
|
|
849
989
|
})
|
|
850
|
-
}, [providers, providerLabel, t])
|
|
990
|
+
}, [providers, providerLabel, t, colorForModel, providerColorOf])
|
|
851
991
|
|
|
852
992
|
return (
|
|
853
993
|
<section className={css.section}>
|
|
@@ -929,6 +1069,13 @@ function RankCard({ title, rows, total, t, onTip }: {
|
|
|
929
1069
|
{/* The share keeps its own line: the hover detail never
|
|
930
1070
|
displaces it (悬浮详情不占用百分比的位置). */}
|
|
931
1071
|
<span className={css.modelProvider}>{share(r.tokens)}</span>
|
|
1072
|
+
{/* Share bar: the row's slice of the total, in its own colour. */}
|
|
1073
|
+
<div className={css.rowTrack} aria-hidden="true">
|
|
1074
|
+
<i
|
|
1075
|
+
className={css.rowFill}
|
|
1076
|
+
style={{ width: total > 0 ? `${Math.min(100, (r.tokens / total) * 100)}%` : '0%', background: r.color }}
|
|
1077
|
+
/>
|
|
1078
|
+
</div>
|
|
932
1079
|
</div>
|
|
933
1080
|
<div className={css.modelValues}>
|
|
934
1081
|
<span className={css.modelTokens}>{formatCompact(r.tokens)}</span>
|
package/src/client/api.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* prefix-stripping proxy; the host's own route key stays absolute
|
|
12
12
|
* (`/token-total/api` in src/routes.ts), because only the browser half is relative.
|
|
13
13
|
*/
|
|
14
|
-
import type { BackfillStatus, SessionUsageTree, TokenTotalRange, TokenTotalRequest } from '../wire.ts'
|
|
14
|
+
import type { BackfillStatus, SessionUsageDetail, SessionUsageTree, TokenTotalRange, TokenTotalRequest } from '../wire.ts'
|
|
15
15
|
|
|
16
16
|
/** One wire failure. */
|
|
17
17
|
export class UsageApiError extends Error {
|
|
@@ -28,13 +28,14 @@ interface WireResponse<T> {
|
|
|
28
28
|
value: T
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
async function post<T>(method: string, body: unknown): Promise<T> {
|
|
31
|
+
async function post<T>(method: string, body: unknown, signal?: AbortSignal): Promise<T> {
|
|
32
32
|
let res: Response
|
|
33
33
|
try {
|
|
34
34
|
res = await fetch(`token-total/api/${method}`, {
|
|
35
35
|
method: 'POST',
|
|
36
36
|
headers: { 'content-type': 'application/json' },
|
|
37
37
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
38
|
+
signal,
|
|
38
39
|
})
|
|
39
40
|
} catch (err) {
|
|
40
41
|
throw new UsageApiError('network', err instanceof Error ? err.message : String(err))
|
|
@@ -73,3 +74,8 @@ export async function rebuildStats(): Promise<BackfillStatus> {
|
|
|
73
74
|
export async function fetchSessionUsage(sessionId: string): Promise<SessionUsageTree> {
|
|
74
75
|
return post<SessionUsageTree>('session', { sessionId })
|
|
75
76
|
}
|
|
77
|
+
|
|
78
|
+
/** Log-owned requests and live route states for the per-session Usage view. */
|
|
79
|
+
export async function fetchSessionDetail(sessionId: string, signal?: AbortSignal): Promise<SessionUsageDetail> {
|
|
80
|
+
return post<SessionUsageDetail>('session-detail', { sessionId }, signal)
|
|
81
|
+
}
|
package/src/client/format.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
// Shared with the host half (src/shared.ts has no Node/DOM types, so the
|
|
12
12
|
// client bundle may import it at runtime).
|
|
13
|
-
export { providerOf, modelNameOf, daysInRange as daysBetween } from '../shared.ts'
|
|
13
|
+
export { providerOf, modelNameOf, normalizeModelName, daysInRange as daysBetween } from '../shared.ts'
|
|
14
14
|
|
|
15
15
|
/** Exact token counts with thousands separators — no 万/亿/k/M units, the
|
|
16
16
|
* user reads the precise number (long values shrink to fit via FitText).
|
|
@@ -62,19 +62,23 @@ export function indexOfDay(day: string): number {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
export function shortDay(day: string): string {
|
|
65
|
-
|
|
65
|
+
// Bucket keys may carry an hour suffix ("YYYY-MM-DDTHH"); only the date
|
|
66
|
+
// prefix is a date — Number('22T14') is NaN and used to render "10/NaN".
|
|
67
|
+
const parts = day.slice(0, 10).split('-')
|
|
66
68
|
const m = parts[1]
|
|
67
69
|
const d = parts[2]
|
|
68
70
|
return `${Number(m)}/${Number(d)}`
|
|
69
71
|
}
|
|
70
72
|
|
|
71
|
-
/** X-axis label for a trend bucket key.
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
73
|
+
/** X-axis label for a trend bucket key. `mode` is what the axis READS AS,
|
|
74
|
+
* chosen by the caller from the window it covers:
|
|
75
|
+
* - 'hour' — a single-day hourly span (今日): the date is constant, so the
|
|
76
|
+
* axis shows the bare clock ("8:00");
|
|
77
|
+
* - 'day' — anything spanning several days (3天 hourly, any daily range):
|
|
78
|
+
* the axis shows the short date ("10/22"); on multi-day hourly spans one
|
|
79
|
+
* label marks each day. The tooltip still carries the full date + hour. */
|
|
80
|
+
export function bucketAxisLabel(key: string, mode: 'day' | 'hour'): string {
|
|
81
|
+
return mode === 'hour' ? `${Number(key.slice(11, 13))}:00` : shortDay(key)
|
|
78
82
|
}
|
|
79
83
|
|
|
80
84
|
/** Tooltip title for a trend bucket key: the date in day mode, date + hour
|
package/src/client/index.tsx
CHANGED
|
@@ -38,6 +38,8 @@ import { ContextUsageEntry, CONTEXT_LOCALE_NS, CONTEXT_LOCALES } from './Context
|
|
|
38
38
|
import { installContextTakeover } from './context-takeover.ts'
|
|
39
39
|
import { LOCALE_NS, en, zh, zhTW, type TokenTotalKey } from './locales.ts'
|
|
40
40
|
import { isCompatibleGlass, whenGlassReady } from './glass.ts'
|
|
41
|
+
import { SessionUsageView } from './SessionUsageView.tsx'
|
|
42
|
+
import { SESSION_USAGE_NS, usageEn, usageZh, usageZhTW, type SessionUsageKey } from './session-usage-locales.ts'
|
|
41
43
|
import css from './TokenTotalPanel.module.css'
|
|
42
44
|
|
|
43
45
|
/** This bundle's npm package name — the key the Plugins page matches on. */
|
|
@@ -77,6 +79,7 @@ export type TokenTotalPanelPageProps = PropsLocale<typeof LOCALE_NS> & {
|
|
|
77
79
|
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
78
80
|
interface LocaleNamespaceMap {
|
|
79
81
|
'tokenTotal': TokenTotalKey
|
|
82
|
+
'sessionUsage': SessionUsageKey
|
|
80
83
|
}
|
|
81
84
|
}
|
|
82
85
|
|
|
@@ -172,7 +175,12 @@ export function apply(ctx: Context): void {
|
|
|
172
175
|
const offZhTw = ctx.locale.register(LOCALE_NS, 'zh-TW', zhTW)
|
|
173
176
|
const offContext = Object.entries(CONTEXT_LOCALES).map(([locale, dict]) =>
|
|
174
177
|
ctx.locale.register(CONTEXT_LOCALE_NS, locale, dict as Record<string, string>))
|
|
175
|
-
|
|
178
|
+
const offSession = [
|
|
179
|
+
ctx.locale.register(SESSION_USAGE_NS, 'zh', usageZh),
|
|
180
|
+
ctx.locale.register(SESSION_USAGE_NS, 'en', usageEn),
|
|
181
|
+
ctx.locale.register(SESSION_USAGE_NS, 'zh-TW', usageZhTW),
|
|
182
|
+
]
|
|
183
|
+
return () => { offZh(); offEn(); offZhTw(); for (const off of [...offContext, ...offSession]) off() }
|
|
176
184
|
}, 'dsh-token-total: dictionaries')
|
|
177
185
|
|
|
178
186
|
// Join the background plugin's frosted-glass sheet when it is installed,
|
|
@@ -274,6 +282,17 @@ export function apply(ctx: Context): void {
|
|
|
274
282
|
locale: LOCALE_NS,
|
|
275
283
|
}, StatsPanelIcon))
|
|
276
284
|
|
|
285
|
+
// Native session-scoped View: the host owns its tab, selection and composer.
|
|
286
|
+
// Waiting for declaration keeps older hosts without this seat harmless.
|
|
287
|
+
const sessionT = ctx.locale.bind(SESSION_USAGE_NS)
|
|
288
|
+
ctx.slots.inject('conversation.view', () => ctx.slots.register({
|
|
289
|
+
name: 'conversation.view',
|
|
290
|
+
id: 'token-total-usage',
|
|
291
|
+
order: 20,
|
|
292
|
+
label: () => sessionT('tab'),
|
|
293
|
+
locale: SESSION_USAGE_NS,
|
|
294
|
+
}, SessionUsageView))
|
|
295
|
+
|
|
277
296
|
// The bottom-bar takeover: shadow the official stats pills (same id, lower
|
|
278
297
|
// priority — the slot's lowest live entry renders) with this plugin's
|
|
279
298
|
// always-on precision, token breakdown and live-speed readouts.
|