@mzzsfy/dsh-usage-panel 0.4.3
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 +132 -0
- package/cordis.patch.yml +8 -0
- package/package.json +49 -0
- package/src/client.js +1424 -0
- package/src/history.mjs +117 -0
- package/src/historyStore.mjs +61 -0
- package/src/index.js +777 -0
- package/src/notify.mjs +424 -0
- package/src/parsers.mjs +327 -0
- package/src/poller.mjs +60 -0
- package/test/client-id.test.mjs +16 -0
- package/test/history.test.mjs +170 -0
- package/test/historyStore.test.mjs +107 -0
- package/test/notify-poll.test.mjs +31 -0
- package/test/notify.route.test.mjs +447 -0
- package/test/notify.test.mjs +498 -0
- package/test/parity.test.mjs +119 -0
- package/test/parsers.test.mjs +286 -0
- package/test/poller.test.mjs +88 -0
- package/test/route.test.mjs +171 -0
- package/test/switch-guard.test.mjs +32 -0
package/src/history.mjs
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// 历史快照纯逻辑层:序列键映射 / 档位对齐去重 / 时间修剪 / 硬上限 / 月窗口聚合。
|
|
2
|
+
// 数据结构与落盘 history.json 一致:{ [账号id:序列键]: { granularity, points: [{t, v}] } }。
|
|
3
|
+
|
|
4
|
+
// 窗口 label <-> 序列键后缀 <-> 采样档位,设计文档映射表。
|
|
5
|
+
export const SEQUENCE_TIERS = {
|
|
6
|
+
'5h': '10m',
|
|
7
|
+
'7d': '1h',
|
|
8
|
+
month: '1h',
|
|
9
|
+
balance: '1h',
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const MIN_MS = 60 * 1000
|
|
13
|
+
export const GRANULARITY_MS = {
|
|
14
|
+
'10m': 10 * MIN_MS,
|
|
15
|
+
'1h': 60 * MIN_MS,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// 留存期点数:短窗口 7 天,长窗口 30 天。
|
|
19
|
+
export const RETENTION_POINTS = {
|
|
20
|
+
'10m': 7 * 24 * 6,
|
|
21
|
+
'1h': 30 * 24,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// 硬点数上限 = 2 倍留存上界,异常兜底。
|
|
25
|
+
export const HARD_POINT_CAP = {
|
|
26
|
+
'10m': 2 * RETENTION_POINTS['10m'],
|
|
27
|
+
'1h': 2 * RETENTION_POINTS['1h'],
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const LABEL_TO_SUFFIX = { '5小时': '5h', '7天': '7d', 月: 'month' }
|
|
31
|
+
|
|
32
|
+
export function labelToSuffix(label) {
|
|
33
|
+
return Object.prototype.hasOwnProperty.call(LABEL_TO_SUFFIX, label) ? LABEL_TO_SUFFIX[label] : null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function granularityOf(suffix) {
|
|
37
|
+
return SEQUENCE_TIERS[suffix] || null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 就近对齐到最近档(设计场景组要求:10:07 与 10:14 同归 10:10 档实现档内去重;
|
|
41
|
+
// 文档"向下对齐"字样与场景矛盾,以场景为准)。
|
|
42
|
+
export function alignTs(t, granularityMs) {
|
|
43
|
+
return Math.round(t / granularityMs) * granularityMs
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function newSequenceStore() {
|
|
47
|
+
return {}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function retentionMs(granularity) {
|
|
51
|
+
return RETENTION_POINTS[granularity] * GRANULARITY_MS[granularity]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 时间修剪:丢弃早于 now - 留存期 的点。
|
|
55
|
+
export function pruneSequence(store, seqKey, now) {
|
|
56
|
+
const seq = store[seqKey]
|
|
57
|
+
if (!seq) return
|
|
58
|
+
const earliest = now - retentionMs(seq.granularity)
|
|
59
|
+
seq.points = seq.points.filter((point) => point.t >= earliest)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 追加快照:向下对齐档位;档内已有点不改写(保档内最前一次);先时间修剪后硬上限。
|
|
63
|
+
export function appendPoint(store, seqKey, t, v, now) {
|
|
64
|
+
const granularity = granularityOf(seqKey.slice(seqKey.indexOf(':') + 1))
|
|
65
|
+
if (granularity === null) return
|
|
66
|
+
const granularityMs = GRANULARITY_MS[granularity]
|
|
67
|
+
const aligned = alignTs(t, granularityMs)
|
|
68
|
+
if (!store[seqKey]) store[seqKey] = { granularity, points: [] }
|
|
69
|
+
const seq = store[seqKey]
|
|
70
|
+
if (!seq.points.some((point) => point.t === aligned)) {
|
|
71
|
+
seq.points.push({ t: aligned, v })
|
|
72
|
+
seq.points.sort((a, b) => a.t - b.t)
|
|
73
|
+
}
|
|
74
|
+
pruneSequence(store, seqKey, now === undefined ? aligned : now)
|
|
75
|
+
if (seq.points.length > HARD_POINT_CAP[granularity]) {
|
|
76
|
+
seq.points = seq.points.slice(seq.points.length - HARD_POINT_CAP[granularity])
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 读数 -> 快照取样:额度窗口按 label 映射,值优先 remaining(消耗口径)回落 utilization 百分点;
|
|
81
|
+
// 余额账号单序列取 remaining 回落 total。
|
|
82
|
+
export function readingToSnapshots(reading) {
|
|
83
|
+
if (!reading || typeof reading !== 'object') return []
|
|
84
|
+
if (reading.kind === 'quota') {
|
|
85
|
+
const windows = Array.isArray(reading.windows) ? reading.windows : []
|
|
86
|
+
return windows
|
|
87
|
+
.map((win) => {
|
|
88
|
+
const suffix = labelToSuffix(win && win.label)
|
|
89
|
+
if (suffix === null) return null
|
|
90
|
+
const hasRemaining = win.remaining !== null && win.remaining !== undefined && Number.isFinite(Number(win.remaining))
|
|
91
|
+
const value = hasRemaining ? Number(win.remaining) : Number(win.utilization)
|
|
92
|
+
return Number.isFinite(value) ? { suffix, value, tier: SEQUENCE_TIERS[suffix] } : null
|
|
93
|
+
})
|
|
94
|
+
.filter(Boolean)
|
|
95
|
+
}
|
|
96
|
+
if (reading.kind === 'balance') {
|
|
97
|
+
const entries = Array.isArray(reading.entries) ? reading.entries : []
|
|
98
|
+
const entry = entries[0]
|
|
99
|
+
if (!entry) return []
|
|
100
|
+
const hasRemaining = entry.remaining !== null && entry.remaining !== undefined && Number.isFinite(Number(entry.remaining))
|
|
101
|
+
const value = hasRemaining ? Number(entry.remaining) : Number(entry.total)
|
|
102
|
+
return Number.isFinite(value) ? [{ suffix: 'balance', value, tier: SEQUENCE_TIERS.balance }] : []
|
|
103
|
+
}
|
|
104
|
+
return []
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 月窗口聚合:从账号余额序列取当月日历月的点重建月序列(v2 新增序列,host 按快照产出)。
|
|
108
|
+
// 月界取宿主本地时区月初零点。
|
|
109
|
+
export function buildMonthSequence(store, accountId, now) {
|
|
110
|
+
const balance = store[accountId + ':balance']
|
|
111
|
+
if (!balance) return
|
|
112
|
+
const nowDate = new Date(now)
|
|
113
|
+
const monthStart = new Date(nowDate.getFullYear(), nowDate.getMonth(), 1).getTime()
|
|
114
|
+
const points = balance.points
|
|
115
|
+
.filter((point) => point.t >= monthStart && point.t < now)
|
|
116
|
+
store[accountId + ':month'] = { granularity: SEQUENCE_TIERS.month, points: points.map((p) => ({ ...p })) }
|
|
117
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// 历史文件持久化守卫:读取/解析失败时把坏文件备份为 .bak(覆盖旧备份)并拒绝写入,
|
|
2
|
+
// 防止空 store 覆盖 history.json 造成数据丢失;恢复成功读取后解除损坏标记。
|
|
3
|
+
// IO 以参数注入,host 半区传真实 fs,单测传临时目录。
|
|
4
|
+
|
|
5
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
6
|
+
import { dirname } from 'node:path'
|
|
7
|
+
|
|
8
|
+
const BAK_SUFFIX = '.bak'
|
|
9
|
+
export const HISTORY_BROKEN_MESSAGE = '历史文件已损坏已备份,已暂停写入以防数据丢失'
|
|
10
|
+
|
|
11
|
+
function isMissing(error) {
|
|
12
|
+
return Boolean(error) && error.code === 'ENOENT'
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function createHistoryStore({ file, io }) {
|
|
16
|
+
const fs = io || { mkdir, readFile, rename, writeFile }
|
|
17
|
+
let sequences = {}
|
|
18
|
+
let loaded = false
|
|
19
|
+
let broken = false
|
|
20
|
+
let chain = Promise.resolve()
|
|
21
|
+
return {
|
|
22
|
+
get sequences() {
|
|
23
|
+
return sequences
|
|
24
|
+
},
|
|
25
|
+
get broken() {
|
|
26
|
+
return broken
|
|
27
|
+
},
|
|
28
|
+
// 损坏状态下不短路,重读以支持恢复(坏文件已备份移走,重读得到 ENOENT 即解除)
|
|
29
|
+
ensure() {
|
|
30
|
+
if (loaded && !broken) return Promise.resolve(sequences)
|
|
31
|
+
loaded = true
|
|
32
|
+
return fs
|
|
33
|
+
.readFile(file, 'utf8')
|
|
34
|
+
.then((text) => {
|
|
35
|
+
const parsed = JSON.parse(text)
|
|
36
|
+
if (parsed && parsed.sequences && typeof parsed.sequences === 'object') sequences = parsed.sequences
|
|
37
|
+
broken = false
|
|
38
|
+
})
|
|
39
|
+
.catch((error) => {
|
|
40
|
+
if (isMissing(error)) {
|
|
41
|
+
broken = false
|
|
42
|
+
return undefined
|
|
43
|
+
}
|
|
44
|
+
broken = true
|
|
45
|
+
// 备份失败不阻断损坏标记,写入仍被拒绝
|
|
46
|
+
return fs.rename(file, file + BAK_SUFFIX).catch(() => {})
|
|
47
|
+
})
|
|
48
|
+
.then(() => sequences)
|
|
49
|
+
},
|
|
50
|
+
persist() {
|
|
51
|
+
if (broken) return Promise.reject(new Error(HISTORY_BROKEN_MESSAGE))
|
|
52
|
+
// 写链毒化防护:链上失败不传播到后续写入(调用方 await 本次结果感知单次失败)
|
|
53
|
+
const result = chain.then(async () => {
|
|
54
|
+
await fs.mkdir(dirname(file), { recursive: true })
|
|
55
|
+
await fs.writeFile(file, JSON.stringify({ sequences }), 'utf8')
|
|
56
|
+
})
|
|
57
|
+
chain = result.catch(() => {})
|
|
58
|
+
return result
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
}
|