@mzzsfy/dsh-usage-dash 0.2.0 → 0.3.0
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 +2 -2
- package/package.json +1 -1
- package/src/client.js +148 -29
- package/src/query.js +13 -0
- package/test/client.test.mjs +108 -6
- package/test/query.test.mjs +20 -0
package/README.md
CHANGED
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
- 三粒度视图:天(7/30/90 天/自定义日期段)、小时(24 小时/3 天/7 天/15 天)、分钟(3 小时/24 小时/3 天/7 天,10 分钟桶粒度),后两者为滚动窗口;各视图时间范围统一 4 挡,时/分选择上限与数据保留期一致
|
|
10
10
|
- 汇总卡六张:Tokens 用量(服务商总口径)、会话数量、请求数量、最常用模型、平均缓存命中率、活跃天数(恒按天口径,不随视图切换);配置定价规则后 Tokens 卡头部行右侧显示估算费用
|
|
11
11
|
- 活动热力图:GitHub 风格周列×星期行,26 周窗口,五档色阶,悬停明细;仅按天视图展示
|
|
12
|
-
- 缓存命中率曲线:日粒度命中率 +
|
|
12
|
+
- 缓存命中率曲线:日粒度命中率 + 右侧副轴,并叠加平均生成速度曲线(单独颜色,读数走悬停;仅含有时长数据的槽,整图无时长数据不绘制),悬停显示当前时段命中率、平均生成速度与 token 明细(配置定价规则后附当前时段估算费用行,受「费用显示」开关;三粒度趋势图通用)
|
|
13
13
|
- 模型 donut 与列表:按 token 前 5 模型占比环形图(中心为总量),列表含命中率与占比,并为每模型显示平均生成速度(tok/s,无时长数据不显示),悬停联动
|
|
14
|
-
- 三粒度堆叠柱状趋势图(数据量大时裁最旧并提示)
|
|
14
|
+
- 三粒度堆叠柱状趋势图(数据量大时裁最旧并提示);图例可点击切换显隐:点击单选(仅显示该项,左轴刻度按可见项归一)/再点恢复全部,Ctrl+点击多选,隐藏最后一项无效
|
|
15
15
|
- 回扫状态行默认隐藏:首次启用自动回扫历史会话,运行中显示进度(常显);右上角为折叠箭头与刷新图标,折叠层内展开扫描异常日志块(逐条:时间/类型/明细,计数即明细条数,上限 200 条超限丢最旧,重建时清空)与「重建」入口(二次确认 3 秒后清库重扫),采集错误常显;工具栏分主区与右侧操作区,窄宽度时挡位组在主区内换行
|
|
16
16
|
- 会话底栏接管:替换官方信息行为增强版,偏好卡三个开关默认全开——精确缓存命中率(两位小数)、会话 Token 明细(总/命中缓存/未命中缓存/输出)、费用显示(底栏费用项与趋势 tooltip 费用行);三开关全关时与官方逐字节一致
|
|
17
17
|
- 会话尾部单轮行:每轮对话结束后动作行上方显示该轮 token 摘要与估算费用(最新一轮常显,历史轮悬停显现,触屏恒显;有产出文件的轮次自动让位产物行)
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mzzsfy/dsh-usage-dash",
|
|
3
3
|
"description": "用量统计面板:天/小时/分钟三粒度 token 与请求统计,26 周活跃热力图,会话底栏信息接管与回合级用量注入,多条件分时段定价规则与费用估算;复刻自 github.com/HaoyueQin/dsh-usage-statistics-panel,感谢原作者 HaoyueQin",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.3.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"exports": {
|
package/src/client.js
CHANGED
|
@@ -13,7 +13,7 @@ const MINUTE_PRESET_MINUTES = { '3h': 3 * 60, '24h': 24 * 60, '3d': 3 * 24 * 60,
|
|
|
13
13
|
|
|
14
14
|
const DEFAULT_RANGE = '30'
|
|
15
15
|
const DEFAULT_HOUR_PRESET = '24h'
|
|
16
|
-
const DEFAULT_MINUTE_PRESET = '
|
|
16
|
+
const DEFAULT_MINUTE_PRESET = '24h'
|
|
17
17
|
|
|
18
18
|
// 天视图渲染上限;时/分上限 = 闭区间桶数(hour N+1 槽,minute N/10+1 槽)
|
|
19
19
|
const DAY_MAX_SLOTS = 180
|
|
@@ -126,6 +126,8 @@ const MESSAGES_ZH = {
|
|
|
126
126
|
cacheRateHint: '时间段内缓存命中 token 占输入 token 的比例',
|
|
127
127
|
cacheHitRate: '缓存命中率',
|
|
128
128
|
hitRateLegend: '缓存命中率',
|
|
129
|
+
avgSpeed: '平均生成速度',
|
|
130
|
+
speedLegend: '平均生成速度',
|
|
129
131
|
topModel: '最常用模型',
|
|
130
132
|
topModelHint: '按 token 用量排序,非调用次数',
|
|
131
133
|
heatmap: '活跃热力图',
|
|
@@ -251,6 +253,8 @@ const MESSAGES_EN = {
|
|
|
251
253
|
cacheRateHint: 'Cache-hit tokens as a share of input tokens within the range',
|
|
252
254
|
cacheHitRate: 'Cache-hit rate',
|
|
253
255
|
hitRateLegend: 'Cache-hit rate',
|
|
256
|
+
avgSpeed: 'Avg speed',
|
|
257
|
+
speedLegend: 'Avg speed',
|
|
254
258
|
topModel: 'Top model',
|
|
255
259
|
topModelHint: 'Ranked by token usage, not call count',
|
|
256
260
|
heatmap: 'Activity heatmap',
|
|
@@ -383,13 +387,15 @@ function formatCompact(value) {
|
|
|
383
387
|
function formatPercent(value) {
|
|
384
388
|
return (Math.round(value * 10) / 10).toFixed(1) + '%'
|
|
385
389
|
}
|
|
390
|
+
// tooltip 数值缺席占位
|
|
391
|
+
const TOOLTIP_MISSING = '—'
|
|
386
392
|
function cacheRate(hit, miss) {
|
|
387
393
|
const total = hit + miss
|
|
388
394
|
return total <= 0 ? null : (hit / total) * 100
|
|
389
395
|
}
|
|
390
396
|
function cacheRateText(hit, miss) {
|
|
391
397
|
const rate = cacheRate(hit, miss)
|
|
392
|
-
return rate === null ?
|
|
398
|
+
return rate === null ? TOOLTIP_MISSING : formatPercent(rate)
|
|
393
399
|
}
|
|
394
400
|
|
|
395
401
|
// 模型键展示分段与定价/存储口径同源:首个 / 前 vendor 段,余为模型段
|
|
@@ -417,6 +423,15 @@ function minuteTickLabel(key) {
|
|
|
417
423
|
return time === MIDNIGHT_TIME ? `${shortDay(key.slice(0, DAY_KEY_LENGTH))} ${time}` : time
|
|
418
424
|
}
|
|
419
425
|
|
|
426
|
+
// 悬浮窗槽标签:完整日期去 T 分隔,小时以 h 后缀标定(避免 i18n 单位问题),分钟保留 HH:MM
|
|
427
|
+
function hourSlotLabel(key) {
|
|
428
|
+
return `${key.slice(0, DAY_KEY_LENGTH)} ${key.slice(DAY_KEY_LENGTH + 1)}h`
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function minuteSlotLabel(key) {
|
|
432
|
+
return `${key.slice(0, DAY_KEY_LENGTH)} ${key.slice(DAY_KEY_LENGTH + 1)}`
|
|
433
|
+
}
|
|
434
|
+
|
|
420
435
|
function isEmptyRange(value) {
|
|
421
436
|
return value.tokens === 0 && value.cacheHit === 0 && value.requests === 0 && value.turns === 0
|
|
422
437
|
}
|
|
@@ -488,6 +503,13 @@ const BAR_WIDTH_RATIO = 0.62
|
|
|
488
503
|
const BAR_MIN_WIDTH = 3
|
|
489
504
|
const BAR_MAX_WIDTH = 30
|
|
490
505
|
const AXIS_TICK_COUNT = 4
|
|
506
|
+
// 速度刻度上限钳底:全零或无速度防除零(速度不设轴,读数走 tooltip)
|
|
507
|
+
const SPEED_SCALE_FLOOR = 1
|
|
508
|
+
// 轴上限留白系数:数据峰不顶满绘图区,顶部留出标注空间
|
|
509
|
+
const AXIS_SCALE_HEADROOM = 1.1
|
|
510
|
+
// 图例键:折线项与模型项共处同一显隐集合
|
|
511
|
+
const LEGEND_KEY_RATE = 'rate'
|
|
512
|
+
const LEGEND_KEY_SPEED = 'speed'
|
|
491
513
|
|
|
492
514
|
function niceTicks(max, count) {
|
|
493
515
|
if (max <= 0 || count <= 0) return []
|
|
@@ -500,14 +522,43 @@ function niceTicks(max, count) {
|
|
|
500
522
|
return ticks
|
|
501
523
|
}
|
|
502
524
|
|
|
503
|
-
//
|
|
525
|
+
// 图例显隐切换:current 为 null 表示全部可见;普通点击单选/再点恢复,
|
|
526
|
+
// ctrl 单项切换,隐藏最后一项为无操作(返回原引用)
|
|
527
|
+
function legendToggle(current, key, ctrl) {
|
|
528
|
+
if (ctrl) {
|
|
529
|
+
const next = new Set(current ?? [])
|
|
530
|
+
if (next.has(key)) next.delete(key)
|
|
531
|
+
else next.add(key)
|
|
532
|
+
return next.size === 0 ? current : next
|
|
533
|
+
}
|
|
534
|
+
const solo = current !== null && current.size === 1 && current.has(key)
|
|
535
|
+
return solo ? null : new Set([key])
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// 左轴刻度:速度刻度存在时左轴标定速度(tok/s),否则标定 token;
|
|
539
|
+
// 刻度值统一换算为绘图区高度占比
|
|
540
|
+
function leftAxisTicks(tokenTicks, tokenMax, speedTicks, speedMax) {
|
|
541
|
+
const useSpeed = speedTicks.length > 0
|
|
542
|
+
return (useSpeed ? speedTicks : tokenTicks).map((tick) => ({
|
|
543
|
+
label: formatCompact(tick),
|
|
544
|
+
ratio: tick / (useSpeed ? speedMax : tokenMax),
|
|
545
|
+
}))
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// 堆叠柱几何:模型序即堆叠序(哨兵最后画柱顶),输出槽分段与左轴刻度;
|
|
549
|
+
// maxTotal 按可见模型求和,单选模型时刻度跟随归一
|
|
504
550
|
function trendLayout(slots, modelOrder, avail, labelMinPitch) {
|
|
505
551
|
const plotHeight = CHART_HEIGHT - CHART_PAD.top - CHART_PAD.bottom
|
|
506
552
|
const innerWidth = Math.max(1, avail - CHART_PAD.left - CHART_PAD.right)
|
|
507
553
|
const count = slots.length
|
|
508
554
|
const step = count > 1 ? innerWidth / (count - 1) : innerWidth
|
|
509
555
|
const barWidth = Math.max(BAR_MIN_WIDTH, Math.min(BAR_MAX_WIDTH, step * BAR_WIDTH_RATIO))
|
|
510
|
-
const
|
|
556
|
+
const slotVisibleTotal = (slot) => modelOrder.reduce((sum, model) => sum + (slot.byModel[model] ?? 0), 0)
|
|
557
|
+
const maxTotal = Math.max(1, ...slots.map(slotVisibleTotal))
|
|
558
|
+
// 轴上限 = 数据峰 × 留白系数,柱高分母与左轴刻度分母同源
|
|
559
|
+
const scaleMax = maxTotal * AXIS_SCALE_HEADROOM
|
|
560
|
+
// 可见模型无任何数据时左轴无标定对象,空刻度防钳底值漏成假刻度
|
|
561
|
+
const visibleTotal = slots.reduce((sum, slot) => sum + slotVisibleTotal(slot), 0)
|
|
511
562
|
const bars = slots.map((slot, index) => {
|
|
512
563
|
const centerX = CHART_PAD.left + barWidth / 2 + index * step
|
|
513
564
|
let yBottom = CHART_PAD.top + plotHeight
|
|
@@ -515,7 +566,7 @@ function trendLayout(slots, modelOrder, avail, labelMinPitch) {
|
|
|
515
566
|
for (const model of modelOrder) {
|
|
516
567
|
const tokens = slot.byModel[model] ?? 0
|
|
517
568
|
if (tokens === 0) continue
|
|
518
|
-
const height = (tokens /
|
|
569
|
+
const height = (tokens / scaleMax) * plotHeight
|
|
519
570
|
yBottom -= height
|
|
520
571
|
segments.push({ model, y: yBottom, height })
|
|
521
572
|
}
|
|
@@ -528,7 +579,8 @@ function trendLayout(slots, modelOrder, avail, labelMinPitch) {
|
|
|
528
579
|
step,
|
|
529
580
|
barWidth,
|
|
530
581
|
maxTotal,
|
|
531
|
-
|
|
582
|
+
scaleMax,
|
|
583
|
+
ticks: visibleTotal === 0 ? [] : niceTicks(maxTotal, AXIS_TICK_COUNT),
|
|
532
584
|
labelEvery: Math.max(1, Math.ceil(labelMinPitch / step)),
|
|
533
585
|
bars,
|
|
534
586
|
}
|
|
@@ -560,6 +612,20 @@ function trendRatePoints(slots, bars, plotHeight) {
|
|
|
560
612
|
return points
|
|
561
613
|
}
|
|
562
614
|
|
|
615
|
+
// 速度曲线点:仅带速度槽产出,高度按刻度上限归一
|
|
616
|
+
function trendSpeedPoints(slots, bars, plotHeight, scaleMax) {
|
|
617
|
+
const points = []
|
|
618
|
+
slots.forEach((slot, index) => {
|
|
619
|
+
if (slot.speed === undefined) return
|
|
620
|
+
points.push({
|
|
621
|
+
day: slot.day,
|
|
622
|
+
x: bars[index].x,
|
|
623
|
+
y: CHART_PAD.top + plotHeight - (slot.speed / scaleMax) * plotHeight,
|
|
624
|
+
})
|
|
625
|
+
})
|
|
626
|
+
return points
|
|
627
|
+
}
|
|
628
|
+
|
|
563
629
|
// Catmull-Rom 转三次贝塞尔:控制点取邻点差六分之一,端点折返
|
|
564
630
|
function smoothPath(points) {
|
|
565
631
|
if (points.length === 0) return ''
|
|
@@ -619,6 +685,16 @@ function modelSpeedText(speed) {
|
|
|
619
685
|
return `${formatTokensPerSecond(speed)} tok/s`
|
|
620
686
|
}
|
|
621
687
|
|
|
688
|
+
// tooltip 速度行文本:无速度与命中率同款占位符
|
|
689
|
+
function speedTipText(speed) {
|
|
690
|
+
return speed === undefined ? TOOLTIP_MISSING : modelSpeedText(speed)
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// 速度刻度上限:全零或无速度钳底,防除零
|
|
694
|
+
function speedScaleMax(slots) {
|
|
695
|
+
return Math.max(SPEED_SCALE_FLOOR, ...slots.map((slot) => slot.speed ?? 0))
|
|
696
|
+
}
|
|
697
|
+
|
|
622
698
|
// 热力图:窗口固定 26 周,与所选范围无关
|
|
623
699
|
const HEAT_WEEKS = 26
|
|
624
700
|
const HEAT_ROW_COUNT = 7
|
|
@@ -1473,9 +1549,9 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
1473
1549
|
const STYLE_CSS = `
|
|
1474
1550
|
.ud-panel{display:flex;flex-direction:column;gap:12px;font-size:13px;color:var(--dsw-alias-label-primary);
|
|
1475
1551
|
--ud-chart-1:color-mix(in srgb,#0576ff 70%,white);--ud-chart-2:color-mix(in srgb,#2f6f37 70%,white);--ud-chart-3:color-mix(in srgb,#c46212 70%,white);--ud-chart-4:color-mix(in srgb,#975bf1 70%,white);--ud-chart-5:color-mix(in srgb,#d34591 70%,white);--ud-chart-other:color-mix(in srgb,#576270 70%,white);
|
|
1476
|
-
--dsw-heat-0:#ebedf0;--dsw-heat-1:#dbe3ff;--dsw-heat-2:#b7c5ff;--dsw-heat-3:#8ea4ff;--dsw-heat-4:#6884ff;--dsw-heat-5:#4d6bfe;--ud-trend-line:#0576ff}
|
|
1552
|
+
--dsw-heat-0:#ebedf0;--dsw-heat-1:#dbe3ff;--dsw-heat-2:#b7c5ff;--dsw-heat-3:#8ea4ff;--dsw-heat-4:#6884ff;--dsw-heat-5:#4d6bfe;--ud-trend-line:#0576ff;--ud-trend-speed:#0ca678}
|
|
1477
1553
|
body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,white);--ud-chart-2:color-mix(in srgb,#2f6f37 65%,white);--ud-chart-3:color-mix(in srgb,#c46212 65%,white);--ud-chart-4:color-mix(in srgb,#975bf1 65%,white);--ud-chart-5:color-mix(in srgb,#d34591 65%,white);--ud-chart-other:color-mix(in srgb,#576270 65%,white);
|
|
1478
|
-
--dsw-heat-0:#21262d;--dsw-heat-1:#2f4bd0;--dsw-heat-2:#4d6bfe;--dsw-heat-3:#6e8bff;--dsw-heat-4:#93aaff;--dsw-heat-5:#c4d0ff;--ud-trend-line:#4d6bfe}
|
|
1554
|
+
--dsw-heat-0:#21262d;--dsw-heat-1:#2f4bd0;--dsw-heat-2:#4d6bfe;--dsw-heat-3:#6e8bff;--dsw-heat-4:#93aaff;--dsw-heat-5:#c4d0ff;--ud-trend-line:#4d6bfe;--ud-trend-speed:#2fbf8f}
|
|
1479
1555
|
.ud-toolbar{display:flex;align-items:flex-start;gap:8px}
|
|
1480
1556
|
.ud-toolbar-main{display:flex;align-items:center;gap:8px;flex-wrap:wrap;flex:1 1 auto;min-width:0}
|
|
1481
1557
|
.ud-group{display:flex;align-items:center;gap:2px;padding:3px;border:1px solid var(--dsw-alias-border-l2);border-radius:9px;background:var(--dsw-alias-bg-layer-1)}
|
|
@@ -1535,7 +1611,8 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
1535
1611
|
.ud-grid{stroke:var(--dsw-alias-border-l1);stroke-width:1}
|
|
1536
1612
|
.ud-axis{fill:var(--dsw-alias-label-tertiary);font-size:11px;font-variant-numeric:tabular-nums}
|
|
1537
1613
|
.ud-legend{display:flex;flex-wrap:wrap;gap:4px 12px}
|
|
1538
|
-
.ud-legend-item{display:inline-flex;align-items:center;gap:6px;font-size:11px;color:var(--dsw-alias-label-secondary);min-width:0}
|
|
1614
|
+
.ud-legend-item{display:inline-flex;align-items:center;gap:6px;font-size:11px;color:var(--dsw-alias-label-secondary);min-width:0;user-select:none;cursor:pointer}
|
|
1615
|
+
.ud-legend-item--off{opacity:.35}
|
|
1539
1616
|
.ud-legend-item span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
1540
1617
|
.ud-legend-swatch{width:8px;height:8px;border-radius:2px;flex:none}
|
|
1541
1618
|
.ud-heat-wrap{width:100%;min-width:0;overflow:hidden}
|
|
@@ -1558,8 +1635,11 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
1558
1635
|
.ud-bar{transform-box:fill-box;transform-origin:center}
|
|
1559
1636
|
.ud-bar-hit{fill:transparent;pointer-events:all}
|
|
1560
1637
|
.ud-trend{stroke:var(--ud-trend-line);opacity:.9;fill:none;pointer-events:none}
|
|
1638
|
+
.ud-trend--speed{stroke:var(--ud-trend-speed)}
|
|
1561
1639
|
.ud-trend-dot{fill:var(--ud-trend-line);stroke:var(--dsw-alias-bg-layer-1);stroke-width:${TREND_DOT_RING}px;pointer-events:none}
|
|
1640
|
+
.ud-trend-dot--speed{fill:var(--ud-trend-speed)}
|
|
1562
1641
|
.ud-legend-swatch--line{height:2px;border-radius:1px;background:var(--ud-trend-line)}
|
|
1642
|
+
.ud-legend-swatch--line--speed{background:var(--ud-trend-speed)}
|
|
1563
1643
|
.ud-model-usage{display:flex;flex-wrap:wrap;align-items:flex-start;gap:16px}
|
|
1564
1644
|
.ud-donut-wrap{flex:0 0 auto}
|
|
1565
1645
|
.ud-donut-seg{cursor:pointer;outline:none;transition:stroke-width .12s ease}
|
|
@@ -1701,14 +1781,22 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
1701
1781
|
h(FitText, null, String(stats.activeDays))))
|
|
1702
1782
|
}
|
|
1703
1783
|
|
|
1704
|
-
function Legend({ models, colorFor, t = defaultT }) {
|
|
1784
|
+
function Legend({ models, colorFor, speedEnabled = false, isVisible, onItem, t = defaultT }) {
|
|
1785
|
+
const itemProps = (key) => ({
|
|
1786
|
+
className: cx('ud-legend-item', isVisible && !isVisible(key) && 'ud-legend-item--off'),
|
|
1787
|
+
onClick: onItem ? (event) => onItem(key, event.ctrlKey || event.metaKey) : undefined,
|
|
1788
|
+
role: onItem ? 'button' : undefined,
|
|
1789
|
+
})
|
|
1705
1790
|
return h('div', { className: 'ud-legend' },
|
|
1706
|
-
models.map((item) => h('span', { key: item.model,
|
|
1791
|
+
models.map((item) => h('span', { key: item.model, title: item.model === OTHER_MODEL ? t('other') : item.model, ...itemProps(item.model) },
|
|
1707
1792
|
h('i', { className: 'ud-legend-swatch', style: { background: colorFor(item.model) } }),
|
|
1708
1793
|
h('span', null, item.model === OTHER_MODEL ? t('other') : item.model))),
|
|
1709
|
-
h('span', { key: 'hit-rate',
|
|
1794
|
+
h('span', { key: 'hit-rate', title: t('hitRateLegend'), ...itemProps(LEGEND_KEY_RATE) },
|
|
1710
1795
|
h('i', { className: 'ud-legend-swatch ud-legend-swatch--line' }),
|
|
1711
|
-
h('span', null, t('hitRateLegend')))
|
|
1796
|
+
h('span', null, t('hitRateLegend'))),
|
|
1797
|
+
speedEnabled ? h('span', { key: 'speed', title: t('speedLegend'), ...itemProps(LEGEND_KEY_SPEED) },
|
|
1798
|
+
h('i', { className: 'ud-legend-swatch ud-legend-swatch--line ud-legend-swatch--line--speed' }),
|
|
1799
|
+
h('span', null, t('speedLegend'))) : null)
|
|
1712
1800
|
}
|
|
1713
1801
|
|
|
1714
1802
|
const colorForModel = (models) => (model) => {
|
|
@@ -1718,10 +1806,12 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
1718
1806
|
return `var(--ud-chart-${rank})`
|
|
1719
1807
|
}
|
|
1720
1808
|
|
|
1721
|
-
function TrendChart({ title, notes, slots, modelOrder, colorFor, labelFor, labelMinPitch, busy, legendModels, panelRef, costCurrency = '', costEnabled = false, t = defaultT }) {
|
|
1809
|
+
function TrendChart({ title, notes, slots, modelOrder, colorFor, labelFor, slotLabelFor, labelMinPitch, busy, legendModels, panelRef, costCurrency = '', costEnabled = false, t = defaultT }) {
|
|
1722
1810
|
const wrapRef = useRef(null)
|
|
1723
1811
|
const [avail, setAvail] = useState(CHART_NOMINAL_WIDTH)
|
|
1724
1812
|
const [hover, setHover] = useState(null)
|
|
1813
|
+
// 图例显隐:null = 全部可见;键集 = 模型项与折线项(rate/speed)
|
|
1814
|
+
const [visibleKeys, setVisibleKeys] = useState(null)
|
|
1725
1815
|
// prefs 仅用于 hover tooltip 的费用显隐,经 ref 读取:开关切换不触发本组件重渲染,
|
|
1726
1816
|
// 避免宿主设置弹窗滚动锚定被重渲染扰动而跳变
|
|
1727
1817
|
const prefsRef = useRef(statsLineState.get())
|
|
@@ -1735,11 +1825,23 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
1735
1825
|
observer.observe(element)
|
|
1736
1826
|
return () => observer.disconnect()
|
|
1737
1827
|
}, [])
|
|
1738
|
-
const
|
|
1828
|
+
const hasSpeed = slots.some((slot) => slot.speed !== undefined)
|
|
1829
|
+
const visibleSet = visibleKeys ?? new Set([...modelOrder, LEGEND_KEY_RATE, ...(hasSpeed ? [LEGEND_KEY_SPEED] : [])])
|
|
1830
|
+
const visibleModels = modelOrder.filter((model) => visibleSet.has(model))
|
|
1831
|
+
const showRate = visibleSet.has(LEGEND_KEY_RATE)
|
|
1832
|
+
const showSpeed = hasSpeed && visibleSet.has(LEGEND_KEY_SPEED)
|
|
1833
|
+
const layout = trendLayout(slots, visibleModels, avail, labelMinPitch)
|
|
1739
1834
|
const plotRight = CHART_PAD.left + (slots.length - 1) * layout.step + layout.barWidth
|
|
1740
1835
|
const ratePoints = trendRatePoints(slots, layout.bars, layout.plotHeight)
|
|
1836
|
+
const speedMax = speedScaleMax(slots)
|
|
1837
|
+
const speedAxisMax = speedMax * AXIS_SCALE_HEADROOM
|
|
1838
|
+
const speedPoints = showSpeed ? trendSpeedPoints(slots, layout.bars, layout.plotHeight, speedAxisMax) : []
|
|
1839
|
+
// 左轴标定:可见柱有数据标 token;无柱数据且速度线可见标速度(tok/s);否则空
|
|
1840
|
+
const speedAxisTicks = layout.ticks.length === 0 && showSpeed ? niceTicks(speedMax, AXIS_TICK_COUNT) : []
|
|
1841
|
+
const yTicks = leftAxisTicks(layout.ticks, layout.scaleMax, speedAxisTicks, speedAxisMax)
|
|
1741
1842
|
const hoverSlot = hover ? slots[hover.index] : null
|
|
1742
|
-
const hoverRatePoint = hoverSlot ? ratePoints.find((point) => point.day === hoverSlot.day) : null
|
|
1843
|
+
const hoverRatePoint = showRate && hoverSlot ? ratePoints.find((point) => point.day === hoverSlot.day) : null
|
|
1844
|
+
const hoverSpeedPoint = hoverSlot ? speedPoints.find((point) => point.day === hoverSlot.day) : null
|
|
1743
1845
|
const pick = (index) => (event) => setHover({ index, anchor: event.currentTarget })
|
|
1744
1846
|
const clear = () => setHover(null)
|
|
1745
1847
|
const otherEntries = hoverSlot
|
|
@@ -1754,13 +1856,16 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
1754
1856
|
className: 'ud-chart', viewBox: `0 0 ${avail} ${CHART_HEIGHT}`, width: '100%', role: 'img',
|
|
1755
1857
|
'aria-label': title, onMouseLeave: clear,
|
|
1756
1858
|
},
|
|
1757
|
-
|
|
1758
|
-
const y = CHART_PAD.top + layout.plotHeight -
|
|
1759
|
-
return h('g', { key: tick },
|
|
1859
|
+
yTicks.map((tick) => {
|
|
1860
|
+
const y = CHART_PAD.top + layout.plotHeight - tick.ratio * layout.plotHeight
|
|
1861
|
+
return h('g', { key: tick.label },
|
|
1760
1862
|
h('line', { className: 'ud-grid', x1: CHART_PAD.left, x2: plotRight, y1: y, y2: y }),
|
|
1761
|
-
h('text', { className: 'ud-axis', x: CHART_PAD.left - AXIS_LABEL_GAP, y: y + AXIS_LABEL_BASELINE, textAnchor: 'end' },
|
|
1863
|
+
h('text', { className: 'ud-axis', x: CHART_PAD.left - AXIS_LABEL_GAP, y: y + AXIS_LABEL_BASELINE, textAnchor: 'end' }, tick.label))
|
|
1762
1864
|
}),
|
|
1763
|
-
|
|
1865
|
+
speedAxisTicks.length > 0
|
|
1866
|
+
? h('text', { className: 'ud-axis', x: CHART_PAD.left - AXIS_LABEL_GAP, y: CHART_PAD.top + AXIS_LABEL_BASELINE, textAnchor: 'end' }, 'tok/s')
|
|
1867
|
+
: null,
|
|
1868
|
+
(showRate ? rateAxisTicks() : []).map((tick) => {
|
|
1764
1869
|
const y = CHART_PAD.top + layout.plotHeight - (tick / PERCENT_SCALE) * layout.plotHeight
|
|
1765
1870
|
return h('text', {
|
|
1766
1871
|
key: `rate-${tick}`, className: 'ud-axis-rate',
|
|
@@ -1778,30 +1883,42 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
1778
1883
|
slots.map((slot, index) => (index % layout.labelEvery === 0 || index === slots.length - 1)
|
|
1779
1884
|
? h('text', { key: slot.day, className: 'ud-axis', x: layout.bars[index].x, y: CHART_HEIGHT - X_LABEL_OFFSET, textAnchor: 'middle' }, labelFor(slot.day))
|
|
1780
1885
|
: null),
|
|
1781
|
-
h('path', {
|
|
1886
|
+
showRate ? h('path', {
|
|
1782
1887
|
className: 'ud-trend', d: smoothPath(ratePoints),
|
|
1783
1888
|
strokeWidth: TREND_LINE_WIDTH, strokeLinejoin: 'round', strokeLinecap: 'round',
|
|
1784
|
-
}),
|
|
1889
|
+
}) : null,
|
|
1890
|
+
showSpeed ? h('path', {
|
|
1891
|
+
className: cx('ud-trend', 'ud-trend--speed'), d: smoothPath(speedPoints),
|
|
1892
|
+
strokeWidth: TREND_LINE_WIDTH, strokeLinejoin: 'round', strokeLinecap: 'round',
|
|
1893
|
+
}) : null,
|
|
1785
1894
|
hoverRatePoint
|
|
1786
1895
|
? h('circle', { className: 'ud-trend-dot', cx: hoverRatePoint.x, cy: hoverRatePoint.y, r: TREND_DOT_RADIUS })
|
|
1787
1896
|
: null,
|
|
1897
|
+
hoverSpeedPoint
|
|
1898
|
+
? h('circle', { className: cx('ud-trend-dot', 'ud-trend-dot--speed'), cx: hoverSpeedPoint.x, cy: hoverSpeedPoint.y, r: TREND_DOT_RADIUS })
|
|
1899
|
+
: null,
|
|
1788
1900
|
slots.map((slot, index) => h('rect', {
|
|
1789
1901
|
key: `hit-${slot.day}`, className: 'ud-bar-hit',
|
|
1790
1902
|
x: layout.bars[index].x - layout.step / 2, y: CHART_PAD.top, width: layout.step, height: layout.plotHeight,
|
|
1791
1903
|
onMouseEnter: pick(index), onFocus: pick(index), onMouseLeave: clear, onBlur: clear,
|
|
1792
1904
|
})))),
|
|
1793
|
-
h(Legend, {
|
|
1905
|
+
h(Legend, {
|
|
1906
|
+
models: legendModels, colorFor, speedEnabled: hasSpeed,
|
|
1907
|
+
isVisible: (key) => visibleSet.has(key),
|
|
1908
|
+
onItem: (key, ctrl) => setVisibleKeys(legendToggle(visibleKeys, key, ctrl)),
|
|
1909
|
+
}),
|
|
1794
1910
|
h(ChartTip, { anchor: hover ? hover.anchor : null, panelRef },
|
|
1795
1911
|
hoverSlot
|
|
1796
1912
|
? [
|
|
1797
|
-
h('div', { key: 'title', className: 'ud-tip-title' }, hoverSlot.day),
|
|
1913
|
+
h('div', { key: 'title', className: 'ud-tip-title' }, slotLabelFor ? slotLabelFor(hoverSlot.day) : hoverSlot.day),
|
|
1798
1914
|
h('div', { key: 'total', className: 'ud-tip-row' }, `${t('total')}: ${formatTokens(hoverSlot.total)}`),
|
|
1799
|
-
...legendModels.map((item) => h('div', { key: `m-${item.model}`, className: 'ud-tip-row' },
|
|
1915
|
+
...legendModels.filter((item) => visibleSet.has(item.model)).map((item) => h('div', { key: `m-${item.model}`, className: 'ud-tip-row' },
|
|
1800
1916
|
h('i', { className: 'ud-legend-swatch', style: { background: colorFor(item.model) } }),
|
|
1801
1917
|
`${item.model === OTHER_MODEL ? t('other') : item.model}: ${formatTokens(hoverSlot.byModel[item.model] ?? 0)}`)),
|
|
1802
|
-
...otherEntries.map(([model, tokens]) => h('div', { key: `om-${model}`, className: 'ud-tip-row ud-tip-row--sub' },
|
|
1803
|
-
`${model}: ${formatTokens(tokens)}`)),
|
|
1804
|
-
h('div', { key: 'rate', className: 'ud-tip-row' }, `${t('cacheHitRate')}: ${cacheRateText(hoverSlot.cacheHit, hoverSlot.cacheMiss)}`),
|
|
1918
|
+
...(visibleSet.has(OTHER_MODEL) ? otherEntries.map(([model, tokens]) => h('div', { key: `om-${model}`, className: 'ud-tip-row ud-tip-row--sub' },
|
|
1919
|
+
`${model}: ${formatTokens(tokens)}`)) : []),
|
|
1920
|
+
showRate ? h('div', { key: 'rate', className: 'ud-tip-row' }, `${t('cacheHitRate')}: ${cacheRateText(hoverSlot.cacheHit, hoverSlot.cacheMiss)}`) : null,
|
|
1921
|
+
showSpeed ? h('div', { key: 'speed', className: 'ud-tip-row' }, `${t('avgSpeed')}: ${speedTipText(hoverSlot.speed)}`) : null,
|
|
1805
1922
|
costEnabled && prefsRef.current.costDisplay && hoverSlot.cost !== undefined
|
|
1806
1923
|
? h('div', { key: 'cost', className: 'ud-tip-row' }, `≈ ${formatCost(hoverSlot.cost, costCurrency)}`)
|
|
1807
1924
|
: null,
|
|
@@ -2413,6 +2530,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2413
2530
|
: id === 'hour' ? t('trendLimitedHour', { n: count }) : t('trendLimitedMinute', { n: count }))
|
|
2414
2531
|
const presetLabel = (t, view, id) => t(`${view === 'hour' ? 'hourPreset' : 'minutePreset'}.${id}`)
|
|
2415
2532
|
const tickLabelFor = (view) => (view === 'day' ? shortDay : view === 'hour' ? hourTickLabel : minuteTickLabel)
|
|
2533
|
+
const slotLabelFor = (view) => (view === 'hour' ? hourSlotLabel : view === 'minute' ? minuteSlotLabel : (day) => day)
|
|
2416
2534
|
|
|
2417
2535
|
function UsageDashPanel({ t = defaultT }) {
|
|
2418
2536
|
const [view, setView] = useState('day')
|
|
@@ -2667,6 +2785,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2667
2785
|
modelOrder: trendSource.models.map((item) => item.model),
|
|
2668
2786
|
colorFor,
|
|
2669
2787
|
labelFor: tickLabelFor(view),
|
|
2788
|
+
slotLabelFor: slotLabelFor(view),
|
|
2670
2789
|
labelMinPitch: pointActive ? LABEL_PITCH_TIME : LABEL_PITCH_DAY,
|
|
2671
2790
|
busy,
|
|
2672
2791
|
legendModels: trendSource.models,
|
package/src/query.js
CHANGED
|
@@ -105,6 +105,8 @@ export function aggregateRange(rows, g, from, to) {
|
|
|
105
105
|
const modelTotals = new Map()
|
|
106
106
|
const providerTotals = new Map()
|
|
107
107
|
const activeBuckets = new Set()
|
|
108
|
+
// 槽级速度配对:桶串 → {outputTokens, durationMs},与模型级同口径(仅带时长行)
|
|
109
|
+
const slotSpeeds = new Map()
|
|
108
110
|
for (const row of rows) {
|
|
109
111
|
const slot = slotByKey.get(row.bucket)
|
|
110
112
|
// 桶串未落在枚举序列(如改粒度前的历史残行)不可归属,跳过防崩
|
|
@@ -115,6 +117,12 @@ export function aggregateRange(rows, g, from, to) {
|
|
|
115
117
|
activeBuckets.add(row.bucket)
|
|
116
118
|
slot.byModel[row.model] = (slot.byModel[row.model] ?? 0) + tokens
|
|
117
119
|
slot.byProvider[row.provider] = (slot.byProvider[row.provider] ?? 0) + tokens
|
|
120
|
+
if (row.durationMs) {
|
|
121
|
+
const pair = slotSpeeds.get(row.bucket) ?? { outputTokens: 0, durationMs: 0 }
|
|
122
|
+
pair.outputTokens += row.outputTokens
|
|
123
|
+
pair.durationMs += row.durationMs
|
|
124
|
+
slotSpeeds.set(row.bucket, pair)
|
|
125
|
+
}
|
|
118
126
|
const modelTotal = modelTotals.get(row.model)
|
|
119
127
|
if (modelTotal) {
|
|
120
128
|
modelTotal.tokens += tokens
|
|
@@ -130,6 +138,11 @@ export function aggregateRange(rows, g, from, to) {
|
|
|
130
138
|
}
|
|
131
139
|
providerTotals.set(row.provider, (providerTotals.get(row.provider) ?? 0) + tokens)
|
|
132
140
|
}
|
|
141
|
+
// 槽级 speed 条件挂:无时长数据的槽不挂字段(存量槽形契约不变)
|
|
142
|
+
for (const slot of slots) {
|
|
143
|
+
const pair = slotSpeeds.get(slot.day)
|
|
144
|
+
if (pair && pair.durationMs > 0) slot.speed = pair.outputTokens / (pair.durationMs / MS_PER_SECOND)
|
|
145
|
+
}
|
|
133
146
|
const totals = { tokens: 0, requests: 0, turns: 0, cacheHit: 0, cacheMiss: 0 }
|
|
134
147
|
for (const slot of slots) {
|
|
135
148
|
totals.tokens += slot.total
|
package/test/client.test.mjs
CHANGED
|
@@ -15,6 +15,9 @@ const {
|
|
|
15
15
|
CHART_PAD,
|
|
16
16
|
DAY_MAX_SLOTS,
|
|
17
17
|
DAY_PRESETS,
|
|
18
|
+
DEFAULT_HOUR_PRESET,
|
|
19
|
+
DEFAULT_MINUTE_PRESET,
|
|
20
|
+
DEFAULT_RANGE,
|
|
18
21
|
DONUT_CENTER_XY,
|
|
19
22
|
DONUT_CIRCUMFERENCE,
|
|
20
23
|
DONUT_OUTER_RADIUS,
|
|
@@ -80,6 +83,13 @@ const {
|
|
|
80
83
|
trimSlots,
|
|
81
84
|
trendLayout,
|
|
82
85
|
trendRatePoints,
|
|
86
|
+
trendSpeedPoints,
|
|
87
|
+
speedTipText,
|
|
88
|
+
speedScaleMax,
|
|
89
|
+
legendToggle,
|
|
90
|
+
leftAxisTicks,
|
|
91
|
+
hourSlotLabel,
|
|
92
|
+
minuteSlotLabel,
|
|
83
93
|
validatePricingRules,
|
|
84
94
|
} = core
|
|
85
95
|
|
|
@@ -129,6 +139,13 @@ test('预设定义表覆盖三视图', () => {
|
|
|
129
139
|
assert.deepEqual(MINUTE_PRESETS, ['3h', '24h', '3d', '7d'])
|
|
130
140
|
})
|
|
131
141
|
|
|
142
|
+
test('默认挡位均在其挡位列表内,分钟窗口可解析', () => {
|
|
143
|
+
assert.ok(DAY_PRESETS.includes(DEFAULT_RANGE))
|
|
144
|
+
assert.ok(HOUR_PRESETS.includes(DEFAULT_HOUR_PRESET))
|
|
145
|
+
assert.ok(MINUTE_PRESETS.includes(DEFAULT_MINUTE_PRESET))
|
|
146
|
+
assert.notEqual(resolveMinuteRange(DEFAULT_MINUTE_PRESET, NOW), null)
|
|
147
|
+
})
|
|
148
|
+
|
|
132
149
|
test('时/分挡位文案键逐挡齐备,含跨表同 id 挡位', () => {
|
|
133
150
|
// Given 时/分挡位表存在同 id('24h') When 逐挡查词典 Then 两语言均有对应键,漏配即回退键名
|
|
134
151
|
for (const id of HOUR_PRESETS) {
|
|
@@ -320,23 +337,53 @@ test('trendLayout 单槽列距铺满且柱宽取上限', () => {
|
|
|
320
337
|
assert.equal(layout.maxTotal, 50)
|
|
321
338
|
})
|
|
322
339
|
|
|
323
|
-
test('trendLayout
|
|
340
|
+
test('trendLayout 零值槽无分段,无可见数据时刻度为空表', () => {
|
|
324
341
|
const layout = trendLayout([{ day: '2026-03-15', total: 0, byModel: {} }], [], 720, 46)
|
|
325
342
|
assert.deepEqual(layout.bars[0].segments, [])
|
|
326
|
-
assert.deepEqual(layout.ticks, [
|
|
343
|
+
assert.deepEqual(layout.ticks, [])
|
|
327
344
|
})
|
|
328
345
|
|
|
329
|
-
test('trendLayout
|
|
346
|
+
test('trendLayout 堆叠分段自底向上,轴上限为数据峰乘留白系数', () => {
|
|
330
347
|
const layout = trendLayout([{ day: 'd', total: 50, byModel: { a: 30, b: 20 } }], ['a', 'b'], 720, 46)
|
|
331
348
|
const [segA, segB] = layout.bars[0].segments
|
|
332
349
|
assert.deepEqual(segA.model, 'a')
|
|
333
350
|
assert.deepEqual(segB.model, 'b')
|
|
334
|
-
approx(segA.height, (30 / 50) * 184)
|
|
335
|
-
approx(segB.height, (20 / 50) * 184)
|
|
336
|
-
approx(segA.height + segB.height, 184)
|
|
351
|
+
approx(segA.height, (30 / (50 * 1.1)) * 184)
|
|
352
|
+
approx(segB.height, (20 / (50 * 1.1)) * 184)
|
|
353
|
+
approx(segA.height + segB.height, (50 / (50 * 1.1)) * 184)
|
|
337
354
|
approx(segB.y + segB.height, segA.y)
|
|
338
355
|
})
|
|
339
356
|
|
|
357
|
+
test('trendLayout 数据峰不顶满绘图区,留白系数 1.1', () => {
|
|
358
|
+
const layout = trendLayout([{ day: 'd', total: 30, byModel: { a: 30 } }], ['a'], 720, 46)
|
|
359
|
+
const [segA] = layout.bars[0].segments
|
|
360
|
+
approx(segA.height + CHART_PAD.top, CHART_PAD.top + (1 / 1.1) * 184)
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
test('trendLayout 可见模型无数据时左轴刻度为空表', () => {
|
|
364
|
+
const slots = [{ day: 'd', total: 50, byModel: { a: 50 } }]
|
|
365
|
+
assert.deepEqual(trendLayout(slots, [], 720, 46).ticks, [])
|
|
366
|
+
assert.deepEqual(trendLayout(slots, ['b'], 720, 46).ticks, [])
|
|
367
|
+
assert.ok(trendLayout(slots, ['a'], 720, 46).ticks.length > 0)
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
test('leftAxisTicks 速度模式标定速度刻度,否则标定 token 刻度', () => {
|
|
371
|
+
const tokenTicks = leftAxisTicks([100, 200], 200, [], 1)
|
|
372
|
+
assert.deepEqual(tokenTicks.map((tick) => tick.label), ['100', '200'])
|
|
373
|
+
approx(tokenTicks[0].ratio, 0.5)
|
|
374
|
+
const speedTicks = leftAxisTicks([], 1, [0.5, 1], 1)
|
|
375
|
+
assert.deepEqual(speedTicks.map((tick) => tick.label), ['0.5', '1'])
|
|
376
|
+
approx(speedTicks[0].ratio, 0.5)
|
|
377
|
+
approx(speedTicks[1].ratio, 1)
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
test('hour/minute 悬浮窗槽标签:T 换空格,小时带 h 后缀,分钟保 HH:MM', () => {
|
|
381
|
+
assert.equal(hourSlotLabel('2026-03-15T14'), '2026-03-15 14h')
|
|
382
|
+
assert.equal(hourSlotLabel('2026-03-15T00'), '2026-03-15 00h')
|
|
383
|
+
assert.equal(minuteSlotLabel('2026-03-15T14:30'), '2026-03-15 14:30')
|
|
384
|
+
assert.equal(minuteSlotLabel('2026-03-15T00:00'), '2026-03-15 00:00')
|
|
385
|
+
})
|
|
386
|
+
|
|
340
387
|
test('trendLayout 标签密度随列距收敛', () => {
|
|
341
388
|
const slots = Array.from({ length: 100 }, (_, i) => ({ day: `d${i}`, total: 0, byModel: {} }))
|
|
342
389
|
const layout = trendLayout(slots, [], 720, 46)
|
|
@@ -473,6 +520,61 @@ test('trendRatePoints 点映射列中心与百分比高度且零数据日跳过'
|
|
|
473
520
|
approx(points[1].y, CHART_PAD.top + plotHeight - 0.5 * plotHeight)
|
|
474
521
|
})
|
|
475
522
|
|
|
523
|
+
test('trendSpeedPoints 点映射列中心与刻度上限比例高度且无速度槽跳过', () => {
|
|
524
|
+
const slots = [
|
|
525
|
+
{ day: 'd0', speed: 50 },
|
|
526
|
+
{ day: 'd1', total: 10 },
|
|
527
|
+
{ day: 'd2', speed: 25 },
|
|
528
|
+
]
|
|
529
|
+
const bars = [{ x: 10 }, { x: 20 }, { x: 30 }]
|
|
530
|
+
const plotHeight = CHART_HEIGHT - CHART_PAD.top - CHART_PAD.bottom
|
|
531
|
+
const points = trendSpeedPoints(slots, bars, plotHeight, 100)
|
|
532
|
+
assert.equal(points.length, 2)
|
|
533
|
+
assert.equal(points[0].day, 'd0')
|
|
534
|
+
assert.equal(points[0].x, 10)
|
|
535
|
+
approx(points[0].y, CHART_PAD.top + plotHeight - 0.5 * plotHeight)
|
|
536
|
+
assert.equal(points[1].day, 'd2')
|
|
537
|
+
assert.equal(points[1].x, 30)
|
|
538
|
+
approx(points[1].y, CHART_PAD.top + plotHeight - 0.25 * plotHeight)
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
test('speedTipText 无速度为占位符有速度为官方吞吐口径', () => {
|
|
542
|
+
assert.equal(speedTipText(undefined), '—')
|
|
543
|
+
assert.equal(speedTipText(5.5556), '5.6 tok/s')
|
|
544
|
+
assert.equal(speedTipText(12.34), '12 tok/s')
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
test('legendToggle 普通点击单选,再点恢复全部,ctrl 多选且禁全藏', () => {
|
|
548
|
+
// null 表示全部可见
|
|
549
|
+
assert.deepEqual(legendToggle(null, 'a', false), new Set(['a']))
|
|
550
|
+
// 单选态再点同项恢复全部(null)
|
|
551
|
+
assert.equal(legendToggle(new Set(['a']), 'a', false), null)
|
|
552
|
+
// 单选态点他项 → 切换单选目标
|
|
553
|
+
assert.deepEqual(legendToggle(new Set(['a']), 'b', false), new Set(['b']))
|
|
554
|
+
// ctrl 切换单项:加入与移除
|
|
555
|
+
assert.deepEqual(legendToggle(new Set(['a']), 'b', true), new Set(['a', 'b']))
|
|
556
|
+
assert.deepEqual(legendToggle(new Set(['a', 'b']), 'b', true), new Set(['a']))
|
|
557
|
+
// ctrl 隐藏最后一项:无操作,保持原态
|
|
558
|
+
const solo = new Set(['a'])
|
|
559
|
+
assert.equal(legendToggle(solo, 'a', true), solo)
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
test('trendLayout 刻度跟随可见模型:单模型刻度按该槽内合计归一', () => {
|
|
563
|
+
const slots = [
|
|
564
|
+
{ day: 'd0', total: 50, byModel: { a: 30, b: 20 } },
|
|
565
|
+
{ day: 'd1', total: 90, byModel: { a: 40, b: 50 } },
|
|
566
|
+
]
|
|
567
|
+
assert.equal(trendLayout(slots, ['a', 'b'], 720, 46).maxTotal, 90)
|
|
568
|
+
assert.equal(trendLayout(slots, ['a'], 720, 46).maxTotal, 40)
|
|
569
|
+
assert.equal(trendLayout(slots, [], 720, 46).maxTotal, 1)
|
|
570
|
+
})
|
|
571
|
+
|
|
572
|
+
test('speedScaleMax 全零或空槽钳底防除零,混合取最大速度', () => {
|
|
573
|
+
assert.equal(speedScaleMax([]), 1)
|
|
574
|
+
assert.equal(speedScaleMax([{ day: 'd0' }, { day: 'd1', speed: 0 }]), 1)
|
|
575
|
+
assert.equal(speedScaleMax([{ day: 'd0', speed: 30 }, { day: 'd1', speed: 12.5 }]), 30)
|
|
576
|
+
})
|
|
577
|
+
|
|
476
578
|
test('smoothPath 空点集为空串单点为移动命令', () => {
|
|
477
579
|
assert.equal(smoothPath([]), '')
|
|
478
580
|
assert.equal(smoothPath([{ x: 1, y: 2 }]), 'M 1 2')
|
package/test/query.test.mjs
CHANGED
|
@@ -296,6 +296,26 @@ test('纯缓存行带时长:输出 0 计入分母,speed 为 0', () => {
|
|
|
296
296
|
assert.equal(out.models[0].speed, 0)
|
|
297
297
|
})
|
|
298
298
|
|
|
299
|
+
test('槽级 speed 配对聚合:同槽带时长行合计,无时长槽无 speed 字段', () => {
|
|
300
|
+
const rows = [
|
|
301
|
+
makeRow({ bucket: '2020-01-01', model: 'm1', provider: 'p1', outputTokens: 20, durationMs: 6500 }),
|
|
302
|
+
makeRow({ bucket: '2020-01-01', model: 'm2', provider: 'p2', outputTokens: 30, durationMs: 2500 }),
|
|
303
|
+
makeRow({ bucket: '2020-01-02', model: 'm1', provider: 'p1', outputTokens: 100 }),
|
|
304
|
+
]
|
|
305
|
+
const out = aggregateRange(rows, 'D', '2020-01-01', '2020-01-02')
|
|
306
|
+
assert.equal(out.daily[0].speed, (20 + 30) / ((6500 + 2500) / 1000))
|
|
307
|
+
assert.equal('speed' in out.daily[1], false)
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
test('attachCosts 保留槽级 speed 字段供曲线消费', () => {
|
|
311
|
+
const rows = [
|
|
312
|
+
makeRow({ bucket: '2020-01-01T01', model: 'm1', provider: 'p1', outputTokens: 20, durationMs: 4000 }),
|
|
313
|
+
]
|
|
314
|
+
const result = aggregateRange(rows, 'H', '2020-01-01T00', '2020-01-01T02')
|
|
315
|
+
const out = attachCosts(result, rows, 'H', [ruleOf()])
|
|
316
|
+
assert.equal(out.daily[1].speed, 5)
|
|
317
|
+
})
|
|
318
|
+
|
|
299
319
|
test('attachCosts H 槽按桶起点计价并归集 totals 与 models', () => {
|
|
300
320
|
const rows = [
|
|
301
321
|
makeRow({ bucket: '2020-01-01T01', model: 'm1', provider: 'p1', inputTokens: 1000000, outputTokens: 500000 }),
|