@mzzsfy/dsh-usage-panel 0.4.4 → 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/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",
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
- for (const account of due) {
760
- // 单账号失败不中止本轮其余账号(broken 等持久态下尤为关键)
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
+ }
@@ -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
+ })