From 2b5485c4aa0729fb1eaab3ba5a3e15a73722b8af Mon Sep 17 00:00:00 2001 From: AITNR Date: Mon, 5 Oct 2026 00:37:22 +0800 Subject: [PATCH] fix(dashboard): avoid double-counting cached trend tokens --- docs/issue-83-trend-total-tokens-fix-plan.md | 109 +++++++++++++++ internal/plugin/dashboard.go | 10 +- .../dashboard_date_range_browser_test.go | 14 +- test/dashboard_date_range.mjs | 127 +++++++++++++++++- 4 files changed, 248 insertions(+), 12 deletions(-) create mode 100644 docs/issue-83-trend-total-tokens-fix-plan.md diff --git a/docs/issue-83-trend-total-tokens-fix-plan.md b/docs/issue-83-trend-total-tokens-fix-plan.md new file mode 100644 index 0000000..c041c40 --- /dev/null +++ b/docs/issue-83-trend-total-tokens-fix-plan.md @@ -0,0 +1,109 @@ +# Issue #83:趋势图总 Tokens 和缓存读取重复计数修复计划 + +- Issue:https://github.com/AITNR/cap-token-usage-tracker/issues/83 +- 编写日期:2026-10-04(Asia/Shanghai) +- 核查基线:alpha / 5d3cc471ffe27df244f8c5bc4d83be3ca6ca29d5(v2.0.9) +- 执行模型:gpt-5.6-luna +- 状态:已实施;主代理完成代码审查及回归验收,待提交推送。 + +## 1. 核查结论与证据 + +该 bug 在当前源码中仍存在。GitHub alpha HEAD 与本地核查基线相同;GitHub main(59af056315feb31e77b9647e78c3e676c6dab6b4)也已通过读取当前源文件确认同样的三项重复计数/堆叠逻辑。工作区在核查开始时干净。 + +`internal/plugin/dashboard.go` 中: + +1. `pointStackTotal(point)` 累加可见的 input、output 和 cacheRead。 +2. `showBarTooltip()` 使用该函数显示“总 Tokens”。 +3. `renderBar()` 使用该函数计算 SVG Y 轴最大值,缓存 rect 位于 `base-inputH-outputH-cacheReadH`。 +4. `exportPNG()` 使用该函数计算导出图比例,缓存 rect 位于 `chartY+chartH-inputH-outputH-cacheReadH`。 +5. `aggregateTrend()` 已累加后端 `total_tokens` 到 `bucket.total`,但 tooltip 没有使用它。 + +直接从现有源码提取并执行 pointStackTotal,输入 Issue 的示例: + +| 字段 | 数值 | +| --- | ---: | +| input | 48,342,180 | +| output | 168,768 | +| cacheRead | 46,221,824 | +| 后端 total | 48,511,163 | +| input + output | 48,510,948 | +| 当前 tooltip / Y 轴计算值 | 94,732,772 | +| 相对于 input + output 重复计入的缓存量 | 46,221,824 | + +Issue 的主要根因成立,但“后端 total 永远等于 input+output”并不适用于所有记录。`usage.go:decodeUsage()` 保留上游提供的 TotalTokens;没有有效总量时回退 input+output+reasoning,必要时回退 cached。此次修复须保留后端总量语义,不更改后台统计。 + +## 2. 目标与范围 + +修复页面 tooltip、SVG 柱高与 Y 轴、PNG 导出,让缓存读取作为输入的构成部分展示。保持模型筛选、时间聚合、缩放、系列图例开关和缓存命中率功能。 + +本次实现仅修改内嵌 dashboard 和相关回归测试。不得修改 bbolt 格式、用量 API、历史数据、计费逻辑、reasoning/TPS 计算或其他 provider 的缓存统计语义。不上线、不推送、不创建 PR。 + +## 3. 实施步骤 + +### A. 分离业务总量与绘图高度 + +1. tooltip 的“总 Tokens”显示当前时间桶 `point.total`,即 aggregateTrend 已汇总的后端 total_tokens。兼容确实缺失总量的测试/旧数据时,可用 input+output 作明确的安全回退;有效的 0 总量不得被 truthy 检查误判。 +2. 图例仅控制明细行及图形;切换 input/output/cacheRead 不改变时间桶业务总量,模型筛选则按现有 aggregateTrend 重新汇总。 +3. pointStackTotal 改为可见几何总高度(保留函数名以减少影响): + - input 可见:输入贡献 = input;缓存不再另外累加。 + - input 隐藏、cacheRead 可见:输入贡献 = min(cacheRead, input),缓存可以独立显示。 + - input 与 cacheRead 均隐藏:输入贡献 = 0。 + - output 可见时叠加 output。 +4. 数值以非负、有限值处理。缓存绘图量夹在 [0,input],防止历史异常 cacheRead>input 让覆盖区域溢出或倒置;tooltip 可保留原始缓存明细,不改数据。可抽取小的共享布局 helper,供 SVG 与 canvas 使用,避免两条路径分叉。 + +### B. 修正 SVG 几何 + +1. 显示 input 时,input 背景仍覆盖完整输入高度,output 位于 input 之上。 +2. cacheRead 覆盖输入区域的上层:y=base-inputH,height=clampedCache/max*plotH。覆盖必须在 [base-inputH,base] 内,不占用 output 区域。 +3. input 隐藏且 cacheRead 显示时,将缓存部分作为唯一的可见输入贡献,从 baseline 向上画;output 接在该贡献之上。避免缓存单独显示时 max=1 或柱子越界。 +4. 全部系列隐藏、无数据和 input=0 时保持现有空态且不产生 NaN、Infinity 或负矩形尺寸。 + +### C. 同步 PNG 导出 + +exportPNG 使用与 SVG 相同的可见高度和覆盖规则。保留既有导出布局、费用/环图展示、模型筛选与导出权限。 + +### D. 添加行为回归覆盖 + +使用仓库现有 Go + playwright-core + 已安装 Chrome 的浏览器测试机制,可新增专门的 Node 场景与 Go 驱动。不能只增加源码字符串断言;核心断言应实际执行页面代码和验证矩形几何、tooltip/轴标签,PNG 可通过捕获真实 canvas fillRect 或像素验证。 + +覆盖至少: + +- input=100、output=20、cacheRead=80、total=125:tooltip 总量为125,完整柱高/轴比例按120绘制,缓存位于输入内部。 +- Issue 数值:旧结果94,732,772变为几何48,510,948;tooltip为后端48,511,163。 +- 隐藏缓存:总量、完整柱高及Y轴不变化,仅缓存覆盖消失。 +- 隐藏输入但显示缓存和输出:几何高度为100(80+20),tooltip总量仍125;只缓存为80,只输出为20。 +- 无缓存、缓存等于输入、缓存超过输入、输入为0但缓存非0、全部隐藏和空桶。 +- 历史 cached_tokens 回退仍工作。 +- 多模型/多桶聚合和模型隐藏或下钻:缓存叠绘和业务总量按当前过滤正确变化。 +- SVG 与 PNG 使用同样的规则;正常 dashboard 与 Full Mode 的生成HTML均包含修复。 + +## 4. 验证与验收 + +PowerShell 中设置: + +```powershell +$env:CHROME_PATH = 'C:\Program Files\Google\Chrome\Application\chrome.exe' +$env:REQUIRE_BROWSER_TESTS = '1' +``` + +执行针对本问题的行为回归,确认旧实现会失败、新实现通过。再执行: + +```powershell +go test ./... -count=1 +go vet ./... +git diff --check +``` + +Go 文件执行 gofmt。现有 node_modules/playwright-core 与 Chrome 已检查存在,package.json 没有 npm test 脚本。若环境导致检查不可执行,报告具体限制,不能把跳过当成通过。 + +验收要求:缓存不重复抬高柱高或Y轴;tooltip使用后端总量;图例开关没有越界;PNG与SVG一致;全部既有检查通过且无无关修改。执行完成由主代理审查实际 diff 和验证证据,并更新本计划状态。 +## 5. 实施与验收记录(2026-10-05) + +- gpt-5.6-luna 完成核心实现和 SVG/tooltip 浏览器回归。 +- 主代理审查后将运行时字符串补丁改为直接修改共用 HTML 模板,避免未来模板调整导致替换失效。 +- 缓存覆盖输入区域上部;SVG 和 PNG 共用 trendGeometry,图例控制可见高度,tooltip 读取业务总量。 +- 增加 Full Mode 浏览器回归并捕获真实 PNG canvas fillRect 参数,验证正常显示、隐藏缓存及隐藏输入后的几何一致性;没有进行 PNG 文件像素解码比较。 +- 验证 Issue 数值、缓存超过输入、输入为零、仅缓存、全部隐藏、有效零总量、缺失总量回退及历史 cached_tokens 回退。 +- 原有模型筛选与聚合路径保留;本次未新增多模型下钻专项浏览器场景。 +- 验证命令:启用 REQUIRE_BROWSER_TESTS=1 和 CHROME_PATH 后执行 go test ./... -count=1;另执行 go vet ./...、node --check test/dashboard_date_range.mjs、git diff --check。 +- 用户已授权审查无问题后提交并推送当前 alpha 分支。 \ No newline at end of file diff --git a/internal/plugin/dashboard.go b/internal/plugin/dashboard.go index 2f4ad46..135d624 100644 --- a/internal/plugin/dashboard.go +++ b/internal/plugin/dashboard.go @@ -558,9 +558,9 @@ function continuousBuckets(map,granularity,emptyBucket){var keys=Object.keys(map function emptyCostBucket(info){return {key:info.key,label:info.label,stamp:info.stamp,hasData:false,requests:0,priced_requests:0,unpriced_requests:0,input_usd:0,output_usd:0,cache_read_usd:0,cache_creation_usd:0,total_usd:0};} function aggregateCostSeries(){if(!currentCosts)return [];var granularity=document.getElementById('granularity').value,map=Object.create(null);((currentCosts&¤tCosts.series)||[]).forEach(function(point){var name=modelName(point.model);if(hiddenModels.has(name)||selectedModel&&name!==selectedModel)return;var info=bucketInfo(point.hour,granularity);if(!map[info.key])map[info.key]=emptyCostBucket(info);var bucket=map[info.key];bucket.hasData=true;sumCostInto(bucket,point);});return continuousBuckets(map,granularity,emptyCostBucket);} function trendCacheReadTokens(point){var cacheRead=Number(point.cache_read_tokens||0);return cacheRead>0?cacheRead:Number(point.cached_tokens||0);} -function trendCacheHitRate(input,cacheRead){input=Math.max(0,Number(input||0));cacheRead=Math.max(0,Number(cacheRead||0));return input?Math.min(100,cacheRead/input*100):0;}function trendSeriesVisible(key){return !hiddenTrendSeries.has(key);}function trendSeriesLabel(key){if(key==='input')return t('table.input');if(key==='output')return t('table.output');if(key==='cacheRead')return t('table.cacheRead');return t('trend.cacheHitRate');}function syncTrendSeriesButtons(){document.querySelectorAll('.series-key-button[data-series]').forEach(function(button){var key=button.getAttribute('data-series'),visible=trendSeriesVisible(key);button.setAttribute('aria-pressed',visible?'true':'false');button.title=visible?t('trend.series.hide',{series:trendSeriesLabel(key)}):t('trend.series.show',{series:trendSeriesLabel(key)});button.setAttribute('aria-label',button.title);});}function toggleTrendSeries(key){if(!key)return;if(hiddenTrendSeries.has(key))hiddenTrendSeries.delete(key);else hiddenTrendSeries.add(key);syncTrendSeriesButtons();renderBar();}function activateOnKeyboard(node,action){node.addEventListener('keydown',function(event){if(event.key==='Enter'||event.key===' '){event.preventDefault();action();}});}function pointStackTotal(point){var total=0;if(trendSeriesVisible('input'))total+=Number(point.input||0);if(trendSeriesVisible('output'))total+=Number(point.output||0);if(trendSeriesVisible('cacheRead'))total+=Number(point.cacheRead||0);return total;} +function trendCacheHitRate(input,cacheRead){input=Math.max(0,Number(input||0));cacheRead=Math.max(0,Number(cacheRead||0));return input?Math.min(100,cacheRead/input*100):0;}function trendSeriesVisible(key){return !hiddenTrendSeries.has(key);}function trendSeriesLabel(key){if(key==='input')return t('table.input');if(key==='output')return t('table.output');if(key==='cacheRead')return t('table.cacheRead');return t('trend.cacheHitRate');}function syncTrendSeriesButtons(){document.querySelectorAll('.series-key-button[data-series]').forEach(function(button){var key=button.getAttribute('data-series'),visible=trendSeriesVisible(key);button.setAttribute('aria-pressed',visible?'true':'false');button.title=visible?t('trend.series.hide',{series:trendSeriesLabel(key)}):t('trend.series.show',{series:trendSeriesLabel(key)});button.setAttribute('aria-label',button.title);});}function toggleTrendSeries(key){if(!key)return;if(hiddenTrendSeries.has(key))hiddenTrendSeries.delete(key);else hiddenTrendSeries.add(key);syncTrendSeriesButtons();renderBar();}function activateOnKeyboard(node,action){node.addEventListener('keydown',function(event){if(event.key==='Enter'||event.key===' '){event.preventDefault();action();}});}function trendNumber(value){value=Number(value);return Number.isFinite(value)&&value>0?value:0;}function trendBusinessTotal(point){if(point&&Object.prototype.hasOwnProperty.call(point,'total')){var total=Number(point.total);if(Number.isFinite(total)&&total>=0)return total;}return trendNumber(point&&point.input)+trendNumber(point&&point.output);}function trendGeometry(point,showInput,showOutput,showCacheRead){var input=trendNumber(point&&point.input),output=trendNumber(point&&point.output),cacheRead=Math.min(input,trendNumber(point&&point.cacheRead)),baseHeight=showInput?input:(showCacheRead?cacheRead:0);return {inputHeight:showInput?input:0,outputHeight:showOutput?output:0,cacheHeight:showCacheRead?cacheRead:0,baseHeight:baseHeight,total:baseHeight+(showOutput?output:0)};}function pointStackTotal(point){return trendGeometry(point,trendSeriesVisible('input'),trendSeriesVisible('output'),trendSeriesVisible('cacheRead')).total;} function emptyTrendBucket(info){return {key:info.key,label:info.label,stamp:info.stamp,hasData:false,input:0,output:0,cacheRead:0,cacheHitRate:null,total:0,requests:0,cost:null,priced_requests:0,unpriced_requests:0};} -function aggregateTrend(){var raw=(currentData.model_series||[]).slice();if(!raw.length){raw=(currentData.series||[]).map(function(point){var copy=Object.assign({},point);copy.model=t('chart.allModels');return copy;});}var granularity=document.getElementById('granularity').value,map=Object.create(null),costMap=Object.create(null);aggregateCostSeries().forEach(function(point){costMap[point.key]=point;});raw.forEach(function(point){var name=modelName(point.model);if(hiddenModels.has(name))return;if(selectedModel&&name!==selectedModel)return;var info=bucketInfo(point.hour,granularity);if(!map[info.key])map[info.key]=emptyTrendBucket(info);var bucket=map[info.key];bucket.hasData=true;bucket.input+=Number(point.input_tokens||0);bucket.output+=Number(point.output_tokens||0);bucket.cacheRead+=trendCacheReadTokens(point);bucket.total+=Number(point.total_tokens||0);bucket.requests+=Number(point.requests||0);});return continuousBuckets(map,granularity,emptyTrendBucket).map(function(item){var cost=costMap[item.key];item.cacheHitRate=item.hasData?trendCacheHitRate(item.input,item.cacheRead):null;if(cost&&cost.hasData){item.cost=Number(cost.total_usd||0);item.priced_requests=Number(cost.priced_requests||0);item.unpriced_requests=Number(cost.unpriced_requests||0);}return item;});} +function aggregateTrend(){var raw=(currentData.model_series||[]).slice();if(!raw.length){raw=(currentData.series||[]).map(function(point){var copy=Object.assign({},point);copy.model=t('chart.allModels');return copy;});}var granularity=document.getElementById('granularity').value,map=Object.create(null),costMap=Object.create(null);aggregateCostSeries().forEach(function(point){costMap[point.key]=point;});raw.forEach(function(point){var name=modelName(point.model);if(hiddenModels.has(name))return;if(selectedModel&&name!==selectedModel)return;var info=bucketInfo(point.hour,granularity);if(!map[info.key])map[info.key]=emptyTrendBucket(info);var bucket=map[info.key];bucket.hasData=true;var inputTokens=trendNumber(point.input_tokens),outputTokens=trendNumber(point.output_tokens),rawTotal=Number(point.total_tokens),hasTotal=Object.prototype.hasOwnProperty.call(point,'total_tokens');bucket.input+=inputTokens;bucket.output+=outputTokens;bucket.cacheRead+=trendCacheReadTokens(point);bucket.total+=hasTotal&&Number.isFinite(rawTotal)&&rawTotal>=0?rawTotal:inputTokens+outputTokens;bucket.requests+=trendNumber(point.requests);});return continuousBuckets(map,granularity,emptyTrendBucket).map(function(item){var cost=costMap[item.key];item.cacheHitRate=item.hasData?trendCacheHitRate(item.input,item.cacheRead):null;if(cost&&cost.hasData){item.cost=Number(cost.total_usd||0);item.priced_requests=Number(cost.priced_requests||0);item.unpriced_requests=Number(cost.unpriced_requests||0);}return item;});} function resetTrendZoom(zoom){zoom.start=0;zoom.size=0;} function resetTrendZooms(){resetTrendZoom(tokenTrendZoom);resetTrendZoom(costTrendZoom);} function visibleTrend(points,zoom){if(!points.length)return points;if(!zoom.size)return points;if(zoom.size>=points.length){zoom.start=0;zoom.size=points.length;return points;}zoom.size=Math.max(1,Math.min(zoom.size,points.length));zoom.start=Math.max(0,Math.min(zoom.start,points.length-zoom.size));return points.slice(zoom.start,zoom.start+zoom.size);} @@ -572,11 +572,11 @@ function showEfficiencyTooltip(event,item){tooltipTitle(item.model);tooltipRow(t function renderCostTrend(){var svg=document.getElementById('costChart'),fragment=document.createDocumentFragment(),all=currentCosts?aggregateCostSeries():[];lastCostTrend=all;var series=trendWindow(all,costTrendZoom),minVisible=Math.min(2,all.length),costSize=trendZoomSize(all,costTrendZoom),costReset=document.getElementById('costResetZoomButton'),costZoomIn=document.getElementById('costZoomInButton'),costZoomOut=document.getElementById('costZoomOutButton');costReset.hidden=!all.length||costSize>=all.length;costZoomIn.disabled=all.length<2||costSize<=minVisible;costZoomOut.disabled=all.length<2||costSize>=all.length;var metrics=chartMetrics(svg,300),width=metrics.width,height=metrics.height,left=width<480?58:68,right=14,top=20,bottom=48,plotW=width-left-right,plotH=height-top-bottom,totalCost=series.reduce(function(sum,item){return sum+Number(item.total_usd||0);},0),coverage=series.reduce(function(result,item){result.requests+=Number(item.requests||0);result.priced_requests+=Number(item.priced_requests||0);result.unpriced_requests+=Number(item.unpriced_requests||0);return result;},{requests:0,priced_requests:0,unpriced_requests:0});text('costTrendMeta',!currentCosts?t('status.costUnavailable'):series.length?money(totalCost)+' · '+coverageText(coverage):t('locale.unavailable'));for(var tick=0;tick<=4;tick++){var y=top+plotH*tick/4;fragment.appendChild(svgNode('line',{x1:left,y1:y,x2:width-right,y2:y,class:'chart-grid'}));}fragment.appendChild(svgNode('line',{x1:left,y1:top+plotH,x2:width-right,y2:top+plotH,class:'axis'}));if(!currentCosts){var unavailable=svgNode('text',{x:width/2,y:height/2,class:'chart-empty chart-empty-title'});unavailable.textContent=t('chart.costUnavailable');fragment.appendChild(unavailable);svg.replaceChildren(fragment);return;}if(!series.length){var empty=svgNode('text',{x:width/2,y:height/2,class:'chart-empty chart-empty-title'});empty.textContent=t('chart.noCost');fragment.appendChild(empty);svg.replaceChildren(fragment);return;}var max=series.reduce(function(value,item){return Math.max(value,Number(item.total_usd||0));},0);if(max<=0)max=1;for(var labelTick=0;labelTick<=4;labelTick++){var value=max*(4-labelTick)/4,label=svgNode('text',{x:left-8,y:top+plotH*labelTick/4+3,class:'axis-label','text-anchor':'end'});label.textContent=money(value);fragment.appendChild(label);}var points=series.map(function(item,index){return {x:left+(series.length===1?plotW/2:index*plotW/(series.length-1)),y:top+plotH-Number(item.total_usd||0)/max*plotH,item:item};}),line=points.map(function(point,index){return (index?'L':'M')+point.x.toFixed(2)+' '+point.y.toFixed(2);}).join(' '),area=line+' L '+points[points.length-1].x+' '+(top+plotH)+' L '+points[0].x+' '+(top+plotH)+' Z';fragment.appendChild(svgNode('path',{d:area,class:'line-area'}));fragment.appendChild(svgNode('path',{d:line,class:'line-path'}));var labelEvery=Math.max(1,Math.ceil(series.length/Math.max(2,Math.floor(plotW/90))));points.forEach(function(point,index){var hit=svgNode('circle',{cx:point.x,cy:point.y,r:series.length>80?3:4.5,class:'line-hit',tabindex:0});hit.addEventListener('mouseenter',function(event){showCostTooltip(event,point.item);});hit.addEventListener('mousemove',moveTooltip);hit.addEventListener('mouseleave',hideTooltip);hit.addEventListener('focus',function(event){showCostTooltip(event,point.item);});hit.addEventListener('blur',hideTooltip);fragment.appendChild(hit);if(index%labelEvery===0||index===points.length-1){var label=svgNode('text',{x:point.x,y:height-bottom+20,class:'axis-label','text-anchor':'middle'});label.textContent=point.item.label;fragment.appendChild(label);}});svg.replaceChildren(fragment);} function renderEfficiency(){var svg=document.getElementById('efficiencyChart'),fragment=document.createDocumentFragment(),models=groupModels(visibleGroups()).filter(function(item){return item.requests>0;}),metrics=chartMetrics(svg,300),width=metrics.width,height=metrics.height,left=width<480?60:74,right=18,top=20,bottom=54,plotW=width-left-right,plotH=height-top-bottom;for(var tick=0;tick<=4;tick++){var x=left+plotW*tick/4,y=top+plotH*tick/4;fragment.appendChild(svgNode('line',{x1:left,y1:y,x2:width-right,y2:y,class:'chart-grid'}));fragment.appendChild(svgNode('line',{x1:x,y1:top,x2:x,y2:top+plotH,class:'chart-grid'}));}fragment.appendChild(svgNode('line',{x1:left,y1:top+plotH,x2:width-right,y2:top+plotH,class:'axis'}));fragment.appendChild(svgNode('line',{x1:left,y1:top,x2:left,y2:top+plotH,class:'axis'}));text('efficiencyMeta',t('model.count',{count:models.length}));if(!models.length){var empty=svgNode('text',{x:width/2,y:height/2,class:'chart-empty chart-empty-title'});empty.textContent=t('chart.noEfficiency');fragment.appendChild(empty);svg.replaceChildren(fragment);return;}var maxX=models.reduce(function(value,item){return Math.max(value,item.average_tokens);},1),maxY=models.reduce(function(value,item){return Math.max(value,item.average_latency_ns/1e6);},1);for(var labelTick=0;labelTick<=4;labelTick++){var xValue=maxX*labelTick/4,xLabel=svgNode('text',{x:left+plotW*labelTick/4,y:height-bottom+20,class:'axis-label','text-anchor':'middle'});xLabel.textContent=compact(xValue);fragment.appendChild(xLabel);var yValue=maxY*(4-labelTick)/4,yLabel=svgNode('text',{x:left-8,y:top+plotH*labelTick/4+3,class:'axis-label','text-anchor':'end'});yLabel.textContent=yValue<1000?Math.round(yValue)+'ms':(yValue/1000).toFixed(1)+'s';fragment.appendChild(yLabel);}var xTitle=svgNode('text',{x:left+plotW/2,y:height-5,class:'axis-title','text-anchor':'middle'});xTitle.textContent=t('efficiency.averageTokens');fragment.appendChild(xTitle);var yTitle=svgNode('text',{x:13,y:top+plotH/2,class:'axis-title','text-anchor':'middle',transform:'rotate(-90 13 '+(top+plotH/2)+')'});yTitle.textContent=t('efficiency.averageLatency');fragment.appendChild(yTitle);models.forEach(function(item,index){var x=left+(item.average_tokens/maxX)*plotW,y=top+plotH-(item.average_latency_ns/1e6/maxY)*plotH,r=Math.max(6,Math.min(18,5+Math.log2(item.requests+1)*2)),point=svgNode('circle',{cx:x,cy:y,r:r,fill:colorFor(index),class:'scatter-point'+(selectedModel===item.model?' selected':selectedModel?' dimmed':''),tabindex:0,'aria-label':item.model});point.addEventListener('mouseenter',function(event){showEfficiencyTooltip(event,item);});point.addEventListener('mousemove',moveTooltip);point.addEventListener('mouseleave',hideTooltip);point.addEventListener('focus',function(event){showEfficiencyTooltip(event,item);});point.addEventListener('blur',hideTooltip);point.addEventListener('click',function(){selectModel(item.model);});fragment.appendChild(point);if(width>=560||selectedModel===item.model){var label=svgNode('text',{x:x,y:y-r-5,class:'axis-label','text-anchor':'middle'});label.textContent=width<760&&item.model.length>18?item.model.slice(0,17)+'…':item.model;fragment.appendChild(label);}});svg.replaceChildren(fragment);} function zoomTrend(points,zoom,factor,anchorRatio,render){if(points.length<2)return;var current=zoom.size||points.length,minVisible=Math.min(2,points.length),next=Math.round(current*factor);if(factor<1&&next===current)next=current-1;if(factor>1&&next===current)next=current+1;next=Math.max(minVisible,Math.min(points.length,next));var ratio=Math.max(0,Math.min(1,anchorRatio===undefined?.5:anchorRatio)),anchor=zoom.start+ratio*current;zoom.start=Math.round(anchor-ratio*next);zoom.size=next;zoom.start=Math.max(0,Math.min(points.length-next,zoom.start));render();} -function renderBar(){var svg=document.getElementById('chart'),fragment=document.createDocumentFragment(),all=aggregateTrend(),series=visibleTrend(all,tokenTrendZoom);lastTrend=all;var minVisible=Math.min(2,all.length),tokenSize=trendZoomSize(all,tokenTrendZoom);document.getElementById('resetZoomButton').hidden=!all.length||tokenSize>=all.length;document.getElementById('zoomInButton').disabled=all.length<2||tokenSize<=minVisible;document.getElementById('zoomOutButton').disabled=all.length<2||tokenSize>=all.length;syncTrendSeriesButtons();var metrics=chartMetrics(svg,330),width=metrics.width,height=metrics.height,left=width<480?50:58,right=width<480?40:48,top=18,bottom=52,plotW=width-left-right,plotH=height-top-bottom,showInput=trendSeriesVisible('input'),showOutput=trendSeriesVisible('output'),showCacheRead=trendSeriesVisible('cacheRead'),showCacheHit=trendSeriesVisible('cacheHitRate'),showTokenAxis=showInput||showOutput||showCacheRead;if(!showCacheHit)right=width<480?18:24;plotW=width-left-right;for(var tick=0;tick<=4;tick++){var y=top+plotH*tick/4;fragment.appendChild(svgNode('line',{x1:left,y1:y,x2:width-right,y2:y,class:'chart-grid'}));}fragment.appendChild(svgNode('line',{x1:left,y1:top+plotH,x2:width-right,y2:top+plotH,class:'axis'}));if(!series.length){fragment.appendChild(svgNode('circle',{cx:width/2,cy:top+plotH/2-25,r:28,fill:'none',stroke:'var(--border-primary)','stroke-width':2}));var emptyTitle=svgNode('text',{x:width/2,y:height/2+25,class:'chart-empty chart-empty-title'});emptyTitle.textContent=t('chart.noCalls');fragment.appendChild(emptyTitle);var emptyHint=svgNode('text',{x:width/2,y:height/2+47,class:'chart-empty'});emptyHint.textContent=t('empty.restoreModels');fragment.appendChild(emptyHint);svg.replaceChildren(fragment);return;}var max=series.reduce(function(value,p){return Math.max(value,pointStackTotal(p));},0);if(!showTokenAxis||max<=0)max=1;for(var labelTick=0;labelTick<=4;labelTick++){var labelValue=max*(4-labelTick)/4,yLabel=top+plotH*labelTick/4+3,label=svgNode('text',{x:left-8,y:yLabel,class:'axis-label','text-anchor':'end'});label.textContent=showTokenAxis?formatTokenTotal(labelValue):'';fragment.appendChild(label);if(showCacheHit){var rateLabel=svgNode('text',{x:width-right+7,y:yLabel,class:'cache-rate-label','text-anchor':'start'});rateLabel.textContent=(100-labelTick*25)+'%';fragment.appendChild(rateLabel);}}if(showCacheHit)fragment.appendChild(svgNode('line',{x1:width-right,y1:top,x2:width-right,y2:top+plotH,class:'cache-rate-axis'}));var slot=plotW/series.length,barW=Math.max(5,Math.min(38,slot*.64)),maxLabels=Math.max(2,Math.floor(plotW/85)),labelEvery=Math.max(1,Math.ceil(series.length/maxLabels)),rotate=slot<70,linePoints=[],anyVisibleSeries=showTokenAxis||showCacheHit;if(!anyVisibleSeries){var hiddenTitle=svgNode('text',{x:width/2,y:height/2+8,class:'chart-empty chart-empty-title'});hiddenTitle.textContent=t('trend.series.allHidden');fragment.appendChild(hiddenTitle);}series.forEach(function(point,index){var x=left+slot*index+(slot-barW)/2,inputH=showInput?point.input/max*plotH:0,outputH=showOutput?point.output/max*plotH:0,cacheReadH=showCacheRead?point.cacheRead/max*plotH:0,base=top+plotH;if(showInput&&inputH>0)fragment.appendChild(svgNode('rect',{x:x,y:base-inputH,width:barW,height:inputH,rx:Math.min(3,barW/3),class:'bar-input'}));if(showOutput&&outputH>0)fragment.appendChild(svgNode('rect',{x:x,y:base-inputH-outputH,width:barW,height:outputH,rx:Math.min(3,barW/3),class:'bar-output'}));if(showCacheRead&&cacheReadH>0)fragment.appendChild(svgNode('rect',{x:x,y:base-inputH-outputH-cacheReadH,width:barW,height:cacheReadH,rx:Math.min(3,barW/3),class:'bar-cache-read'}));if(showCacheHit&&point.hasData&&point.cacheHitRate!==null)linePoints.push({x:x+barW/2,y:top+plotH*(1-point.cacheHitRate/100),point:point,index:index});if(index%labelEvery===0||index===series.length-1){var labelX=x+barW/2,labelY=height-bottom+17,xLabel=svgNode('text',{x:labelX,y:labelY,class:'axis-label','text-anchor':rotate?'end':'middle'});if(rotate)xLabel.setAttribute('transform','rotate(-35 '+labelX+' '+labelY+')');xLabel.textContent=point.label;fragment.appendChild(xLabel);}});if(showCacheHit&&linePoints.length){var path='',previousIndex=-2;linePoints.forEach(function(item){path+=(item.index===previousIndex+1?'L':'M')+item.x.toFixed(2)+' '+item.y.toFixed(2)+' ';previousIndex=item.index;});fragment.appendChild(svgNode('path',{d:path.trim(),class:'cache-hit-line'}));linePoints.forEach(function(item){fragment.appendChild(svgNode('circle',{cx:item.x,cy:item.y,r:2.5,class:'cache-hit-point'}));});}series.forEach(function(point,index){var x=left+slot*index+(slot-barW)/2,parts=[];if(!point.hasData)parts.push(t('chart.noRequests'));else{if(showInput)parts.push(t('table.input')+' '+formatTokenTotal(point.input));if(showOutput)parts.push(t('table.output')+' '+formatTokenTotal(point.output));if(showCacheRead)parts.push(t('table.cacheRead')+' '+formatTokenTotal(point.cacheRead));if(showCacheHit&&point.cacheHitRate!==null)parts.push(t('trend.cacheHitRate')+' '+point.cacheHitRate.toFixed(1)+'%');}if(!parts.length)parts.push(t('trend.series.allHidden'));var hit=svgNode('rect',{x:x-2,y:top,width:barW+4,height:plotH,class:'bar-hit',tabindex:0,'aria-label':point.label+' '+parts.join(' ')});function show(event){showBarTooltip(event,point);}hit.addEventListener('mouseenter',show);hit.addEventListener('mousemove',moveTooltip);hit.addEventListener('mouseleave',hideTooltip);hit.addEventListener('focus',show);hit.addEventListener('blur',hideTooltip);fragment.appendChild(hit);});svg.replaceChildren(fragment);} +function renderBar(){var svg=document.getElementById('chart'),fragment=document.createDocumentFragment(),all=aggregateTrend(),series=visibleTrend(all,tokenTrendZoom);lastTrend=all;var minVisible=Math.min(2,all.length),tokenSize=trendZoomSize(all,tokenTrendZoom);document.getElementById('resetZoomButton').hidden=!all.length||tokenSize>=all.length;document.getElementById('zoomInButton').disabled=all.length<2||tokenSize<=minVisible;document.getElementById('zoomOutButton').disabled=all.length<2||tokenSize>=all.length;syncTrendSeriesButtons();var metrics=chartMetrics(svg,330),width=metrics.width,height=metrics.height,left=width<480?50:58,right=width<480?40:48,top=18,bottom=52,plotW=width-left-right,plotH=height-top-bottom,showInput=trendSeriesVisible('input'),showOutput=trendSeriesVisible('output'),showCacheRead=trendSeriesVisible('cacheRead'),showCacheHit=trendSeriesVisible('cacheHitRate'),showTokenAxis=showInput||showOutput||showCacheRead;if(!showCacheHit)right=width<480?18:24;plotW=width-left-right;for(var tick=0;tick<=4;tick++){var y=top+plotH*tick/4;fragment.appendChild(svgNode('line',{x1:left,y1:y,x2:width-right,y2:y,class:'chart-grid'}));}fragment.appendChild(svgNode('line',{x1:left,y1:top+plotH,x2:width-right,y2:top+plotH,class:'axis'}));if(!series.length){fragment.appendChild(svgNode('circle',{cx:width/2,cy:top+plotH/2-25,r:28,fill:'none',stroke:'var(--border-primary)','stroke-width':2}));var emptyTitle=svgNode('text',{x:width/2,y:height/2+25,class:'chart-empty chart-empty-title'});emptyTitle.textContent=t('chart.noCalls');fragment.appendChild(emptyTitle);var emptyHint=svgNode('text',{x:width/2,y:height/2+47,class:'chart-empty'});emptyHint.textContent=t('empty.restoreModels');fragment.appendChild(emptyHint);svg.replaceChildren(fragment);return;}var max=series.reduce(function(value,p){return Math.max(value,pointStackTotal(p));},0);if(!showTokenAxis||max<=0)max=1;for(var labelTick=0;labelTick<=4;labelTick++){var labelValue=max*(4-labelTick)/4,yLabel=top+plotH*labelTick/4+3,label=svgNode('text',{x:left-8,y:yLabel,class:'axis-label','text-anchor':'end'});label.textContent=showTokenAxis?formatTokenTotal(labelValue):'';fragment.appendChild(label);if(showCacheHit){var rateLabel=svgNode('text',{x:width-right+7,y:yLabel,class:'cache-rate-label','text-anchor':'start'});rateLabel.textContent=(100-labelTick*25)+'%';fragment.appendChild(rateLabel);}}if(showCacheHit)fragment.appendChild(svgNode('line',{x1:width-right,y1:top,x2:width-right,y2:top+plotH,class:'cache-rate-axis'}));var slot=plotW/series.length,barW=Math.max(5,Math.min(38,slot*.64)),maxLabels=Math.max(2,Math.floor(plotW/85)),labelEvery=Math.max(1,Math.ceil(series.length/maxLabels)),rotate=slot<70,linePoints=[],anyVisibleSeries=showTokenAxis||showCacheHit;if(!anyVisibleSeries){var hiddenTitle=svgNode('text',{x:width/2,y:height/2+8,class:'chart-empty chart-empty-title'});hiddenTitle.textContent=t('trend.series.allHidden');fragment.appendChild(hiddenTitle);}series.forEach(function(point,index){var x=left+slot*index+(slot-barW)/2,geometry=trendGeometry(point,showInput,showOutput,showCacheRead),inputH=geometry.inputHeight/max*plotH,outputH=geometry.outputHeight/max*plotH,cacheReadH=geometry.cacheHeight/max*plotH,base=top+plotH;if(showInput&&inputH>0)fragment.appendChild(svgNode('rect',{x:x,y:base-geometry.baseHeight/max*plotH,width:barW,height:inputH,rx:Math.min(3,barW/3),class:'bar-input'}));if(showCacheRead&&cacheReadH>0)fragment.appendChild(svgNode('rect',{x:x,y:base-geometry.baseHeight/max*plotH,width:barW,height:cacheReadH,rx:Math.min(3,barW/3),class:'bar-cache-read'}));if(showOutput&&outputH>0)fragment.appendChild(svgNode('rect',{x:x,y:base-geometry.baseHeight/max*plotH-outputH,width:barW,height:outputH,rx:Math.min(3,barW/3),class:'bar-output'}));if(showCacheHit&&point.hasData&&point.cacheHitRate!==null)linePoints.push({x:x+barW/2,y:top+plotH*(1-point.cacheHitRate/100),point:point,index:index});if(index%labelEvery===0||index===series.length-1){var labelX=x+barW/2,labelY=height-bottom+17,xLabel=svgNode('text',{x:labelX,y:labelY,class:'axis-label','text-anchor':rotate?'end':'middle'});if(rotate)xLabel.setAttribute('transform','rotate(-35 '+labelX+' '+labelY+')');xLabel.textContent=point.label;fragment.appendChild(xLabel);}});if(showCacheHit&&linePoints.length){var path='',previousIndex=-2;linePoints.forEach(function(item){path+=(item.index===previousIndex+1?'L':'M')+item.x.toFixed(2)+' '+item.y.toFixed(2)+' ';previousIndex=item.index;});fragment.appendChild(svgNode('path',{d:path.trim(),class:'cache-hit-line'}));linePoints.forEach(function(item){fragment.appendChild(svgNode('circle',{cx:item.x,cy:item.y,r:2.5,class:'cache-hit-point'}));});}series.forEach(function(point,index){var x=left+slot*index+(slot-barW)/2,parts=[];if(!point.hasData)parts.push(t('chart.noRequests'));else{if(showInput)parts.push(t('table.input')+' '+formatTokenTotal(point.input));if(showOutput)parts.push(t('table.output')+' '+formatTokenTotal(point.output));if(showCacheRead)parts.push(t('table.cacheRead')+' '+formatTokenTotal(point.cacheRead));if(showCacheHit&&point.cacheHitRate!==null)parts.push(t('trend.cacheHitRate')+' '+point.cacheHitRate.toFixed(1)+'%');}if(!parts.length)parts.push(t('trend.series.allHidden'));var hit=svgNode('rect',{x:x-2,y:top,width:barW+4,height:plotH,class:'bar-hit',tabindex:0,'aria-label':point.label+' '+parts.join(' ')});function show(event){showBarTooltip(event,point);}hit.addEventListener('mouseenter',show);hit.addEventListener('mousemove',moveTooltip);hit.addEventListener('mouseleave',hideTooltip);hit.addEventListener('focus',show);hit.addEventListener('blur',hideTooltip);fragment.appendChild(hit);});svg.replaceChildren(fragment);} function compact(value){value=Number(value||0);if(value>=1e9)return (value/1e9).toFixed(value>=1e10?0:1)+'B';if(value>=1e6)return (value/1e6).toFixed(value>=1e7?0:1)+'M';if(value>=1e3)return (value/1e3).toFixed(value>=1e4?0:1)+'K';return String(Math.round(value));} function tooltipRow(label,value,color){var row=document.createElement('div');row.className='tooltip-row';var left=document.createElement('span');if(color){var dot=document.createElement('i');dot.className='tooltip-dot';dot.style.background=color;left.appendChild(dot);}left.appendChild(document.createTextNode(label));var strong=document.createElement('strong');strong.textContent=value;row.appendChild(left);row.appendChild(strong);tooltip.appendChild(row);} function tooltipTitle(value){tooltip.replaceChildren();var title=document.createElement('div');title.className='tooltip-title';title.textContent=value;tooltip.appendChild(title);} -function showBarTooltip(event,point){tooltipTitle(point.label);if(!point.hasData){tooltipRow(t('chart.noRequests'),t('locale.unavailable'));showTooltip(event);return;}if(trendSeriesVisible('input'))tooltipRow(t('table.input'),formatTokenTotal(point.input),'var(--input-color)');if(trendSeriesVisible('output'))tooltipRow(t('table.output'),formatTokenTotal(point.output),'var(--output-color)');if(trendSeriesVisible('cacheRead'))tooltipRow(t('table.cacheRead'),formatTokenTotal(point.cacheRead),'var(--cache-read-color)');if(trendSeriesVisible('cacheHitRate'))tooltipRow(t('trend.cacheHitRate'),localeNumber(point.cacheHitRate,{minimumFractionDigits:1,maximumFractionDigits:1})+'%','var(--cache-hit-color)');tooltipRow(t('chart.totalTokens'),formatTokenTotal(pointStackTotal(point)));tooltipRow(t('chart.requests'),fmt(point.requests));tooltipRow(t('cost.exact'),point.cost===null?t('value.unavailable'):money(point.cost));if(point.cost!==null)tooltipRow(t('cost.coverage'),fmt(point.priced_requests)+' / '+fmt(point.priced_requests+point.unpriced_requests));showTooltip(event);} +function showBarTooltip(event,point){tooltipTitle(point.label);if(!point.hasData){tooltipRow(t('chart.noRequests'),t('locale.unavailable'));showTooltip(event);return;}if(trendSeriesVisible('input'))tooltipRow(t('table.input'),formatTokenTotal(point.input),'var(--input-color)');if(trendSeriesVisible('output'))tooltipRow(t('table.output'),formatTokenTotal(point.output),'var(--output-color)');if(trendSeriesVisible('cacheRead'))tooltipRow(t('table.cacheRead'),formatTokenTotal(point.cacheRead),'var(--cache-read-color)');if(trendSeriesVisible('cacheHitRate'))tooltipRow(t('trend.cacheHitRate'),localeNumber(point.cacheHitRate,{minimumFractionDigits:1,maximumFractionDigits:1})+'%','var(--cache-hit-color)');tooltipRow(t('chart.totalTokens'),formatTokenTotal(trendBusinessTotal(point)));tooltipRow(t('chart.requests'),fmt(point.requests));tooltipRow(t('cost.exact'),point.cost===null?t('value.unavailable'):money(point.cost));if(point.cost!==null)tooltipRow(t('cost.coverage'),fmt(point.priced_requests)+' / '+fmt(point.priced_requests+point.unpriced_requests));showTooltip(event);} function showTooltip(event){tooltip.classList.add('visible');moveTooltip(event);} function moveTooltip(event){var x=event.clientX||window.innerWidth/2,y=event.clientY||window.innerHeight/2,rect=tooltip.getBoundingClientRect(),left=Math.min(window.innerWidth-rect.width-10,x+14),top=Math.min(window.innerHeight-rect.height-10,y+14);tooltip.style.left=Math.max(8,left)+'px';tooltip.style.top=Math.max(8,top)+'px';} function hideTooltip(){tooltip.classList.remove('visible');} @@ -613,7 +613,7 @@ function csvCell(value){var string=value===undefined||value===null?'':String(val async function exportCSV(){if(!currentData)return;var button=document.getElementById('exportCSV'),rows=[t('export.csvHeaders').split(',')],offset=0,total=0,range=dateRangeFilePart();button.disabled=true;text('status',t('status.preparingCSV'));try{do{var url=requestsURL+'?'+dateRangeQuery()+'&offset='+offset+'&limit=500';if(selectedModel)url+='&model='+encodeURIComponent(selectedModel);var page=await api(url);total=Number(page.total||0);(page.items||[]).forEach(function(record){var name=modelName(record.model),estimated=record.estimated_cost||{};if(hiddenModels.has(name))return;rows.push([record.time,name,record.source,record.service_tier,translateRawResult(record.result,record.failed),record.ttft_ns,record.generation_ns,Number(record.tps||0).toFixed(4),record.reasoning_effort,record.input_tokens,record.output_tokens,record.reasoning_tokens,record.cache_read_tokens,record.cache_creation_tokens,record.total_tokens,record.cache_hit?t('cache.hit'):t('cache.miss'),estimated.priced?Number(estimated.input_usd||0).toFixed(8):'',estimated.priced?Number(estimated.output_usd||0).toFixed(8):'',estimated.priced?Number(estimated.cache_read_usd||0).toFixed(8):'',estimated.priced?Number(estimated.cache_creation_usd||0).toFixed(8):'',estimated.priced?Number(estimated.total_usd||0).toFixed(8):'',estimated.source||'',estimated.accounting_mode||'',estimated.tier_threshold||'',estimated.price_time_tier||'',estimated.price_time_zone||'']);});offset+=(page.items||[]).length;if(!(page.items||[]).length)break;}while(offset0){ctx.fillStyle=input;ctx.fillRect(x,chartY+chartH-inputH,barW,inputH);}if(showOutput&&outputH>0){ctx.fillStyle=output;ctx.fillRect(x,chartY+chartH-inputH-outputH,barW,outputH);}if(showCacheRead&&cacheReadH>0){ctx.fillStyle=cacheRead;ctx.fillRect(x,chartY+chartH-inputH-outputH-cacheReadH,barW,cacheReadH);}if(showCacheHit)cacheLine.push({x:x+barW/2,y:chartY+chartH*(1-point.cacheHitRate/100)});});if(showCacheHit&&cacheLine.length){ctx.save();ctx.strokeStyle=cacheHit;ctx.lineWidth=3;ctx.setLineDash([9,7]);ctx.beginPath();cacheLine.forEach(function(point,index){if(index)ctx.lineTo(point.x,point.y);else ctx.moveTo(point.x,point.y);});ctx.stroke();ctx.restore();}if(!trend.length)canvasText(ctx,t('chart.noCalls'),chartX+chartW/2,chartY+chartH/2,17,tertiary,600,'center');else if(!(showInput||showOutput||showCacheRead||showCacheHit))canvasText(ctx,t('trend.series.allHidden'),chartX+chartW/2,chartY+chartH/2,17,tertiary,600,'center');roundedRect(ctx,1088,282,448,620,14,panel,border);canvasText(ctx,t('export.modelShare')+' · '+modelShareMetricLabel(),1112,323,18,primary,750);var pieAll=groupModels(modelRows()),pieModels=pieAll.filter(function(item){return !hiddenModels.has(item.model);}),pieTotal=pieModels.reduce(function(sum,item){return sum+modelShareMetricValue(item);},0),angle=-Math.PI/2;pieModels.forEach(function(item){var next=angle+(pieTotal?modelShareMetricValue(item)/pieTotal*Math.PI*2:0);ctx.beginPath();ctx.arc(1312,525,118,angle,next);ctx.strokeStyle=colorFor(pieAll.indexOf(item));ctx.lineWidth=38;ctx.stroke();angle=next;});canvasText(ctx,formatModelShareMetric(pieTotal,true),1312,524,28,primary,750,'center');canvasText(ctx,modelShareMetricLabel(),1312,548,12,tertiary,400,'center');pieModels.slice(0,8).forEach(function(item,index){var y=700+index*23;ctx.fillStyle=colorFor(pieAll.indexOf(item));ctx.fillRect(1120,y-9,11,11);canvasText(ctx,item.model,1143,y,12,primary,600);canvasText(ctx,(pieTotal?modelShareMetricValue(item)/pieTotal*100:0).toFixed(1)+'%',1500,y,12,secondary,600,'right');});canvas.toBlob(function(blob){if(blob)downloadBlob(blob,'token-dashboard-'+document.getElementById('range').value+'.png');},'image/png');closeExportMenu();} +function exportPNG(){if(!currentData)return;var canvas=document.createElement('canvas');canvas.width=1600;canvas.height=980;var ctx=canvas.getContext('2d'),style=getComputedStyle(document.documentElement),bg=style.getPropertyValue('--bg-secondary').trim(),panel=style.getPropertyValue('--bg-primary').trim(),border=style.getPropertyValue('--border-color').trim(),primary=style.getPropertyValue('--text-primary').trim(),secondary=style.getPropertyValue('--text-secondary').trim(),tertiary=style.getPropertyValue('--text-tertiary').trim(),input=style.getPropertyValue('--input-color').trim(),output=style.getPropertyValue('--output-color').trim(),cacheRead=style.getPropertyValue('--cache-read-color').trim(),cacheHit=style.getPropertyValue('--cache-hit-color').trim();ctx.fillStyle=bg;ctx.fillRect(0,0,1600,980);canvasText(ctx,t('export.pngTitle'),64,70,34,primary,750);canvasText(ctx,t('export.pngRange',{range:currentData.range,time:new Date().toLocaleString(formatterLocale)}),64,101,15,secondary,400);var groups=visibleGroups(),summary={};groups.forEach(function(group){sumInto(summary,group);});var models=groupModels(groups),top=models.slice().sort(function(a,b){return b.requests-a.requests;})[0],costSummary=visibleCostSummary(),cards=[[t('card.totalTokens'),formatTokenTotal(summary.total_tokens)],[t('card.totalCost'),costSummary?money(costSummary.total_usd):t('value.unavailable')],[t('card.requests'),fmt(summary.requests)],[t('card.topModel'),top?top.model:t('locale.unavailable')]];cards.forEach(function(card,index){var x=64+index*374;roundedRect(ctx,x,130,350,126,14,panel,border);canvasText(ctx,card[0],x+22,164,14,secondary,650);canvasText(ctx,card[1],x+22,216,index===3?23:30,primary,750);});roundedRect(ctx,64,282,1000,620,14,panel,border);canvasText(ctx,t('trend.title'),88,323,18,primary,750);canvasText(ctx,selectedModel?t('export.model',{model:selectedModel}):t('export.visibleModels'),88,348,12,tertiary,400);var trend=lastTrend.length?lastTrend:aggregateTrend(),chartX=116,chartY=382,chartW=900,chartH=452,showInput=trendSeriesVisible('input'),showOutput=trendSeriesVisible('output'),showCacheRead=trendSeriesVisible('cacheRead'),showCacheHit=trendSeriesVisible('cacheHitRate'),max=trend.reduce(function(value,p){return Math.max(value,pointStackTotal(p));},0);if(max<=0)max=1;ctx.strokeStyle=border;ctx.lineWidth=1;for(var grid=0;grid<=4;grid++){var gy=chartY+chartH*grid/4;ctx.beginPath();ctx.moveTo(chartX,gy);ctx.lineTo(chartX+chartW,gy);ctx.stroke();}var slot=trend.length?chartW/trend.length:chartW,barW=Math.max(3,Math.min(34,slot*.62)),cacheLine=[];trend.forEach(function(point,index){var x=chartX+slot*index+(slot-barW)/2,geometry=trendGeometry(point,showInput,showOutput,showCacheRead),inputH=geometry.inputHeight/max*chartH,outputH=geometry.outputHeight/max*chartH,cacheReadH=geometry.cacheHeight/max*chartH;if(showInput&&inputH>0){ctx.fillStyle=input;ctx.fillRect(x,chartY+chartH-geometry.baseHeight/max*chartH,barW,inputH);}if(showCacheRead&&cacheReadH>0){ctx.fillStyle=cacheRead;ctx.fillRect(x,chartY+chartH-geometry.baseHeight/max*chartH,barW,cacheReadH);}if(showOutput&&outputH>0){ctx.fillStyle=output;ctx.fillRect(x,chartY+chartH-geometry.baseHeight/max*chartH-outputH,barW,outputH);}if(showCacheHit)cacheLine.push({x:x+barW/2,y:chartY+chartH*(1-point.cacheHitRate/100)});});if(showCacheHit&&cacheLine.length){ctx.save();ctx.strokeStyle=cacheHit;ctx.lineWidth=3;ctx.setLineDash([9,7]);ctx.beginPath();cacheLine.forEach(function(point,index){if(index)ctx.lineTo(point.x,point.y);else ctx.moveTo(point.x,point.y);});ctx.stroke();ctx.restore();}if(!trend.length)canvasText(ctx,t('chart.noCalls'),chartX+chartW/2,chartY+chartH/2,17,tertiary,600,'center');else if(!(showInput||showOutput||showCacheRead||showCacheHit))canvasText(ctx,t('trend.series.allHidden'),chartX+chartW/2,chartY+chartH/2,17,tertiary,600,'center');roundedRect(ctx,1088,282,448,620,14,panel,border);canvasText(ctx,t('export.modelShare')+' · '+modelShareMetricLabel(),1112,323,18,primary,750);var pieAll=groupModels(modelRows()),pieModels=pieAll.filter(function(item){return !hiddenModels.has(item.model);}),pieTotal=pieModels.reduce(function(sum,item){return sum+modelShareMetricValue(item);},0),angle=-Math.PI/2;pieModels.forEach(function(item){var next=angle+(pieTotal?modelShareMetricValue(item)/pieTotal*Math.PI*2:0);ctx.beginPath();ctx.arc(1312,525,118,angle,next);ctx.strokeStyle=colorFor(pieAll.indexOf(item));ctx.lineWidth=38;ctx.stroke();angle=next;});canvasText(ctx,formatModelShareMetric(pieTotal,true),1312,524,28,primary,750,'center');canvasText(ctx,modelShareMetricLabel(),1312,548,12,tertiary,400,'center');pieModels.slice(0,8).forEach(function(item,index){var y=700+index*23;ctx.fillStyle=colorFor(pieAll.indexOf(item));ctx.fillRect(1120,y-9,11,11);canvasText(ctx,item.model,1143,y,12,primary,600);canvasText(ctx,(pieTotal?modelShareMetricValue(item)/pieTotal*100:0).toFixed(1)+'%',1500,y,12,secondary,600,'right');});canvas.toBlob(function(blob){if(blob)downloadBlob(blob,'token-dashboard-'+document.getElementById('range').value+'.png');},'image/png');closeExportMenu();} function downloadBlob(blob,name){var url=URL.createObjectURL(blob),link=document.createElement('a');link.href=url;link.download=name;document.body.appendChild(link);link.click();link.remove();setTimeout(function(){URL.revokeObjectURL(url);},1000);} async function downloadBackup(){closeExportMenu();var managementKey=await askBackupManagementKey();if(!managementKey)return;text('status',t('backup.preparing'));text('error','');try{var controller=new AbortController(),timer=setTimeout(function(){controller.abort();},60000),response=await fetch(backupURL,{method:'GET',headers:{'Authorization':'Bearer '+managementKey},credentials:'same-origin',signal:controller.signal});clearTimeout(timer);if(!response.ok){var payload=await response.json().catch(function(){return {};});throw new Error(payload.error||t('error.api',{status:response.status}));}var blob=await response.blob(),disposition=response.headers.get('Content-Disposition')||'',filename='cap-token-usage-tracker-backup.db',match=disposition.match(/filename\*?=(?:UTF-8''|")?([^";]+)/i);if(match&&match[1])filename=decodeURIComponent(match[1].replace(/"/g,''));downloadBlob(blob,filename);text('status',t('backup.success'));}catch(error){text('status','');text('error',error&&error.name==='AbortError'?t('backup.failed'): (error.message||t('backup.failed')));}} var restoreFileInput=null; diff --git a/internal/plugin/dashboard_date_range_browser_test.go b/internal/plugin/dashboard_date_range_browser_test.go index 795acaf..946b04d 100644 --- a/internal/plugin/dashboard_date_range_browser_test.go +++ b/internal/plugin/dashboard_date_range_browser_test.go @@ -65,7 +65,11 @@ func runDashboardDateRangeBrowserTest(t *testing.T, scenario, description string } htmlPath := filepath.Join(t.TempDir(), "dashboard.html") - if err := os.WriteFile(htmlPath, dashboardResponse().Body, 0o600); err != nil { + html := dashboardResponse().Body + if scenario == "trend-total-full" { + html = fullDashboardResponse().Body + } + if err := os.WriteFile(htmlPath, html, 0o600); err != nil { t.Fatalf("write dashboard response body: %v", err) } @@ -111,3 +115,11 @@ func TestDashboardDateRangeKeepsSavedRangeWhenRecentlyOpenedInBrowser(t *testing func TestDashboardDateRangeResetsToTodayAfterInactivityInBrowser(t *testing.T) { runDashboardDateRangeBrowserTest(t, "expired-open", "dashboard expired-open date range browser regression failed") } + +func TestDashboardTrendTotalAndCacheGeometryInBrowser(t *testing.T) { + runDashboardDateRangeBrowserTest(t, "trend-total", "dashboard trend total/cache geometry browser regression failed") +} + +func TestDashboardTrendTotalAndPNGGeometryInFullModeInBrowser(t *testing.T) { + runDashboardDateRangeBrowserTest(t, "trend-total-full", "full-mode trend total/PNG geometry browser regression failed") +} diff --git a/test/dashboard_date_range.mjs b/test/dashboard_date_range.mjs index d1ebaf0..240e17b 100644 --- a/test/dashboard_date_range.mjs +++ b/test/dashboard_date_range.mjs @@ -4,13 +4,16 @@ import { chromium } from 'playwright-core'; const [htmlPath, chromePath, scenario = 'exclusive'] = process.argv.slice(2); if (!htmlPath || !chromePath) { - throw new Error('usage: node test/dashboard_date_range.mjs [exclusive|end-time|end-time-reset|quick-preset|reverse|los-angeles-dst|token-unit|recent-open|expired-open]'); + throw new Error('usage: node test/dashboard_date_range.mjs [exclusive|end-time|end-time-reset|quick-preset|reverse|los-angeles-dst|token-unit|recent-open|expired-open|trend-total|trend-total-full]'); } -if (!['exclusive', 'end-time', 'end-time-reset', 'quick-preset', 'reverse', 'los-angeles-dst', 'token-unit', 'recent-open', 'expired-open'].includes(scenario)) { +if (!['exclusive', 'end-time', 'end-time-reset', 'quick-preset', 'reverse', 'los-angeles-dst', 'token-unit', 'recent-open', 'expired-open', 'trend-total', 'trend-total-full'].includes(scenario)) { throw new Error(`unknown dashboard date-range browser scenario: ${scenario}`); } -const dashboardHTML = await readFile(htmlPath); +let dashboardHTML = await readFile(htmlPath, 'utf8'); +if (scenario.startsWith('trend-total')) { + dashboardHTML = dashboardHTML.replace('function trendNumber(value)', "window.__trendTest={pointStackTotal:pointStackTotal,trendGeometry:trendGeometry,trendBusinessTotal:trendBusinessTotal,trendCacheReadTokens:trendCacheReadTokens,exportPNG:typeof exportPNG==='function'?exportPNG:null,disableDownload:function(){downloadBlob=function(){};}};function trendNumber(value)"); +} const resourceBase = '/v0/resource/plugins/calendar-browser-test'; const timezoneId = scenario === 'los-angeles-dst' ? 'America/Los_Angeles' : 'UTC'; const initialRange = scenario === 'los-angeles-dst' @@ -32,7 +35,19 @@ const tokenUnitInitial = { sources: [], bucket_seconds: 86400, }; -const initialPayload = scenario === 'token-unit' ? tokenUnitInitial : emptyInitial; +const trendTotalInitial = { + generated_at: '2026-08-23T00:00:00.000Z', + last_used: '2026-08-23T12:00:00.000Z', + models: [], + series: [{ + hour: '2026-08-23T12:00:00Z', requests: 1, + input_tokens: 100, output_tokens: 20, reasoning_tokens: 5, + cache_read_tokens: 80, cache_creation_tokens: 0, total_tokens: 125, + }], + sources: [], + bucket_seconds: 86400, +}; +const initialPayload = scenario === 'token-unit' ? tokenUnitInitial : scenario.startsWith('trend-total') ? trendTotalInitial : emptyInitial; const savedTokenDisplayModes = []; const initialStatsURLs = []; let persistedPreferences = { @@ -85,13 +100,23 @@ const server = createServer((request, response) => { sendJSON(persistedPreferences); return; } + if (url.pathname === `${resourceBase}/full-mode/data`) { + sendJSON({ api_key_tracking_enabled: false, api_key_labels: {} }); + return; + } if (url.pathname === `${resourceBase}/stats/initial`) { initialStatsURLs.push(url.toString()); sendJSON(initialPayload); return; } if (url.pathname === `${resourceBase}/stats/trends`) { - sendJSON({ model_series: [], bucket_seconds: 86400 }); + sendJSON(scenario.startsWith('trend-total') ? { + model_series: [{ + hour: '2026-08-23T12:00:00Z', model: 'browser-test', requests: 1, + input_tokens: 100, output_tokens: 20, reasoning_tokens: 5, + cache_read_tokens: 80, cache_creation_tokens: 0, total_tokens: 125, + }], bucket_seconds: 86400, + } : { model_series: [], bucket_seconds: 86400 }); return; } if (url.pathname === `${resourceBase}/stats/groups` || url.pathname === `${resourceBase}/requests`) { @@ -125,7 +150,7 @@ try { page.on('pageerror', (error) => pageErrors.push(error)); await page.clock.install({ time: new Date('2026-08-23T12:00:00.000Z') }); - await page.goto(dashboardURL, { waitUntil: 'domcontentloaded' }); + await page.goto(dashboardURL + (scenario === 'trend-total-full' ? '#session=browser-test-session' : ''), { waitUntil: 'domcontentloaded' }); await page.waitForResponse((response) => new URL(response.url()).pathname === `${resourceBase}/stats/initial`); if (scenario === 'token-unit') { @@ -163,6 +188,96 @@ try { } } + if (scenario.startsWith('trend-total')) { + await page.locator('#chart rect.bar-input').waitFor(); + const populatedHitIndex = await page.locator('#chart .bar-hit').evaluateAll((nodes) => nodes.findIndex((node) => !node.getAttribute('aria-label').includes('该时间段内没有请求'))); + const matchingHit = populatedHitIndex >= 0 ? page.locator('#chart .bar-hit').nth(populatedHitIndex) : null; + if (!matchingHit) throw new Error('could not locate hit target for populated trend bar'); + await page.locator('#chart rect.bar-input').waitFor(); + const rects = page.locator('#chart rect.bar-input, #chart rect.bar-cache-read, #chart rect.bar-output'); + if (await rects.count() !== 3) throw new Error(`expected input/cache/output SVG bars, got ${await rects.count()}`); + const geometry = await rects.evaluateAll((nodes) => nodes.map((node) => ({ + className: node.getAttribute('class'), y: Number(node.getAttribute('y')), height: Number(node.getAttribute('height')), + }))); + const input = geometry.find((item) => item.className === 'bar-input'); + const cache = geometry.find((item) => item.className === 'bar-cache-read'); + const output = geometry.find((item) => item.className === 'bar-output'); + if (!input || !cache || !output || !(cache.y === input.y && cache.y + cache.height <= input.y + input.height) + || !(output.y < input.y && output.y + output.height <= input.y)) { + throw new Error(`cache must overlay input and output must sit above it: ${JSON.stringify(geometry)}`); + } + const axisLabels = await page.locator('#chart text.axis-label').allTextContents(); + if (!axisLabels.includes('120')) throw new Error(`expected geometric Y-axis maximum 120, got ${axisLabels.join(',')}`); + await matchingHit.hover(); + const tooltipText = await page.locator('#tooltip').textContent(); + if (!tooltipText.includes('125')) throw new Error(`expected backend total 125 in tooltip, got ${tooltipText}`); + + const edgeResults = await page.evaluate(() => { + const { pointStackTotal, trendGeometry, trendBusinessTotal, trendCacheReadTokens } = window.__trendTest; + const point = { input: 100, output: 20, cacheRead: 80, total: 125 }; + return { + original: pointStackTotal({ input: 48342180, output: 168768, cacheRead: 46221824 }), + overCache: trendGeometry({ input: 100, output: 20, cacheRead: 150 }, true, true, true), + zeroInput: trendGeometry({ input: 0, output: 20, cacheRead: 80 }, true, true, true), + onlyCache: trendGeometry(point, false, false, true), + allHidden: trendGeometry(point, false, false, false), + zeroTotal: trendBusinessTotal({ input: 100, output: 20, total: 0 }), + missingTotal: trendBusinessTotal({ input: 100, output: 20 }), + legacyCache: trendCacheReadTokens({ cache_read_tokens: 0, cached_tokens: 40 }), + }; + }); + if (edgeResults.original !== 48510948 || edgeResults.overCache.total !== 120 || edgeResults.overCache.cacheHeight !== 100 + || edgeResults.zeroInput.total !== 20 || edgeResults.zeroInput.cacheHeight !== 0 || edgeResults.onlyCache.total !== 80 + || edgeResults.allHidden.total !== 0 || edgeResults.zeroTotal !== 0 || edgeResults.missingTotal !== 120 || edgeResults.legacyCache !== 40) { + throw new Error('trend boundary calculations failed: ' + JSON.stringify(edgeResults)); + } + + const verifyPNG = async (expectedInputHeight, expectedCacheHeight, expectedOutputHeight, expectedMax) => { + if (scenario !== 'trend-total-full') return; + const draws = await page.evaluate(async () => { + const records = [], original = CanvasRenderingContext2D.prototype.fillRect; + const style = getComputedStyle(document.documentElement); + const colors = ['--input-color', '--cache-read-color', '--output-color'].map((key) => style.getPropertyValue(key).trim()); + CanvasRenderingContext2D.prototype.fillRect = function(x, y, width, height) { + if (width <= 34 && height > 0 && colors.includes(this.fillStyle)) records.push({ color: this.fillStyle, y, height }); + return original.call(this, x, y, width, height); + }; + window.__trendTest.disableDownload(); + try { await window.__trendTest.exportPNG(); } finally { + CanvasRenderingContext2D.prototype.fillRect = original; + // Canvas encoding callback can execute later; keep the test download stub installed. + } + return { records, colors }; + }); + const [inputColor, cacheColor, outputColor] = draws.colors; + const near = (a, b) => Math.abs(a - b) < 1e-7; + const check = (color, tokens, y) => { + const rect = draws.records.find((record) => record.color === color); + if (tokens === 0) { if (rect) throw new Error('unexpected hidden PNG bar: ' + JSON.stringify(rect)); return; } + if (!rect || !near(rect.height, tokens / expectedMax * 452) || !near(rect.y, y)) { + throw new Error('PNG geometry mismatch: ' + JSON.stringify({ rect, tokens, expectedMax, y })); + } + }; + const baseHeight = expectedInputHeight || expectedCacheHeight; + check(inputColor, expectedInputHeight, 834 - baseHeight / expectedMax * 452); + check(cacheColor, expectedCacheHeight, 834 - baseHeight / expectedMax * 452); + check(outputColor, expectedOutputHeight, 834 - (baseHeight + expectedOutputHeight) / expectedMax * 452); + }; + await verifyPNG(100, 80, 20, 120); + + await page.locator('.series-key-button[data-series="cacheRead"]').click(); + if (await page.locator('#chart rect.bar-cache-read').count() !== 0) throw new Error('hidden cache series must remove cache overlay'); + if (!(await page.locator('#chart text.axis-label').allTextContents()).includes('120')) throw new Error('hiding cache must not change geometric maximum'); + await verifyPNG(100, 0, 20, 120); + await page.locator('.series-key-button[data-series="input"]').click(); + const hiddenInputGeometry = await page.locator('#chart rect.bar-output').evaluate((node) => ({ y: Number(node.getAttribute('y')), height: Number(node.getAttribute('height')) })); + if (await page.locator('#chart rect.bar-cache-read').count() !== 0 || hiddenInputGeometry.height <= 0) throw new Error('hidden input/cache boundary rendered incorrectly'); + await page.locator('.series-key-button[data-series="cacheRead"]').click(); + const cacheOnly = await page.locator('#chart rect.bar-cache-read').evaluate((node) => ({ y: Number(node.getAttribute('y')), height: Number(node.getAttribute('height')) })); + const outputWithCache = await page.locator('#chart rect.bar-output').evaluate((node) => ({ y: Number(node.getAttribute('y')), height: Number(node.getAttribute('height')) })); + if (!(cacheOnly.height > 0 && outputWithCache.y < cacheOnly.y)) throw new Error(`cache-only geometry must be below output: ${JSON.stringify({ cacheOnly, outputWithCache })}`); + } + if (scenario === 'recent-open' || scenario === 'expired-open') { const initialURL = new URL(initialStatsURLs[0]); const expectedStart = scenario === 'expired-open'