@mzzsfy/dsh-usage-panel 0.4.4 → 0.5.0
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 +2 -2
- package/package.json +1 -1
- package/src/client.js +24 -12
- package/src/index.js +6 -5
- package/src/notify.mjs +2 -2
- package/src/poller.mjs +13 -0
- package/test/notify.route.test.mjs +8 -7
- package/test/notify.test.mjs +4 -4
- package/test/poller.test.mjs +48 -0
package/README.md
CHANGED
|
@@ -73,7 +73,7 @@ usage-panel:
|
|
|
73
73
|
balanceThreshold: null # 余额阈值,null 为不启用
|
|
74
74
|
resetNotice: true # 窗口重置时通知上一窗口峰值
|
|
75
75
|
toast: true # 页内 toast 通道
|
|
76
|
-
webhookUrl: '' #
|
|
76
|
+
webhookUrl: '' # webhook 目标 URL,留空禁用;面板原文回显
|
|
77
77
|
imTargets: [] # dsh-im 投递目标 [{botId, targetId}]
|
|
78
78
|
```
|
|
79
79
|
|
|
@@ -88,7 +88,7 @@ usage-panel:
|
|
|
88
88
|
### 通知接口
|
|
89
89
|
|
|
90
90
|
- `GET /api/usage-panel/notifications`:通知投影长轮询;`cursor` 缺省=首拉立即返回全量,`cursor` 等于当前版本时挂起至事件或超时,落后或超前(宿主重启版本回退)立即返回全量,客户端以响应 version 重置游标自愈;响应 `{units, version}`
|
|
91
|
-
- `GET|POST /api/usage-panel/notify-config`:全局通知规则;`webhookUrl`
|
|
91
|
+
- `GET|POST /api/usage-panel/notify-config`:全局通知规则;`webhookUrl` 原文随响应回显
|
|
92
92
|
- `POST /api/usage-panel/test-webhook` / `POST /api/usage-panel/test-im`:测试投递,返回真实结果
|
|
93
93
|
- `GET /api/usage-panel/im-targets?botId=`:列出 dsh-im 该 bot 已保存投递目标
|
|
94
94
|
|
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
|
+
"version": "0.5.0",
|
|
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 }),
|
|
@@ -992,6 +992,7 @@ function NotifyConfigCard() {
|
|
|
992
992
|
.then((res) => {
|
|
993
993
|
if (!alive) return
|
|
994
994
|
setConfig(res && res.notify ? res.notify : {})
|
|
995
|
+
setWebhookUrl(res && res.notify && typeof res.notify.webhookUrl === 'string' ? res.notify.webhookUrl : '')
|
|
995
996
|
setImAvailable(res ? res.imAvailable === true : false)
|
|
996
997
|
})
|
|
997
998
|
.catch((err) => { if (alive) setError('读取通知配置失败:' + (err && err.message ? err.message : String(err))) })
|
|
@@ -1097,6 +1098,9 @@ function NotifyConfigCard() {
|
|
|
1097
1098
|
}
|
|
1098
1099
|
const targets = Array.isArray(config.imTargets) ? config.imTargets : []
|
|
1099
1100
|
const boundBots = imBoundBotIds(targets)
|
|
1101
|
+
// 已配置态与测试/保存分流均由 webhookUrl 派生;草稿编辑中不被其他 patch 回执重置
|
|
1102
|
+
const savedWebhookUrl = typeof config.webhookUrl === 'string' ? config.webhookUrl.trim() : ''
|
|
1103
|
+
const webhookDirty = webhookUrl.trim() !== savedWebhookUrl
|
|
1100
1104
|
// 通知配置一次即久,默认折叠:summary 摘要行常显状态,展开才是完整配置
|
|
1101
1105
|
return h('details', { className: 'up-card up-fold' },
|
|
1102
1106
|
h('summary', { className: 'up-fold__summary' },
|
|
@@ -1104,7 +1108,7 @@ function NotifyConfigCard() {
|
|
|
1104
1108
|
h('span', { className: 'up-dot' + (config.enabled === true ? '' : ' up-dot--off') }),
|
|
1105
1109
|
h('span', { className: 'up-meta' }, config.enabled === true ? '已启用' : '已关闭'),
|
|
1106
1110
|
targets.length > 0 ? h('span', { className: 'up-meta' }, targets.length + ' 个 IM 目标') : null,
|
|
1107
|
-
|
|
1111
|
+
savedWebhookUrl.length > 0 ? h('span', { className: 'up-meta' }, 'webhook 已配置') : null,
|
|
1108
1112
|
),
|
|
1109
1113
|
h('div', { className: 'up-card__row' },
|
|
1110
1114
|
h(Switch, {
|
|
@@ -1156,29 +1160,37 @@ function NotifyConfigCard() {
|
|
|
1156
1160
|
onChange: (checked) => patch({ toast: checked }),
|
|
1157
1161
|
}),
|
|
1158
1162
|
h('div', { className: 'up-card__row' },
|
|
1159
|
-
h('span', { className: 'up-field__label', title: 'host 直发的 Slack 兼容 JSON
|
|
1160
|
-
|
|
1163
|
+
h('span', { className: 'up-field__label', title: 'host 直发的 Slack 兼容 JSON 通知;URL 原文回显,改动保存后自动发送测试' }, 'Webhook'),
|
|
1164
|
+
savedWebhookUrl.length > 0 ? h('span', { className: 'up-dot', title: '已配置' }) : null,
|
|
1161
1165
|
h('span', { className: 'up-field' },
|
|
1162
1166
|
h('input', {
|
|
1163
|
-
type: '
|
|
1167
|
+
type: 'text', style: { width: '260px' },
|
|
1164
1168
|
value: webhookUrl,
|
|
1165
1169
|
onChange: (e) => setWebhookUrl(e.target.value),
|
|
1166
|
-
placeholder:
|
|
1170
|
+
placeholder: 'https://hooks.example.com/…',
|
|
1167
1171
|
})),
|
|
1168
1172
|
h('button', {
|
|
1169
1173
|
className: 'up-btn',
|
|
1170
1174
|
onClick: () => {
|
|
1171
|
-
if (
|
|
1172
|
-
if (
|
|
1175
|
+
if (!webhookDirty) {
|
|
1176
|
+
if (savedWebhookUrl.length === 0) { notify('请先填写 webhook URL', 'error'); return }
|
|
1173
1177
|
testWebhook()
|
|
1174
1178
|
return
|
|
1175
1179
|
}
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1180
|
+
// 草稿与已保存值不同即保存(含清空=禁用);清空保存后通道关闭,不再自动测试误导报错
|
|
1181
|
+
const disabling = webhookUrl.trim().length === 0
|
|
1182
|
+
api('/api/usage-panel/notify-config', { method: 'POST', body: JSON.stringify({ webhookUrl: webhookUrl.trim() }) })
|
|
1183
|
+
.then((res) => {
|
|
1184
|
+
if (res && res.notify) {
|
|
1185
|
+
setConfig(res.notify)
|
|
1186
|
+
setWebhookUrl(typeof res.notify.webhookUrl === 'string' ? res.notify.webhookUrl : '')
|
|
1187
|
+
}
|
|
1188
|
+
notify(disabling ? 'webhook 已禁用' : '通知配置已保存', 'ok')
|
|
1189
|
+
if (!disabling) testWebhook()
|
|
1190
|
+
})
|
|
1179
1191
|
.catch(fail)
|
|
1180
1192
|
},
|
|
1181
|
-
},
|
|
1193
|
+
}, webhookDirty ? '保存并测试' : '测试'),
|
|
1182
1194
|
),
|
|
1183
1195
|
// IM 通道:目标来自 dsh-im 已保存目录,勾选即自动保存;新建与平台测试在 dsh-im 设置页完成
|
|
1184
1196
|
imAvailable ? null : h('span', { className: 'up-meta' }, 'dsh-im 未安装,IM 通道不可用'),
|
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/notify.mjs
CHANGED
|
@@ -405,7 +405,7 @@ export function resolvedNotifySettings(raw) {
|
|
|
405
405
|
}
|
|
406
406
|
}
|
|
407
407
|
|
|
408
|
-
// 面板可见配置:webhookUrl
|
|
408
|
+
// 面板可见配置:webhookUrl 原文随响应回显,面板所见即所存。
|
|
409
409
|
export function publicNotify(resolved) {
|
|
410
410
|
return {
|
|
411
411
|
enabled: resolved.enabled,
|
|
@@ -414,7 +414,7 @@ export function publicNotify(resolved) {
|
|
|
414
414
|
resetNotice: resolved.resetNotice,
|
|
415
415
|
toast: resolved.toast,
|
|
416
416
|
imTargets: resolved.imTargets,
|
|
417
|
-
|
|
417
|
+
webhookUrl: resolved.webhookUrl,
|
|
418
418
|
}
|
|
419
419
|
}
|
|
420
420
|
|
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
|
+
}
|
|
@@ -176,22 +176,22 @@ test('评估接线: custom 账号 extract 规则驱动 balance 读数全链路(
|
|
|
176
176
|
}
|
|
177
177
|
})
|
|
178
178
|
|
|
179
|
-
test('notify-config: GET
|
|
179
|
+
test('notify-config: GET 回显 webhookUrl 原文', async () => {
|
|
180
180
|
// Given 已配置 webhook 的 settings
|
|
181
181
|
const { ctx, routes } = makeCtx({ settingsValue: { notify: { enabled: true, webhookUrl: 'https://hooks.example.com/private' } } })
|
|
182
182
|
apply(ctx)
|
|
183
183
|
// When 读取通知配置
|
|
184
184
|
const res = await call(routes, '/api/usage-panel/notify-config', makeReq('GET'))
|
|
185
|
-
// Then
|
|
185
|
+
// Then 原文随响应回显, webhookConfigured 已移除
|
|
186
186
|
assert.equal(res.status, 200)
|
|
187
|
-
assert.equal(res.payload.notify.webhookUrl,
|
|
188
|
-
assert.equal(res.payload.notify
|
|
187
|
+
assert.equal(res.payload.notify.webhookUrl, 'https://hooks.example.com/private')
|
|
188
|
+
assert.equal('webhookConfigured' in res.payload.notify, false)
|
|
189
189
|
assert.equal(res.payload.imAvailable, false)
|
|
190
190
|
})
|
|
191
191
|
|
|
192
192
|
test('notify-config: 非法补丁 400, 合法补丁合并生效', async () => {
|
|
193
|
-
// Given 合法 ctx
|
|
194
|
-
const { ctx, routes } = makeCtx({ settingsValue: { notify: { enabled: true } } })
|
|
193
|
+
// Given 合法 ctx,已配置 webhook
|
|
194
|
+
const { ctx, routes } = makeCtx({ settingsValue: { notify: { enabled: true, webhookUrl: 'https://hooks.example.com/keep' } } })
|
|
195
195
|
apply(ctx)
|
|
196
196
|
// When 提交越界阈值
|
|
197
197
|
const bad = await call(routes, '/api/usage-panel/notify-config', makeReq('POST', { quotaThresholdPct: 0 }))
|
|
@@ -199,12 +199,13 @@ test('notify-config: 非法补丁 400, 合法补丁合并生效', async () => {
|
|
|
199
199
|
assert.equal(bad.status, 400)
|
|
200
200
|
// When 提交合法补丁(仅改阈值与余额阈值)
|
|
201
201
|
const good = await call(routes, '/api/usage-panel/notify-config', makeReq('POST', { quotaThresholdPct: 80, balanceThreshold: 20 }))
|
|
202
|
-
// Then 生效且未提供的键保持原值
|
|
202
|
+
// Then 生效且未提供的键保持原值(部分补丁不得清掉已存 webhook)
|
|
203
203
|
assert.equal(good.status, 200)
|
|
204
204
|
const view = await call(routes, '/api/usage-panel/notify-config', makeReq('GET'))
|
|
205
205
|
assert.equal(view.payload.notify.quotaThresholdPct, 80)
|
|
206
206
|
assert.equal(view.payload.notify.balanceThreshold, 20)
|
|
207
207
|
assert.equal(view.payload.notify.enabled, true)
|
|
208
|
+
assert.equal(view.payload.notify.webhookUrl, 'https://hooks.example.com/keep')
|
|
208
209
|
})
|
|
209
210
|
|
|
210
211
|
test('test-webhook: 返回真实投递结果而非谎报成功', async () => {
|
package/test/notify.test.mjs
CHANGED
|
@@ -418,14 +418,14 @@ test('读侧归一: 残缺配置回退默认值', () => {
|
|
|
418
418
|
assert.equal(resolved.webhookUrl, '')
|
|
419
419
|
})
|
|
420
420
|
|
|
421
|
-
test('
|
|
421
|
+
test('面板回显: publicNotify 返回 webhookUrl 原文', () => {
|
|
422
422
|
// Given 已配置 webhook 的归一配置
|
|
423
423
|
const resolved = resolvedNotifySettings({ webhookUrl: 'https://hooks.example.com/private' })
|
|
424
424
|
// When 转面板可见形态
|
|
425
425
|
const view = publicNotify(resolved)
|
|
426
|
-
// Then
|
|
427
|
-
assert.equal(view.webhookUrl,
|
|
428
|
-
assert.equal(view
|
|
426
|
+
// Then 原文随响应回显, webhookConfigured 已移除
|
|
427
|
+
assert.equal(view.webhookUrl, 'https://hooks.example.com/private')
|
|
428
|
+
assert.equal('webhookConfigured' in view, false)
|
|
429
429
|
assert.equal(view.toast, true)
|
|
430
430
|
})
|
|
431
431
|
|
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
|
+
})
|