@mzzsfy/dsh-usage-panel 0.4.3 → 0.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/package.json +1 -1
- package/src/client.js +1 -1
- package/src/index.js +6 -5
- package/src/poller.mjs +13 -0
- package/test/client-scope.test.mjs +54 -0
- package/test/poller.test.mjs +48 -0
package/README.md
CHANGED
|
@@ -123,10 +123,10 @@ custom 端点支持自定义请求方法(GET/POST/PUT/DELETE/PATCH)、请求头(
|
|
|
123
123
|
- 早期版本允许无 `id` 的账号(落盘时按索引一次性补齐);当前前端保存时总携带 `id`,不再做旧数据特判
|
|
124
124
|
- 账号卡片渲染未做 memo 化、趋势浮层常挂载:数据规模受账号上限(20)与悬浮窗口点数(24)双重钳制,无可感知卡顿,后置观察
|
|
125
125
|
|
|
126
|
-
## 开发安装(
|
|
126
|
+
## 开发安装(仓库工作副本直挂,不经 npm 发布)
|
|
127
127
|
|
|
128
128
|
```sh
|
|
129
|
-
dsh
|
|
129
|
+
node scripts/dev-link.mjs dsh-usage-panel # 仓库根执行:归一 profile 依赖行 + 挂 junction
|
|
130
130
|
```
|
|
131
131
|
|
|
132
|
-
`
|
|
132
|
+
工作副本以 junction 挂进 profile,改代码保存即热重载(host 半区)或刷新页面生效(client 半区),无需发版;规约与全仓归一见仓库根 `node scripts/dev-link.mjs all`。
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mzzsfy/dsh-usage-panel",
|
|
3
3
|
"description": "多平台 LLM 账号余额与额度面板:支持 DeepSeek/OpenRouter/Kimi/智谱/MiniMax/NewApi 及自定义端点,自动定期查询,趋势图与历史快照,用量/余额阈值与窗口重置多通道通知",
|
|
4
|
-
"version": "0.4.
|
|
4
|
+
"version": "0.4.5",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"exports": {
|
package/src/client.js
CHANGED
|
@@ -898,7 +898,7 @@ function DetailDialog(props) {
|
|
|
898
898
|
ranges.map((r) => h('button', {
|
|
899
899
|
key: r, className: 'up-btn', disabled: r === range,
|
|
900
900
|
onClick: () => setRange(r),
|
|
901
|
-
}, r === 'all' ? '全部' : '近 ' + r)),
|
|
901
|
+
}, r === 'all' ? '全部' : '近 ' + ({ '7d': '7 天', '30d': '30 天' })[r])),
|
|
902
902
|
h('button', { className: 'up-btn', onClick: props.onClose }, '关闭'),
|
|
903
903
|
),
|
|
904
904
|
h(TrendChart, { label: props.label, points, onDetail: null }),
|
package/src/index.js
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
} from './parsers.mjs'
|
|
17
17
|
import { readingToSnapshots, appendPoint, buildMonthSequence, newSequenceStore } from './history.mjs'
|
|
18
18
|
import { createHistoryStore } from './historyStore.mjs'
|
|
19
|
-
import { createBackoff, isShortWindowTier, tierIntervalSec, lastQuerySecOf, isDue } from './poller.mjs'
|
|
19
|
+
import { createBackoff, isShortWindowTier, tierIntervalSec, lastQuerySecOf, isDue, runLimited } from './poller.mjs'
|
|
20
20
|
import {
|
|
21
21
|
DEFAULT_QUOTA_THRESHOLD_PCT,
|
|
22
22
|
WEBHOOK_TIMEOUT_MS,
|
|
@@ -39,6 +39,9 @@ import {
|
|
|
39
39
|
const FETCH_TIMEOUT_MS = 20 * 1000
|
|
40
40
|
const BODY_MAX_BYTES = 256 * 1024
|
|
41
41
|
const MAX_ACCOUNTS = 20
|
|
42
|
+
// 单轮轮询的受限并发:串行执行时单账号 20s 超时即可拖满整轮(pollInFlight 互斥
|
|
43
|
+
// 使后续 tick 全部跳过,轮询事实停摆),并发上限同时压住上游瞬时压力
|
|
44
|
+
const POLL_CONCURRENCY = 4
|
|
42
45
|
// 通知长轮询服务端挂起上限,与 turn-notify 同值;客户端超时须大于此值
|
|
43
46
|
const LONG_POLL_WAIT_MS = 25 * 1000
|
|
44
47
|
// 数据目录支持 env 注入(测试隔离);缺省落 ~/.dsh/dsh-usage-panel
|
|
@@ -756,10 +759,8 @@ export function apply(ctx) {
|
|
|
756
759
|
pollInFlight = true
|
|
757
760
|
ensureHistory()
|
|
758
761
|
.then(async () => {
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
await runQuery(account).catch(() => {})
|
|
762
|
-
}
|
|
762
|
+
// 受限并发执行:单账号失败/超时不拖满整轮,后续 tick 不因互斥停摆
|
|
763
|
+
await runLimited(due, POLL_CONCURRENCY, (account) => runQuery(account).catch(() => {}))
|
|
763
764
|
await persistConfig().catch(() => {})
|
|
764
765
|
})
|
|
765
766
|
.catch(() => {})
|
package/src/poller.mjs
CHANGED
|
@@ -58,3 +58,16 @@ export function isDue({ lastQuerySec, nowSec, intervalSec }) {
|
|
|
58
58
|
if (lastQuerySec === null) return true
|
|
59
59
|
return nowSec - lastQuerySec >= intervalSec
|
|
60
60
|
}
|
|
61
|
+
|
|
62
|
+
// 受限并发执行:worker 池共享游标逐项取号(取号同步完成,无交错),单任务
|
|
63
|
+
// 失败/慢速只占用一个 worker 位,不拖满整轮;每任务结果经 task 感知,失败由
|
|
64
|
+
// 调用方在 task 内消化(本函数恒 resolve)
|
|
65
|
+
export async function runLimited(items, limit, task) {
|
|
66
|
+
let cursor = 0
|
|
67
|
+
const worker = async () => {
|
|
68
|
+
while (cursor < items.length) {
|
|
69
|
+
await task(items[cursor++])
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
|
|
73
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// client.js 求值形态守卫:宿主以经典 script 整源求值,顶层词法声明落页面全局词法环境,
|
|
2
|
+
// 跨 bundle 同名即整脚本 SyntaxError 拒载。本包 client.js 以单条 __ModuleLoader__.load
|
|
3
|
+
// 语句承载全部代码(声明均在 factory 作用域内),本守卫锁定"顶层零词法声明"不变量,
|
|
4
|
+
// 防未来声明误置顶层。vm.runInContext 与浏览器经典 script 同语义(全局词法环境跨脚本共享)。
|
|
5
|
+
import { test } from 'node:test'
|
|
6
|
+
import assert from 'node:assert/strict'
|
|
7
|
+
import { readFileSync } from 'node:fs'
|
|
8
|
+
import { fileURLToPath } from 'node:url'
|
|
9
|
+
import { dirname, join } from 'node:path'
|
|
10
|
+
import vm from 'node:vm'
|
|
11
|
+
|
|
12
|
+
const CLIENT_SOURCE = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'client.js'), 'utf8').trimEnd()
|
|
13
|
+
const DECLARATION_NAMES = [
|
|
14
|
+
...new Set(
|
|
15
|
+
[...CLIENT_SOURCE.matchAll(/^(?:const|let|var|class|(?:async )?function\*?) ([A-Za-z_$][\w$]*)/gm)].map((match) => match[1])
|
|
16
|
+
),
|
|
17
|
+
]
|
|
18
|
+
const BUNDLE_ID = '@mzzsfy/dsh-usage-panel'
|
|
19
|
+
|
|
20
|
+
test('Given 同 context 已有外部 CSS 声明(宿主经典 script 全局词法环境), When 整源求值 client.js, Then 双源共存不拒载且外部声明原值不变', () => {
|
|
21
|
+
const ctx = vm.createContext({ window: { __ModuleLoader__: { load: () => {} } } })
|
|
22
|
+
vm.runInContext('const CSS = 1', ctx)
|
|
23
|
+
vm.runInContext(CLIENT_SOURCE, ctx)
|
|
24
|
+
assert.equal(vm.runInContext('CSS', ctx), 1)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('Given client.js 已整源求值, When 同 context 再声明外部 CSS, Then 双源共存不拒载', () => {
|
|
28
|
+
const ctx = vm.createContext({ window: { __ModuleLoader__: { load: () => {} } } })
|
|
29
|
+
vm.runInContext(CLIENT_SOURCE, ctx)
|
|
30
|
+
vm.runInContext('const CSS = 1', ctx)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('Given client.js 整源求值完成, When 逐名以 const 重声明探针行首声明名, Then 全局词法环境零泄漏', () => {
|
|
34
|
+
const ctx = vm.createContext({ window: { __ModuleLoader__: { load: () => {} } } })
|
|
35
|
+
vm.runInContext(CLIENT_SOURCE, ctx)
|
|
36
|
+
const leaked = DECLARATION_NAMES.filter((name) => {
|
|
37
|
+
try {
|
|
38
|
+
vm.runInContext(`const ${name} = null`, ctx)
|
|
39
|
+
return false
|
|
40
|
+
} catch {
|
|
41
|
+
return true
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
assert.deepEqual(leaked, [])
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('Given 宿主 loader 就绪, When 整源求值 client.js, Then 自注册照常发生且 id 不变', () => {
|
|
48
|
+
const registrations = []
|
|
49
|
+
const ctx = vm.createContext({ window: { __ModuleLoader__: { load: (registration) => registrations.push(registration) } } })
|
|
50
|
+
vm.runInContext(CLIENT_SOURCE, ctx)
|
|
51
|
+
assert.equal(registrations.length, 1)
|
|
52
|
+
assert.equal(registrations[0].id, BUNDLE_ID)
|
|
53
|
+
assert.equal(typeof registrations[0].factory, 'function')
|
|
54
|
+
})
|
package/test/poller.test.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
lastQuerySecOf,
|
|
13
13
|
isDue,
|
|
14
14
|
isShortWindowTier,
|
|
15
|
+
runLimited,
|
|
15
16
|
} from '../src/poller.mjs'
|
|
16
17
|
|
|
17
18
|
test('场景: 失败退避指数增长并封顶', () => {
|
|
@@ -86,3 +87,50 @@ test('场景: 短窗口档判定,从未成功(含最近失败)按短档,失败
|
|
|
86
87
|
assert.equal(isShortWindowTier({ ok: false, reading: null }, readingHasShort), true, '失败不塌缩:含短窗账号保持短档节奏')
|
|
87
88
|
assert.equal(isShortWindowTier({ ok: false, reading: null }, false), true, '失败的长窗账号也归短档:10 分钟节奏 + 退避压制重试')
|
|
88
89
|
})
|
|
90
|
+
|
|
91
|
+
test('场景: 受限并发执行——全项被处理,慢任务不阻塞其他 worker', async () => {
|
|
92
|
+
// Given 6 项任务,并发 2:每个任务挂起直到放行;并发内慢任务(后放行)只占 1 个 worker 位
|
|
93
|
+
const processed = []
|
|
94
|
+
const release = []
|
|
95
|
+
const task = async (item) => {
|
|
96
|
+
await new Promise((resolve) => release.push(resolve))
|
|
97
|
+
processed.push(item)
|
|
98
|
+
}
|
|
99
|
+
const done = runLimited([1, 2, 3, 4, 5, 6], 2, task)
|
|
100
|
+
// When 恰有 2 个任务在途(worker 数 = 并发上限)
|
|
101
|
+
assert.equal(release.length, 2, '并发上限决定在途任务数')
|
|
102
|
+
// 放行在途任务使其完成并取号下一项
|
|
103
|
+
release.splice(0).forEach((resolve) => resolve())
|
|
104
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
105
|
+
assert.equal(release.length, 2, '完成后立即补位')
|
|
106
|
+
release.splice(0).forEach((resolve) => resolve())
|
|
107
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
108
|
+
release.splice(0).forEach((resolve) => resolve())
|
|
109
|
+
await done
|
|
110
|
+
// Then 全部 6 项被处理,无重复无遗漏
|
|
111
|
+
assert.equal(processed.length, 6)
|
|
112
|
+
assert.deepEqual([...processed].sort((a, b) => a - b), [1, 2, 3, 4, 5, 6])
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('场景: 受限并发执行——单任务失败不中断其余任务(失败由 task 内消化)', async () => {
|
|
116
|
+
// Given task 对偶数项抛错但就地消化(生产用法:runQuery().catch 口径),runLimited 恒 resolve
|
|
117
|
+
const processed = []
|
|
118
|
+
const task = async (item) => {
|
|
119
|
+
try {
|
|
120
|
+
if (item % 2 === 0) throw new Error('boom-' + item)
|
|
121
|
+
processed.push(item)
|
|
122
|
+
} catch { /* 单项失败只占自己的 worker 位 */ }
|
|
123
|
+
}
|
|
124
|
+
// When 5 项、并发 2
|
|
125
|
+
await runLimited([1, 2, 3, 4, 5], 2, task)
|
|
126
|
+
// Then 奇数项全被处理
|
|
127
|
+
assert.deepEqual([...processed].sort((a, b) => a - b), [1, 3, 5])
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('场景: 受限并发执行——空列表与并发大于任务数', async () => {
|
|
131
|
+
const processed = []
|
|
132
|
+
await runLimited([], 4, async (item) => processed.push(item))
|
|
133
|
+
assert.deepEqual(processed, [])
|
|
134
|
+
await runLimited([1, 2], 8, async (item) => processed.push(item))
|
|
135
|
+
assert.deepEqual(processed, [1, 2])
|
|
136
|
+
})
|