w-web-api 1.0.77 → 1.0.78
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/dist/css/{app.ae472c4d.css → app.9fbdb016.css} +1 -1
- package/dist/index.tmp +1 -1
- package/dist/js/app-legacy.075a8cf9.js +1 -0
- package/dist/js/app.075a8cf9.js +1 -0
- package/dist/js/{chunk-vendors.47cf6375.js → chunk-vendors-legacy.de0404f6.js} +1 -1
- package/dist/js/{chunk-vendors-legacy.47cf6375.js → chunk-vendors.de0404f6.js} +1 -1
- package/dist/w-web-api.umd.js +2 -2
- package/dist/w-web-api.umd.js.map +1 -1
- package/docs/WWebApi.html +122 -2
- package/docs/index.html +1 -1
- package/docs/server_WWebApi.mjs.html +5 -2
- package/docs/src_components_Common_CheckYesNo.vue.html +1 -1
- package/package.json +16 -14
- package/server/WWebApi.mjs +4 -1
- package/server/procApis.mjs +26 -14
- package/server/procLang.mjs +0 -17
- package/server/srLog.mjs +13 -4
- package/server/staLogs/staEvent.mjs +8 -120
- package/server/staLogs/staLogsCore.callWorker.mjs +78 -0
- package/server/staLogs/staLogsCore.mjs +420 -0
- package/server/staLogs/staLogsCore.shellWorker.mjs +20 -0
- package/server/staLogs/test_staEvent.mjs +23 -7
- package/settings.json +1 -0
- package/src/components/LayoutContentEdit.vue +14 -2
- package/src/components/LayoutContentStats.vue +445 -557
- package/tableTags.json +1 -1
- package/dist/js/app-legacy.2aa145e5.js +0 -1
- package/dist/js/app.2aa145e5.js +0 -1
- package/logs/2026-06-19T16.log +0 -6
- package/logs/2026-06-20T11.log +0 -4
- package/logs/2026-06-20T14.log +0 -2
- package/logs/2026-06-21T20.log +0 -1
- package/logs/2026-06-21T21.log +0 -4
- package/logs/2026-06-22T07.log +0 -12
- package/logs/2026-06-22T08.log +0 -808
- package/logs/2026-06-22T09.log +0 -104
- package/logs/2026-06-22T10.log +0 -26
- package/logs/2026-06-22T11.log +0 -860
- package/logs/2026-06-22T12.log +0 -430
- package/logs/2026-06-22T23.log +0 -1547
- package/logs/2026-06-23T00.log +0 -473
- package/logs/2026-06-23T08.log +0 -231
- package/logs/2026-06-23T09.log +0 -179
- package/logs/2026-07-01T11.log +0 -488
- package/logs/2026-07-09T22.log +0 -370
- package/logs/2026-07-09T23.log +0 -370
- package/logs/2026-07-10T12.log +0 -284
- package/logs/2026-07-10T13.log +0 -777
- package/logs/2026-07-11T17.log +0 -2
- package/logs/2026-07-23T16.log +0 -856
- package/logs/2026-08-19T22.log +0 -0
- package/logs/2026-08-25T20.log +0 -0
- package/logs/2026-08-25T21.log +0 -1891
- package/z/344/277/256/346/255/243/350/246/217/345/212/203/346/233/270.md +0 -246
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import readline from 'readline'
|
|
4
|
+
import ot from 'dayjs'
|
|
5
|
+
import get from 'lodash-es/get.js'
|
|
6
|
+
import isfun from 'wsemi/src/isfun.mjs'
|
|
7
|
+
import isestr from 'wsemi/src/isestr.mjs'
|
|
8
|
+
import fsTreeFolder from 'wsemi/src/fsTreeFolder.mjs'
|
|
9
|
+
import filterVpfsByWindow from './filterVpfsByWindow.mjs'
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
//staLogsCore: staEvent (各 event 時序分桶) 之核心 (鏡像 w-web-sso server/staLogs/staLogsCore.mjs, ADR-051)
|
|
13
|
+
// 1. 逐行先比 event 再算時間: time 為 epoch ms 數值 (pino) 直接數值比較, 桶鍵以分鐘整數 memo 查表, 不逐行 dayjs; 就地累加不建陣列
|
|
14
|
+
// 2. 檔級彙總快取: w-syslog 依寫入當下時鐘切檔, 整點跨過後舊檔不再被寫入, 每檔彙總以 (name, size, mtimeMs) 為鍵快取;
|
|
15
|
+
// 檔內最早可計入行 minTimeMs > tStartMs 時窗過濾為 no-op 才沿用, 否則 (邊界檔) 重掃; 當前小時檔 size 變動自然重掃; 快取依 fmt 分開
|
|
16
|
+
// 彙總相依於掃描當下之窗 (窗外行不進 agg), 故 entry 另記 scanTStartMs, 沿用須同時滿足「掃描時無行被窗過濾 (minTimeMs > scanTStartMs)」
|
|
17
|
+
// 與「整檔仍在新窗內 (minTimeMs > 新 tStartMs)」; 否則 timeLength 由小變大 (窗後退) 時會沿用以窄窗掃出之不完整彙總而靜默漏算
|
|
18
|
+
// 3. single-flight: 同參數併發呼叫共用同一 in-flight promise
|
|
19
|
+
// 4. 資料夾項目略過; 單檔讀取錯誤略過該檔 (不入快取、不整體失敗)
|
|
20
|
+
// 輸出形狀與原 staEvent.mjs 一致 (golden test: test/unit-staEvent-golden.test.mjs 以改造前實作產出之 expected 深比較)
|
|
21
|
+
// 刻意差異: 無 time (或 time 無法解析) 之行不計入任何桶 (原實作 ot(undefined) 視為 now 而計入當前桶)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
//快取: `${fdLog}|${fmt}` → Map(檔名 → { size, mtimeMs, minTimeMs, scanTStartMs, agg })
|
|
25
|
+
let kpCache = new Map()
|
|
26
|
+
|
|
27
|
+
//single-flight: key → promise
|
|
28
|
+
let kpInflight = new Map()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
function normTimeInterval(timeInterval) {
|
|
32
|
+
//非 'day' 一律視同 'hr' (原以 ==='day' 決定 fmt、==='hr' 決定 unit, 傳其他值時兩者不一致, 此處收斂為單一判斷)
|
|
33
|
+
return timeInterval === 'day' ? 'day' : 'hr'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
function genPlan(timeLength = 7, timeInterval = 'hr', opt = {}) {
|
|
38
|
+
|
|
39
|
+
//fdLog
|
|
40
|
+
let fdLog = get(opt, 'fdLog')
|
|
41
|
+
if (!isestr(fdLog)) {
|
|
42
|
+
fdLog = './logs'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
//timeInterval, fmt, unit
|
|
46
|
+
let ti = normTimeInterval(timeInterval)
|
|
47
|
+
let fmt = ti === 'day' ? 'YYYY-MM-DD' : 'YYYY-MM-DDTHH'
|
|
48
|
+
let unit = ti === 'day' ? 'day' : 'hour'
|
|
49
|
+
|
|
50
|
+
//now, 測試可由 opt.timeNow (epoch ms) 釘住
|
|
51
|
+
let timeNow = get(opt, 'timeNow')
|
|
52
|
+
let now = (typeof timeNow === 'number') ? ot(timeNow) : ot()
|
|
53
|
+
|
|
54
|
+
//tStart
|
|
55
|
+
let tStart = now.subtract(timeLength, 'day')
|
|
56
|
+
let tStartMs = tStart.valueOf()
|
|
57
|
+
|
|
58
|
+
//kpTime, 產生完整的時間區間 (無資料時段亦補 { count: 0 }, 圖表 x 軸不留空洞)
|
|
59
|
+
let tCurr = tStart.startOf(unit)
|
|
60
|
+
let tEnd = now.startOf(unit)
|
|
61
|
+
let kpTime = []
|
|
62
|
+
while (!tCurr.isAfter(tEnd)) {
|
|
63
|
+
kpTime.push(tCurr.format(fmt))
|
|
64
|
+
tCurr = tCurr.add(1, unit)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { fdLog, timeInterval: ti, fmt, unit, tStart, tStartMs, kpTime, timeNow: now.valueOf() }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
function listFiles(fdLog, tStart, fmt) {
|
|
72
|
+
|
|
73
|
+
//vpfs
|
|
74
|
+
let vpfs = fsTreeFolder(fdLog)
|
|
75
|
+
|
|
76
|
+
//剔除資料夾項目 (fsTreeFolder 會列出子資料夾, 對其開讀取串流會 throw)
|
|
77
|
+
vpfs = vpfs.filter((vpf) => {
|
|
78
|
+
return !get(vpf, 'isFolder', false)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
//開檔前剔除窗外檔, 見該模組註解
|
|
82
|
+
vpfs = filterVpfsByWindow(vpfs, tStart, fmt)
|
|
83
|
+
|
|
84
|
+
//stat
|
|
85
|
+
let files = []
|
|
86
|
+
for (let vpf of vpfs) {
|
|
87
|
+
try {
|
|
88
|
+
let st = fs.statSync(vpf.path)
|
|
89
|
+
if (!st.isFile()) {
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
files.push({ path: vpf.path, name: vpf.name, size: st.size, mtimeMs: st.mtimeMs })
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
//列出後即被移除 (cleanLogs) → 略過
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return files
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
function createBucketLabeler(fmt) {
|
|
104
|
+
//以分鐘整數為 memo 鍵 (任何整分鐘偏移之時區皆與逐行 format 等價), 標籤仍由 dayjs 產生
|
|
105
|
+
let kp = new Map()
|
|
106
|
+
return (tms) => {
|
|
107
|
+
let k = Math.floor(tms / 60000)
|
|
108
|
+
let s = kp.get(k)
|
|
109
|
+
if (s === undefined) {
|
|
110
|
+
s = ot(k * 60000).format(fmt)
|
|
111
|
+
kp.set(k, s)
|
|
112
|
+
}
|
|
113
|
+
return s
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
function toMs(t) {
|
|
119
|
+
if (typeof t === 'number') {
|
|
120
|
+
return t
|
|
121
|
+
}
|
|
122
|
+
if (isestr(t)) {
|
|
123
|
+
return ot(t).valueOf() //ISO 字串 (非 w-syslog 之來源) 走慢路徑; 無法解析回 NaN
|
|
124
|
+
}
|
|
125
|
+
return NaN
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
function readLines(fp, onLine) {
|
|
130
|
+
return new Promise((resolve, reject) => {
|
|
131
|
+
let errTemp = null
|
|
132
|
+
let stream = fs.createReadStream(fp, { encoding: 'utf8' })
|
|
133
|
+
let rl = readline.createInterface({
|
|
134
|
+
input: stream,
|
|
135
|
+
crlfDelay: Infinity,
|
|
136
|
+
})
|
|
137
|
+
rl.on('line', onLine)
|
|
138
|
+
let onError = (err) => {
|
|
139
|
+
errTemp = err
|
|
140
|
+
rl.close()
|
|
141
|
+
}
|
|
142
|
+
stream.on('error', onError)
|
|
143
|
+
rl.on('error', onError) //readline Interface 會把 input 之 error 再拋一次, 未監聽即 unhandled
|
|
144
|
+
stream.on('close', () => {
|
|
145
|
+
if (errTemp) {
|
|
146
|
+
reject(errTemp)
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
resolve()
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
//掃一檔: 各桶 { count, kp:{event:n} }, 任何帶 event 字串之行皆計入 (通用, 不鎖定單一 event)
|
|
157
|
+
async function scanFile(fp, tStartMs, fmt, labeler = null) {
|
|
158
|
+
if (!isfun(labeler)) {
|
|
159
|
+
labeler = createBucketLabeler(fmt)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
let agg = { minTimeMs: null, ev: {} }
|
|
163
|
+
|
|
164
|
+
await readLines(fp, (line) => {
|
|
165
|
+
|
|
166
|
+
//v
|
|
167
|
+
let v = null
|
|
168
|
+
try {
|
|
169
|
+
v = JSON.parse(line)
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
return //略過非 JSON / 壞行
|
|
173
|
+
}
|
|
174
|
+
if (v === null || typeof v !== 'object') {
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
//event, 先比對再算時間
|
|
179
|
+
let e = v.event
|
|
180
|
+
if (!isestr(e)) {
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
//tms
|
|
185
|
+
let tms = toMs(v.time)
|
|
186
|
+
if (Number.isNaN(tms)) {
|
|
187
|
+
return //無 time 或無法解析: 不計入 (刻意差異, 見檔頭)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
//minTimeMs, 對所有可計入之行取最小值 (不論是否在窗內), 供快取有效性判斷
|
|
191
|
+
if (agg.minTimeMs === null || tms < agg.minTimeMs) {
|
|
192
|
+
agg.minTimeMs = tms
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
//窗判斷, 對齊原 t.isAfter(tStart)
|
|
196
|
+
if (!(tms > tStartMs)) {
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
//accumulate
|
|
201
|
+
let label = labeler(tms)
|
|
202
|
+
let b = agg.ev[label]
|
|
203
|
+
if (b === undefined) {
|
|
204
|
+
b = { count: 0, kp: {} }
|
|
205
|
+
agg.ev[label] = b
|
|
206
|
+
}
|
|
207
|
+
b.count += 1
|
|
208
|
+
b.kp[e] = (b.kp[e] || 0) + 1
|
|
209
|
+
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
return agg
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
//掃描一批檔案, 回傳 [{ name, ok, agg | err }]; 單檔失敗不影響其他檔 (worker 端亦呼叫此函式)
|
|
217
|
+
async function scanFiles(files, tStartMs, fmt) {
|
|
218
|
+
let labeler = createBucketLabeler(fmt)
|
|
219
|
+
let rs = []
|
|
220
|
+
for (let f of files) {
|
|
221
|
+
try {
|
|
222
|
+
let agg = await scanFile(f.path, tStartMs, fmt, labeler)
|
|
223
|
+
rs.push({ name: f.name, ok: true, agg })
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
rs.push({ name: f.name, ok: false, err: String(get(err, 'message', err)) })
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return rs
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
function mergeAgg(target, agg) {
|
|
234
|
+
for (let label in agg.ev) {
|
|
235
|
+
let s = agg.ev[label]
|
|
236
|
+
let b = target[label]
|
|
237
|
+
if (b === undefined) {
|
|
238
|
+
b = { count: 0, kp: {} }
|
|
239
|
+
target[label] = b
|
|
240
|
+
}
|
|
241
|
+
b.count += s.count
|
|
242
|
+
for (let e in s.kp) {
|
|
243
|
+
b.kp[e] = (b.kp[e] || 0) + s.kp[e]
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return target
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
function buildOutput(merged, kpTime) {
|
|
251
|
+
//對齊原 merge({}, kpTime, gsLog) + Object.keys().sort(): 窗內桶補零, 資料桶 (含未來時間桶) 聯集
|
|
252
|
+
let kp = {}
|
|
253
|
+
for (let label of kpTime) {
|
|
254
|
+
kp[label] = { count: 0 }
|
|
255
|
+
}
|
|
256
|
+
for (let label in merged) {
|
|
257
|
+
let b = merged[label]
|
|
258
|
+
kp[label] = { count: b.count, ...b.kp }
|
|
259
|
+
}
|
|
260
|
+
return Object.keys(kp)
|
|
261
|
+
.sort()
|
|
262
|
+
.map((time) => ({
|
|
263
|
+
time,
|
|
264
|
+
data: kp[time],
|
|
265
|
+
}))
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
function getCacheKey(fdLog, fmt) {
|
|
270
|
+
return `${path.resolve(fdLog)}|${fmt}`
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
function getCacheDir(key) {
|
|
275
|
+
let m = kpCache.get(key)
|
|
276
|
+
if (!m) {
|
|
277
|
+
m = new Map()
|
|
278
|
+
kpCache.set(key, m)
|
|
279
|
+
}
|
|
280
|
+
return m
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
function clearCache() {
|
|
285
|
+
kpCache.clear()
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
function getCacheSize(fdLog = './logs', timeInterval = 'hr') {
|
|
290
|
+
let fmt = normTimeInterval(timeInterval) === 'day' ? 'YYYY-MM-DD' : 'YYYY-MM-DDTHH'
|
|
291
|
+
let m = kpCache.get(getCacheKey(fdLog, fmt))
|
|
292
|
+
return m ? m.size : 0
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
async function run(plan, opt = {}) {
|
|
297
|
+
|
|
298
|
+
//useCache
|
|
299
|
+
let useCache = get(opt, 'useCache') !== false
|
|
300
|
+
|
|
301
|
+
//scanFilesFn, callWorker 注入 worker 版
|
|
302
|
+
let scanFilesFn = get(opt, 'scanFiles')
|
|
303
|
+
if (!isfun(scanFilesFn)) {
|
|
304
|
+
scanFilesFn = scanFiles
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
//srLog
|
|
308
|
+
let srLog = get(opt, 'srLog', null)
|
|
309
|
+
|
|
310
|
+
//files
|
|
311
|
+
let files = listFiles(plan.fdLog, plan.tStart, plan.fmt)
|
|
312
|
+
|
|
313
|
+
//cacheDir
|
|
314
|
+
let cacheDir = getCacheDir(getCacheKey(plan.fdLog, plan.fmt))
|
|
315
|
+
|
|
316
|
+
//分流: 快取可沿用 vs 需重掃
|
|
317
|
+
let toScan = []
|
|
318
|
+
let kpAgg = {}
|
|
319
|
+
for (let f of files) {
|
|
320
|
+
let c = useCache ? cacheDir.get(f.name) : undefined
|
|
321
|
+
//沿用條件: 檔未變 + 彙總完整 (掃描時無行被窗過濾: minTimeMs > scanTStartMs) + 整檔仍在新窗內 (minTimeMs > 新 tStartMs)
|
|
322
|
+
// 只檢查後者時, timeLength 由小變大 (tStart 後退) 會沿用以窄窗掃出之不完整彙總 → 靜默漏算
|
|
323
|
+
let b = c !== undefined &&
|
|
324
|
+
c.size === f.size &&
|
|
325
|
+
c.mtimeMs === f.mtimeMs &&
|
|
326
|
+
(c.minTimeMs === null || (c.minTimeMs > c.scanTStartMs && c.minTimeMs > plan.tStartMs))
|
|
327
|
+
if (b) {
|
|
328
|
+
kpAgg[f.name] = c.agg
|
|
329
|
+
}
|
|
330
|
+
else {
|
|
331
|
+
toScan.push(f)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
//淘汰已不存在之檔 (cleanLogs 刪舊檔)
|
|
336
|
+
if (useCache) {
|
|
337
|
+
let kpName = new Set(files.map((f) => f.name))
|
|
338
|
+
for (let name of cacheDir.keys()) {
|
|
339
|
+
if (!kpName.has(name)) {
|
|
340
|
+
cacheDir.delete(name)
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
//scan
|
|
346
|
+
let errors = []
|
|
347
|
+
if (toScan.length > 0) {
|
|
348
|
+
let rs = await scanFilesFn(toScan.map((f) => ({ path: f.path, name: f.name })), plan.tStartMs, plan.fmt)
|
|
349
|
+
let kpRs = {}
|
|
350
|
+
for (let r of rs) {
|
|
351
|
+
kpRs[r.name] = r
|
|
352
|
+
}
|
|
353
|
+
for (let f of toScan) {
|
|
354
|
+
let r = kpRs[f.name]
|
|
355
|
+
if (r && r.ok) {
|
|
356
|
+
kpAgg[f.name] = r.agg
|
|
357
|
+
if (useCache) {
|
|
358
|
+
cacheDir.set(f.name, { size: f.size, mtimeMs: f.mtimeMs, minTimeMs: r.agg.minTimeMs, scanTStartMs: plan.tStartMs, agg: r.agg })
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
else {
|
|
362
|
+
let err = get(r, 'err', 'no result')
|
|
363
|
+
errors.push({ name: f.name, err })
|
|
364
|
+
if (useCache) {
|
|
365
|
+
cacheDir.delete(f.name)
|
|
366
|
+
}
|
|
367
|
+
if (srLog && isfun(srLog.warn)) {
|
|
368
|
+
srLog.warn({ event: 'fun-staLogs', key: 'staLogsFileSkipped', name: f.name, err })
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
//merge
|
|
375
|
+
let merged = {}
|
|
376
|
+
for (let f of files) {
|
|
377
|
+
let agg = kpAgg[f.name]
|
|
378
|
+
if (agg) {
|
|
379
|
+
mergeAgg(merged, agg)
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
//output
|
|
384
|
+
let rs = buildOutput(merged, plan.kpTime)
|
|
385
|
+
return {
|
|
386
|
+
rs,
|
|
387
|
+
stat: {
|
|
388
|
+
nFiles: files.length,
|
|
389
|
+
nScanned: toScan.length,
|
|
390
|
+
nCached: files.length - toScan.length,
|
|
391
|
+
errors,
|
|
392
|
+
},
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
//staLogs: 核心入口, 回傳 { rs, stat }
|
|
398
|
+
function staLogs(timeLength = 7, timeInterval = 'hr', opt = {}) {
|
|
399
|
+
//非 async: 併發呼叫須回傳同一個 promise 物件
|
|
400
|
+
|
|
401
|
+
//plan
|
|
402
|
+
let plan = genPlan(timeLength, timeInterval, opt)
|
|
403
|
+
|
|
404
|
+
//single-flight
|
|
405
|
+
let key = JSON.stringify([path.resolve(plan.fdLog), timeLength, plan.timeInterval, get(opt, 'timeNow', null)])
|
|
406
|
+
if (kpInflight.has(key)) {
|
|
407
|
+
return kpInflight.get(key)
|
|
408
|
+
}
|
|
409
|
+
let pm = run(plan, opt)
|
|
410
|
+
.finally(() => {
|
|
411
|
+
kpInflight.delete(key)
|
|
412
|
+
})
|
|
413
|
+
kpInflight.set(key, pm)
|
|
414
|
+
|
|
415
|
+
return pm
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
export { staLogs, genPlan, listFiles, scanFile, scanFiles, mergeAgg, buildOutput, clearCache, getCacheSize, normTimeInterval }
|
|
420
|
+
export default staLogs
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { parentPort } from 'worker_threads'
|
|
2
|
+
import { scanFiles } from './staLogsCore.mjs'
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
//worker 只負責掃描主執行緒交付之檔案清單, 回傳每檔彙總; 快取本體留在主執行緒 (staLogsCore.mjs)
|
|
6
|
+
parentPort.on('message', async (param) => {
|
|
7
|
+
try {
|
|
8
|
+
let r = await scanFiles(param.files, param.tStartMs, param.fmt)
|
|
9
|
+
parentPort.postMessage({
|
|
10
|
+
mode: 'done',
|
|
11
|
+
payload: r,
|
|
12
|
+
})
|
|
13
|
+
}
|
|
14
|
+
catch (err) {
|
|
15
|
+
parentPort.postMessage({
|
|
16
|
+
mode: 'error',
|
|
17
|
+
payload: String((err && err.message) || err),
|
|
18
|
+
})
|
|
19
|
+
}
|
|
20
|
+
})
|
|
@@ -13,14 +13,24 @@ import staEvent from './staEvent.mjs'
|
|
|
13
13
|
import filterVpfsByWindow from './filterVpfsByWindow.mjs'
|
|
14
14
|
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
//測試用臨時資料夾一律落在 ./test/_tmp/(與測試同層、跑完即刪;不污染真實 ./logs)
|
|
17
|
+
//不可用 ./tmp/:該處為 AI 代理之暫存區,隨時會被整個清除,測試資料放那裡會被無預警刪掉
|
|
18
|
+
let fdTmpRoot = './test/_tmp'
|
|
19
|
+
let fdLog = `${fdTmpRoot}/_logs-test-staEvent`
|
|
20
|
+
|
|
21
|
+
//移除 ./test/_tmp 本身(僅在已空時;非空代表尚有其他測試之臨時資料,留著)
|
|
22
|
+
function rmTmpRootIfEmpty() {
|
|
23
|
+
try {
|
|
24
|
+
fs.rmdirSync(fdTmpRoot)
|
|
25
|
+
}
|
|
26
|
+
catch (e) {} //ENOTEMPTY / ENOENT 皆屬正常
|
|
27
|
+
}
|
|
18
28
|
|
|
19
29
|
//以「當前小時正中(:30)」為錨點,避免測試剛好跨小時邊界導致 bucket 飄移
|
|
20
30
|
let anchor = ot().startOf('hour')
|
|
21
|
-
let tNow = anchor.add(30, 'minute')
|
|
22
|
-
let tPrev = anchor.subtract(30, 'minute')
|
|
23
|
-
let tOld = anchor.subtract(30, 'day')
|
|
31
|
+
let tNow = anchor.add(30, 'minute') //本小時 bucket
|
|
32
|
+
let tPrev = anchor.subtract(30, 'minute') //前一小時 bucket
|
|
33
|
+
let tOld = anchor.subtract(30, 'day') //30 天前(超出 timeLength=7,應被排除)
|
|
24
34
|
|
|
25
35
|
let fmtHr = 'YYYY-MM-DDTHH'
|
|
26
36
|
let keyNow = tNow.format(fmtHr)
|
|
@@ -112,7 +122,7 @@ function testFilterVpfsByWindowDirect() {
|
|
|
112
122
|
//實際開檔讀行),證明 filterVpfsByWindow.mjs:22 之 slice 防線在 staEvent.mjs:57 呼叫處確實生效,
|
|
113
123
|
//而非僅單元測試層級成立。
|
|
114
124
|
async function testGranularityAdaptiveIntegration() {
|
|
115
|
-
let fdLog2 =
|
|
125
|
+
let fdLog2 = `${fdTmpRoot}/_logs-test-staEvent-granularity`
|
|
116
126
|
fs.rmSync(fdLog2, { recursive: true, force: true })
|
|
117
127
|
fs.mkdirSync(fdLog2, { recursive: true })
|
|
118
128
|
|
|
@@ -185,6 +195,8 @@ async function main() {
|
|
|
185
195
|
testFilterVpfsByWindowDirect()
|
|
186
196
|
await testGranularityAdaptiveIntegration()
|
|
187
197
|
|
|
198
|
+
rmTmpRootIfEmpty()
|
|
199
|
+
|
|
188
200
|
console.log('=== test_staEvent PASS ===')
|
|
189
201
|
console.log(`hr buckets: ${rs.length}, 本小時=${JSON.stringify(bNow.data)}, 前一小時=${JSON.stringify(bPrev.data)}`)
|
|
190
202
|
}
|
|
@@ -193,6 +205,10 @@ async function main() {
|
|
|
193
205
|
main().catch((err) => {
|
|
194
206
|
console.error('=== test_staEvent FAIL ===')
|
|
195
207
|
console.error(err)
|
|
196
|
-
try {
|
|
208
|
+
try {
|
|
209
|
+
fs.rmSync(fdLog, { recursive: true, force: true })
|
|
210
|
+
rmTmpRootIfEmpty()
|
|
211
|
+
}
|
|
212
|
+
catch (e) {}
|
|
197
213
|
process.exit(1)
|
|
198
214
|
})
|
package/settings.json
CHANGED
|
@@ -285,6 +285,10 @@ export default {
|
|
|
285
285
|
errJsonField: '',
|
|
286
286
|
errSave: '',
|
|
287
287
|
|
|
288
|
+
//submitting: 元件層雙擊 / 重入防護 (CLAUDE.md 三層防護之第 1 層)。本頁按鈕為原生 <button>(保留既有 UI 與 baseline,不換 WButtonCircle),
|
|
289
|
+
//故以旗標取代 promiseUnlock:同步第 2 次點擊直接 return;非同步期間另有頁面層 updateLoading overlay,後端層有 pmKeyMutex
|
|
290
|
+
submitting: false,
|
|
291
|
+
|
|
288
292
|
methodItems: ['get', 'post', 'put', 'del'],
|
|
289
293
|
authTypeItems: ['none', 'bearer', 'apikey', 'basic'],
|
|
290
294
|
|
|
@@ -340,9 +344,13 @@ export default {
|
|
|
340
344
|
|
|
341
345
|
onClickSave: function() {
|
|
342
346
|
let vo = this
|
|
347
|
+
if (vo.submitting) {
|
|
348
|
+
return //元件層: 同步雙擊第 2 次直接擋掉
|
|
349
|
+
}
|
|
350
|
+
vo.submitting = true
|
|
343
351
|
vo.submitSave()
|
|
344
352
|
.catch(function(err) { console.log('catch', err); vo.$alert(vo.$t('anUnexpectedErrorOccurred'), { type: 'error' }) })
|
|
345
|
-
.finally(function() { vo.$ui.updateLoading(false) })
|
|
353
|
+
.finally(function() { vo.submitting = false; vo.$ui.updateLoading(false) })
|
|
346
354
|
},
|
|
347
355
|
|
|
348
356
|
submitSave: function() {
|
|
@@ -411,6 +419,10 @@ export default {
|
|
|
411
419
|
|
|
412
420
|
doDelete: function() {
|
|
413
421
|
let vo = this
|
|
422
|
+
if (vo.submitting) {
|
|
423
|
+
return //元件層: 重入防護 (confirm modal 關閉後之連點)
|
|
424
|
+
}
|
|
425
|
+
vo.submitting = true
|
|
414
426
|
let core = async function() {
|
|
415
427
|
|
|
416
428
|
// 1) 清空錯誤
|
|
@@ -436,7 +448,7 @@ export default {
|
|
|
436
448
|
}
|
|
437
449
|
core()
|
|
438
450
|
.catch(function(err) { console.log('catch', err); vo.$alert(vo.$t('anUnexpectedErrorOccurred'), { type: 'error' }) })
|
|
439
|
-
.finally(function() { vo.$ui.updateLoading(false) })
|
|
451
|
+
.finally(function() { vo.submitting = false; vo.$ui.updateLoading(false) })
|
|
440
452
|
},
|
|
441
453
|
|
|
442
454
|
onClickCancel: function() {
|