dsh-usage-badge 0.1.12 → 0.1.13
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 +1 -1
- package/docs/design-notes.md +19 -1
- package/lib/client.js +10 -0
- package/lib/fold.js +21 -2
- package/lib/index.js +180 -40
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -83,7 +83,7 @@ dsh plugin --profile web remove dsh-usage-badge
|
|
|
83
83
|
|
|
84
84
|
## 已知限制
|
|
85
85
|
|
|
86
|
-
- **首次启动要冷扫一遍全部会话日志**(本机 338 份 / 348 MB 实测约 19 秒,随机器负载浮动;按解码字节数让出事件循环,最长一次阻塞约 0.4 秒,宿主启动与界面都不会被卡住,徽标先显示「…」再填上数字)。**之后每次启动只读增量**:只解压新追加的那几帧 —— 同一台机器实测「1 个日志、2 KB、57 ms」,没有任何变化时「0 个、16 ms
|
|
86
|
+
- **首次启动要冷扫一遍全部会话日志**(本机 338 份 / 348 MB 实测约 19 秒,随机器负载浮动;按解码字节数让出事件循环,最长一次阻塞约 0.4 秒,宿主启动与界面都不会被卡住,徽标先显示「…」再填上数字)。**之后每次启动只读增量**:只解压新追加的那几帧 —— 同一台机器实测「1 个日志、2 KB、57 ms」,没有任何变化时「0 个、16 ms」。**挂载插件本身不碰磁盘**(只登记一条路由,实测 0.75 ms),打开缓存库、读历史、折日志都发生在界面第一次请求时 —— 那时宿主早已启动完成。启动的每一段都有数字:日志里的 `ready in …`、面板上的**「上次启动」**一行会写明打开库 / 载入 / 列日志 / 折叠各用了多久,觉得慢时看这一行就知道慢在哪。
|
|
87
87
|
- **历史依赖日志,但日志变短不再吃掉历史**:日志被删掉后靠缓存继续计入;缓存也丢了,那段历史就没了。同一个会话的日志**变短或还原成更早的备份**时,缓存会**把更完整的那份留着**(折叠结果变小就只作为备份副本存下,统计仍用完整的那份),要等日志真的更全、却不再包含某一天,那天才会被丢掉。**例外是改名**:会话是按「日志所在目录」识别的,把某个会话目录改名会让同一份用量各算一份(见下)。DSH 自己从不裁日志:格式升级时它另写一代新文件、旧代保留,而实测换代后统计结果逐项相同。
|
|
88
88
|
- **日粒度区间仍只覆盖最近一年**:`近 7 日` / `近 30 日` / `近 12 个月` 与它们下面的滚动热力图都基于快照的最近 400 天。要看更早的日子,用 **`按年`**:那一档的月度合计、年度总额与**自然年热力图**都是直接查缓存库的,任何一年都在。
|
|
89
89
|
- **只统计 DSH 记进日志的调用**:某个东西直接请求外部 API 且不向 DSH 上报 usage,这部分统计不到。
|
package/docs/design-notes.md
CHANGED
|
@@ -353,6 +353,24 @@ dsh-usage-badge
|
|
|
353
353
|
|
|
354
354
|
**等价性核对(真机)**:同一份 334 MB / 338 个日志,「读快照 + 只折尾部」与「清空缓存全量重折」在**共同可见的 68 天上逐日完全一致**(input、请求数逐项相等);库里另外多的 3 天来自**日志已被删除**的会话 —— 那正是缓存有意保留的历史,全量重折本来就看不回来。
|
|
355
355
|
|
|
356
|
+
**挂载时插件什么都不做,是真的什么都不做**。DSH 的 Loader 在 `boot()` 里挂载整棵插件树,所以「晚点再加载这个插件」不是插件能选的 —— 能选的是**挂载之后干什么**。`apply()` 现在只做一件事:往 `ctx.webServer` 登记一条路由(实测 **0.75 ms**,不开库、不读价格表、不碰日志、连 `sessions` 目录都不列)。真正的活全部落在**界面第一次请求**上:那条请求不可能早于宿主启动完成。
|
|
357
|
+
|
|
358
|
+
启动的每一段都有数字,不再靠感觉:
|
|
359
|
+
|
|
360
|
+
| 段 | 什么时候做 | 含义 | 变大意味着 |
|
|
361
|
+
|---|---|---|---|
|
|
362
|
+
| 挂载 | DSH 启动时 | 登记路由 | 不该长大 |
|
|
363
|
+
| 打开库 `storeMs` | 第一次请求 | 打开 `cache.sqlite`、设 pragma、查 schema | 磁盘慢 / 杀毒软件在扫那个文件 |
|
|
364
|
+
| 载入 `loadMs` | 第一次请求 | 把窗口内的历史读进内存 | 缓存库大,或磁盘慢 |
|
|
365
|
+
| 列日志 `listMs` | 每次折叠 | 递归遍历 `sessions` 目录 | 会话特别多,或磁盘慢 |
|
|
366
|
+
| 折叠 `foldMs` | 每次折叠 | 解码新追加的字节 + 重建合并视图 | 增量确实很大(会同时报读了多少 KB / 折了几个日志) |
|
|
367
|
+
|
|
368
|
+
`ready in …`(日志)与面板上的「上次启动」一行报的就是这条链路;`waitedMs` 单独记「从挂载到界面来问」等了多久 —— 那是宿主的启动时间,不是插件的成本。
|
|
369
|
+
|
|
370
|
+
本机实测(348 MB / 341 个日志,已是最坏的那类安装):`apply()` **0.75 ms**;第一次请求里打开库 22 ms、载入 46 ms、列日志 16 ms、折叠 41 ms(1 个日志、8 KB),**合计 ~103 ms**;之后每轮 18 ms 左右,没有变化时就只有列日志 + stat。
|
|
371
|
+
|
|
372
|
+
**兜底计时器**:如果宿主压根没人打开界面(没人请求数据),`DSH_USAGE_BADGE_WARM_MS`(默认 3000 ms)之后也会自己热身一次,纯粹为了让日志里有那条 `ready` 行;两条路径都会走同一个 `warmOnce()`,所以只会热身一次。
|
|
373
|
+
|
|
356
374
|
### `按年` 视图:年份列表属于数据,不属于代码
|
|
357
375
|
|
|
358
376
|
历史不删,就迟早要看更早的年。这一年视图有三条决定:
|
|
@@ -399,7 +417,7 @@ npm test
|
|
|
399
417
|
| `test/pricing.verify.mjs` | 用临时 `DSH_HOME` + 合成会话日志跑真实宿主半,断言精确金额。第一组用例**逐字移植自桌面壳的 `pricing-key-case.verify.mjs`**(同样的 fixture、同样的期望值),用来证明移植后的定价与原实现一致;另覆盖上下文档位(严格大于、`"128K"` 紧凑写法、缓存 token 计入上下文)、峰谷 `days` 规则、跨币种换算、前缀行的四条优先级规则(精确行优先、长前缀优先、星号不是分隔符、裸 `*` 仍是死配置)、隐式前缀回退(官方行兜住自己的变体、分隔符边界让 `gpt-4` 吞不掉 `gpt-4o`、开关经 PUT 路由后在**同一个进程内**立刻重算),以及未匹配模型不计价的语义(哪些行算「匹配」、token 仍计入、快照列出被跳过的路由)、逐行/表级的 `honorHolidays`(既有「节假日盖过行内规则」也有「行内拒绝日历后照样收峰价」)、行编辑器的写入路径(整份 `overrides` 经 PUT 落盘、其它字段与两个开关不受影响)、**`default` 行是整体替换而非字段合并**(清空的字段真的消失、`{}` 被当成「没有这一行」)、以及**一行 `null` 覆盖行**被跳过而不是让 `/summary` 500 |
|
|
400
418
|
| `test/holidays.verify.mjs` | 节假日日历的行为:工作日节假日全天空闲、**调休周末仍按空闲价**(政策原文说周末全天空闲)、旧配置里的 `holidays.workdays` 被忽略、关闭日历时回到旧的按星期行为、自定义 `extra`、同一个月内不同日子的分类互不干扰,以及**2027 年的单日端午/中秋**(同一天 10:30,节当天 x1、旁边的周三 x2)与 2027-02-14 调休周日 |
|
|
401
419
|
| `test/official-pricing.verify.mjs` | 解析器对**中英文两个页面快照**的逐字段断言(含旧模型别名、时区识别与换算);页面结构变化时会抛错而不是静默返回错价;`应用` 的合并语义(未知字段存活、已有的 `default` 行不动、没有 `default` 行时按官方第一条补一个并报告 `defaultFrom`、页面第一条确实是最便宜那档);并用**被 stub 掉的 fetch** 跑通两个路由,最后验证应用后的价格真的参与计费(含未列出模型按生成的 default 行计价),以及兜底行的完整生命周期:删除 → 回到内置模板并把 `defaultSource` 报成 `template` → 再次应用从官方第一条生成回来 → 手改过的兜底行不会被应用覆盖 |
|
|
402
|
-
| `test/cache.verify.mjs` | 缓存存储层:**两个后端跑同一套契约断言**(会话游标与桶的往返、`hourly` 槽位与逐请求记录、重写会话即替换它的日期集合、`years()` 的年份列表与有界区间读),以及只有跨重启才看得见的行为 —— **删掉会话日志后历史仍在**(新挂载的宿主照旧报出同样的金额)、**没有变化的一轮刷新不写文件**、**超出加载窗口的那天留在库里只是不读进来**(换个更宽的窗口还能取到)、旧 `cache.json` **迁移一次**并把旧文件改名为 `.migrated`(且 `.migrated` 之后**不再被读**)、**读不出来的缓存文件被改名保留并新建一个**、**更新版本的库只读使用且绝不改名**、加载窗口必须大于快照窗口这条不变量、**增量折叠**(重启只读新追加的字节且读的字节数正好等于追加量、两天相加不翻倍、无变化时读 0
|
|
420
|
+
| `test/cache.verify.mjs` | 缓存存储层:**两个后端跑同一套契约断言**(会话游标与桶的往返、`hourly` 槽位与逐请求记录、重写会话即替换它的日期集合、`years()` 的年份列表与有界区间读),以及只有跨重启才看得见的行为 —— **删掉会话日志后历史仍在**(新挂载的宿主照旧报出同样的金额)、**没有变化的一轮刷新不写文件**、**超出加载窗口的那天留在库里只是不读进来**(换个更宽的窗口还能取到)、旧 `cache.json` **迁移一次**并把旧文件改名为 `.migrated`(且 `.migrated` 之后**不再被读**)、**读不出来的缓存文件被改名保留并新建一个**、**更新版本的库只读使用且绝不改名**、加载窗口必须大于快照窗口这条不变量、**增量折叠**(重启只读新追加的字节且读的字节数正好等于追加量、两天相加不翻倍、无变化时读 0、游标是日志大小而不是最早那天的戳、半行不算而补全后只算一次),**启动让出宿主**(还没折叠的挂载如实说「没折过」而不是看起来没数据、等待与工作分开计时),以及 `按年` 路由(三年以前那一年照样读得到、12 个月恒定、`daily` 带节假日标记与 provider 拆分、没有数据的年份是 12 个空月而不是报错、非法年份 400)。 |
|
|
403
421
|
| `test/client.render.mjs` | 用替身 Loader 加载浏览器 bundle,检查 slot 注册形状与 `id`,再用 `react-dom/server` 渲染两个面板,断言金额格式、区间页签(含 `按年`:下拉只列出宿主报的那几年、选中的那一年被选中、月份轴恒为 12、点数是 12 × 序列数、四张卡片是那一年的合计、这一档不画热力图、没有年份时不渲染空下拉)、四序列折线的条数与配色、图例开关(单独显示某一条时的折线条数,以及面积填充有没有跟着走)、悬停 tooltip 的装配、provider 页签、热力图的格数(371 − 今天之后的那几天)与分档、官方价格表内容、两个开关(前缀回退与未匹配模型计价:开/关两种状态各自渲染出的说明与动作、default 行的来源标注、以及「哪些模型没价格」的提示)、**覆盖行编辑器**(每行的编辑/删除动作、倍率与峰谷列恒定存在、表单用被编辑行的真实数值预填而不是空模板、自定义规则才展开规则字段、新增行不给删除按钮、**兜底行同样可编辑可删除且不提供那些不生效的字段**、**重名键与「改过的行」会被保存拦下**、**旧宿主不会被说成「兜底行还没有」**、以及「保留未显示字段」与拒绝写 NaN 的源码断言)、节假日徽标与日历区块、展开/收起两种行形态、**没有用量的那天金额仍贴右**(token 槽位渲染但为空)、**弹窗那一段 CSS 里不许出现任何宿主颜色令牌(`--dsw-`),必须自带浅色/深色两套 `--dub-*` 调色板**,以及空数据 / 稀疏 provider 两个边界不崩 |
|
|
404
422
|
| `test/smoke.mjs` | 对着**本机真实**的 `$DSH_HOME` 跑一遍,打印徽标金额、今日 token 构成、当前峰谷档位、节假日日历状态、最近 7 天,以及滚动热力图那 371 天的分档分布(有用量的天数与四档各多少天,都挤在一档说明这张图会显得很平) |
|
|
405
423
|
| `test/official-pricing.live.mjs` | **联网**核对:抓真实页面并与 `test/fixtures/` 的快照逐字段比对,确认官方页面仍能解析、价格未变 |
|
package/lib/client.js
CHANGED
|
@@ -1937,6 +1937,16 @@ html[data-theme="dark"] .dub-mask{
|
|
|
1937
1937
|
'新增一行',
|
|
1938
1938
|
),
|
|
1939
1939
|
),
|
|
1940
|
+
// How the last start spent its time. A slow start used to be a feeling; these are the
|
|
1941
|
+
// numbers behind it, so "it is slow again" can say *which* phase grew.
|
|
1942
|
+
config?.boot?.readyAt
|
|
1943
|
+
? h(
|
|
1944
|
+
'div',
|
|
1945
|
+
{ className: 'dub-note' },
|
|
1946
|
+
`上次启动:共 ${config.boot.totalMs} ms —— 打开库 ${config.boot.storeMs} ms · 载入 ${config.boot.loadMs} ms · 列日志 ${config.boot.listMs} ms · 折叠 ${config.boot.foldMs} ms` +
|
|
1947
|
+
`(${config.boot.folded}/${config.boot.files} 个日志,读 ${Math.round((config.boot.bytesRead ?? 0) / 1024)} KB;共 ${config.boot.sessions} 个会话)`,
|
|
1948
|
+
)
|
|
1949
|
+
: null,
|
|
1940
1950
|
!effective
|
|
1941
1951
|
? h('div', { className: 'dub-note' }, '正在读取当前配置…')
|
|
1942
1952
|
: h(
|
package/lib/fold.js
CHANGED
|
@@ -444,6 +444,15 @@ export function createAggregator({ sessionsRoot, store, log = () => {} }) {
|
|
|
444
444
|
const cursors = new Map()
|
|
445
445
|
let days = new Map()
|
|
446
446
|
let lastRefresh = null
|
|
447
|
+
/**
|
|
448
|
+
* Whether the stored history has been read into this process yet.
|
|
449
|
+
*
|
|
450
|
+
* `loadCache()` is deliberately **not** called when the plugin mounts: mounting happens inside DSH's
|
|
451
|
+
* own startup, and reading 300+ sessions' payloads there put forty-odd milliseconds (more on a cold
|
|
452
|
+
* disk, or under a virus scanner) on the host's boot path for numbers nothing has asked for yet.
|
|
453
|
+
* The first `refresh()` — which is what the badge's first request runs — does it instead.
|
|
454
|
+
*/
|
|
455
|
+
let loadedOnce = false
|
|
447
456
|
|
|
448
457
|
function mergeDays() {
|
|
449
458
|
days = mergeRecords(records.values())
|
|
@@ -487,6 +496,7 @@ export function createAggregator({ sessionsRoot, store, log = () => {} }) {
|
|
|
487
496
|
}
|
|
488
497
|
mergeDays()
|
|
489
498
|
if (!quiet) log(`cache loaded: ${records.size} session(s) from ${store.kind}`)
|
|
499
|
+
loadedOnce = true
|
|
490
500
|
return records.size > 0
|
|
491
501
|
}
|
|
492
502
|
|
|
@@ -507,11 +517,20 @@ export function createAggregator({ sessionsRoot, store, log = () => {} }) {
|
|
|
507
517
|
* reason the store is not a single document, and it means there is no save timer to lose
|
|
508
518
|
* work in and no moment where a burst of edits has to be batched.
|
|
509
519
|
*
|
|
510
|
-
* @returns {Promise<{files:number, folded:number, tailed:number, bytesRead:number, ms:number}>} what this pass did.
|
|
520
|
+
* @returns {Promise<{files:number, folded:number, tailed:number, bytesRead:number, loadMs:number, listMs:number, ms:number}>} what this pass did.
|
|
511
521
|
*/
|
|
512
522
|
async function refresh() {
|
|
513
523
|
const started = Date.now()
|
|
524
|
+
let loadMs = 0
|
|
525
|
+
if (!loadedOnce) {
|
|
526
|
+
// The first pass of a process is where the stored history comes in — see `loadedOnce`.
|
|
527
|
+
const beforeLoad = Date.now()
|
|
528
|
+
loadCache()
|
|
529
|
+
loadMs = Date.now() - beforeLoad
|
|
530
|
+
}
|
|
531
|
+
const beforeList = Date.now()
|
|
514
532
|
const files = listLogFiles(sessionsRoot)
|
|
533
|
+
const listMs = Date.now() - beforeList
|
|
515
534
|
let folded = 0
|
|
516
535
|
let tailed = 0
|
|
517
536
|
let bytesRead = 0
|
|
@@ -602,7 +621,7 @@ export function createAggregator({ sessionsRoot, store, log = () => {} }) {
|
|
|
602
621
|
// being refreshed — and, because nothing writes it again, that history stays in the store
|
|
603
622
|
// for good rather than being erased by the log's absence.
|
|
604
623
|
if (folded > 0) mergeDays()
|
|
605
|
-
lastRefresh = { files: files.length, folded, tailed, bytesRead, ms: Date.now() - started, at: Date.now() }
|
|
624
|
+
lastRefresh = { files: files.length, folded, tailed, bytesRead, loadMs, listMs, ms: Date.now() - started, at: Date.now() }
|
|
606
625
|
return lastRefresh
|
|
607
626
|
}
|
|
608
627
|
|
package/lib/index.js
CHANGED
|
@@ -87,6 +87,16 @@ const SNAPSHOT_TTL_MS = 2000
|
|
|
87
87
|
*/
|
|
88
88
|
export const PRICING_PUSH_DELAY_MS = 3000
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* How long the plugin waits before warming itself up when nobody has asked for numbers.
|
|
92
|
+
*
|
|
93
|
+
* The **first request** is what really starts the work (see `warmOnce`), and that request cannot
|
|
94
|
+
* arrive before the harness is up — so this timer is only the fallback for a host nobody is looking
|
|
95
|
+
* at. It is deliberately generous: mounting happens inside DSH's own startup, and nothing the plugin
|
|
96
|
+
* does belongs on that path.
|
|
97
|
+
*/
|
|
98
|
+
export const STARTUP_WARM_DELAY_MS = 3000
|
|
99
|
+
|
|
90
100
|
/** How long after startup the first automatic sync runs, when sync is enabled. */
|
|
91
101
|
export const STARTUP_SYNC_DELAY_MS = 8000
|
|
92
102
|
|
|
@@ -586,9 +596,46 @@ export function apply(ctx) {
|
|
|
586
596
|
// The folded history lives in whichever store this runtime can offer — SQLite when
|
|
587
597
|
// `node:sqlite` is there, the original single-document JSON otherwise. Either way it is one
|
|
588
598
|
// file inside the plugin's own directory.
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
599
|
+
//
|
|
600
|
+
// **Nothing is opened here.** `apply()` runs inside DSH's own startup, and a plugin must not put
|
|
601
|
+
// its I/O on that path: opening the database, reading the price table and parsing the stored
|
|
602
|
+
// history all happen on first use — which in practice is the interface's first request, long after
|
|
603
|
+
// the harness is up. `boot` records how long each phase took, so a slow start names its culprit.
|
|
604
|
+
let opened = null
|
|
605
|
+
function ensureOpen() {
|
|
606
|
+
if (opened) return opened
|
|
607
|
+
const storeOpenStarted = Date.now()
|
|
608
|
+
const store = createCacheStore({ dir: dataDir, log })
|
|
609
|
+
const storeMs = Date.now() - storeOpenStarted
|
|
610
|
+
const aggregator = createAggregator({ sessionsRoot, store, log })
|
|
611
|
+
// The price table is read here rather than at mount for the same reason; `refreshOnce()` re-reads
|
|
612
|
+
// it on every pass, so an edit still applies without a restart.
|
|
613
|
+
const { path: pricingSourcePath } = readRawPricing(pricingPaths)
|
|
614
|
+
if (pricingSourcePath === legacyPricingPath) {
|
|
615
|
+
log(`reading the price table from the pre-isolation path ${legacyPricingPath}; the next write moves it to ${pricingPath}`)
|
|
616
|
+
}
|
|
617
|
+
pricing = readPricing(pricingPaths)
|
|
618
|
+
rules = resolveHolidayRules(pricing.holidays)
|
|
619
|
+
opened = { store, aggregator }
|
|
620
|
+
boot.storeMs = storeMs
|
|
621
|
+
return opened
|
|
622
|
+
}
|
|
623
|
+
const boot = {
|
|
624
|
+
mountedAt: Date.now(),
|
|
625
|
+
at: 0,
|
|
626
|
+
waitedMs: 0,
|
|
627
|
+
storeMs: 0,
|
|
628
|
+
loadMs: 0,
|
|
629
|
+
listMs: 0,
|
|
630
|
+
foldMs: 0,
|
|
631
|
+
totalMs: 0,
|
|
632
|
+
files: 0,
|
|
633
|
+
sessions: 0,
|
|
634
|
+
folded: 0,
|
|
635
|
+
tailed: 0,
|
|
636
|
+
bytesRead: 0,
|
|
637
|
+
readyAt: 0,
|
|
638
|
+
}
|
|
592
639
|
|
|
593
640
|
/**
|
|
594
641
|
* The sync system, created **on first use** rather than when the plugin mounts.
|
|
@@ -611,7 +658,7 @@ export function apply(ctx) {
|
|
|
611
658
|
if (!lock.acquired) log(`usage-badge is read-only here: ${lock.reason}`)
|
|
612
659
|
const engine = new SyncEngine({
|
|
613
660
|
dataDir,
|
|
614
|
-
store: lock.acquired ? store : null,
|
|
661
|
+
store: lock.acquired ? ensureOpen().store : null,
|
|
615
662
|
log,
|
|
616
663
|
config: document,
|
|
617
664
|
state: document.state,
|
|
@@ -641,19 +688,20 @@ export function apply(ctx) {
|
|
|
641
688
|
return system
|
|
642
689
|
}
|
|
643
690
|
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
let pricing = readPricing(pricingPaths)
|
|
649
|
-
let rules = resolveHolidayRules(pricing.holidays)
|
|
691
|
+
let pricing = null
|
|
692
|
+
// A "nothing on" default so anything that reads `rules` before the first load (a log line, say)
|
|
693
|
+
// cannot trip over it; `ensureOpen()` replaces it with the real rules.
|
|
694
|
+
let rules = resolveHolidayRules(undefined)
|
|
650
695
|
let snapshot = null
|
|
651
696
|
let inFlight = null
|
|
652
697
|
let official = null
|
|
698
|
+
/** The status of the warm-up pass, until the first snapshot has reported it. */
|
|
699
|
+
let firstStatus = null
|
|
653
700
|
|
|
654
701
|
/** Serialize concurrent refreshes so a burst of polls folds the logs once. */
|
|
655
702
|
function refreshOnce() {
|
|
656
703
|
if (!inFlight) {
|
|
704
|
+
const { aggregator } = ensureOpen()
|
|
657
705
|
inFlight = (async () => {
|
|
658
706
|
const status = await aggregator.refresh()
|
|
659
707
|
// Re-read pricing every pass so an edit applies without a restart.
|
|
@@ -871,7 +919,7 @@ export function apply(ctx) {
|
|
|
871
919
|
// this reload is for) and the log says why, but the run is still what it was. `loadCache`
|
|
872
920
|
// already swallows a store it cannot read; this covers everything else.
|
|
873
921
|
try {
|
|
874
|
-
aggregator.loadCache({ quiet: true })
|
|
922
|
+
ensureOpen().aggregator.loadCache({ quiet: true })
|
|
875
923
|
} catch (error) {
|
|
876
924
|
log(`the folded view could not be reloaded after the sync: ${error?.message ?? error}`)
|
|
877
925
|
}
|
|
@@ -906,7 +954,11 @@ export function apply(ctx) {
|
|
|
906
954
|
}
|
|
907
955
|
|
|
908
956
|
async function getSnapshot() {
|
|
909
|
-
const
|
|
957
|
+
const { store, aggregator } = ensureOpen()
|
|
958
|
+
// The first request reports the fold it caused, rather than immediately re-checking and reporting
|
|
959
|
+
// "nothing changed" — the numbers the caller is about to see came from that pass.
|
|
960
|
+
const status = firstStatus ?? (await refreshOnce())
|
|
961
|
+
firstStatus = null
|
|
910
962
|
// Nothing changed and the previous payload is still fresh: reuse it rather
|
|
911
963
|
// than re-costing every day on each poll.
|
|
912
964
|
if (snapshot && status.folded === 0 && Date.now() - snapshot.generatedAt < SNAPSHOT_TTL_MS) return snapshot
|
|
@@ -1060,24 +1112,64 @@ export function apply(ctx) {
|
|
|
1060
1112
|
log(`exit sync: ${instance.last?.result ?? outcome} — ${instance.last?.message ?? ''}`)
|
|
1061
1113
|
}
|
|
1062
1114
|
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
} catch (error) {
|
|
1079
|
-
log(`initial fold failed: ${error?.message ?? error}`)
|
|
1115
|
+
/**
|
|
1116
|
+
* The one warm-up pass: open, load the stored history, fold what changed, record the phases.
|
|
1117
|
+
*
|
|
1118
|
+
* It runs on the **first request**, not at mount. Mounting happens inside DSH's own startup and the
|
|
1119
|
+
* plugin has no business doing I/O there — so nothing is opened until something actually asks for
|
|
1120
|
+
* numbers, which is the interface's first poll, long after the harness is up. A fallback timer
|
|
1121
|
+
* (`DSH_USAGE_BADGE_WARM_MS`) covers a host nobody is looking at; whichever happens first wins, and
|
|
1122
|
+
* this is memoised so a burst of polls still only warms once.
|
|
1123
|
+
*/
|
|
1124
|
+
let warmPromise = null
|
|
1125
|
+
function warmOnce() {
|
|
1126
|
+
if (warmPromise) return warmPromise
|
|
1127
|
+
if (!boot.at) {
|
|
1128
|
+
boot.at = Date.now()
|
|
1129
|
+
boot.waitedMs = boot.at - boot.mountedAt
|
|
1080
1130
|
}
|
|
1131
|
+
warmPromise = (async () => {
|
|
1132
|
+
const { store, aggregator } = ensureOpen()
|
|
1133
|
+
try {
|
|
1134
|
+
const status = await refreshOnce()
|
|
1135
|
+
firstStatus = status
|
|
1136
|
+
// The SQLite store has already committed every session it folded; the JSON fallback
|
|
1137
|
+
// batches, and a short-lived host would otherwise pay the full cold scan on every start.
|
|
1138
|
+
aggregator.flush()
|
|
1139
|
+
const passMs = status.ms ?? 0
|
|
1140
|
+
const loadMs = status.loadMs ?? 0
|
|
1141
|
+
const listMs = status.listMs ?? 0
|
|
1142
|
+
Object.assign(boot, {
|
|
1143
|
+
loadMs,
|
|
1144
|
+
listMs,
|
|
1145
|
+
passMs,
|
|
1146
|
+
// What is left of the pass once loading and listing are accounted for: the folding itself.
|
|
1147
|
+
foldMs: Math.max(0, passMs - loadMs - listMs),
|
|
1148
|
+
totalMs: Date.now() - boot.at,
|
|
1149
|
+
files: status.files ?? 0,
|
|
1150
|
+
sessions: aggregator.sessionCount,
|
|
1151
|
+
folded: status.folded ?? 0,
|
|
1152
|
+
tailed: status.tailed ?? 0,
|
|
1153
|
+
bytesRead: status.bytesRead ?? 0,
|
|
1154
|
+
readyAt: Date.now(),
|
|
1155
|
+
})
|
|
1156
|
+
log(
|
|
1157
|
+
`ready in ${boot.totalMs}ms (${boot.waitedMs}ms after mount) — open ${boot.storeMs}ms, load ${boot.loadMs}ms,` +
|
|
1158
|
+
` list ${boot.listMs}ms, fold ${boot.foldMs}ms;` +
|
|
1159
|
+
` ${boot.sessions} session(s) over ${boot.files} log(s), ${boot.folded} folded` +
|
|
1160
|
+
` (${boot.tailed} from the tail only, ${Math.round(boot.bytesRead / 1024)} KB read)` +
|
|
1161
|
+
`; cache: ${store.kind}` +
|
|
1162
|
+
(rules.enabled ? `; holiday calendar on (${rules.holidays.size} holidays)` : ''),
|
|
1163
|
+
)
|
|
1164
|
+
if (rules.missingYears?.length) {
|
|
1165
|
+
log(`holiday calendar has no data for ${rules.missingYears.join(', ')} — those days are treated as ordinary weekdays`)
|
|
1166
|
+
}
|
|
1167
|
+
} catch (error) {
|
|
1168
|
+
log(`initial fold failed: ${error?.message ?? error}`)
|
|
1169
|
+
}
|
|
1170
|
+
return boot
|
|
1171
|
+
})()
|
|
1172
|
+
return warmPromise
|
|
1081
1173
|
}
|
|
1082
1174
|
|
|
1083
1175
|
async function handle(req, res) {
|
|
@@ -1085,7 +1177,12 @@ export function apply(ctx) {
|
|
|
1085
1177
|
const route = url.pathname.slice(ROUTE_PREFIX.length) || '/'
|
|
1086
1178
|
|
|
1087
1179
|
try {
|
|
1180
|
+
// Every route needs the store and the price table; only the data routes additionally need the
|
|
1181
|
+
// logs folded, so the fold waits for those (see `warmOnce`).
|
|
1182
|
+
ensureOpen()
|
|
1088
1183
|
if (route === '/summary' && (req.method === 'GET' || req.method === 'HEAD')) {
|
|
1184
|
+
// The interface's first poll is what starts the plugin's work: see `warmOnce`.
|
|
1185
|
+
await warmOnce()
|
|
1089
1186
|
sendJson(res, 200, await getSnapshot())
|
|
1090
1187
|
return
|
|
1091
1188
|
}
|
|
@@ -1099,6 +1196,13 @@ export function apply(ctx) {
|
|
|
1099
1196
|
sendJson(res, 400, { error: `year must be a four-digit calendar year, got ${JSON.stringify(year)}` })
|
|
1100
1197
|
return
|
|
1101
1198
|
}
|
|
1199
|
+
// A year older than the loading window is only in the store, so this route needs the logs
|
|
1200
|
+
// folded first — same warm-up the badge's own first request runs.
|
|
1201
|
+
await warmOnce()
|
|
1202
|
+
// This route never reports the warm pass, so do not let it be consumed by the next /summary:
|
|
1203
|
+
// that one should judge the logs as they are when it asks.
|
|
1204
|
+
firstStatus = null
|
|
1205
|
+
const { store } = ensureOpen()
|
|
1102
1206
|
const records = store.load({ since: `${year}-01-01`, until: `${year}-12-31` })
|
|
1103
1207
|
const dayRows = mergeRecords(records)
|
|
1104
1208
|
sendJson(res, 200, summarizeYear(dayRows, pricing, rules, year))
|
|
@@ -1106,6 +1210,7 @@ export function apply(ctx) {
|
|
|
1106
1210
|
}
|
|
1107
1211
|
|
|
1108
1212
|
if (route === '/config' && req.method === 'GET') {
|
|
1213
|
+
const { store } = ensureOpen()
|
|
1109
1214
|
const calendar = describeCalendar(rules)
|
|
1110
1215
|
// The bundled table is reported separately from the resolved rules: a
|
|
1111
1216
|
// deployment with the calendar off still needs to see what data is
|
|
@@ -1161,6 +1266,9 @@ export function apply(ctx) {
|
|
|
1161
1266
|
// neither exists, so the built-in template is in force.
|
|
1162
1267
|
pricingSource: raw.path,
|
|
1163
1268
|
},
|
|
1269
|
+
// How the last start spent its time. A slow start is then attributable instead of a
|
|
1270
|
+
// feeling: the panel shows this line, and the same numbers go to the log.
|
|
1271
|
+
boot: { ...boot },
|
|
1164
1272
|
})
|
|
1165
1273
|
return
|
|
1166
1274
|
}
|
|
@@ -1368,16 +1476,40 @@ export function apply(ctx) {
|
|
|
1368
1476
|
// asynchronous, and a value read after it would be whatever the environment holds by then — which a
|
|
1369
1477
|
// test that sets an override for exactly this mount would already have taken away.
|
|
1370
1478
|
const startupDelay = delayFor('DSH_USAGE_BADGE_STARTUP_SYNC_MS', STARTUP_SYNC_DELAY_MS)
|
|
1479
|
+
// Same reasoning for the fallback warm-up: read the knob now, not inside the timer callback.
|
|
1480
|
+
const warmDelay = delayFor('DSH_USAGE_BADGE_WARM_MS', STARTUP_WARM_DELAY_MS)
|
|
1371
1481
|
// Unloading before the first fold finishes used to schedule the first sync *after* the unload: the
|
|
1372
1482
|
// callback ran anyway, took the lock back, and synced through a store that had already been closed
|
|
1373
1483
|
// (`statement has been finalized`), leaving a lock file behind for a host that was gone.
|
|
1374
1484
|
let disposed = false
|
|
1375
|
-
|
|
1485
|
+
let warmTimer = null
|
|
1486
|
+
/**
|
|
1487
|
+
* Everything the plugin does automatically, in one place: warm the fold, then start the automatic
|
|
1488
|
+
* sync and the remote-device read.
|
|
1489
|
+
*
|
|
1490
|
+
* The fold itself is **not** what this timer is for — the interface's first request does that
|
|
1491
|
+
* (`warmOnce`), and that request cannot arrive before the harness is up. This is only the
|
|
1492
|
+
* fallback for a host nobody is looking at, which is why it waits a moment: nothing here belongs
|
|
1493
|
+
* in DSH's boot path. A zero delay means "warm on mount", which is what a test wants.
|
|
1494
|
+
*/
|
|
1495
|
+
let scheduled = false
|
|
1496
|
+
const begin = () => {
|
|
1497
|
+
warmTimer = null
|
|
1376
1498
|
if (disposed) return
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1499
|
+
void warmOnce().then(() => {
|
|
1500
|
+
if (disposed) return
|
|
1501
|
+
if (!scheduled) {
|
|
1502
|
+
scheduled = true
|
|
1503
|
+
scheduleStartupSync(startupDelay)
|
|
1504
|
+
// Fire and forget: this only fills in what the server holds, for the panel's next read.
|
|
1505
|
+
void refreshRemoteDevices()
|
|
1506
|
+
}
|
|
1507
|
+
})
|
|
1508
|
+
}
|
|
1509
|
+
if (warmDelay > 0) {
|
|
1510
|
+
warmTimer = setTimeout(begin, warmDelay)
|
|
1511
|
+
warmTimer.unref?.()
|
|
1512
|
+
} else begin()
|
|
1381
1513
|
// The disposer is **async on purpose**, and everything below depends on the order:
|
|
1382
1514
|
//
|
|
1383
1515
|
// 1. stop automatic work that would fire into a process that is going away;
|
|
@@ -1391,13 +1523,18 @@ export function apply(ctx) {
|
|
|
1391
1523
|
disposed = true
|
|
1392
1524
|
// Any pending automatic work is dropped on unload: a push that fires while the host is going away
|
|
1393
1525
|
// would write state for a process that no longer exists.
|
|
1526
|
+
if (warmTimer) clearTimeout(warmTimer)
|
|
1394
1527
|
if (pricingPushTimer) clearTimeout(pricingPushTimer)
|
|
1395
1528
|
if (startupSyncTimer) clearTimeout(startupSyncTimer)
|
|
1396
1529
|
if (system?.running) log('unloading while a sync is in flight; it stops at its next step boundary')
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1530
|
+
// A host that was never asked for numbers never opened anything: there is no store to close, no
|
|
1531
|
+
// lock to hand back, and nothing worth publishing on the way out.
|
|
1532
|
+
if (opened) {
|
|
1533
|
+
try {
|
|
1534
|
+
await syncOnExit()
|
|
1535
|
+
} catch (error) {
|
|
1536
|
+
log(`exit sync failed: ${error?.message ?? error}`)
|
|
1537
|
+
}
|
|
1401
1538
|
}
|
|
1402
1539
|
// **And the lock is handed back.** Not releasing it leaves `host.lock` naming this dead process, and a
|
|
1403
1540
|
// re-mount inside the same process — a hot reload, or a test mounting the same home twice — is then
|
|
@@ -1413,10 +1550,13 @@ export function apply(ctx) {
|
|
|
1413
1550
|
system.engine.abort()
|
|
1414
1551
|
system.engine.release()
|
|
1415
1552
|
}
|
|
1553
|
+
// Unconditional, and `releaseLock` itself only removes a lock whose pid is this process: a lock left
|
|
1554
|
+
// behind by an earlier mount of this same process (a hot reload) is ours to clean up even when this
|
|
1555
|
+
// mount never created a sync system.
|
|
1416
1556
|
releaseLock(dataDir)
|
|
1417
|
-
aggregator.close()
|
|
1557
|
+
if (opened) opened.aggregator.close()
|
|
1418
1558
|
}
|
|
1419
|
-
}, 'usage-badge: warm the fold, schedule the first sync, and close the cache on unload')
|
|
1559
|
+
}, 'usage-badge: warm the fold on first use, schedule the first sync, and close the cache on unload')
|
|
1420
1560
|
|
|
1421
1561
|
log(`mounted on ${ROUTE_PREFIX} (home: ${home}; data: ${dataDir})`)
|
|
1422
1562
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-usage-badge",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.13",
|
|
4
4
|
"description": "DeepSeek Harness 的用量徽标插件:侧边栏底部一行显示当天的 token 总量与估算费用,点开可看 24 小时 / 近 7 日 / 近 30 日 / 近 12 个月 / 按年用量,按 provider 筛选,并按 DeepSeek 官方定价与中国法定节假日计价。A usage badge plugin for DeepSeek Harness: today's tokens and estimated spend in the sidebar, with 24h / 7d / 30d / 12-month and per-year charts, official DeepSeek pricing and Chinese public holidays.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|