@logboard/cli 1.0.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.
Files changed (114) hide show
  1. package/.env.example +37 -0
  2. package/README.md +200 -0
  3. package/bin/logboard +536 -0
  4. package/client/logger.js +309 -0
  5. package/config/index.js +142 -0
  6. package/config.js +2 -0
  7. package/controllers/AnalyticsController.js +46 -0
  8. package/controllers/ApiAnalyticsController.js +129 -0
  9. package/controllers/ApiKeyController.js +58 -0
  10. package/controllers/AuthController.js +131 -0
  11. package/controllers/HealthController.js +56 -0
  12. package/controllers/LogController.js +197 -0
  13. package/controllers/OrgController.js +152 -0
  14. package/controllers/RoleConfigController.js +20 -0
  15. package/controllers/SettingsController.js +39 -0
  16. package/controllers/StreamController.js +55 -0
  17. package/controllers/UiController.js +789 -0
  18. package/controllers/UserController.js +79 -0
  19. package/lib/batchWriter.js +57 -0
  20. package/lib/cleanup.js +67 -0
  21. package/lib/ejs.js +103 -0
  22. package/lib/emitter.js +5 -0
  23. package/lib/healthMonitor.js +245 -0
  24. package/lib/logger.js +21 -0
  25. package/lib/streams.js +32 -0
  26. package/lib/theme.js +77 -0
  27. package/lib/userStore.js +13 -0
  28. package/lib/utils.js +44 -0
  29. package/middleware/apiKey.js +82 -0
  30. package/middleware/auth.js +55 -0
  31. package/middleware/ipWhitelist.js +59 -0
  32. package/middleware/org.js +85 -0
  33. package/middleware/pageAccess.js +20 -0
  34. package/middleware/rateLimit.js +29 -0
  35. package/middleware/roles.js +11 -0
  36. package/package.json +77 -0
  37. package/routes/alerts.js +18 -0
  38. package/routes/analytics.js +26 -0
  39. package/routes/api-analytics.js +30 -0
  40. package/routes/api-keys.js +12 -0
  41. package/routes/archive.js +91 -0
  42. package/routes/audit.js +50 -0
  43. package/routes/auth.js +22 -0
  44. package/routes/bookmarks.js +13 -0
  45. package/routes/health.js +11 -0
  46. package/routes/logs.js +88 -0
  47. package/routes/metrics.js +66 -0
  48. package/routes/notifications.js +14 -0
  49. package/routes/orgs.js +98 -0
  50. package/routes/registration.js +202 -0
  51. package/routes/role-config.js +97 -0
  52. package/routes/saved-searches.js +12 -0
  53. package/routes/server.js +151 -0
  54. package/routes/settings.js +28 -0
  55. package/routes/status.js +21 -0
  56. package/routes/stream.js +11 -0
  57. package/routes/super.js +129 -0
  58. package/routes/ui.js +120 -0
  59. package/routes/users.js +13 -0
  60. package/server.js +172 -0
  61. package/services/AlertRulesService.js +323 -0
  62. package/services/AnalyticsService.js +665 -0
  63. package/services/ApiAnalyticsService.js +471 -0
  64. package/services/ApiKeyService.js +166 -0
  65. package/services/AuditService.js +249 -0
  66. package/services/AuthService.js +234 -0
  67. package/services/BookmarkService.js +49 -0
  68. package/services/GlobalSettingsService.js +44 -0
  69. package/services/LogService.js +1066 -0
  70. package/services/MetricsService.js +116 -0
  71. package/services/NotificationService.js +70 -0
  72. package/services/OrgService.js +217 -0
  73. package/services/ReportService.js +247 -0
  74. package/services/RoleConfigService.js +201 -0
  75. package/services/SavedSearchService.js +63 -0
  76. package/services/SettingsService.js +220 -0
  77. package/services/UserService.js +121 -0
  78. package/setup.js +132 -0
  79. package/views/404.ejs +8 -0
  80. package/views/alerts.ejs +190 -0
  81. package/views/analytics.ejs +209 -0
  82. package/views/api-analytics.ejs +660 -0
  83. package/views/api-keys.ejs +150 -0
  84. package/views/archive.ejs +123 -0
  85. package/views/audit.ejs +314 -0
  86. package/views/bookmarks.ejs +54 -0
  87. package/views/custom-dashboard.ejs +162 -0
  88. package/views/dashboard.ejs +186 -0
  89. package/views/diff.ejs +98 -0
  90. package/views/health.ejs +269 -0
  91. package/views/heatmap.ejs +126 -0
  92. package/views/insights.ejs +334 -0
  93. package/views/invite.ejs +74 -0
  94. package/views/live.ejs +299 -0
  95. package/views/login.ejs +64 -0
  96. package/views/logo.png +0 -0
  97. package/views/logs.ejs +754 -0
  98. package/views/notifications.ejs +58 -0
  99. package/views/partials/head.ejs +282 -0
  100. package/views/partials/sidebar.ejs +168 -0
  101. package/views/register.ejs +100 -0
  102. package/views/roles.ejs +279 -0
  103. package/views/saved-searches.ejs +51 -0
  104. package/views/service-map.ejs +142 -0
  105. package/views/settings.ejs +1159 -0
  106. package/views/sidebar.ejs +129 -0
  107. package/views/status.ejs +100 -0
  108. package/views/super-admin-admins.ejs +58 -0
  109. package/views/super-admin-analytics.ejs +49 -0
  110. package/views/super-admin-orgs.ejs +310 -0
  111. package/views/super-admin-profile.ejs +77 -0
  112. package/views/super-admin-settings.ejs +108 -0
  113. package/views/super-admin-system.ejs +46 -0
  114. package/views/users.ejs +153 -0
@@ -0,0 +1,665 @@
1
+ 'use strict';
2
+ const fs = require('fs');
3
+ const fsP = require('fs').promises;
4
+ const path = require('path');
5
+ const readline = require('readline');
6
+ const {
7
+ getToday,
8
+ sanitizeAppName,
9
+ sanitizeDate,
10
+ safeLogPath,
11
+ formatBytes,
12
+ } = require('../lib/utils');
13
+
14
+ class AnalyticsService {
15
+ constructor (org) {
16
+ this._org = org || null;
17
+ this._logsDir
18
+ = org && org.logsDir ? org.logsDir : require('../config').LOG_BASE_DIR;
19
+ }
20
+
21
+
22
+
23
+ // ─── Overview dashboard stats ─────────────────────────────────────────────
24
+
25
+ async getOverview (allowedApps = []) {
26
+ const today = getToday();
27
+ let dirs = [];
28
+ try {
29
+ dirs = await fsP.readdir(this._logsDir);
30
+ } catch (e) {
31
+ if (e.code !== 'ENOENT') {
32
+ throw e;
33
+ }
34
+ }
35
+ if (allowedApps && allowedApps.length) {
36
+ dirs = dirs.filter((d) => allowedApps.includes(d));
37
+ } else {
38
+ dirs = dirs.filter(
39
+ (d) =>
40
+ ![
41
+ 'app',
42
+ 'unknown',
43
+ '_archive',
44
+ '_tmp',
45
+ 'logboard',
46
+ 'system',
47
+ ].includes(d.toLowerCase()),
48
+ );
49
+ }
50
+ // Filter to allowed services if RBAC is active
51
+ if (allowedApps && allowedApps.length) {
52
+ dirs = dirs.filter((d) => allowedApps.includes(d));
53
+ } else {
54
+ // Always skip reserved names
55
+ dirs = dirs.filter(
56
+ (d) =>
57
+ ![
58
+ 'app',
59
+ 'unknown',
60
+ '_archive',
61
+ '_tmp',
62
+ 'logboard',
63
+ 'system',
64
+ ].includes(d.toLowerCase()),
65
+ );
66
+ }
67
+
68
+ let totalToday = 0,
69
+ totalErrors = 0,
70
+ totalBytes = 0;
71
+ const services = [];
72
+
73
+ for (const app of dirs) {
74
+ const appPath = path.join(this._logsDir, app);
75
+ try {
76
+ const stat = await fsP.stat(appPath);
77
+ if (!stat.isDirectory()) {
78
+ continue;
79
+ }
80
+ const files = await fsP.readdir(appPath);
81
+ let appBytes = 0;
82
+ for (const f of files.filter((f) => f.endsWith('.log'))) {
83
+ try {
84
+ appBytes += (await fsP.stat(path.join(appPath, f))).size;
85
+ } catch {}
86
+ }
87
+ totalBytes += appBytes;
88
+
89
+ // Count today's logs
90
+ const todayFile = path.join(appPath, `${today}.log`);
91
+ const counts = await this._countLevels(todayFile);
92
+ totalToday += counts.total;
93
+ totalErrors += counts.error || 0;
94
+ services.push({
95
+ appName: app,
96
+ today: counts.total,
97
+ error: counts.error || 0,
98
+ bytes: appBytes,
99
+ });
100
+ } catch {}
101
+ }
102
+
103
+ return {
104
+ services: services.length,
105
+ logsToday: totalToday,
106
+ errorsToday: totalErrors,
107
+ errorRate: totalToday
108
+ ? ((totalErrors / totalToday) * 100).toFixed(1)
109
+ : '0.0',
110
+ totalStorage: formatBytes(totalBytes),
111
+ totalStorageBytes: totalBytes,
112
+ serviceList: services.sort((a, b) => b.today - a.today),
113
+ };
114
+ }
115
+
116
+ // ─── Hourly volume for a given date ───────────────────────────────────────
117
+
118
+ async getHourlyVolume (service, date, allowedApps = []) {
119
+ date = date || getToday();
120
+ try {
121
+ sanitizeDate(date);
122
+ } catch {
123
+ return this._emptyHours(date);
124
+ }
125
+
126
+ const hours = Array.from({ length: 24 }, (_, i) => ({
127
+ hour: i,
128
+ total: 0,
129
+ error: 0,
130
+ warn: 0,
131
+ info: 0,
132
+ debug: 0,
133
+ }));
134
+
135
+ const processFile = async (filePath) => {
136
+ if (!fs.existsSync(filePath)) {
137
+ return;
138
+ }
139
+ await new Promise((resolve) => {
140
+ const stream = fs.createReadStream(filePath, 'utf8');
141
+ const rl = readline.createInterface({
142
+ input: stream,
143
+ crlfDelay: Infinity,
144
+ });
145
+ rl.on('line', (line) => {
146
+ if (!line.trim()) {
147
+ return;
148
+ }
149
+ try {
150
+ const p = JSON.parse(line);
151
+ const h = new Date(p.ts).getUTCHours();
152
+ const lvl = (p.level || 'info').toLowerCase();
153
+ hours[h].total++;
154
+ if (hours[h][lvl] !== undefined) {
155
+ hours[h][lvl]++;
156
+ }
157
+ } catch {}
158
+ });
159
+ rl.on('close', resolve);
160
+ rl.on('error', resolve);
161
+ stream.on('error', resolve);
162
+ });
163
+ };
164
+
165
+ if (service) {
166
+ try {
167
+ sanitizeAppName(service);
168
+ } catch {
169
+ return this._emptyHours(date);
170
+ }
171
+ await processFile(safeLogPath(this._logsDir, service, date));
172
+ } else {
173
+ let dirs = [];
174
+ try {
175
+ dirs = await fsP.readdir(this._logsDir);
176
+ } catch {}
177
+ for (const app of dirs) {
178
+ const fp = path.join(this._logsDir, app, `${date}.log`);
179
+ await processFile(fp);
180
+ }
181
+
182
+ // RBAC: filter to allowed services; always skip reserved names
183
+ if (allowedApps && allowedApps.length) {
184
+ dirs = dirs.filter((d) => allowedApps.includes(d));
185
+ } else {
186
+ dirs = dirs.filter(
187
+ (d) =>
188
+ ![
189
+ 'app',
190
+ 'unknown',
191
+ '_archive',
192
+ '_tmp',
193
+ 'logboard',
194
+ 'system',
195
+ ].includes(d.toLowerCase()),
196
+ );
197
+ }
198
+ }
199
+ return { date, service: service || 'all', hours };
200
+ }
201
+
202
+ // ─── Level breakdown ──────────────────────────────────────────────────────
203
+
204
+ async getLevelBreakdown (service, date) {
205
+ date = date || getToday();
206
+ let filePaths = [];
207
+ try {
208
+ if (service) {
209
+ sanitizeAppName(service);
210
+ sanitizeDate(date);
211
+ filePaths = [safeLogPath(this._logsDir, service, date)];
212
+ } else {
213
+ sanitizeDate(date);
214
+ let dirs = [];
215
+ try {
216
+ dirs = await fsP.readdir(this._logsDir);
217
+ } catch {}
218
+ for (const app of dirs) {
219
+ filePaths.push(path.join(this._logsDir, app, `${date}.log`));
220
+ }
221
+
222
+ // RBAC: filter to allowed services; always skip reserved names
223
+ if (allowedApps && allowedApps.length) {
224
+ dirs = dirs.filter((d) => allowedApps.includes(d));
225
+ } else {
226
+ dirs = dirs.filter(
227
+ (d) =>
228
+ ![
229
+ 'app',
230
+ 'unknown',
231
+ '_archive',
232
+ '_tmp',
233
+ 'logboard',
234
+ 'system',
235
+ ].includes(d.toLowerCase()),
236
+ );
237
+ }
238
+ }
239
+ } catch {
240
+ return { error: 0, warn: 0, info: 0, debug: 0, total: 0 };
241
+ }
242
+
243
+ const counts = { error: 0, warn: 0, info: 0, debug: 0, total: 0 };
244
+ for (const fp of filePaths) {
245
+ const c = await this._countLevels(fp);
246
+ counts.error += c.error || 0;
247
+ counts.warn += c.warn || 0;
248
+ counts.info += c.info || 0;
249
+ counts.debug += c.debug || 0;
250
+ counts.total += c.total || 0;
251
+ }
252
+ return counts;
253
+ }
254
+
255
+ // ─── Top services by volume ───────────────────────────────────────────────
256
+
257
+ async getTopServices (date, topN = 10, allowedApps = []) {
258
+ date = date || getToday();
259
+ let dirs = [];
260
+ try {
261
+ dirs = await fsP.readdir(this._logsDir);
262
+ } catch {}
263
+ if (allowedApps && allowedApps.length) {
264
+ dirs = dirs.filter((d) => allowedApps.includes(d));
265
+ } else {
266
+ dirs = dirs.filter(
267
+ (d) =>
268
+ ![
269
+ 'app',
270
+ 'unknown',
271
+ '_archive',
272
+ '_tmp',
273
+ 'logboard',
274
+ 'system',
275
+ ].includes(d.toLowerCase()),
276
+ );
277
+ }
278
+ const results = [];
279
+ for (const app of dirs) {
280
+ const fp = path.join(this._logsDir, app, `${date}.log`);
281
+ const c = await this._countLevels(fp);
282
+ if (c.total > 0) {
283
+ results.push({ appName: app, ...c });
284
+ }
285
+ }
286
+
287
+ // RBAC: filter to allowed services; always skip reserved names
288
+ if (allowedApps && allowedApps.length) {
289
+ dirs = dirs.filter((d) => allowedApps.includes(d));
290
+ } else {
291
+ dirs = dirs.filter(
292
+ (d) =>
293
+ ![
294
+ 'app',
295
+ 'unknown',
296
+ '_archive',
297
+ '_tmp',
298
+ 'logboard',
299
+ 'system',
300
+ ].includes(d.toLowerCase()),
301
+ );
302
+ }
303
+ return results.sort((a, b) => b.total - a.total).slice(0, topN);
304
+ }
305
+
306
+ // ─── Recent errors ────────────────────────────────────────────────────────
307
+
308
+ async getRecentErrors (limit = 20, allowedApps = []) {
309
+ const today = getToday();
310
+ const errors = [];
311
+ let dirs = [];
312
+ try {
313
+ dirs = await fsP.readdir(this._logsDir);
314
+ } catch {}
315
+
316
+ // RBAC: filter to allowed services; always skip reserved names
317
+ if (allowedApps && allowedApps.length) {
318
+ dirs = dirs.filter((d) => allowedApps.includes(d));
319
+ } else {
320
+ dirs = dirs.filter(
321
+ (d) =>
322
+ ![
323
+ 'app',
324
+ 'unknown',
325
+ '_archive',
326
+ '_tmp',
327
+ 'logboard',
328
+ 'system',
329
+ ].includes(d.toLowerCase()),
330
+ );
331
+ }
332
+ for (const app of dirs) {
333
+ const fp = path.join(this._logsDir, app, `${today}.log`);
334
+ if (!fs.existsSync(fp)) {
335
+ continue;
336
+ }
337
+ await new Promise((resolve) => {
338
+ const stream = fs.createReadStream(fp, 'utf8');
339
+ const rl = readline.createInterface({
340
+ input: stream,
341
+ crlfDelay: Infinity,
342
+ });
343
+ rl.on('line', (line) => {
344
+ if (!line.trim()) {
345
+ return;
346
+ }
347
+ try {
348
+ const p = JSON.parse(line);
349
+ if ((p.level || '').toLowerCase() === 'error') {
350
+ errors.push({ ...p, _appName: app });
351
+ }
352
+ } catch {}
353
+ });
354
+ rl.on('close', resolve);
355
+ rl.on('error', resolve);
356
+ stream.on('error', resolve);
357
+ });
358
+ }
359
+ return errors
360
+ .sort((a, b) => new Date(b.ts) - new Date(a.ts))
361
+ .slice(0, limit);
362
+ }
363
+
364
+ // ─── Multi-day trend (last N days) ───────────────────────────────────────
365
+
366
+ async getDailyTrend (service, days = 7, allowedApps = []) {
367
+ const trend = [];
368
+ for (let i = days - 1; i >= 0; i--) {
369
+ const d = new Date();
370
+ d.setDate(d.getDate() - i);
371
+ const date = d.toISOString().slice(0, 10);
372
+ const breakdown = await this.getLevelBreakdown(service, date);
373
+ trend.push({ date, ...breakdown });
374
+ }
375
+ return trend;
376
+ }
377
+
378
+ // ─── Private helpers ──────────────────────────────────────────────────────
379
+
380
+ async _countLevels (filePath) {
381
+ const counts = { error: 0, warn: 0, info: 0, debug: 0, total: 0 };
382
+ if (!fs.existsSync(filePath)) {
383
+ return counts;
384
+ }
385
+ return new Promise((resolve) => {
386
+ const stream = fs.createReadStream(filePath, 'utf8');
387
+ const rl = readline.createInterface({
388
+ input: stream,
389
+ crlfDelay: Infinity,
390
+ });
391
+ rl.on('line', (line) => {
392
+ if (!line.trim()) {
393
+ return;
394
+ }
395
+ counts.total++;
396
+ try {
397
+ const lvl = (JSON.parse(line).level || 'info').toLowerCase();
398
+ if (counts[lvl] !== undefined) {
399
+ counts[lvl]++;
400
+ }
401
+ } catch {
402
+ counts.info++;
403
+ }
404
+ });
405
+ rl.on('close', () => resolve(counts));
406
+ rl.on('error', () => resolve(counts));
407
+ stream.on('error', () => resolve(counts));
408
+ });
409
+ }
410
+
411
+ _emptyHours (date) {
412
+ return {
413
+ date,
414
+ service: 'all',
415
+ hours: Array.from({ length: 24 }, (_, i) => ({
416
+ hour: i,
417
+ total: 0,
418
+ error: 0,
419
+ warn: 0,
420
+ info: 0,
421
+ debug: 0,
422
+ })),
423
+ };
424
+ }
425
+
426
+ // ─── Status page ─────────────────────────────────────────────────────────
427
+ async getStatusPage () {
428
+ const today = getToday();
429
+ const dirs = [];
430
+ try {
431
+ (await fsP.readdir(this._logsDir)).forEach((d) => dirs.push(d));
432
+ } catch {}
433
+
434
+ const services = [];
435
+ for (const app of dirs) {
436
+ if (app.startsWith('_')) {
437
+ continue;
438
+ } // skip _archive
439
+ const appPath = path.join(this._logsDir, app);
440
+ try {
441
+ const stat = await fsP.stat(appPath);
442
+ if (!stat.isDirectory()) {
443
+ continue;
444
+ }
445
+ } catch {
446
+ continue;
447
+ }
448
+
449
+ // Build 90-day history
450
+ const history = [];
451
+ const now = new Date();
452
+ let uptimeDays = 0,
453
+ totalDays = 0;
454
+ for (let i = 89; i >= 0; i--) {
455
+ const d = new Date(now);
456
+ d.setDate(d.getDate() - i);
457
+ const dateStr = d.toISOString().slice(0, 10);
458
+ const fp = safeLogPath(this._logsDir, app, dateStr);
459
+ try {
460
+ if (!require('fs').existsSync(fp)) {
461
+ history.push({ date: dateStr, status: 'no-data' });
462
+ continue;
463
+ }
464
+ const counts = await this._countLevels(fp);
465
+ const rate = counts.total
466
+ ? ((counts.error || 0) / counts.total) * 100
467
+ : 0;
468
+ const status
469
+ = rate > 10 ? 'degraded' : rate > 3 ? 'warning' : 'operational';
470
+ history.push({
471
+ date: dateStr,
472
+ status,
473
+ total: counts.total,
474
+ errors: counts.error || 0,
475
+ });
476
+ totalDays++;
477
+ if (status === 'operational') {
478
+ uptimeDays++;
479
+ }
480
+ } catch {
481
+ history.push({ date: dateStr, status: 'no-data' });
482
+ }
483
+ }
484
+ const uptime = totalDays
485
+ ? ((uptimeDays / totalDays) * 100).toFixed(1)
486
+ : '0.0';
487
+ const last = history[history.length - 1];
488
+ services.push({
489
+ appName: app,
490
+ uptime,
491
+ status: last?.status || 'no-data',
492
+ history,
493
+ });
494
+ }
495
+
496
+ const overall = services.every((s) => s.status === 'operational')
497
+ ? 'operational'
498
+ : services.some((s) => s.status === 'degraded')
499
+ ? 'degraded'
500
+ : services.some((s) => s.status === 'warning')
501
+ ? 'warning'
502
+ : 'operational';
503
+
504
+ return { overall, services, generatedAt: new Date().toISOString() };
505
+ }
506
+
507
+ async getHeatmap (appName) {
508
+ const { sanitizeAppName, safeLogPath } = require('../lib/utils');
509
+ appName = sanitizeAppName(appName);
510
+ const days = [];
511
+ const today = new Date();
512
+ let totalErrors = 0,
513
+ daysWithErrors = 0,
514
+ worstDay = null;
515
+ for (let i = 89; i >= 0; i--) {
516
+ const d = new Date(today);
517
+ d.setDate(d.getDate() - i);
518
+ const dateStr = d.toISOString().slice(0, 10);
519
+ const fp = safeLogPath(this._logsDir, appName, dateStr);
520
+ try {
521
+ if (!require('fs').existsSync(fp)) {
522
+ days.push({ date: dateStr, errors: 0, total: 0 });
523
+ continue;
524
+ }
525
+ const c = await this._countLevels(fp);
526
+ days.push({ date: dateStr, errors: c.error || 0, total: c.total || 0 });
527
+ totalErrors += c.error || 0;
528
+ if (c.error > 0) {
529
+ daysWithErrors++;
530
+ if (!worstDay || c.error > worstDay.errors) {
531
+ worstDay = { date: dateStr, errors: c.error };
532
+ }
533
+ }
534
+ } catch {
535
+ days.push({ date: dateStr, errors: 0, total: 0 });
536
+ }
537
+ }
538
+ const avgErrorsPerDay = daysWithErrors ? totalErrors / daysWithErrors : 0;
539
+ return { days, totalErrors, daysWithErrors, worstDay, avgErrorsPerDay };
540
+ }
541
+
542
+ // ── Per-service health for health page ──────────────────────────────────────
543
+ async getServiceHealth (allowedApps = []) {
544
+ const today = getToday();
545
+ const services = [];
546
+ let dirs = [];
547
+ try {
548
+ dirs = await fsP.readdir(this._logsDir);
549
+ } catch {}
550
+ if (allowedApps && allowedApps.length) {
551
+ dirs = dirs.filter((d) => allowedApps.includes(d));
552
+ } else {
553
+ dirs = dirs.filter(
554
+ (d) =>
555
+ ![
556
+ 'app',
557
+ 'unknown',
558
+ '_archive',
559
+ '_tmp',
560
+ 'logboard',
561
+ 'system',
562
+ ].includes(d.toLowerCase()),
563
+ );
564
+ }
565
+
566
+ for (const appName of dirs) {
567
+ if (appName.startsWith('_') || appName.endsWith('-requests')) {
568
+ continue;
569
+ }
570
+ const appDir = path.join(this._logsDir, appName);
571
+ try {
572
+ if (!(await fsP.stat(appDir)).isDirectory()) {
573
+ continue;
574
+ }
575
+ } catch {
576
+ continue;
577
+ }
578
+
579
+ // RBAC: filter to allowed services; always skip reserved names
580
+ if (allowedApps && allowedApps.length) {
581
+ dirs = dirs.filter((d) => allowedApps.includes(d));
582
+ } else {
583
+ dirs = dirs.filter(
584
+ (d) =>
585
+ ![
586
+ 'app',
587
+ 'unknown',
588
+ '_archive',
589
+ '_tmp',
590
+ 'logboard',
591
+ 'system',
592
+ ].includes(d.toLowerCase()),
593
+ );
594
+ }
595
+ const fp = safeLogPath(this._logsDir, appName, today);
596
+ let logsToday = 0,
597
+ errors = 0,
598
+ warns = 0,
599
+ lastSeen = null,
600
+ recentErrors = [];
601
+
602
+ try {
603
+ if (fs.existsSync(fp)) {
604
+ await new Promise((resolve) => {
605
+ const rl = readline.createInterface({
606
+ input: fs.createReadStream(fp, 'utf8'),
607
+ crlfDelay: Infinity,
608
+ });
609
+ rl.on('line', (line) => {
610
+ if (!line.trim()) {
611
+ return;
612
+ }
613
+ logsToday++;
614
+ try {
615
+ const p = JSON.parse(line);
616
+ const lvl = (p.level || '').toLowerCase();
617
+ if (lvl === 'error' || lvl === 'fatal') {
618
+ errors++;
619
+ if (recentErrors.length < 3) {
620
+ recentErrors.push(p.message || line.slice(0, 80));
621
+ }
622
+ }
623
+ if (lvl === 'warn') {
624
+ warns++;
625
+ }
626
+ if (p.ts) {
627
+ lastSeen = p.ts;
628
+ }
629
+ } catch {}
630
+ });
631
+ rl.on('close', resolve);
632
+ rl.on('error', resolve);
633
+ });
634
+ }
635
+ } catch {}
636
+
637
+ const errorRate = logsToday ? Math.round((errors / logsToday) * 100) : 0;
638
+ const status
639
+ = errorRate > 10 ? 'critical' : errorRate > 3 ? 'warn' : 'ok';
640
+ const lastSeenStr = lastSeen
641
+ ? new Date(lastSeen).toLocaleTimeString([], {
642
+ hour: '2-digit',
643
+ minute: '2-digit',
644
+ })
645
+ : null;
646
+
647
+ services.push({
648
+ appName,
649
+ logsToday,
650
+ errors,
651
+ warns,
652
+ errorRate,
653
+ status,
654
+ lastSeen: lastSeenStr,
655
+ recentErrors,
656
+ });
657
+ }
658
+
659
+ return services.sort((a, b) => b.errors - a.errors);
660
+ }
661
+ }
662
+
663
+ module.exports = AnalyticsService;
664
+
665
+ // ── Per-service health for health page ───────────────────────────────────