dsh-all-usage 1.0.8 → 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,18 @@
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
+
5
17
  ## [1.0.8] - 2026-08-23
6
18
 
7
19
  ### Added
@@ -62,6 +74,7 @@ All notable changes to `dsh-all-usage` are documented here.
62
74
  - Allowed same-origin browser balance GET requests that omit `Origin` while retaining token protection.
63
75
  - Standardized English date buckets, range filters, streaks, heatmap dates, and export timestamps on UTC.
64
76
 
77
+ [1.0.9]: https://github.com/ParticleLight/dsh-all-usage/releases/tag/v1.0.9
65
78
  [1.0.8]: https://github.com/ParticleLight/dsh-all-usage/releases/tag/v1.0.8
66
79
  [1.0.7]: https://github.com/ParticleLight/dsh-all-usage/releases/tag/v1.0.7
67
80
  [1.0.6]: https://github.com/ParticleLight/dsh-all-usage/releases/tag/v1.0.6
package/README.md CHANGED
@@ -19,14 +19,15 @@ DeepSeek Harness 全量用量看板:按模型、供应商、工作区和时间
19
19
  - **界面语言**:在看板顶部切换中文与 English;选择会保存到浏览器本地
20
20
  - **完整历史与增量重建**:基线扫描全部可读历史会话;独立用量账本同时作为每会话游标——未变化的会话直接复用账本,新增事件只增量回填,长历史重启不再全量重建
21
21
  - **重启免读**:用持久化日志的 revision 作为每会话的变更信号(只读头部行 + stat,不读全量)——日志未变的会话重启时连事件都不读,直接从账本复用;仅日志变化(新增/修改)的会话才做增量读取
22
+ - **数据健康与按需刷新**:扫描完成后浏览器只检查轻量状态版本,只有用量、别名或同步状态变化时才拉完整历史;显示本次数据更新时间、历史扫描健康、revision 免读、实际读取、账本恢复和失败,网络异常保留上次成功数据并可重试
22
23
  - **Token 口径**:输入按「未含缓存命中」计,缓存命中 / 写入与推理独立成桶;全 0 用量的重放事件不会覆盖已记录的真实用量,仅缓存命中的请求也会计入
23
24
 
24
25
  ### 最近更新
25
26
 
26
- **v1.0.8**
27
+ **v1.0.9**
27
28
 
28
- - 重启免读:用持久化日志的 revision 做每会话变更信号,未变化会话重启时完全不读事件,直接复用账本;仅在日志变化时增量读取
29
- - 账本记录持久化每会话的日志 revision(旧记录首次重读后自动回填)
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 匹配)的会话
@@ -105,14 +107,15 @@ A full usage dashboard for DeepSeek Harness. Analyze tokens, cache behavior, acc
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
107
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
108
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
109
112
 
110
113
  ### Latest Update
111
114
 
112
- **v1.0.8**
115
+ **v1.0.9**
113
116
 
114
- - Restart with no re-read: the persisted log revision acts as a per-session change signal, so unchanged sessions are applied from the ledger on restart without reading any events; only changed logs are read incrementally
115
- - The ledger now stores each session's log revision; existing rows are re-read once and backfilled automatically
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
@@ -102,6 +102,23 @@ function apply(ctx) {
102
102
  let aliasWriteChain = Promise.resolve()
103
103
  let balanceCache = { fetchedAt: 0, payload: null }
104
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
+ }
105
122
  const ledgerRecords = new Map()
106
123
  let ledgerUnit = null
107
124
  let ledgerReady = Promise.resolve()
@@ -132,6 +149,55 @@ function apply(ctx) {
132
149
  function num(v) {
133
150
  return typeof v === 'number' && Number.isFinite(v) ? v : 0
134
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
+ }
135
201
  async function safeContextTimeout(ms) {
136
202
  if (disposed) return false
137
203
  try {
@@ -168,7 +234,9 @@ function apply(ctx) {
168
234
  scan.scanned = 0
169
235
  scan.total = 0
170
236
  scan.failed = 0
237
+ resetSyncState()
171
238
  baselineRetryDelay = 1000
239
+ markStatsChanged()
172
240
  return aggregationGeneration
173
241
  }
174
242
  function ensureDay(dayMap, date) {
@@ -275,6 +343,7 @@ function apply(ctx) {
275
343
  const next = { seq: eventSeq, wsId, time, values, modelId, sid }
276
344
  usageByStep.set(key, next)
277
345
  adjustUsage(wsId, time, values, modelId, 1, sid)
346
+ markStatsChanged()
278
347
  }
279
348
  function addDayTurn(dayMap, date, wsId, sid) {
280
349
  const day = ensureDay(dayMap, date)
@@ -287,6 +356,7 @@ function apply(ctx) {
287
356
  totals.turns += 1
288
357
  addDayTurn(byDay, dayKey(time), wsId, sid)
289
358
  addDayTurn(byDayUtc, dayKeyUtc(time), wsId, sid)
359
+ markStatsChanged()
290
360
  }
291
361
  function routeLabel(route) {
292
362
  if (route && typeof route.model === 'string' && route.model !== '') return (typeof route.provider === 'string' && route.provider !== '' ? route.provider + ' / ' : '') + route.model
@@ -363,7 +433,10 @@ function apply(ctx) {
363
433
  if (record === null || record === undefined) return
364
434
  for (const turn of record.turns) addTurn(turn.workspaceId, turn.time, record.sessionId)
365
435
  for (const item of record.usage) adjustUsage(item.workspaceId, item.time, item.values, item.modelId, 1, record.sessionId)
366
- 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
+ }
367
440
  }
368
441
  function ledgerRank(record) {
369
442
  return [typeof record.lastSeq === 'number' ? record.lastSeq : -1, record.source === 'flush' ? 1 : 0, typeof record.updatedAt === 'number' ? record.updatedAt : 0]
@@ -508,6 +581,7 @@ function apply(ctx) {
508
581
  async function runBaseline(generation = aggregationGeneration) {
509
582
  if (scan.started || disposed || generation !== aggregationGeneration) return
510
583
  scan.started = true
584
+ beginSync()
511
585
  await ledgerReady
512
586
  if (disposed || generation !== aggregationGeneration) return
513
587
  let setupFailed = false
@@ -527,19 +601,25 @@ function apply(ctx) {
527
601
  } catch (err) {
528
602
  console.error('[all-usage] workspace list failed:', err)
529
603
  setupFailed = true
604
+ noteSyncError('workspace-list-failed')
530
605
  }
531
606
  let records = null
532
607
  try {
533
608
  records = await ctx.sessionQuery.listSessions()
534
609
  } catch (err) {
535
610
  console.error('[all-usage] session list failed:', err)
611
+ if (disposed || generation !== aggregationGeneration) return
612
+ noteSyncError('session-list-failed')
536
613
  }
614
+ if (disposed || generation !== aggregationGeneration) return
537
615
  // v1.0.8: cheap per-session change signal (header line + stat, no full-log read)
538
616
  let snapshots = null
539
617
  if (sessionPersistence !== undefined && typeof sessionPersistence.listSnapshots === 'function') {
540
618
  try {
541
619
  const rows = await sessionPersistence.listSnapshots()
620
+ if (disposed || generation !== aggregationGeneration) return
542
621
  if (Array.isArray(rows)) {
622
+ sync.persistenceSnapshotsAvailable = true
543
623
  snapshots = new Map()
544
624
  for (const row of rows) {
545
625
  const rid = row && row.header && typeof row.header.id === 'string' ? row.header.id : undefined
@@ -548,16 +628,22 @@ function apply(ctx) {
548
628
  }
549
629
  } catch (err) {
550
630
  console.error('[all-usage] session persistence snapshots unavailable:', err)
631
+ if (disposed || generation !== aggregationGeneration) return
632
+ sync.persistenceSnapshotsAvailable = false
633
+ markStatsChanged()
551
634
  }
552
635
  }
553
636
  if (disposed || generation !== aggregationGeneration) return
554
637
  if (setupFailed || !Array.isArray(records)) {
555
638
  // A transient registry failure must not be reported as a completed empty scan.
556
639
  scan.started = false
640
+ markStatsChanged()
557
641
  scheduleBaselineRetry(generation)
558
642
  return
559
643
  }
560
644
  scan.total = records.length
645
+ sync.sessionsTotal = records.length
646
+ markStatsChanged()
561
647
  const listedSessionIds = new Set()
562
648
  for (const record of records) {
563
649
  if (record === undefined || record === null || record.header === undefined) continue
@@ -567,12 +653,17 @@ function apply(ctx) {
567
653
  if (sid !== undefined && wsId !== undefined) listedSessionIds.add(sid)
568
654
  }
569
655
  for (const [sid, record] of ledgerRecords) {
570
- 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
+ }
571
660
  }
661
+ if (sync.sessionsRestoredFromLedger > 0) markStatsChanged()
572
662
  for (const record of records) {
573
663
  if (disposed || generation !== aggregationGeneration) return
574
664
  if (record === undefined || record === null || record.header === undefined) {
575
665
  scan.scanned += 1
666
+ markStatsChanged()
576
667
  continue
577
668
  }
578
669
  const sid = record.header.id
@@ -580,6 +671,7 @@ function apply(ctx) {
580
671
  const wsId = cwd === '' ? undefined : pathIndex.get(cwd)
581
672
  if (sid === undefined || wsId === undefined) {
582
673
  scan.scanned += 1
674
+ markStatsChanged()
583
675
  continue
584
676
  }
585
677
  listedSessionIds.add(sid)
@@ -593,14 +685,18 @@ function apply(ctx) {
593
685
  const previousRecord = ledgerRecords.get(sid)
594
686
  const revision = snapshots === null ? undefined : snapshots.get(sid)
595
687
  if (previousRecord !== undefined && typeof previousRecord.lastRevision === 'string' && typeof revision === 'string' && revision === previousRecord.lastRevision) {
688
+ sync.sessionsSkippedByRevision += 1
596
689
  applyLedgerRecord(previousRecord)
597
690
  for (const item of previousRecord.usage) {
598
691
  usageByStep.set(item.key, { seq: item.seq, wsId: item.workspaceId, time: item.time, values: item.values, modelId: item.modelId, sid })
599
692
  }
600
693
  sessionSeq.set(sid, previousRecord.lastSeq)
601
694
  sessionCount.add(sid)
695
+ markStatsChanged()
602
696
  return
603
697
  }
698
+ sync.sessionsRead += 1
699
+ markStatsChanged()
604
700
  const snap = await ctx.sessionQuery.readSession(sid)
605
701
  if (disposed || generation !== aggregationGeneration) return
606
702
  if (snap && Array.isArray(snap.events)) {
@@ -644,21 +740,29 @@ function apply(ctx) {
644
740
  sessionCount.add(sid)
645
741
  }
646
742
  } catch (err) {
743
+ if (generation !== aggregationGeneration) return
744
+ sync.sessionsFailed += 1
745
+ noteSyncError('session-read-failed')
647
746
  const saved = ledgerRecords.get(sid)
648
747
  if (saved !== undefined) {
649
748
  applyLedgerRecord(saved)
749
+ if (saved.turns.length > 0 || saved.usage.length > 0) sync.sessionsRestoredFromLedger += 1
650
750
  sessionSeq.set(sid, -1)
651
751
  sessionCount.add(sid)
652
- } else if (generation === aggregationGeneration) {
752
+ } else {
653
753
  sessionSeq.set(sid, -1)
654
754
  scan.failed += 1
655
755
  }
656
756
  } finally {
657
- if (generation === aggregationGeneration) scan.scanned += 1
757
+ if (generation === aggregationGeneration) {
758
+ scan.scanned += 1
759
+ markStatsChanged()
760
+ }
658
761
  }
659
762
  })
660
763
  if (!(await safeContextTimeout(0))) {
661
764
  scan.started = false
765
+ noteSyncError('baseline-yield-unavailable')
662
766
  scheduleBaselineRetry(generation)
663
767
  return
664
768
  }
@@ -669,6 +773,12 @@ function apply(ctx) {
669
773
  knownSessionIds.clear()
670
774
  for (const sid of listedSessionIds) knownSessionIds.add(sid)
671
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()
672
782
  if (reconcilePending) scheduleReconcileHint()
673
783
  }
674
784
 
@@ -705,6 +815,7 @@ function apply(ctx) {
705
815
  for (const sid of currentIds) knownSessionIds.add(sid)
706
816
  } catch (err) {
707
817
  console.error('[all-usage] session reconciliation failed:', err)
818
+ noteSyncError('session-reconcile-failed')
708
819
  } finally {
709
820
  reconcileInFlight = false
710
821
  if (reconcilePending && !disposed) scheduleReconcileHint()
@@ -778,6 +889,7 @@ function apply(ctx) {
778
889
  const value = g[key]
779
890
  if (typeof value === 'string' && value.trim() !== '') aliases[key] = value
780
891
  }
892
+ markStatsChanged()
781
893
  }
782
894
  } catch (err) {
783
895
  console.error('[all-usage] alias storage unavailable:', err)
@@ -799,6 +911,7 @@ function apply(ctx) {
799
911
  if (alias === '') delete aliases[wsId]
800
912
  else aliases[wsId] = alias
801
913
  persistAliases()
914
+ markStatsChanged()
802
915
  return { ok: true, aliases: Object.assign({}, aliases) }
803
916
  }
804
917
  ctx.effect(() => () => {
@@ -825,6 +938,12 @@ function apply(ctx) {
825
938
  })
826
939
 
827
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
+ }
828
947
  function serializeDays(dayMap) {
829
948
  const result = []
830
949
  for (const pair of dayMap) {
@@ -846,7 +965,7 @@ function apply(ctx) {
846
965
  }
847
966
  function snapshot() {
848
967
  return {
849
- scan: { started: scan.started, done: scan.done, scanned: scan.scanned, total: scan.total, failed: scan.failed },
968
+ ...statusSnapshot(),
850
969
  generatedAt: Date.now(),
851
970
  requestToken,
852
971
  workspaces: Array.from(wsMeta.values(), (w) => ({ id: w.id, title: w.title, path: w.path })),
@@ -980,6 +1099,16 @@ function apply(ctx) {
980
1099
  // ---------- HTTP data routes for the client half ----------
981
1100
  if (webServer !== undefined) {
982
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
+ }))
983
1112
  ctx.effect(() => webServer.register({
984
1113
  kind: 'exact',
985
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.8",
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",