@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,79 @@
|
|
|
1
|
+
const audit = require('../services/AuditService');
|
|
2
|
+
('use strict');
|
|
3
|
+
const UserService = require('../services/UserService');
|
|
4
|
+
const svc = new UserService();
|
|
5
|
+
|
|
6
|
+
class UserController {
|
|
7
|
+
async list (req, res) {
|
|
8
|
+
try {
|
|
9
|
+
res.json(await svc.getAll());
|
|
10
|
+
} catch (e) {
|
|
11
|
+
res.status(500).json({ error: e.message });
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async create (req, res) {
|
|
16
|
+
try {
|
|
17
|
+
const { username, password, role } = req.body;
|
|
18
|
+
res
|
|
19
|
+
.status(201)
|
|
20
|
+
.json(await svc.add(username, password, role, req.user.username));
|
|
21
|
+
} catch (e) {
|
|
22
|
+
res.status(e.status || 500).json({ error: e.message });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async updateRole (req, res) {
|
|
27
|
+
try {
|
|
28
|
+
await svc.updateRole(
|
|
29
|
+
req.params.username,
|
|
30
|
+
req.body.role,
|
|
31
|
+
req.user.username,
|
|
32
|
+
);
|
|
33
|
+
new audit()
|
|
34
|
+
.log(
|
|
35
|
+
req.user?.username,
|
|
36
|
+
'user_delete',
|
|
37
|
+
req.params.username,
|
|
38
|
+
{},
|
|
39
|
+
req.ip || '',
|
|
40
|
+
)
|
|
41
|
+
.catch(() => {});
|
|
42
|
+
res.json({ success: true });
|
|
43
|
+
} catch (e) {
|
|
44
|
+
res.status(e.status || 500).json({ error: e.message });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async resetPassword (req, res) {
|
|
49
|
+
try {
|
|
50
|
+
await svc.resetPassword(
|
|
51
|
+
req.params.username,
|
|
52
|
+
req.body.newPassword,
|
|
53
|
+
req.user.username,
|
|
54
|
+
);
|
|
55
|
+
res.json({ success: true });
|
|
56
|
+
} catch (e) {
|
|
57
|
+
res.status(e.status || 500).json({ error: e.message });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async remove (req, res) {
|
|
62
|
+
try {
|
|
63
|
+
await svc.remove(req.params.username, req.user.username);
|
|
64
|
+
res.json({ success: true });
|
|
65
|
+
} catch (e) {
|
|
66
|
+
res.status(e.status || 500).json({ error: e.message });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async revoke2fa (req, res) {
|
|
71
|
+
try {
|
|
72
|
+
await svc.revoke2fa(req.params.username, req.user.username);
|
|
73
|
+
res.json({ success: true });
|
|
74
|
+
} catch (e) {
|
|
75
|
+
res.status(e.status || 500).json({ error: e.message });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
module.exports = UserController;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const { getLogStream } = require("./streams");
|
|
3
|
+
const logger = require("./logger");
|
|
4
|
+
const config = require("../config");
|
|
5
|
+
|
|
6
|
+
class BatchWriter {
|
|
7
|
+
constructor() {
|
|
8
|
+
this.buffer = new Map(); // key → { appName, date, logsDir, logs[] }
|
|
9
|
+
this.timers = new Map();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// logsDir is optional — defaults to config.LOG_BASE_DIR for backward compat
|
|
13
|
+
write(appName, date, log, logsDir, type) {
|
|
14
|
+
const base = logsDir || config.LOG_BASE_DIR;
|
|
15
|
+
|
|
16
|
+
const key = `${base}\x00${appName}\x00${date}`;
|
|
17
|
+
|
|
18
|
+
if (!this.buffer.has(key)) {
|
|
19
|
+
this.buffer.set(key, { appName, date, logsDir: base, logs: [] });
|
|
20
|
+
}
|
|
21
|
+
const entry = this.buffer.get(key);
|
|
22
|
+
entry.logs.push(log);
|
|
23
|
+
if (entry.logs.length >= config.BATCH_SIZE) {
|
|
24
|
+
this._flushKey(key);
|
|
25
|
+
} else if (!this.timers.has(key)) {
|
|
26
|
+
this.timers.set(
|
|
27
|
+
key,
|
|
28
|
+
setTimeout(() => this._flushKey(key), config.BATCH_TIMEOUT),
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
_flushKey(key) {
|
|
34
|
+
const timer = this.timers.get(key);
|
|
35
|
+
if (timer) {
|
|
36
|
+
clearTimeout(timer);
|
|
37
|
+
this.timers.delete(key);
|
|
38
|
+
}
|
|
39
|
+
const entry = this.buffer.get(key);
|
|
40
|
+
if (!entry || !entry.logs.length) return;
|
|
41
|
+
this.buffer.delete(key);
|
|
42
|
+
const { appName, date, logsDir, logs } = entry;
|
|
43
|
+
const stream = getLogStream(appName, date, logsDir);
|
|
44
|
+
for (const log of logs) {
|
|
45
|
+
stream.write(`${log}\n`, (err) => {
|
|
46
|
+
if (err)
|
|
47
|
+
logger.error(`[BatchWriter] ${appName}/${date}: ${err.message}`);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async flushAll() {
|
|
53
|
+
for (const key of this.buffer.keys()) this._flushKey(key);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = BatchWriter;
|
package/lib/cleanup.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('fs').promises;
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const config = require('../config');
|
|
5
|
+
const logger = require('./logger');
|
|
6
|
+
|
|
7
|
+
async function cleanupOldLogs () {
|
|
8
|
+
logger.info('[Cleanup] Running log retention cleanup…');
|
|
9
|
+
const cutoff = new Date();
|
|
10
|
+
cutoff.setDate(cutoff.getDate() - config.RETENTION_DAYS);
|
|
11
|
+
const cutoffStr = cutoff.toISOString().slice(0, 10);
|
|
12
|
+
|
|
13
|
+
let appDirs;
|
|
14
|
+
try {
|
|
15
|
+
appDirs = await fs.readdir(config.LOG_BASE_DIR);
|
|
16
|
+
} catch (err) {
|
|
17
|
+
if (err.code === 'ENOENT') {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
throw err;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let deleted = 0;
|
|
24
|
+
for (const app of appDirs) {
|
|
25
|
+
const appPath = path.join(config.LOG_BASE_DIR, app);
|
|
26
|
+
try {
|
|
27
|
+
const stat = await fs.stat(appPath);
|
|
28
|
+
if (!stat.isDirectory()) {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const files = await fs.readdir(appPath);
|
|
32
|
+
for (const file of files) {
|
|
33
|
+
const m = file.match(/^(\d{4}-\d{2}-\d{2})\.log$/);
|
|
34
|
+
if (!m || m[1] >= cutoffStr) {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
await fs.unlink(path.join(appPath, file));
|
|
39
|
+
deleted++;
|
|
40
|
+
logger.info(`[Cleanup] Deleted ${app}/${file}`);
|
|
41
|
+
} catch (e) {
|
|
42
|
+
logger.error(
|
|
43
|
+
`[Cleanup] Failed to delete ${app}/${file}: ${e.message}`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
} catch (err) {
|
|
48
|
+
logger.warn(`[Cleanup] Skipping ${app}: ${err.message}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
logger.info(`[Cleanup] Done. Deleted ${deleted} file(s).`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function scheduleCleanup () {
|
|
55
|
+
cleanupOldLogs().catch((err) =>
|
|
56
|
+
logger.error(`[Cleanup] Error: ${err.message}`),
|
|
57
|
+
);
|
|
58
|
+
setInterval(
|
|
59
|
+
() =>
|
|
60
|
+
cleanupOldLogs().catch((err) =>
|
|
61
|
+
logger.error(`[Cleanup] Error: ${err.message}`),
|
|
62
|
+
),
|
|
63
|
+
config.CLEANUP_INTERVAL,
|
|
64
|
+
).unref();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { scheduleCleanup, cleanupOldLogs };
|
package/lib/ejs.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const cache = new Map();
|
|
5
|
+
|
|
6
|
+
function escapeHtml (str) {
|
|
7
|
+
if (str == null) {
|
|
8
|
+
return '';
|
|
9
|
+
}
|
|
10
|
+
return String(str)
|
|
11
|
+
.replace(/&/g, '&')
|
|
12
|
+
.replace(/</g, '<')
|
|
13
|
+
.replace(/>/g, '>')
|
|
14
|
+
.replace(/"/g, '"')
|
|
15
|
+
.replace(/'/g, ''');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function compile (template) {
|
|
19
|
+
let src = `'use strict';\nlet __buf=[];\nconst __e=${escapeHtml.toString()};\n`;
|
|
20
|
+
const re = /<%([-=#]?)([\s\S]*?)%>/g;
|
|
21
|
+
let cursor = 0,
|
|
22
|
+
match;
|
|
23
|
+
while ((match = re.exec(template)) !== null) {
|
|
24
|
+
if (match.index > cursor) {
|
|
25
|
+
src += `__buf.push(${JSON.stringify(template.slice(cursor, match.index))});\n`;
|
|
26
|
+
}
|
|
27
|
+
const type = match[1],
|
|
28
|
+
content = match[2].trim();
|
|
29
|
+
if (!type) {
|
|
30
|
+
src += `${content}\n`;
|
|
31
|
+
} else if (type === '=') {
|
|
32
|
+
src += `__buf.push(__e(${content}));\n`;
|
|
33
|
+
} else if (type === '-') {
|
|
34
|
+
src += `__buf.push(${content});\n`;
|
|
35
|
+
}
|
|
36
|
+
cursor = match.index + match[0].length;
|
|
37
|
+
}
|
|
38
|
+
if (cursor < template.length) {
|
|
39
|
+
src += `__buf.push(${JSON.stringify(template.slice(cursor))});\n`;
|
|
40
|
+
}
|
|
41
|
+
src += 'return __buf.join("");';
|
|
42
|
+
return src;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function render (template, locals, viewsDir) {
|
|
46
|
+
const src = compile(template);
|
|
47
|
+
const merged = {
|
|
48
|
+
allowedCards: [],
|
|
49
|
+
allowedPages: null,
|
|
50
|
+
user: null,
|
|
51
|
+
title: '',
|
|
52
|
+
...(locals || {}),
|
|
53
|
+
};
|
|
54
|
+
// canSee: returns true if allowedPages is null (not loaded) OR pageId is in list
|
|
55
|
+
merged.canSee = function (pageId) {
|
|
56
|
+
if (!merged.allowedPages || !merged.allowedPages.length) {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
return merged.allowedPages.includes(pageId);
|
|
60
|
+
};
|
|
61
|
+
// hasCard: returns true if allowedCards is empty (not loaded) OR cardId is in list
|
|
62
|
+
merged.hasCard = function (cardId) {
|
|
63
|
+
if (!merged.allowedCards || !merged.allowedCards.length) {
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
return merged.allowedCards.includes(cardId);
|
|
67
|
+
};
|
|
68
|
+
const include = function (relPath, extra) {
|
|
69
|
+
const fp = path.resolve(
|
|
70
|
+
viewsDir,
|
|
71
|
+
relPath.endsWith('.ejs') ? relPath : `${relPath}.ejs`,
|
|
72
|
+
);
|
|
73
|
+
return render(
|
|
74
|
+
fs.readFileSync(fp, 'utf8'),
|
|
75
|
+
Object.assign({}, merged, extra || {}),
|
|
76
|
+
path.dirname(fp),
|
|
77
|
+
);
|
|
78
|
+
};
|
|
79
|
+
const fn = new Function('include', 'locals', `with(locals){\n${src}\n}`);
|
|
80
|
+
return fn(include, merged);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function renderFile (filePath, options, callback) {
|
|
84
|
+
try {
|
|
85
|
+
const viewsDir
|
|
86
|
+
= (options && options.settings && options.settings.views)
|
|
87
|
+
|| path.dirname(filePath);
|
|
88
|
+
let tpl;
|
|
89
|
+
if (process.env.NODE_ENV === 'production' && cache.has(filePath)) {
|
|
90
|
+
tpl = cache.get(filePath);
|
|
91
|
+
} else {
|
|
92
|
+
tpl = fs.readFileSync(filePath, 'utf8');
|
|
93
|
+
if (process.env.NODE_ENV === 'production') {
|
|
94
|
+
cache.set(filePath, tpl);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
callback(null, render(tpl, options, viewsDir));
|
|
98
|
+
} catch (err) {
|
|
99
|
+
callback(err);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = { renderFile, render, compile, escapeHtml };
|
package/lib/emitter.js
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const os = require('os');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const config = require('../config');
|
|
5
|
+
const logger = require('../lib/logger');
|
|
6
|
+
|
|
7
|
+
const COOLDOWN_MS = 15 * 60_000;
|
|
8
|
+
const lastFired = { ram: 0, cpu: 0, disk: 0 };
|
|
9
|
+
|
|
10
|
+
function getCpuPercent () {
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
const start = os
|
|
13
|
+
.cpus()
|
|
14
|
+
.map((c) => ({
|
|
15
|
+
idle: c.times.idle,
|
|
16
|
+
total: Object.values(c.times).reduce((a, b) => a + b, 0),
|
|
17
|
+
}));
|
|
18
|
+
setTimeout(() => {
|
|
19
|
+
const end = os
|
|
20
|
+
.cpus()
|
|
21
|
+
.map((c) => ({
|
|
22
|
+
idle: c.times.idle,
|
|
23
|
+
total: Object.values(c.times).reduce((a, b) => a + b, 0),
|
|
24
|
+
}));
|
|
25
|
+
let idleDiff = 0,
|
|
26
|
+
totalDiff = 0;
|
|
27
|
+
for (let i = 0; i < start.length; i++) {
|
|
28
|
+
idleDiff += end[i].idle - start[i].idle;
|
|
29
|
+
totalDiff += end[i].total - start[i].total;
|
|
30
|
+
}
|
|
31
|
+
resolve(
|
|
32
|
+
totalDiff === 0 ? 0 : Math.round((1 - idleDiff / totalDiff) * 100),
|
|
33
|
+
);
|
|
34
|
+
}, 1000);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function getRamPercent () {
|
|
39
|
+
const total = os.totalmem(),
|
|
40
|
+
free = os.freemem();
|
|
41
|
+
return Math.round(((total - free) / total) * 100);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function getDiskPercent () {
|
|
45
|
+
try {
|
|
46
|
+
const { execSync } = require('child_process');
|
|
47
|
+
const logDir = config.LOG_BASE_DIR || '/';
|
|
48
|
+
if (process.platform === 'win32') {
|
|
49
|
+
const out = execSync('wmic logicaldisk get size,freespace,caption', {
|
|
50
|
+
timeout: 3000,
|
|
51
|
+
}).toString();
|
|
52
|
+
const drive = path.parse(logDir).root.replace('\\', '');
|
|
53
|
+
const lines = out.split('\n').filter((l) => l.includes(drive));
|
|
54
|
+
if (lines.length) {
|
|
55
|
+
const [, free, size] = lines[0].trim().split(/\s+/);
|
|
56
|
+
return size ? Math.round((1 - Number(free) / Number(size)) * 100) : 0;
|
|
57
|
+
}
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
const out = execSync(`df -k "${logDir}" 2>/dev/null | tail -1`, {
|
|
61
|
+
timeout: 3000,
|
|
62
|
+
}).toString();
|
|
63
|
+
const parts = out.trim().split(/\s+/);
|
|
64
|
+
const pct = parts[4] ? parseInt(parts[4], 10) : 0;
|
|
65
|
+
return isNaN(pct) ? 0 : pct;
|
|
66
|
+
} catch {
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Render the Slack message template.
|
|
73
|
+
* Template variables: {appName} {metric} {value} {threshold} {host} {time}
|
|
74
|
+
*/
|
|
75
|
+
function renderTemplate (tpl, vars) {
|
|
76
|
+
if (!tpl) {
|
|
77
|
+
return `⚠️ LogBoard Alert: ${vars.metric} at ${vars.value}% on ${vars.host}`;
|
|
78
|
+
}
|
|
79
|
+
return tpl
|
|
80
|
+
.replace(/\{appName\}/g, vars.appName || 'LogBoard')
|
|
81
|
+
.replace(/\{metric\}/g, vars.metric || '')
|
|
82
|
+
.replace(/\{value\}/g, vars.value || '')
|
|
83
|
+
.replace(/\{threshold\}/g, vars.threshold || '')
|
|
84
|
+
.replace(/\{host\}/g, vars.host || os.hostname())
|
|
85
|
+
.replace(/\{time\}/g, vars.time || new Date().toLocaleString());
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function sendSlack (url, text, fields) {
|
|
89
|
+
if (!url) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
const axios = require('axios');
|
|
94
|
+
await axios.post(
|
|
95
|
+
url,
|
|
96
|
+
{ text, attachments: [{ color: 'warning', fields }] },
|
|
97
|
+
{ timeout: 5000 },
|
|
98
|
+
);
|
|
99
|
+
} catch (e) {
|
|
100
|
+
logger.warn(`[HealthMonitor] Slack failed: ${e.message}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function check (settings = {}) {
|
|
105
|
+
const webhookUrl = settings.webhookUrl || config.WEBHOOK_URL || '';
|
|
106
|
+
const ramThresh = settings.alertRamPct ?? config.ALERT_RAM_PCT ?? 85;
|
|
107
|
+
const cpuThresh = settings.alertCpuPct ?? config.ALERT_CPU_PCT ?? 90;
|
|
108
|
+
const diskThresh = settings.alertDiskPct ?? config.ALERT_DISK_PCT ?? 90;
|
|
109
|
+
const alertEnabled = settings.alertsEnabled !== false;
|
|
110
|
+
const template = settings.alertSlackTemplate || '';
|
|
111
|
+
const appName = settings.appName || 'LogBoard';
|
|
112
|
+
const now = Date.now();
|
|
113
|
+
|
|
114
|
+
if (!alertEnabled) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const ramPct = getRamPercent();
|
|
119
|
+
const diskPct = getDiskPercent();
|
|
120
|
+
const cpuPct = await getCpuPercent();
|
|
121
|
+
|
|
122
|
+
const metrics = [
|
|
123
|
+
{
|
|
124
|
+
key: 'ram',
|
|
125
|
+
pct: ramPct,
|
|
126
|
+
thresh: ramThresh,
|
|
127
|
+
label: 'RAM',
|
|
128
|
+
emoji: ':fire:',
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
key: 'cpu',
|
|
132
|
+
pct: cpuPct,
|
|
133
|
+
thresh: cpuThresh,
|
|
134
|
+
label: 'CPU',
|
|
135
|
+
emoji: ':zap:',
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
key: 'disk',
|
|
139
|
+
pct: diskPct,
|
|
140
|
+
thresh: diskThresh,
|
|
141
|
+
label: 'Disk',
|
|
142
|
+
emoji: ':floppy_disk:',
|
|
143
|
+
},
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
for (const m of metrics) {
|
|
147
|
+
if (m.pct < m.thresh) {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (now - lastFired[m.key] < COOLDOWN_MS) {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
lastFired[m.key] = now;
|
|
154
|
+
|
|
155
|
+
const vars = {
|
|
156
|
+
appName,
|
|
157
|
+
metric: m.label,
|
|
158
|
+
value: m.pct,
|
|
159
|
+
threshold: m.thresh,
|
|
160
|
+
host: os.hostname(),
|
|
161
|
+
time: new Date().toLocaleString(),
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const text = template
|
|
165
|
+
? renderTemplate(template, vars)
|
|
166
|
+
: `${m.emoji} *${appName}* — High ${m.label} Usage: *${m.pct}%* (threshold: ${m.thresh}%) on \`${os.hostname()}\``;
|
|
167
|
+
|
|
168
|
+
if (webhookUrl) {
|
|
169
|
+
await sendSlack(webhookUrl, text, [
|
|
170
|
+
{
|
|
171
|
+
title: `${m.label} Used`,
|
|
172
|
+
value: `${m.pct}% (threshold: ${m.thresh}%)`,
|
|
173
|
+
short: true,
|
|
174
|
+
},
|
|
175
|
+
{ title: 'App', value: appName, short: true },
|
|
176
|
+
{ title: 'Host', value: os.hostname(), short: true },
|
|
177
|
+
{ title: 'Time', value: vars.time, short: true },
|
|
178
|
+
]);
|
|
179
|
+
logger.warn(`[HealthMonitor] ${m.label} alert fired: ${m.pct}%`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Also notify via Discord if configured
|
|
183
|
+
if (settings.discordUrl) {
|
|
184
|
+
try {
|
|
185
|
+
await require('axios').post(
|
|
186
|
+
settings.discordUrl,
|
|
187
|
+
{
|
|
188
|
+
username: appName,
|
|
189
|
+
embeds: [
|
|
190
|
+
{
|
|
191
|
+
title: `⚠️ ${m.label} Alert`,
|
|
192
|
+
description: `${m.pct}% used (threshold: ${m.thresh}%)`,
|
|
193
|
+
color: 0xf59e0b,
|
|
194
|
+
fields: [{ name: 'Host', value: os.hostname(), inline: true }],
|
|
195
|
+
timestamp: new Date().toISOString(),
|
|
196
|
+
},
|
|
197
|
+
],
|
|
198
|
+
},
|
|
199
|
+
{ timeout: 5000 },
|
|
200
|
+
);
|
|
201
|
+
} catch {}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return { ramPct, cpuPct, diskPct };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
let _interval = null;
|
|
209
|
+
|
|
210
|
+
function start (getSettings) {
|
|
211
|
+
if (_interval) {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
_interval = setInterval(async () => {
|
|
215
|
+
try {
|
|
216
|
+
const s
|
|
217
|
+
= typeof getSettings === 'function'
|
|
218
|
+
? await getSettings()
|
|
219
|
+
: getSettings || {};
|
|
220
|
+
await check(s);
|
|
221
|
+
} catch (e) {
|
|
222
|
+
logger.warn(`[HealthMonitor] check error: ${e.message}`);
|
|
223
|
+
}
|
|
224
|
+
}, 60_000);
|
|
225
|
+
if (_interval.unref) {
|
|
226
|
+
_interval.unref();
|
|
227
|
+
}
|
|
228
|
+
logger.info('[HealthMonitor] Started — checking every 60s');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function stop () {
|
|
232
|
+
if (_interval) {
|
|
233
|
+
clearInterval(_interval);
|
|
234
|
+
_interval = null;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
module.exports = {
|
|
239
|
+
start,
|
|
240
|
+
stop,
|
|
241
|
+
check,
|
|
242
|
+
getRamPercent,
|
|
243
|
+
getCpuPercent,
|
|
244
|
+
getDiskPercent,
|
|
245
|
+
};
|
package/lib/logger.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const LEVELS = { error: 0, warn: 1, info: 2, debug: 3 };
|
|
3
|
+
const cur = LEVELS[process.env.LOG_LEVEL] ?? LEVELS.info;
|
|
4
|
+
|
|
5
|
+
function log (level, message) {
|
|
6
|
+
if (LEVELS[level] > cur) {
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
const line = JSON.stringify({ ts: new Date().toISOString(), level, message });
|
|
10
|
+
(level === 'error' || level === 'warn'
|
|
11
|
+
? process.stderr
|
|
12
|
+
: process.stdout
|
|
13
|
+
).write(`${line}\n`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
module.exports = {
|
|
17
|
+
error: (m) => log('error', m),
|
|
18
|
+
warn: (m) => log('warn', m),
|
|
19
|
+
info: (m) => log('info', m),
|
|
20
|
+
debug: (m) => log('debug', m),
|
|
21
|
+
};
|
package/lib/streams.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const config = require('../config');
|
|
5
|
+
const logger = require('./logger');
|
|
6
|
+
const { ensureDir } = require('./utils');
|
|
7
|
+
|
|
8
|
+
const activeStreams = new Map();
|
|
9
|
+
|
|
10
|
+
function getLogStream (appName, date, logsDir) {
|
|
11
|
+
const baseDir = logsDir || config.LOG_BASE_DIR;
|
|
12
|
+
const key = `${baseDir}\x00${appName}\x00${date}`;
|
|
13
|
+
if (activeStreams.has(key)) { return activeStreams.get(key); }
|
|
14
|
+
|
|
15
|
+
const dir = path.join(baseDir, appName);
|
|
16
|
+
ensureDir(dir);
|
|
17
|
+
const filePath = path.join(dir, `${date}.log`);
|
|
18
|
+
const stream = fs.createWriteStream(filePath, { flags: 'a' });
|
|
19
|
+
stream.on('error', (err) => {
|
|
20
|
+
logger.error(`[Stream] ${filePath}: ${err.message}`);
|
|
21
|
+
activeStreams.delete(key);
|
|
22
|
+
});
|
|
23
|
+
activeStreams.set(key, stream);
|
|
24
|
+
return stream;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function closeAllStreams () {
|
|
28
|
+
for (const s of activeStreams.values()) { s.end(); }
|
|
29
|
+
activeStreams.clear();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { getLogStream, closeAllStreams };
|
package/lib/theme.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const PRESETS = {
|
|
4
|
+
'dark-indigo': { mode: 'dark', r: 99, g: 102, b: 241, label: 'Dark Indigo' },
|
|
5
|
+
'dark-violet': { mode: 'dark', r: 139, g: 92, b: 246, label: 'Dark Violet' },
|
|
6
|
+
'dark-rose': { mode: 'dark', r: 244, g: 63, b: 94, label: 'Dark Rose' },
|
|
7
|
+
'dark-emerald': {
|
|
8
|
+
mode: 'dark',
|
|
9
|
+
r: 16,
|
|
10
|
+
g: 185,
|
|
11
|
+
b: 129,
|
|
12
|
+
label: 'Dark Emerald',
|
|
13
|
+
},
|
|
14
|
+
'dark-sky': { mode: 'dark', r: 14, g: 165, b: 233, label: 'Dark Sky' },
|
|
15
|
+
'dark-amber': { mode: 'dark', r: 245, g: 158, b: 11, label: 'Dark Amber' },
|
|
16
|
+
'light-indigo': {
|
|
17
|
+
mode: 'light',
|
|
18
|
+
r: 99,
|
|
19
|
+
g: 102,
|
|
20
|
+
b: 241,
|
|
21
|
+
label: 'Light Indigo',
|
|
22
|
+
},
|
|
23
|
+
'light-violet': {
|
|
24
|
+
mode: 'light',
|
|
25
|
+
r: 139,
|
|
26
|
+
g: 92,
|
|
27
|
+
b: 246,
|
|
28
|
+
label: 'Light Violet',
|
|
29
|
+
},
|
|
30
|
+
'light-sky': { mode: 'light', r: 14, g: 165, b: 233, label: 'Light Sky' },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Build CSS variable overrides + data-theme setter for the given settings.
|
|
35
|
+
* Called inside head.ejs on every page render.
|
|
36
|
+
*/
|
|
37
|
+
function buildThemeSnippet (s) {
|
|
38
|
+
if (!s || typeof s !== 'object') {
|
|
39
|
+
return '';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Read accent from settings; fall back to default indigo
|
|
43
|
+
const r = Math.max(0, Math.min(255, Number(s.accentR ?? s.accentR ?? 99)));
|
|
44
|
+
const g = Math.max(0, Math.min(255, Number(s.accentG ?? 102)));
|
|
45
|
+
const b = Math.max(0, Math.min(255, Number(s.accentB ?? 241)));
|
|
46
|
+
const mode = s.themeMode === 'light' ? 'light' : 'dark';
|
|
47
|
+
|
|
48
|
+
// Lighter variant for text on accent backgrounds
|
|
49
|
+
const lr = Math.min(255, r + 55);
|
|
50
|
+
const lg = Math.min(255, g + 55);
|
|
51
|
+
const lb = Math.min(255, b + 55);
|
|
52
|
+
|
|
53
|
+
return [
|
|
54
|
+
'<style id="lb-theme">',
|
|
55
|
+
':root {',
|
|
56
|
+
` --accent: rgb(${r},${g},${b});`,
|
|
57
|
+
` --accent-l: rgb(${lr},${lg},${lb});`,
|
|
58
|
+
` --accent-dim: rgba(${r},${g},${b},.15);`,
|
|
59
|
+
` --accent-btn: rgb(${r},${g},${b});`,
|
|
60
|
+
'}',
|
|
61
|
+
'</style>',
|
|
62
|
+
'<script>',
|
|
63
|
+
'(function(){',
|
|
64
|
+
' var stored = localStorage.getItem(\'theme\');',
|
|
65
|
+
` var mode = stored || '${mode}';`,
|
|
66
|
+
' document.documentElement.setAttribute(\'data-theme\', mode);',
|
|
67
|
+
' // Apply accent vars immediately (prevents flash)',
|
|
68
|
+
' var s = document.documentElement.style;',
|
|
69
|
+
` s.setProperty('--accent', 'rgb(${r},${g},${b})');`,
|
|
70
|
+
` s.setProperty('--accent-l', 'rgb(${lr},${lg},${lb})');`,
|
|
71
|
+
` s.setProperty('--accent-dim', 'rgba(${r},${g},${b},.15)');`,
|
|
72
|
+
'})();',
|
|
73
|
+
'</script>',
|
|
74
|
+
].join('\n');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = { PRESETS, buildThemeSnippet };
|
package/lib/userStore.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('fs').promises;
|
|
3
|
+
const config = require('../config');
|
|
4
|
+
|
|
5
|
+
async function getUsers () {
|
|
6
|
+
try { return JSON.parse(await fs.readFile(config.USERS_FILE, 'utf8')); } catch (err) { if (err.code === 'ENOENT') { return {}; } throw err; }
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async function saveUsers (users) {
|
|
10
|
+
await fs.writeFile(config.USERS_FILE, JSON.stringify(users, null, 2), 'utf8');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
module.exports = { getUsers, saveUsers };
|