dsh-all-usage 1.0.7 → 1.0.9

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/CHANGELOG.md CHANGED
@@ -2,6 +2,29 @@
2
2
 
3
3
  All notable changes to `dsh-all-usage` are documented here.
4
4
 
5
+ ## [1.0.9] - 2026-08-26
6
+
7
+ ### Added
8
+
9
+ - Added a lightweight, guarded `/api/all-usage/status` contract with a per-Host instance ID, monotonic stats revision, scan progress, and non-sensitive sync-health counters.
10
+ - Added sync-health visibility for revision-skipped sessions, actual log reads, ledger-only recoveries, failures, persistence snapshot availability, and last completed baseline time.
11
+ - Added revision-driven Client refresh: after the initial scan, the dashboard polls only lightweight status and fetches the full historical snapshot only when the Host instance or stats revision changes.
12
+
13
+ ### Fixed
14
+
15
+ - Preserved the last successful usage view after status or full-snapshot failures, exposed a retry action, and added bounded retry backoff instead of silently discarding refresh errors.
16
+
17
+ ## [1.0.8] - 2026-08-23
18
+
19
+ ### Added
20
+
21
+ - Added a restart read-avoidance cursor: on startup the plugin uses the persisted log revision (via `sessionPersistence.listSnapshots()`, a header-line + stat read) as a per-session change signal. Sessions whose log is unchanged since the last ledger write are applied straight from the durable ledger without reading their full event log; only changed or new sessions are read (incrementally).
22
+ - Stored the log revision in each durable ledger record (`lastRevision`); existing rows without it are re-read once and backfilled, so behavior is backward compatible.
23
+
24
+ ### Fixed
25
+
26
+ - Eliminated the per-session full-event read at restart for unchanged persisted sessions, so DSH restarts no longer re-read every session's history from scratch.
27
+
5
28
  ## [1.0.7] - 2026-08-23
6
29
 
7
30
  ### Added
@@ -51,6 +74,8 @@ All notable changes to `dsh-all-usage` are documented here.
51
74
  - Allowed same-origin browser balance GET requests that omit `Origin` while retaining token protection.
52
75
  - Standardized English date buckets, range filters, streaks, heatmap dates, and export timestamps on UTC.
53
76
 
77
+ [1.0.9]: https://github.com/ParticleLight/dsh-all-usage/releases/tag/v1.0.9
78
+ [1.0.8]: https://github.com/ParticleLight/dsh-all-usage/releases/tag/v1.0.8
54
79
  [1.0.7]: https://github.com/ParticleLight/dsh-all-usage/releases/tag/v1.0.7
55
80
  [1.0.6]: https://github.com/ParticleLight/dsh-all-usage/releases/tag/v1.0.6
56
81
  [1.0.5]: https://github.com/ParticleLight/dsh-all-usage/releases/tag/v1.0.5
package/README.md CHANGED
@@ -18,15 +18,16 @@ DeepSeek Harness 全量用量看板:按模型、供应商、工作区和时间
18
18
  - **工作区别名**:在侧栏入口打开看板后管理,持久化保存到 $DSH_HOME/storages 的 KV 单元 `all_usage_aliases`
19
19
  - **界面语言**:在看板顶部切换中文与 English;选择会保存到浏览器本地
20
20
  - **完整历史与增量重建**:基线扫描全部可读历史会话;独立用量账本同时作为每会话游标——未变化的会话直接复用账本,新增事件只增量回填,长历史重启不再全量重建
21
+ - **重启免读**:用持久化日志的 revision 作为每会话的变更信号(只读头部行 + stat,不读全量)——日志未变的会话重启时连事件都不读,直接从账本复用;仅日志变化(新增/修改)的会话才做增量读取
22
+ - **数据健康与按需刷新**:扫描完成后浏览器只检查轻量状态版本,只有用量、别名或同步状态变化时才拉完整历史;显示本次数据更新时间、历史扫描健康、revision 免读、实际读取、账本恢复和失败,网络异常保留上次成功数据并可重试
21
23
  - **Token 口径**:输入按「未含缓存命中」计,缓存命中 / 写入与推理独立成桶;全 0 用量的重放事件不会覆盖已记录的真实用量,仅缓存命中的请求也会计入
22
24
 
23
25
  ### 最近更新
24
26
 
25
- **v1.0.7**
27
+ **v1.0.9**
26
28
 
27
- - 增量游标:启动重扫只增量回填新增事件,未变化的会话直接从独立账本复用,不再全量重建
28
- - 0 用量防护:全 0 用量的重放事件不会覆盖已记录的真实用量;纯缓存命中的请求仍会计入
29
- - 结构化语义声明:API 返回机读的 token 口径(输入不含缓存命中,缓存与推理独立成桶)
29
+ - 按需刷新:扫描完成后只轮询轻量状态,Host instance 或统计 revision 变化时才拉完整历史快照
30
+ - 数据健康:显示本次数据更新时间与历史扫描健康,以及 revision 免读、实际读取、账本恢复、失败与优化可用性;刷新失败保留上次成功数据并支持重试
30
31
 
31
32
  完整版本记录见 [CHANGELOG.md](CHANGELOG.md)。
32
33
 
@@ -81,6 +82,7 @@ dsh plugin --profile web add github:ParticleLight/dsh-all-usage
81
82
  - 会话删除后,已成功 flush 的用量仍从独立账本恢复;会话销毁提示和周期对账只负责触发重建,不会删除账本记录
82
83
  - 同一会话的同一 `turn / step` 只保留一份最终 usage;重试或替换消息会替换旧贡献,不重复累计
83
84
  - 输入 Token 按「未含缓存命中」计(缓存命中 / 写入独立成桶);全 0 用量的重放事件不会覆盖已记录的真实用量,纯缓存命中的请求仍会计入
85
+ - 轻量状态接口只公开 Host 实例、统计 revision、扫描进度与同步计数,不公开会话 ID、工作区路径、提示词或回复正文;完整快照仅在状态变化或手动刷新时获取
84
86
  - 看板中的总处理量 = 输入 + 输出 + 缓存读写 + 推理;缓存命中表示复用的上下文 Token,不等于新生成 Token 或实际费用
85
87
  - 余额查询走 DeepSeek 官方 `/user/balance` 接口;未配置 API Key 时卡片显示引导文案
86
88
  - 仅统计能归属到已注册工作区(按会话 cwd 匹配)的会话
@@ -104,15 +106,16 @@ A full usage dashboard for DeepSeek Harness. Analyze tokens, cache behavior, acc
104
106
  - **Workspace aliases**: manage aliases from the sidebar dashboard; values persist in the $DSH_HOME/storages KV cell `all_usage_aliases`
105
107
  - **Interface language**: switch between Chinese and English from the dashboard header; your choice persists locally in the browser
106
108
  - **Full history & incremental rebuild**: the baseline scans every readable historical session; the durable usage ledger doubles as a per-session cursor, so unchanged sessions are reused straight from the ledger and only newly appended events are folded — long histories restart without a full rebuild
109
+ - **Restart with no re-read**: the persisted log revision (a header-line + stat via `sessionPersistence.listSnapshots()`) acts as a per-session change signal — sessions whose log is unchanged are applied from the ledger on restart without reading their events at all; only changed/new sessions are read incrementally
110
+ - **Data health and on-demand refresh**: after a scan completes, the browser polls only a lightweight status revision and fetches full history only after usage, alias, or sync state changes; it shows the latest full-data update, historical scan health, revision skips, rereads, ledger recovery, and failures while preserving last-good data on network errors
107
111
  - **Token accounting semantics**: input tokens are fresh (exclude cache hits/writes, which sit in separate buckets along with reasoning); all-zero usage replays never overwrite recorded usage, while cache-only requests still count
108
112
 
109
113
  ### Latest Update
110
114
 
111
- **v1.0.7**
115
+ **v1.0.9**
112
116
 
113
- - Incremental scan cursor: restarts re-fold only newly appended events; unchanged sessions are seeded straight from the durable ledger instead of a full rebuild
114
- - All-zero usage guard: zero-usage replays no longer overwrite recorded usage, while pure cache-read requests still count
115
- - Structured token semantics: the API now declares a machine-readable accounting contract (input excludes cache; cache and reasoning are separate buckets)
117
+ - On-demand refresh: after the scan completes, the dashboard polls lightweight status and fetches full history only when the Host instance or stats revision changes
118
+ - Data health: latest full-data update, historical scan health, revision skips, rereads, ledger recovery, failures, and optimization availability are visible; failed refreshes keep last-good data and expose retry
116
119
 
117
120
  See [CHANGELOG.md](CHANGELOG.md) for the complete version history.
118
121
 
@@ -161,6 +164,7 @@ The profile patch layer hot-reloads; save the file and refresh the page.
161
164
  - After a session is deleted, successfully flushed usage is restored from the separate ledger; disposal hints and periodic reconciliation trigger rebuilds without deleting ledger rows
162
165
  - For each session and logical `turn / step`, only the final usage contribution is kept; retries or replaced messages do not double-count
163
166
  - Input tokens are fresh (exclude cache hits/writes, which sit in their own buckets); all-zero usage replays do not overwrite recorded usage and pure cache-read requests still count
167
+ - The lightweight status endpoint exposes only Host instance, stats revision, scan progress, and sync counters. It does not expose session IDs, workspace paths, prompts, or reply bodies; full snapshots are fetched only after status changes or a manual refresh
164
168
  - Processed tokens = input + output + cache read/write + reasoning; a cache hit means reused context, not newly generated tokens or actual cost
165
169
  - Balance data comes from DeepSeek’s official `/user/balance` endpoint; the card shows guidance when no API key is configured
166
170
  - English mode uses UTC for date buckets, range filters, streaks, heatmap dates, and export timestamps; Chinese mode uses local time
Binary file
Binary file
package/lib/client.js CHANGED
@@ -58,6 +58,25 @@ window.__ModuleLoader__.load({
58
58
  isCurrent(seq) { return seq === latest },
59
59
  }
60
60
  }
61
+ function snapshotVersion(data) {
62
+ if (data === null || typeof data !== 'object') return null
63
+ const instanceId = typeof data.instanceId === 'string' ? data.instanceId : ''
64
+ const revision = typeof data.revision === 'number' && Number.isFinite(data.revision) ? data.revision : null
65
+ return instanceId === '' || revision === null ? null : instanceId + ':' + revision
66
+ }
67
+ function statusRequiresFullSnapshot(status, snapshot) {
68
+ if (status === null || typeof status !== 'object' || snapshot === null || typeof snapshot !== 'object') return true
69
+ const statusVersion = snapshotVersion(status)
70
+ const snapshotVersionValue = snapshotVersion(snapshot)
71
+ if (statusVersion === null || snapshotVersionValue === null || statusVersion !== snapshotVersionValue) return true
72
+ const statusScan = status.scan
73
+ const snapshotScan = snapshot.scan
74
+ return !!(statusScan && snapshotScan && !!statusScan.done !== !!snapshotScan.done)
75
+ }
76
+ function retryDelayFor(failures) {
77
+ const count = Math.max(1, Math.min(4, typeof failures === 'number' && Number.isFinite(failures) ? failures : 1))
78
+ return 5000 * Math.pow(2, count - 1)
79
+ }
61
80
  function rangeFilenamePart(range, customRange, utc) {
62
81
  if (range !== 'custom') return range
63
82
  const normalized = normalizeCustomRange(customRange, utc)
@@ -327,6 +346,9 @@ window.__ModuleLoader__.load({
327
346
  .uh-alias-ok:active { transform:scale(.96); }
328
347
  .uh-anim-panel { animation:uh-panel-in .28s ease both; }
329
348
  .uh-progress { font-size:12px; color:var(--dsw-alias-label-secondary); display:flex; align-items:center; gap:10px; }
349
+ .uh-sync-health { margin-top:8px; padding:8px 12px; display:flex; align-items:center; flex-wrap:wrap; gap:6px; border:1px solid var(--dsw-alias-border-l1); border-radius:10px; color:var(--dsw-alias-label-secondary); background:var(--dsw-alias-bg-layer-1); font-size:11px; line-height:1.45; }
350
+ .uh-sync-health.uh-stale { color:var(--dsw-alias-warning, #a55b00); border-color:color-mix(in srgb, var(--dsw-alias-warning, #d9822b) 45%, var(--dsw-alias-border-l1)); }
351
+ .uh-sync-retry { border:0; background:transparent; color:inherit; font:inherit; text-decoration:underline; cursor:pointer; padding:0 2px; }
330
352
  .uh-bar { flex:1; height:6px; border-radius:3px; background:var(--dsw-alias-bg-layer-2); overflow:hidden; max-width:340px; }
331
353
  .uh-fill { height:100%; background:var(--dsw-alias-brand-primary); border-radius:3px; transition:width .3s ease; }
332
354
  .uh-cards { display:grid; grid-template-columns:repeat(auto-fit, minmax(200px, 1fr)); gap:10px; }
@@ -496,6 +518,10 @@ window.__ModuleLoader__.load({
496
518
  if (!r.ok) throw new Error('HTTP ' + r.status)
497
519
  return r.json()
498
520
  })
521
+ const getStatus = () => fetch('/api/all-usage/status', { headers: { accept: 'application/json' } }).then((r) => {
522
+ if (!r.ok) throw new Error('HTTP ' + r.status)
523
+ return r.json()
524
+ })
499
525
  const getBalance = (force, requestToken) => fetch('/api/all-usage/balance' + (force ? '?force=1' : ''), { headers: { accept: 'application/json', 'x-all-usage-request-token': requestToken } }).then((r) => {
500
526
  if (!r.ok) throw new Error('HTTP ' + r.status)
501
527
  return r.json()
@@ -525,6 +551,9 @@ window.__ModuleLoader__.load({
525
551
  const calendarNow = new Date()
526
552
  const latestCalendarDate = fmtDate(calendarNow, useUtc)
527
553
  const [stats, setStats] = React.useState(null)
554
+ const [status, setStatus] = React.useState(null)
555
+ const [statsError, setStatsError] = React.useState('')
556
+ const [lastStatsAt, setLastStatsAt] = React.useState(0)
528
557
  const [balance, setBalance] = React.useState(null)
529
558
  const [range, setRange] = React.useState('90d')
530
559
  const [customRange, setCustomRange] = React.useState({ start: '', end: '' })
@@ -539,9 +568,12 @@ window.__ModuleLoader__.load({
539
568
  const languageMenuRef = React.useRef(null)
540
569
  const statsGateRef = React.useRef(null)
541
570
  if (statsGateRef.current === null) statsGateRef.current = createRequestGate()
571
+ const statusGateRef = React.useRef(null)
572
+ if (statusGateRef.current === null) statusGateRef.current = createRequestGate()
542
573
  const balanceGateRef = React.useRef(null)
543
574
  if (balanceGateRef.current === null) balanceGateRef.current = createRequestGate()
544
575
  const statsGate = statsGateRef.current
576
+ const statusGate = statusGateRef.current
545
577
  const balanceGate = balanceGateRef.current
546
578
  const refreshRef = React.useRef(() => {})
547
579
  const setLanguage = (next) => { if (typeof props.onLanguageChange === 'function') props.onLanguageChange(next === 'en' ? 'en' : 'zh') }
@@ -551,6 +583,16 @@ window.__ModuleLoader__.load({
551
583
  let alive = true
552
584
  let scanDone = false
553
585
  let requestToken = ''
586
+ let appliedSnapshot = null
587
+ let fullFailures = 0
588
+ let statusFailures = 0
589
+ let retryTimer = null
590
+ const clearRetry = () => {
591
+ if (retryTimer !== null) {
592
+ clearTimeout(retryTimer)
593
+ retryTimer = null
594
+ }
595
+ }
554
596
  const refreshBalance = (force) => {
555
597
  if (requestToken === '') return
556
598
  const seq = balanceGate.next()
@@ -559,24 +601,84 @@ window.__ModuleLoader__.load({
559
601
  if (data) setBalance(data)
560
602
  }, () => {})
561
603
  }
604
+ const scheduleRetry = (kind) => {
605
+ if (!alive || retryTimer !== null) return
606
+ const failures = kind === 'full' ? (fullFailures += 1) : (statusFailures += 1)
607
+ retryTimer = setTimeout(() => {
608
+ retryTimer = null
609
+ if (!alive) return
610
+ if (kind === 'full') refreshStats()
611
+ else refreshStatus()
612
+ }, retryDelayFor(failures))
613
+ }
562
614
  const refreshStats = () => {
615
+ statusGate.next()
563
616
  const seq = statsGate.next()
564
617
  getStats().then((data) => {
565
618
  if (!alive || !statsGate.isCurrent(seq)) return
566
- if (data && data.scan) scanDone = !!data.scan.done
567
- const nextToken = data && typeof data.requestToken === 'string' ? data.requestToken : ''
619
+ if (data === null || typeof data !== 'object') {
620
+ setStatsError('full')
621
+ scheduleRetry('full')
622
+ return
623
+ }
624
+ appliedSnapshot = data
625
+ if (data.scan) scanDone = !!data.scan.done
626
+ const nextToken = typeof data.requestToken === 'string' ? data.requestToken : ''
568
627
  const tokenChanged = nextToken !== '' && nextToken !== requestToken
569
628
  requestToken = nextToken
629
+ fullFailures = 0
630
+ clearRetry()
631
+ setStatsError('')
632
+ setLastStatsAt(Date.now())
633
+ setStatus(data)
570
634
  setStats(data)
571
635
  if (tokenChanged) refreshBalance(false)
572
- }, () => {})
636
+ }, () => {
637
+ if (!alive || !statsGate.isCurrent(seq)) return
638
+ setStatsError('full')
639
+ scheduleRetry('full')
640
+ })
641
+ }
642
+ const refreshStatus = () => {
643
+ if (appliedSnapshot === null) { refreshStats(); return }
644
+ const seq = statusGate.next()
645
+ getStatus().then((data) => {
646
+ if (!alive || !statusGate.isCurrent(seq)) return
647
+ if (data === null || typeof data !== 'object') {
648
+ setStatsError('status')
649
+ scheduleRetry('status')
650
+ return
651
+ }
652
+ statusFailures = 0
653
+ const requiresFullSnapshot = statusRequiresFullSnapshot(data, appliedSnapshot)
654
+ if (data.scan) scanDone = !!data.scan.done
655
+ // Do not render a newer status beside an older full snapshot. A full
656
+ // fetch owns the state transition when instance/revision diverges.
657
+ if (requiresFullSnapshot) {
658
+ refreshStats()
659
+ return
660
+ }
661
+ setStatus(data)
662
+ clearRetry()
663
+ setStatsError('')
664
+ }, () => {
665
+ if (!alive || !statusGate.isCurrent(seq)) return
666
+ setStatsError('status')
667
+ scheduleRetry('status')
668
+ })
573
669
  }
574
670
  refreshStats()
575
- const fast = timer.interval(() => { if (!scanDone) refreshStats() }, 2000)
576
- const slow = timer.interval(() => { if (scanDone) refreshStats() }, 15000)
671
+ const fast = timer.interval(() => { if (!scanDone && retryTimer === null) refreshStats() }, 2000)
672
+ const slow = timer.interval(() => { if (scanDone && retryTimer === null) refreshStatus() }, 15000)
577
673
  const bal = timer.interval(() => { refreshBalance(false) }, 60000)
578
- refreshRef.current = () => { refreshStats(); refreshBalance(true) }
579
- return () => { alive = false; fast(); slow(); bal() }
674
+ refreshRef.current = () => {
675
+ clearRetry()
676
+ fullFailures = 0
677
+ statusFailures = 0
678
+ refreshStats()
679
+ refreshBalance(true)
680
+ }
681
+ return () => { alive = false; clearRetry(); fast(); slow(); bal() }
580
682
  }, [])
581
683
 
582
684
  React.useEffect(() => {
@@ -622,12 +724,23 @@ window.__ModuleLoader__.load({
622
724
  const animatedTurns = useCountUp(agg.totals.turns, timer)
623
725
 
624
726
  if (stats === null) {
727
+ const failed = statsError !== ''
625
728
  return React.createElement('div', { className: 'uh-page' },
626
- React.createElement('div', { className: 'uh-panel' }, React.createElement('div', { className: 'uh-empty' }, tr('正在加载用量统计…', 'Loading usage statistics…'))),
729
+ React.createElement('div', { className: 'uh-panel' },
730
+ React.createElement('div', { className: 'uh-empty' }, failed
731
+ ? tr('无法加载用量统计。请重试。', 'Unable to load usage statistics. Please retry.')
732
+ : tr('正在加载用量统计…', 'Loading usage statistics…'),
733
+ ),
734
+ failed ? React.createElement('div', { style: { textAlign: 'center' } },
735
+ React.createElement('button', { className: 'uh-refresh', onClick: onRefresh }, React.createElement(LineIcon, { name: 'refresh', size: 14 }), tr('重试', 'Retry')),
736
+ ) : null,
737
+ ),
627
738
  )
628
739
  }
629
740
 
630
- const scan = stats.scan || { done: true, started: true, scanned: 0, total: 0, failed: 0 }
741
+ const statusPayload = status !== null && typeof status === 'object' ? status : stats
742
+ const scan = statusPayload && statusPayload.scan ? statusPayload.scan : (stats.scan || { done: true, started: true, scanned: 0, total: 0, failed: 0 })
743
+ const sync = statusPayload && statusPayload.sync ? statusPayload.sync : (stats.sync || {})
631
744
  const workspaces = Array.isArray(stats.workspaces) ? stats.workspaces : []
632
745
  const aliases = stats.aliases && typeof stats.aliases === 'object' ? stats.aliases : {}
633
746
  const wsById = new Map()
@@ -985,6 +1098,25 @@ window.__ModuleLoader__.load({
985
1098
  const scanning = !scan.done
986
1099
  const pct = scan.total > 0 ? Math.min(100, Math.round((scan.scanned / scan.total) * 100)) : 40
987
1100
  const isEmpty = scan.done && dayRows.length === 0 && stats.totals.turns === 0
1101
+ const syncCompletedAt = typeof sync.lastCompletedAt === 'number' && sync.lastCompletedAt > 0 ? new Date(sync.lastCompletedAt).toLocaleString(language === 'en' ? 'en-US' : 'zh-CN') : ''
1102
+ const lastStatsText = lastStatsAt > 0 ? new Date(lastStatsAt).toLocaleString(language === 'en' ? 'en-US' : 'zh-CN') : ''
1103
+ const healthTitle = syncCompletedAt === '' ? undefined : (language === 'en' ? 'Historical scan completed ' + syncCompletedAt : '历史扫描完成于 ' + syncCompletedAt)
1104
+ const healthText = language === 'en'
1105
+ ? (lastStatsText !== '' ? 'Updated ' + lastStatsText : (scanning ? 'Refreshing data' : 'Update state pending'))
1106
+ + ' · ' + (sync.sessionsSkippedByRevision || 0) + ' revision reused'
1107
+ + ' · ' + (sync.sessionsRead || 0) + ' read'
1108
+ + ((sync.sessionsRestoredFromLedger || 0) > 0 ? ' · ' + sync.sessionsRestoredFromLedger + ' ledger restored' : '')
1109
+ + ((sync.sessionsFailed || 0) > 0 ? ' · ' + sync.sessionsFailed + ' failed' : '')
1110
+ + ' · ' + (sync.persistenceSnapshotsAvailable === true ? 'revision optimization on' : 'full-read fallback')
1111
+ : (lastStatsText !== '' ? '已更新 ' + lastStatsText : (scanning ? '正在更新数据' : '数据更新准备中'))
1112
+ + ' · revision 复用 ' + (sync.sessionsSkippedByRevision || 0)
1113
+ + ' · 实际读取 ' + (sync.sessionsRead || 0)
1114
+ + ((sync.sessionsRestoredFromLedger || 0) > 0 ? ' · 账本恢复 ' + sync.sessionsRestoredFromLedger : '')
1115
+ + ((sync.sessionsFailed || 0) > 0 ? ' · 失败 ' + sync.sessionsFailed : '')
1116
+ + ' · ' + (sync.persistenceSnapshotsAvailable === true ? '免读优化已启用' : '全量读取回退')
1117
+ const staleText = statsError === '' ? '' : (language === 'en'
1118
+ ? 'Usage data may be stale' + (lastStatsText !== '' ? '; last full update ' + lastStatsText : '')
1119
+ : '用量数据可能已过期' + (lastStatsText !== '' ? ';上次完整更新 ' + lastStatsText : ''))
988
1120
 
989
1121
  return React.createElement('div', { className: 'uh-page' },
990
1122
  React.createElement('div', { className: 'uh-head' },
@@ -1049,6 +1181,11 @@ window.__ModuleLoader__.load({
1049
1181
  React.createElement('span', {}, language === 'en' ? 'Scanning historical sessions: ' + scan.scanned + ' / ' + scan.total + (scan.failed > 0 ? ' (' + scan.failed + ' failed to read)' : '') : '正在统计历史会话 ' + scan.scanned + ' / ' + scan.total + (scan.failed > 0 ? '(' + scan.failed + ' 个读取失败)' : '')),
1050
1182
  React.createElement('div', { className: 'uh-bar' }, React.createElement('div', { className: 'uh-fill', style: { width: pct + '%' } })),
1051
1183
  ) : null,
1184
+ React.createElement('div', { className: 'uh-sync-health' + (staleText !== '' ? ' uh-stale' : ''), title: staleText !== '' ? undefined : healthTitle },
1185
+ React.createElement(LineIcon, { name: staleText !== '' ? 'refresh' : 'clock', size: 14 }),
1186
+ React.createElement('span', {}, staleText !== '' ? staleText : healthText),
1187
+ staleText !== '' ? React.createElement('button', { className: 'uh-sync-retry', onClick: onRefresh }, tr('重试', 'Retry')) : null,
1188
+ ),
1052
1189
  isEmpty ? React.createElement('div', { className: 'uh-panel' },
1053
1190
  React.createElement('div', { className: 'uh-empty' }, tr('还没有使用记录。开始对话后,这里会点亮。', 'No usage recorded yet. This area will light up after you start a conversation.')),
1054
1191
  ) : React.createElement(React.Fragment, null,
@@ -1189,10 +1326,11 @@ window.__ModuleLoader__.load({
1189
1326
  exports.inject = ['timer', 'slots']
1190
1327
  exports.apply = (ctx) => {
1191
1328
  const slots = ctx.get('slots')
1192
- if (slots === undefined) return
1329
+ const timer = ctx.get('timer')
1330
+ if (slots === undefined || timer === undefined) return
1193
1331
  slots.inject('sidebar.footer.action', () => slots.register(
1194
1332
  { name: 'sidebar.footer.action', id: 'all-usage', order: 10 },
1195
- (props) => React.createElement(UsageSidebarEntry, { wide: props.wide, timerCtx: ctx }),
1333
+ (props) => React.createElement(UsageSidebarEntry, { wide: props.wide, timerCtx: timer }),
1196
1334
  ))
1197
1335
  }
1198
1336
  return module.exports;
package/lib/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  import { randomBytes, timingSafeEqual } from 'node:crypto'
4
4
 
5
5
  const name = 'dsh-all-usage'
6
- const inject = ['sessionQuery', 'workspaceRegistry', 'timer', 'storage']
6
+ const inject = ['sessionQuery', 'workspaceRegistry', 'timer', 'sessionPersistence', 'storage']
7
7
 
8
8
  // webServer route handlers do not inherit the connection API fence; keep this plugin
9
9
  // local and require a browser-originated capability for state-changing reads/writes.
@@ -75,6 +75,10 @@ function apply(ctx) {
75
75
  const settings = ctx.get('settings')
76
76
  const storage = ctx.get('storage')
77
77
  const webServer = ctx.get('webServer')
78
+ // v1.0.8: optional sessionPersistence exposes listSnapshots() — a cheap per-session
79
+ // revision (header line + stat, no full-log read) that lets the baseline skip
80
+ // re-reading unchanged sessions after a DSH restart.
81
+ const sessionPersistence = ctx.get('sessionPersistence')
78
82
 
79
83
  // ---------- owned aggregation state ----------
80
84
  const wsMeta = new Map()
@@ -98,6 +102,23 @@ function apply(ctx) {
98
102
  let aliasWriteChain = Promise.resolve()
99
103
  let balanceCache = { fetchedAt: 0, payload: null }
100
104
  const requestToken = randomBytes(32).toString('base64url')
105
+ // A non-secret identity distinguishes HMR/restart revision resets from a stale page.
106
+ const instanceId = randomBytes(12).toString('base64url')
107
+ let statsRevision = 0
108
+ let statsUpdatedAt = Date.now()
109
+ let statsDirtyScheduled = false
110
+ const sync = {
111
+ lastStartedAt: 0,
112
+ lastCompletedAt: 0,
113
+ lastErrorAt: 0,
114
+ lastErrorCode: null,
115
+ persistenceSnapshotsAvailable: false,
116
+ sessionsTotal: 0,
117
+ sessionsRead: 0,
118
+ sessionsSkippedByRevision: 0,
119
+ sessionsRestoredFromLedger: 0,
120
+ sessionsFailed: 0,
121
+ }
101
122
  const ledgerRecords = new Map()
102
123
  let ledgerUnit = null
103
124
  let ledgerReady = Promise.resolve()
@@ -128,6 +149,55 @@ function apply(ctx) {
128
149
  function num(v) {
129
150
  return typeof v === 'number' && Number.isFinite(v) ? v : 0
130
151
  }
152
+ // Coalesce synchronous aggregation writes without adding a timer lifecycle.
153
+ function markStatsChanged() {
154
+ if (disposed || statsDirtyScheduled) return
155
+ statsDirtyScheduled = true
156
+ const flush = () => {
157
+ statsDirtyScheduled = false
158
+ if (disposed) return
159
+ statsRevision += 1
160
+ statsUpdatedAt = Date.now()
161
+ }
162
+ if (typeof queueMicrotask === 'function') queueMicrotask(flush)
163
+ else Promise.resolve().then(flush)
164
+ }
165
+ function resetSyncState() {
166
+ sync.lastStartedAt = 0
167
+ sync.lastCompletedAt = 0
168
+ sync.lastErrorAt = 0
169
+ sync.lastErrorCode = null
170
+ sync.persistenceSnapshotsAvailable = false
171
+ sync.sessionsTotal = 0
172
+ sync.sessionsRead = 0
173
+ sync.sessionsSkippedByRevision = 0
174
+ sync.sessionsRestoredFromLedger = 0
175
+ sync.sessionsFailed = 0
176
+ }
177
+ function beginSync() {
178
+ resetSyncState()
179
+ sync.lastStartedAt = Date.now()
180
+ markStatsChanged()
181
+ }
182
+ function noteSyncError(code) {
183
+ sync.lastErrorAt = Date.now()
184
+ sync.lastErrorCode = code
185
+ markStatsChanged()
186
+ }
187
+ function syncSnapshot() {
188
+ return {
189
+ lastStartedAt: sync.lastStartedAt,
190
+ lastCompletedAt: sync.lastCompletedAt,
191
+ lastErrorAt: sync.lastErrorAt,
192
+ lastErrorCode: sync.lastErrorCode,
193
+ persistenceSnapshotsAvailable: sync.persistenceSnapshotsAvailable,
194
+ sessionsTotal: sync.sessionsTotal,
195
+ sessionsRead: sync.sessionsRead,
196
+ sessionsSkippedByRevision: sync.sessionsSkippedByRevision,
197
+ sessionsRestoredFromLedger: sync.sessionsRestoredFromLedger,
198
+ sessionsFailed: sync.sessionsFailed,
199
+ }
200
+ }
131
201
  async function safeContextTimeout(ms) {
132
202
  if (disposed) return false
133
203
  try {
@@ -164,7 +234,9 @@ function apply(ctx) {
164
234
  scan.scanned = 0
165
235
  scan.total = 0
166
236
  scan.failed = 0
237
+ resetSyncState()
167
238
  baselineRetryDelay = 1000
239
+ markStatsChanged()
168
240
  return aggregationGeneration
169
241
  }
170
242
  function ensureDay(dayMap, date) {
@@ -271,6 +343,7 @@ function apply(ctx) {
271
343
  const next = { seq: eventSeq, wsId, time, values, modelId, sid }
272
344
  usageByStep.set(key, next)
273
345
  adjustUsage(wsId, time, values, modelId, 1, sid)
346
+ markStatsChanged()
274
347
  }
275
348
  function addDayTurn(dayMap, date, wsId, sid) {
276
349
  const day = ensureDay(dayMap, date)
@@ -283,6 +356,7 @@ function apply(ctx) {
283
356
  totals.turns += 1
284
357
  addDayTurn(byDay, dayKey(time), wsId, sid)
285
358
  addDayTurn(byDayUtc, dayKeyUtc(time), wsId, sid)
359
+ markStatsChanged()
286
360
  }
287
361
  function routeLabel(route) {
288
362
  if (route && typeof route.model === 'string' && route.model !== '') return (typeof route.provider === 'string' && route.provider !== '' ? route.provider + ' / ' : '') + route.model
@@ -301,7 +375,7 @@ function apply(ctx) {
301
375
  function ledgerEventKey(event, index) {
302
376
  return typeof event.seq === 'number' ? String(event.seq) : 'event:' + index
303
377
  }
304
- function buildLedgerRecord(session, workspaceId, source = 'scan') {
378
+ function buildLedgerRecord(session, workspaceId, source = 'scan', revision) {
305
379
  const sid = session && typeof session.id === 'string' ? session.id : ''
306
380
  const events = session && Array.isArray(session.events) ? session.events : []
307
381
  if (sid === '' || workspaceId === undefined) return null
@@ -335,7 +409,7 @@ function apply(ctx) {
335
409
  if (previous !== undefined && eventSeq >= 0 && previous.seq > eventSeq) continue
336
410
  usage.set(key, { key, seq: eventSeq, time: event.time, workspaceId, modelId, values })
337
411
  }
338
- return { version: LEDGER_VERSION, sessionId: sid, workspaceId, lastSeq: lastSeqOf(events), source, updatedAt: nextLedgerRevision(), turns: Array.from(turns.values()), usage: Array.from(usage.values()) }
412
+ return { version: LEDGER_VERSION, sessionId: sid, workspaceId, lastSeq: lastSeqOf(events), source, updatedAt: nextLedgerRevision(), lastRevision: typeof revision === 'string' ? revision : undefined, turns: Array.from(turns.values()), usage: Array.from(usage.values()) }
339
413
  }
340
414
  function normalizeLedgerRecord(raw, key) {
341
415
  if (raw === null || typeof raw !== 'object' || raw.version !== LEDGER_VERSION || typeof raw.sessionId !== 'string' || raw.sessionId !== key) return null
@@ -353,13 +427,16 @@ function apply(ctx) {
353
427
  }
354
428
  const updatedAt = typeof raw.updatedAt === 'number' ? raw.updatedAt : 0
355
429
  ledgerRevision = Math.max(ledgerRevision, updatedAt)
356
- return { version: LEDGER_VERSION, sessionId: raw.sessionId, workspaceId: raw.workspaceId, lastSeq: typeof raw.lastSeq === 'number' ? raw.lastSeq : -1, source: raw.source === 'flush' ? 'flush' : 'scan', updatedAt, turns: Array.from(turnMap.values()), usage: Array.from(usageMap.values()) }
430
+ return { version: LEDGER_VERSION, sessionId: raw.sessionId, workspaceId: raw.workspaceId, lastSeq: typeof raw.lastSeq === 'number' ? raw.lastSeq : -1, source: raw.source === 'flush' ? 'flush' : 'scan', updatedAt, lastRevision: typeof raw.lastRevision === 'string' ? raw.lastRevision : undefined, turns: Array.from(turnMap.values()), usage: Array.from(usageMap.values()) }
357
431
  }
358
432
  function applyLedgerRecord(record) {
359
433
  if (record === null || record === undefined) return
360
434
  for (const turn of record.turns) addTurn(turn.workspaceId, turn.time, record.sessionId)
361
435
  for (const item of record.usage) adjustUsage(item.workspaceId, item.time, item.values, item.modelId, 1, record.sessionId)
362
- if (record.turns.length > 0 || record.usage.length > 0) sessionCount.add(record.sessionId)
436
+ if (record.turns.length > 0 || record.usage.length > 0) {
437
+ sessionCount.add(record.sessionId)
438
+ if (record.usage.length > 0) markStatsChanged()
439
+ }
363
440
  }
364
441
  function ledgerRank(record) {
365
442
  return [typeof record.lastSeq === 'number' ? record.lastSeq : -1, record.source === 'flush' ? 1 : 0, typeof record.updatedAt === 'number' ? record.updatedAt : 0]
@@ -504,6 +581,7 @@ function apply(ctx) {
504
581
  async function runBaseline(generation = aggregationGeneration) {
505
582
  if (scan.started || disposed || generation !== aggregationGeneration) return
506
583
  scan.started = true
584
+ beginSync()
507
585
  await ledgerReady
508
586
  if (disposed || generation !== aggregationGeneration) return
509
587
  let setupFailed = false
@@ -523,21 +601,49 @@ function apply(ctx) {
523
601
  } catch (err) {
524
602
  console.error('[all-usage] workspace list failed:', err)
525
603
  setupFailed = true
604
+ noteSyncError('workspace-list-failed')
526
605
  }
527
606
  let records = null
528
607
  try {
529
608
  records = await ctx.sessionQuery.listSessions()
530
609
  } catch (err) {
531
610
  console.error('[all-usage] session list failed:', err)
611
+ if (disposed || generation !== aggregationGeneration) return
612
+ noteSyncError('session-list-failed')
613
+ }
614
+ if (disposed || generation !== aggregationGeneration) return
615
+ // v1.0.8: cheap per-session change signal (header line + stat, no full-log read)
616
+ let snapshots = null
617
+ if (sessionPersistence !== undefined && typeof sessionPersistence.listSnapshots === 'function') {
618
+ try {
619
+ const rows = await sessionPersistence.listSnapshots()
620
+ if (disposed || generation !== aggregationGeneration) return
621
+ if (Array.isArray(rows)) {
622
+ sync.persistenceSnapshotsAvailable = true
623
+ snapshots = new Map()
624
+ for (const row of rows) {
625
+ const rid = row && row.header && typeof row.header.id === 'string' ? row.header.id : undefined
626
+ if (rid !== undefined && row && typeof row.revision === 'string') snapshots.set(rid, row.revision)
627
+ }
628
+ }
629
+ } catch (err) {
630
+ console.error('[all-usage] session persistence snapshots unavailable:', err)
631
+ if (disposed || generation !== aggregationGeneration) return
632
+ sync.persistenceSnapshotsAvailable = false
633
+ markStatsChanged()
634
+ }
532
635
  }
533
636
  if (disposed || generation !== aggregationGeneration) return
534
637
  if (setupFailed || !Array.isArray(records)) {
535
638
  // A transient registry failure must not be reported as a completed empty scan.
536
639
  scan.started = false
640
+ markStatsChanged()
537
641
  scheduleBaselineRetry(generation)
538
642
  return
539
643
  }
540
644
  scan.total = records.length
645
+ sync.sessionsTotal = records.length
646
+ markStatsChanged()
541
647
  const listedSessionIds = new Set()
542
648
  for (const record of records) {
543
649
  if (record === undefined || record === null || record.header === undefined) continue
@@ -547,12 +653,17 @@ function apply(ctx) {
547
653
  if (sid !== undefined && wsId !== undefined) listedSessionIds.add(sid)
548
654
  }
549
655
  for (const [sid, record] of ledgerRecords) {
550
- if (!listedSessionIds.has(sid)) applyLedgerRecord(record)
656
+ if (!listedSessionIds.has(sid)) {
657
+ applyLedgerRecord(record)
658
+ if (record.turns.length > 0 || record.usage.length > 0) sync.sessionsRestoredFromLedger += 1
659
+ }
551
660
  }
661
+ if (sync.sessionsRestoredFromLedger > 0) markStatsChanged()
552
662
  for (const record of records) {
553
663
  if (disposed || generation !== aggregationGeneration) return
554
664
  if (record === undefined || record === null || record.header === undefined) {
555
665
  scan.scanned += 1
666
+ markStatsChanged()
556
667
  continue
557
668
  }
558
669
  const sid = record.header.id
@@ -560,6 +671,7 @@ function apply(ctx) {
560
671
  const wsId = cwd === '' ? undefined : pathIndex.get(cwd)
561
672
  if (sid === undefined || wsId === undefined) {
562
673
  scan.scanned += 1
674
+ markStatsChanged()
563
675
  continue
564
676
  }
565
677
  listedSessionIds.add(sid)
@@ -567,6 +679,24 @@ function apply(ctx) {
567
679
  if (disposed || generation !== aggregationGeneration) return
568
680
  try {
569
681
  if (sessionSeq.has(sid)) return
682
+ // v1.0.8: when the persisted log revision is unchanged since the last ledger
683
+ // write, the whole readSession (full event transfer) is skipped — the ledger
684
+ // record is applied directly and the live feed keeps catching new events.
685
+ const previousRecord = ledgerRecords.get(sid)
686
+ const revision = snapshots === null ? undefined : snapshots.get(sid)
687
+ if (previousRecord !== undefined && typeof previousRecord.lastRevision === 'string' && typeof revision === 'string' && revision === previousRecord.lastRevision) {
688
+ sync.sessionsSkippedByRevision += 1
689
+ applyLedgerRecord(previousRecord)
690
+ for (const item of previousRecord.usage) {
691
+ usageByStep.set(item.key, { seq: item.seq, wsId: item.workspaceId, time: item.time, values: item.values, modelId: item.modelId, sid })
692
+ }
693
+ sessionSeq.set(sid, previousRecord.lastSeq)
694
+ sessionCount.add(sid)
695
+ markStatsChanged()
696
+ return
697
+ }
698
+ sync.sessionsRead += 1
699
+ markStatsChanged()
570
700
  const snap = await ctx.sessionQuery.readSession(sid)
571
701
  if (disposed || generation !== aggregationGeneration) return
572
702
  if (snap && Array.isArray(snap.events)) {
@@ -585,6 +715,12 @@ function apply(ctx) {
585
715
  usageByStep.set(item.key, { seq: item.seq, wsId: item.workspaceId, time: item.time, values: item.values, modelId: item.modelId, sid })
586
716
  }
587
717
  if (previous.lastSeq >= currentLastSeq) {
718
+ // Content unchanged despite a revision change (rare: ctime-only churn):
719
+ // refresh the stored revision so future restarts can skip the read.
720
+ if (revision !== undefined && previous.lastRevision !== revision) {
721
+ previous.lastRevision = revision
722
+ void persistLedgerRecord(previous)
723
+ }
588
724
  sessionSeq.set(sid, previous.lastSeq)
589
725
  sessionCount.add(sid)
590
726
  return
@@ -593,7 +729,7 @@ function apply(ctx) {
593
729
  } else {
594
730
  foldEvents(wsId, snap.events, undefined, sid)
595
731
  }
596
- const ledger = buildLedgerRecord({ id: sid, header: record.header, events: snap.events }, wsId)
732
+ const ledger = buildLedgerRecord({ id: sid, header: record.header, events: snap.events }, wsId, 'scan', revision)
597
733
  const canonical = ledger === null ? ledgerRecords.get(sid) : storeLedgerRecord(ledger)
598
734
  if (canonical === ledger) {
599
735
  void persistLedgerRecord(ledger)
@@ -604,21 +740,29 @@ function apply(ctx) {
604
740
  sessionCount.add(sid)
605
741
  }
606
742
  } catch (err) {
743
+ if (generation !== aggregationGeneration) return
744
+ sync.sessionsFailed += 1
745
+ noteSyncError('session-read-failed')
607
746
  const saved = ledgerRecords.get(sid)
608
747
  if (saved !== undefined) {
609
748
  applyLedgerRecord(saved)
749
+ if (saved.turns.length > 0 || saved.usage.length > 0) sync.sessionsRestoredFromLedger += 1
610
750
  sessionSeq.set(sid, -1)
611
751
  sessionCount.add(sid)
612
- } else if (generation === aggregationGeneration) {
752
+ } else {
613
753
  sessionSeq.set(sid, -1)
614
754
  scan.failed += 1
615
755
  }
616
756
  } finally {
617
- if (generation === aggregationGeneration) scan.scanned += 1
757
+ if (generation === aggregationGeneration) {
758
+ scan.scanned += 1
759
+ markStatsChanged()
760
+ }
618
761
  }
619
762
  })
620
763
  if (!(await safeContextTimeout(0))) {
621
764
  scan.started = false
765
+ noteSyncError('baseline-yield-unavailable')
622
766
  scheduleBaselineRetry(generation)
623
767
  return
624
768
  }
@@ -629,6 +773,12 @@ function apply(ctx) {
629
773
  knownSessionIds.clear()
630
774
  for (const sid of listedSessionIds) knownSessionIds.add(sid)
631
775
  scan.done = true
776
+ sync.lastCompletedAt = Date.now()
777
+ if (sync.sessionsFailed === 0) {
778
+ sync.lastErrorAt = 0
779
+ sync.lastErrorCode = null
780
+ }
781
+ markStatsChanged()
632
782
  if (reconcilePending) scheduleReconcileHint()
633
783
  }
634
784
 
@@ -665,6 +815,7 @@ function apply(ctx) {
665
815
  for (const sid of currentIds) knownSessionIds.add(sid)
666
816
  } catch (err) {
667
817
  console.error('[all-usage] session reconciliation failed:', err)
818
+ noteSyncError('session-reconcile-failed')
668
819
  } finally {
669
820
  reconcileInFlight = false
670
821
  if (reconcilePending && !disposed) scheduleReconcileHint()
@@ -738,6 +889,7 @@ function apply(ctx) {
738
889
  const value = g[key]
739
890
  if (typeof value === 'string' && value.trim() !== '') aliases[key] = value
740
891
  }
892
+ markStatsChanged()
741
893
  }
742
894
  } catch (err) {
743
895
  console.error('[all-usage] alias storage unavailable:', err)
@@ -759,6 +911,7 @@ function apply(ctx) {
759
911
  if (alias === '') delete aliases[wsId]
760
912
  else aliases[wsId] = alias
761
913
  persistAliases()
914
+ markStatsChanged()
762
915
  return { ok: true, aliases: Object.assign({}, aliases) }
763
916
  }
764
917
  ctx.effect(() => () => {
@@ -785,6 +938,12 @@ function apply(ctx) {
785
938
  })
786
939
 
787
940
  // ---------- snapshot for the client ----------
941
+ function scanSnapshot() {
942
+ return { started: scan.started, done: scan.done, scanned: scan.scanned, total: scan.total, failed: scan.failed }
943
+ }
944
+ function statusSnapshot() {
945
+ return { instanceId, revision: statsRevision, updatedAt: statsUpdatedAt, scan: scanSnapshot(), sync: syncSnapshot() }
946
+ }
788
947
  function serializeDays(dayMap) {
789
948
  const result = []
790
949
  for (const pair of dayMap) {
@@ -806,7 +965,7 @@ function apply(ctx) {
806
965
  }
807
966
  function snapshot() {
808
967
  return {
809
- scan: { started: scan.started, done: scan.done, scanned: scan.scanned, total: scan.total, failed: scan.failed },
968
+ ...statusSnapshot(),
810
969
  generatedAt: Date.now(),
811
970
  requestToken,
812
971
  workspaces: Array.from(wsMeta.values(), (w) => ({ id: w.id, title: w.title, path: w.path })),
@@ -940,6 +1099,16 @@ function apply(ctx) {
940
1099
  // ---------- HTTP data routes for the client half ----------
941
1100
  if (webServer !== undefined) {
942
1101
  const rejectRequest = (res) => sendJson(res, 403, { ok: false, message: 'forbidden' })
1102
+ ctx.effect(() => webServer.register({
1103
+ kind: 'exact',
1104
+ path: '/api/all-usage/status',
1105
+ handler: (req, res) => {
1106
+ if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
1107
+ if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
1108
+ if (!scan.started) void runBaseline()
1109
+ sendJson(res, 200, statusSnapshot())
1110
+ },
1111
+ }))
943
1112
  ctx.effect(() => webServer.register({
944
1113
  kind: 'exact',
945
1114
  path: '/api/all-usage',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-all-usage",
3
- "version": "1.0.7",
3
+ "version": "1.0.9",
4
4
  "description": "DeepSeek Harness usage dashboard with model, provider, workspace, cache, balance, and CSV insights",
5
5
  "repository": {
6
6
  "type": "git",