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 +13 -0
- package/README.md +10 -6
- package/assets/screenshot-1.png +0 -0
- package/assets/screenshot-2.png +0 -0
- package/lib/client.js +149 -11
- package/lib/index.js +134 -5
- package/package.json +1 -1
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.
|
|
27
|
+
**v1.0.9**
|
|
27
28
|
|
|
28
|
-
-
|
|
29
|
-
-
|
|
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.
|
|
115
|
+
**v1.0.9**
|
|
113
116
|
|
|
114
|
-
-
|
|
115
|
-
-
|
|
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
|
package/assets/screenshot-1.png
CHANGED
|
Binary file
|
package/assets/screenshot-2.png
CHANGED
|
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
|
|
567
|
-
|
|
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)
|
|
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 = () => {
|
|
579
|
-
|
|
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' },
|
|
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
|
|
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
|
-
|
|
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:
|
|
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)
|
|
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))
|
|
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
|
|
752
|
+
} else {
|
|
653
753
|
sessionSeq.set(sid, -1)
|
|
654
754
|
scan.failed += 1
|
|
655
755
|
}
|
|
656
756
|
} finally {
|
|
657
|
-
if (generation === aggregationGeneration)
|
|
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
|
-
|
|
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',
|