@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.
- package/.env.example +37 -0
- package/README.md +200 -0
- package/bin/logboard +536 -0
- package/client/logger.js +309 -0
- package/config/index.js +142 -0
- package/config.js +2 -0
- package/controllers/AnalyticsController.js +46 -0
- package/controllers/ApiAnalyticsController.js +129 -0
- package/controllers/ApiKeyController.js +58 -0
- package/controllers/AuthController.js +131 -0
- package/controllers/HealthController.js +56 -0
- package/controllers/LogController.js +197 -0
- package/controllers/OrgController.js +152 -0
- package/controllers/RoleConfigController.js +20 -0
- package/controllers/SettingsController.js +39 -0
- package/controllers/StreamController.js +55 -0
- package/controllers/UiController.js +789 -0
- package/controllers/UserController.js +79 -0
- package/lib/batchWriter.js +57 -0
- package/lib/cleanup.js +67 -0
- package/lib/ejs.js +103 -0
- package/lib/emitter.js +5 -0
- package/lib/healthMonitor.js +245 -0
- package/lib/logger.js +21 -0
- package/lib/streams.js +32 -0
- package/lib/theme.js +77 -0
- package/lib/userStore.js +13 -0
- package/lib/utils.js +44 -0
- package/middleware/apiKey.js +82 -0
- package/middleware/auth.js +55 -0
- package/middleware/ipWhitelist.js +59 -0
- package/middleware/org.js +85 -0
- package/middleware/pageAccess.js +20 -0
- package/middleware/rateLimit.js +29 -0
- package/middleware/roles.js +11 -0
- package/package.json +77 -0
- package/routes/alerts.js +18 -0
- package/routes/analytics.js +26 -0
- package/routes/api-analytics.js +30 -0
- package/routes/api-keys.js +12 -0
- package/routes/archive.js +91 -0
- package/routes/audit.js +50 -0
- package/routes/auth.js +22 -0
- package/routes/bookmarks.js +13 -0
- package/routes/health.js +11 -0
- package/routes/logs.js +88 -0
- package/routes/metrics.js +66 -0
- package/routes/notifications.js +14 -0
- package/routes/orgs.js +98 -0
- package/routes/registration.js +202 -0
- package/routes/role-config.js +97 -0
- package/routes/saved-searches.js +12 -0
- package/routes/server.js +151 -0
- package/routes/settings.js +28 -0
- package/routes/status.js +21 -0
- package/routes/stream.js +11 -0
- package/routes/super.js +129 -0
- package/routes/ui.js +120 -0
- package/routes/users.js +13 -0
- package/server.js +172 -0
- package/services/AlertRulesService.js +323 -0
- package/services/AnalyticsService.js +665 -0
- package/services/ApiAnalyticsService.js +471 -0
- package/services/ApiKeyService.js +166 -0
- package/services/AuditService.js +249 -0
- package/services/AuthService.js +234 -0
- package/services/BookmarkService.js +49 -0
- package/services/GlobalSettingsService.js +44 -0
- package/services/LogService.js +1066 -0
- package/services/MetricsService.js +116 -0
- package/services/NotificationService.js +70 -0
- package/services/OrgService.js +217 -0
- package/services/ReportService.js +247 -0
- package/services/RoleConfigService.js +201 -0
- package/services/SavedSearchService.js +63 -0
- package/services/SettingsService.js +220 -0
- package/services/UserService.js +121 -0
- package/setup.js +132 -0
- package/views/404.ejs +8 -0
- package/views/alerts.ejs +190 -0
- package/views/analytics.ejs +209 -0
- package/views/api-analytics.ejs +660 -0
- package/views/api-keys.ejs +150 -0
- package/views/archive.ejs +123 -0
- package/views/audit.ejs +314 -0
- package/views/bookmarks.ejs +54 -0
- package/views/custom-dashboard.ejs +162 -0
- package/views/dashboard.ejs +186 -0
- package/views/diff.ejs +98 -0
- package/views/health.ejs +269 -0
- package/views/heatmap.ejs +126 -0
- package/views/insights.ejs +334 -0
- package/views/invite.ejs +74 -0
- package/views/live.ejs +299 -0
- package/views/login.ejs +64 -0
- package/views/logo.png +0 -0
- package/views/logs.ejs +754 -0
- package/views/notifications.ejs +58 -0
- package/views/partials/head.ejs +282 -0
- package/views/partials/sidebar.ejs +168 -0
- package/views/register.ejs +100 -0
- package/views/roles.ejs +279 -0
- package/views/saved-searches.ejs +51 -0
- package/views/service-map.ejs +142 -0
- package/views/settings.ejs +1159 -0
- package/views/sidebar.ejs +129 -0
- package/views/status.ejs +100 -0
- package/views/super-admin-admins.ejs +58 -0
- package/views/super-admin-analytics.ejs +49 -0
- package/views/super-admin-orgs.ejs +310 -0
- package/views/super-admin-profile.ejs +77 -0
- package/views/super-admin-settings.ejs +108 -0
- package/views/super-admin-system.ejs +46 -0
- 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 ───────────────────────────────────
|