w-web-api 1.0.81 → 1.0.83

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.
@@ -1,420 +1,441 @@
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
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
+ //timeLength 上限(天):10 年。取此值之理由——超過此範圍之查詢無實務意義,且 kpTime 陣列長度為
32
+ //timeLength×24(hr 粒度),10 年約 87600 筆仍可接受;再大則單是建陣列即造成長時間 CPU 阻塞。
33
+ let MAX_TIME_LENGTH_DAY = 3650
34
+
35
+
36
+ function normTimeInterval(timeInterval) {
37
+ //非 'day' 一律視同 'hr' (原以 ==='day' 決定 fmt、==='hr' 決定 unit, 傳其他值時兩者不一致, 此處收斂為單一判斷)
38
+ return timeInterval === 'day' ? 'day' : 'hr'
39
+ }
40
+
41
+
42
+ function genPlan(timeLength = 7, timeInterval = 'hr', opt = {}) {
43
+
44
+ //fdLog
45
+ let fdLog = get(opt, 'fdLog')
46
+ if (!isestr(fdLog)) {
47
+ fdLog = './logs'
48
+ }
49
+
50
+ //timeInterval, fmt, unit
51
+ let ti = normTimeInterval(timeInterval)
52
+ let fmt = ti === 'day' ? 'YYYY-MM-DD' : 'YYYY-MM-DDTHH'
53
+ let unit = ti === 'day' ? 'day' : 'hour'
54
+
55
+ //now, 測試可由 opt.timeNow (epoch ms) 釘住
56
+ let timeNow = get(opt, 'timeNow')
57
+ let now = (typeof timeNow === 'number') ? ot(timeNow) : ot()
58
+
59
+ //timeLength 驗證(必須在 subtract 之前)。
60
+ //why:dayjs 對非數字、NaN、或超出 Date 可表示範圍之極大值會回 Invalid Date,而 Invalid Date 之 isAfter 恆為
61
+ //false,使下方 `while (!tCurr.isAfter(tEnd))` 成為**同步無窮迴圈**。該迴圈位於 staLogs 之第一行(早於 worker
62
+ //派工),會佔死主執行緒使整個伺服器停止回應、需重啟。getStaEvent 為對外 kpFunExt,任何已認證使用者皆可帶入
63
+ //任意型別,故此處為必要之輸入閘。允許 0(測試以 timeLength=0 取「現在起算」)至 MAX_TIME_LENGTH_DAY。
64
+ if (!Number.isInteger(timeLength) || timeLength < 0 || timeLength > MAX_TIME_LENGTH_DAY) {
65
+ throw new Error('errTimeLengthInvalid')
66
+ }
67
+
68
+ //tStart
69
+ let tStart = now.subtract(timeLength, 'day')
70
+ let tStartMs = tStart.valueOf()
71
+
72
+ //kpTime, 產生完整的時間區間 (無資料時段亦補 { count: 0 }, 圖表 x 軸不留空洞)
73
+ let tCurr = tStart.startOf(unit)
74
+ let tEnd = now.startOf(unit)
75
+ let kpTime = []
76
+ while (!tCurr.isAfter(tEnd)) {
77
+ kpTime.push(tCurr.format(fmt))
78
+ tCurr = tCurr.add(1, unit)
79
+ }
80
+
81
+ return { fdLog, timeInterval: ti, fmt, unit, tStart, tStartMs, kpTime, timeNow: now.valueOf() }
82
+ }
83
+
84
+
85
+ function listFiles(fdLog, tStart, fmt) {
86
+
87
+ //vpfs
88
+ let vpfs = fsTreeFolder(fdLog)
89
+
90
+ //剔除資料夾項目 (fsTreeFolder 會列出子資料夾, 對其開讀取串流會 throw)
91
+ vpfs = vpfs.filter((vpf) => {
92
+ return !get(vpf, 'isFolder', false)
93
+ })
94
+
95
+ //開檔前剔除窗外檔, 見該模組註解
96
+ vpfs = filterVpfsByWindow(vpfs, tStart, fmt)
97
+
98
+ //stat
99
+ let files = []
100
+ for (let vpf of vpfs) {
101
+ try {
102
+ let st = fs.statSync(vpf.path)
103
+ if (!st.isFile()) {
104
+ continue
105
+ }
106
+ files.push({ path: vpf.path, name: vpf.name, size: st.size, mtimeMs: st.mtimeMs })
107
+ }
108
+ catch (err) {
109
+ //列出後即被移除 (cleanLogs) 略過
110
+ }
111
+ }
112
+
113
+ return files
114
+ }
115
+
116
+
117
+ function createBucketLabeler(fmt) {
118
+ //以分鐘整數為 memo 鍵 (任何整分鐘偏移之時區皆與逐行 format 等價), 標籤仍由 dayjs 產生
119
+ let kp = new Map()
120
+ return (tms) => {
121
+ let k = Math.floor(tms / 60000)
122
+ let s = kp.get(k)
123
+ if (s === undefined) {
124
+ s = ot(k * 60000).format(fmt)
125
+ kp.set(k, s)
126
+ }
127
+ return s
128
+ }
129
+ }
130
+
131
+
132
+ function toMs(t) {
133
+ if (typeof t === 'number') {
134
+ return t
135
+ }
136
+ if (isestr(t)) {
137
+ return ot(t).valueOf() //ISO 字串 (非 w-syslog 之來源) 走慢路徑; 無法解析回 NaN
138
+ }
139
+ return NaN
140
+ }
141
+
142
+
143
+ function readLines(fp, onLine) {
144
+ return new Promise((resolve, reject) => {
145
+ let errTemp = null
146
+ let stream = fs.createReadStream(fp, { encoding: 'utf8' })
147
+ let rl = readline.createInterface({
148
+ input: stream,
149
+ crlfDelay: Infinity,
150
+ })
151
+ rl.on('line', onLine)
152
+ let onError = (err) => {
153
+ errTemp = err
154
+ rl.close()
155
+ }
156
+ stream.on('error', onError)
157
+ rl.on('error', onError) //readline Interface 會把 input 之 error 再拋一次, 未監聽即 unhandled
158
+ stream.on('close', () => {
159
+ if (errTemp) {
160
+ reject(errTemp)
161
+ }
162
+ else {
163
+ resolve()
164
+ }
165
+ })
166
+ })
167
+ }
168
+
169
+
170
+ //掃一檔: 各桶 { count, kp:{event:n} }, 任何帶 event 字串之行皆計入 (通用, 不鎖定單一 event)
171
+ async function scanFile(fp, tStartMs, fmt, labeler = null) {
172
+ if (!isfun(labeler)) {
173
+ labeler = createBucketLabeler(fmt)
174
+ }
175
+
176
+ let agg = { minTimeMs: null, ev: {} }
177
+
178
+ await readLines(fp, (line) => {
179
+
180
+ //v
181
+ let v = null
182
+ try {
183
+ v = JSON.parse(line)
184
+ }
185
+ catch (err) {
186
+ return //略過非 JSON / 壞行
187
+ }
188
+ if (v === null || typeof v !== 'object') {
189
+ return
190
+ }
191
+
192
+ //event, 先比對再算時間
193
+ let e = v.event
194
+ if (!isestr(e)) {
195
+ return
196
+ }
197
+
198
+ //tms
199
+ let tms = toMs(v.time)
200
+ if (Number.isNaN(tms)) {
201
+ return //無 time 或無法解析: 不計入 (刻意差異, 見檔頭)
202
+ }
203
+
204
+ //minTimeMs, 對所有可計入之行取最小值 (不論是否在窗內), 供快取有效性判斷
205
+ if (agg.minTimeMs === null || tms < agg.minTimeMs) {
206
+ agg.minTimeMs = tms
207
+ }
208
+
209
+ //窗判斷, 對齊原 t.isAfter(tStart)
210
+ if (!(tms > tStartMs)) {
211
+ return
212
+ }
213
+
214
+ //accumulate
215
+ let label = labeler(tms)
216
+ let b = agg.ev[label]
217
+ if (b === undefined) {
218
+ b = { count: 0, kp: {} }
219
+ agg.ev[label] = b
220
+ }
221
+ b.count += 1
222
+ b.kp[e] = (b.kp[e] || 0) + 1
223
+
224
+ })
225
+
226
+ return agg
227
+ }
228
+
229
+
230
+ //掃描一批檔案, 回傳 [{ name, ok, agg | err }]; 單檔失敗不影響其他檔 (worker 端亦呼叫此函式)
231
+ async function scanFiles(files, tStartMs, fmt) {
232
+ let labeler = createBucketLabeler(fmt)
233
+ let rs = []
234
+ for (let f of files) {
235
+ try {
236
+ let agg = await scanFile(f.path, tStartMs, fmt, labeler)
237
+ rs.push({ name: f.name, ok: true, agg })
238
+ }
239
+ catch (err) {
240
+ rs.push({ name: f.name, ok: false, err: String(get(err, 'message', err)) })
241
+ }
242
+ }
243
+ return rs
244
+ }
245
+
246
+
247
+ function mergeAgg(target, agg) {
248
+ for (let label in agg.ev) {
249
+ let s = agg.ev[label]
250
+ let b = target[label]
251
+ if (b === undefined) {
252
+ b = { count: 0, kp: {} }
253
+ target[label] = b
254
+ }
255
+ b.count += s.count
256
+ for (let e in s.kp) {
257
+ b.kp[e] = (b.kp[e] || 0) + s.kp[e]
258
+ }
259
+ }
260
+ return target
261
+ }
262
+
263
+
264
+ function buildOutput(merged, kpTime) {
265
+ //對齊原 merge({}, kpTime, gsLog) + Object.keys().sort(): 窗內桶補零, 資料桶 (含未來時間桶) 聯集
266
+ let kp = {}
267
+ for (let label of kpTime) {
268
+ kp[label] = { count: 0 }
269
+ }
270
+ for (let label in merged) {
271
+ let b = merged[label]
272
+ kp[label] = { count: b.count, ...b.kp }
273
+ }
274
+ return Object.keys(kp)
275
+ .sort()
276
+ .map((time) => ({
277
+ time,
278
+ data: kp[time],
279
+ }))
280
+ }
281
+
282
+
283
+ function getCacheKey(fdLog, fmt) {
284
+ return `${path.resolve(fdLog)}|${fmt}`
285
+ }
286
+
287
+
288
+ function getCacheDir(key) {
289
+ let m = kpCache.get(key)
290
+ if (!m) {
291
+ m = new Map()
292
+ kpCache.set(key, m)
293
+ }
294
+ return m
295
+ }
296
+
297
+
298
+ function clearCache() {
299
+ kpCache.clear()
300
+ }
301
+
302
+
303
+ function getCacheSize(fdLog = './logs', timeInterval = 'hr') {
304
+ let fmt = normTimeInterval(timeInterval) === 'day' ? 'YYYY-MM-DD' : 'YYYY-MM-DDTHH'
305
+ let m = kpCache.get(getCacheKey(fdLog, fmt))
306
+ return m ? m.size : 0
307
+ }
308
+
309
+
310
+ async function run(plan, opt = {}) {
311
+
312
+ //useCache
313
+ let useCache = get(opt, 'useCache') !== false
314
+
315
+ //scanFilesFn, callWorker 注入 worker 版
316
+ let scanFilesFn = get(opt, 'scanFiles')
317
+ if (!isfun(scanFilesFn)) {
318
+ scanFilesFn = scanFiles
319
+ }
320
+
321
+ //srLog
322
+ let srLog = get(opt, 'srLog', null)
323
+
324
+ //files
325
+ let files = listFiles(plan.fdLog, plan.tStart, plan.fmt)
326
+
327
+ //cacheDir
328
+ let cacheDir = getCacheDir(getCacheKey(plan.fdLog, plan.fmt))
329
+
330
+ //分流: 快取可沿用 vs 需重掃
331
+ let toScan = []
332
+ let kpAgg = {}
333
+ for (let f of files) {
334
+ let c = useCache ? cacheDir.get(f.name) : undefined
335
+ //沿用條件: 檔未變 + 彙總完整 (掃描時無行被窗過濾: minTimeMs > scanTStartMs) + 整檔仍在新窗內 (minTimeMs > 新 tStartMs)
336
+ // 只檢查後者時, timeLength 由小變大 (tStart 後退) 會沿用以窄窗掃出之不完整彙總 → 靜默漏算
337
+ let b = c !== undefined &&
338
+ c.size === f.size &&
339
+ c.mtimeMs === f.mtimeMs &&
340
+ (c.minTimeMs === null || (c.minTimeMs > c.scanTStartMs && c.minTimeMs > plan.tStartMs))
341
+ if (b) {
342
+ kpAgg[f.name] = c.agg
343
+ }
344
+ else {
345
+ toScan.push(f)
346
+ }
347
+ }
348
+
349
+ //淘汰已不存在之檔 (cleanLogs 刪舊檔)
350
+ if (useCache) {
351
+ let kpName = new Set(files.map((f) => f.name))
352
+ for (let name of cacheDir.keys()) {
353
+ if (!kpName.has(name)) {
354
+ cacheDir.delete(name)
355
+ }
356
+ }
357
+ }
358
+
359
+ //scan
360
+ let errors = []
361
+ if (toScan.length > 0) {
362
+ let rs = await scanFilesFn(toScan.map((f) => ({ path: f.path, name: f.name })), plan.tStartMs, plan.fmt)
363
+ let kpRs = {}
364
+ for (let r of rs) {
365
+ kpRs[r.name] = r
366
+ }
367
+ for (let f of toScan) {
368
+ let r = kpRs[f.name]
369
+ if (r && r.ok) {
370
+ kpAgg[f.name] = r.agg
371
+ if (useCache) {
372
+ cacheDir.set(f.name, { size: f.size, mtimeMs: f.mtimeMs, minTimeMs: r.agg.minTimeMs, scanTStartMs: plan.tStartMs, agg: r.agg })
373
+ }
374
+ }
375
+ else {
376
+ let err = get(r, 'err', 'no result')
377
+ errors.push({ name: f.name, err })
378
+ if (useCache) {
379
+ cacheDir.delete(f.name)
380
+ }
381
+ if (srLog && isfun(srLog.warn)) {
382
+ srLog.warn({ event: 'fun-staLogs', key: 'staLogsFileSkipped', name: f.name, err })
383
+ }
384
+ }
385
+ }
386
+ }
387
+
388
+ //merge
389
+ let merged = {}
390
+ for (let f of files) {
391
+ let agg = kpAgg[f.name]
392
+ if (agg) {
393
+ mergeAgg(merged, agg)
394
+ }
395
+ }
396
+
397
+ //output
398
+ let rs = buildOutput(merged, plan.kpTime)
399
+ return {
400
+ rs,
401
+ stat: {
402
+ nFiles: files.length,
403
+ nScanned: toScan.length,
404
+ nCached: files.length - toScan.length,
405
+ errors,
406
+ },
407
+ }
408
+ }
409
+
410
+
411
+ //staLogs: 核心入口, 回傳 { rs, stat }
412
+ function staLogs(timeLength = 7, timeInterval = 'hr', opt = {}) {
413
+ //非 async: 併發呼叫須回傳同一個 promise 物件
414
+
415
+ //plan(genPlan 對非法 timeLength 同步 throw Error;此處轉為 reject 字串 err-key,
416
+ //使前端 $transErr 能依 lang 反查文字,並讓 wrapErrLog 記到的是 key 而非 Error 物件)
417
+ let plan
418
+ try {
419
+ plan = genPlan(timeLength, timeInterval, opt)
420
+ }
421
+ catch (err) {
422
+ return Promise.reject(get(err, 'message', 'errTimeLengthInvalid'))
423
+ }
424
+
425
+ //single-flight
426
+ let key = JSON.stringify([path.resolve(plan.fdLog), timeLength, plan.timeInterval, get(opt, 'timeNow', null)])
427
+ if (kpInflight.has(key)) {
428
+ return kpInflight.get(key)
429
+ }
430
+ let pm = run(plan, opt)
431
+ .finally(() => {
432
+ kpInflight.delete(key)
433
+ })
434
+ kpInflight.set(key, pm)
435
+
436
+ return pm
437
+ }
438
+
439
+
440
+ export { staLogs, genPlan, listFiles, scanFile, scanFiles, mergeAgg, buildOutput, clearCache, getCacheSize, normTimeInterval }
441
+ export default staLogs