@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
package/server.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const express = require('express');
|
|
3
|
+
const helmet = require('helmet');
|
|
4
|
+
const cookieParser = require('cookie-parser');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const config = require('./config');
|
|
7
|
+
const { scheduleCleanup } = require('./lib/cleanup');
|
|
8
|
+
const { closeAllStreams } = require('./lib/streams');
|
|
9
|
+
const BatchWriter = require('./lib/batchWriter');
|
|
10
|
+
const logger = require('./lib/logger');
|
|
11
|
+
const ejsEngine = require('./lib/ejs');
|
|
12
|
+
const { ipWhitelist } = require('./middleware/ipWhitelist');
|
|
13
|
+
const { orgMiddleware, orgMiddlewareUI } = require('./middleware/org');
|
|
14
|
+
|
|
15
|
+
const app = express();
|
|
16
|
+
app.set('trust proxy', true);
|
|
17
|
+
|
|
18
|
+
// ── View engine ────────────────────────────────────────────────────────────
|
|
19
|
+
app.engine('ejs', ejsEngine.renderFile);
|
|
20
|
+
app.set('view engine', 'ejs');
|
|
21
|
+
app.set('views', path.join(__dirname, 'views'));
|
|
22
|
+
|
|
23
|
+
// ── Static assets (logo, future public dir) ────────────────────────────────
|
|
24
|
+
app.use('/public', express.static(path.join(__dirname, 'views')));
|
|
25
|
+
|
|
26
|
+
// ── Security headers ───────────────────────────────────────────────────────
|
|
27
|
+
app.use(helmet({
|
|
28
|
+
contentSecurityPolicy: {
|
|
29
|
+
directives: {
|
|
30
|
+
defaultSrc: ['\'self\''],
|
|
31
|
+
connectSrc: ['\'self\'', `http://localhost:${config.PORT}`, `ws://localhost:${config.PORT}`, 'https://cdn.jsdelivr.net'],
|
|
32
|
+
scriptSrc: ['\'self\'', '\'unsafe-inline\'', 'https://cdn.jsdelivr.net', 'https://unpkg.com'],
|
|
33
|
+
scriptSrcAttr: ['\'unsafe-inline\''],
|
|
34
|
+
styleSrc: ['\'self\'', '\'unsafe-inline\'', 'https://fonts.googleapis.com'],
|
|
35
|
+
fontSrc: ['\'self\'', 'https://fonts.gstatic.com', 'data:'],
|
|
36
|
+
imgSrc: ['\'self\'', 'data:', 'https:', 'http:'], // allow external logo URLs
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
// ── CORS ───────────────────────────────────────────────────────────────────
|
|
42
|
+
app.use((req, res, next) => {
|
|
43
|
+
const { origin } = req.headers;
|
|
44
|
+
if (!origin || config.CORS_ORIGINS.includes('*') || config.CORS_ORIGINS.includes(origin)) {
|
|
45
|
+
if (origin) { res.setHeader('Access-Control-Allow-Origin', origin); }
|
|
46
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS');
|
|
47
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Api-Key, Authorization');
|
|
48
|
+
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
49
|
+
}
|
|
50
|
+
if (req.method === 'OPTIONS') { return res.sendStatus(204); }
|
|
51
|
+
next();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// ── Body / cookies ─────────────────────────────────────────────────────────
|
|
55
|
+
app.use(express.json({ limit: config.MAX_BODY_SIZE }));
|
|
56
|
+
app.use(cookieParser());
|
|
57
|
+
app.use((req, _res, next) => { logger.debug(`${req.method} ${req.path}`); next(); });
|
|
58
|
+
// req.org is attached by orgMiddleware — called inside each route after authenticate
|
|
59
|
+
|
|
60
|
+
// ── Routes ─────────────────────────────────────────────────────────────────
|
|
61
|
+
const healthRoute = require('./routes/health');
|
|
62
|
+
const authRoute = require('./routes/auth');
|
|
63
|
+
const streamRoute = require('./routes/stream');
|
|
64
|
+
const analyticsRoute = require('./routes/analytics');
|
|
65
|
+
const logsRoute = require('./routes/logs');
|
|
66
|
+
const uiRoute = require('./routes/ui');
|
|
67
|
+
const settingsRoute = require('./routes/settings');
|
|
68
|
+
const apiAnalyticsRoute= require('./routes/api-analytics');
|
|
69
|
+
const roleConfigRoute = require('./routes/role-config');
|
|
70
|
+
const usersRoute = require('./routes/users');
|
|
71
|
+
const apiKeysRoute = require('./routes/api-keys');
|
|
72
|
+
const alertsRoute = require('./routes/alerts');
|
|
73
|
+
const auditRoute = require('./routes/audit');
|
|
74
|
+
const statusRoute = require('./routes/status');
|
|
75
|
+
const archiveRoute = require('./routes/archive');
|
|
76
|
+
const notificationsRoute = require('./routes/notifications');
|
|
77
|
+
const savedSearchesRoute = require('./routes/saved-searches');
|
|
78
|
+
const bookmarksRoute = require('./routes/bookmarks');
|
|
79
|
+
const metricsRoute = require('./routes/metrics');
|
|
80
|
+
const orgsRoute = require('./routes/orgs');
|
|
81
|
+
const superRoute = require('./routes/super');
|
|
82
|
+
const registrationRoute = require('./routes/registration');
|
|
83
|
+
|
|
84
|
+
const batchWriter = new BatchWriter();
|
|
85
|
+
logsRoute.setWriter(batchWriter);
|
|
86
|
+
uiRoute.setWriter(batchWriter);
|
|
87
|
+
archiveRoute.setWriter(batchWriter);
|
|
88
|
+
|
|
89
|
+
// ── Mount API routes ───────────────────────────────────────────────────────
|
|
90
|
+
app.use('/api/health', healthRoute);
|
|
91
|
+
app.use('/api/auth', authRoute);
|
|
92
|
+
app.use('/api/logs/stream', streamRoute);
|
|
93
|
+
// IP whitelist only on ingest endpoint
|
|
94
|
+
app.use('/api/logs', ipWhitelist(), logsRoute.router);
|
|
95
|
+
app.use('/api/analytics', analyticsRoute);
|
|
96
|
+
app.use('/api/settings', settingsRoute);
|
|
97
|
+
app.use('/api/api-analytics', apiAnalyticsRoute);
|
|
98
|
+
app.use('/api/role-config', roleConfigRoute);
|
|
99
|
+
app.use('/api/users', usersRoute);
|
|
100
|
+
app.use('/api/api-keys', apiKeysRoute);
|
|
101
|
+
app.use('/api/alerts', alertsRoute);
|
|
102
|
+
app.use('/api/audit', auditRoute);
|
|
103
|
+
app.use('/api/archive', archiveRoute.router);
|
|
104
|
+
app.use('/status', statusRoute);
|
|
105
|
+
app.use('/api/super', superRoute);
|
|
106
|
+
app.use('/api/orgs', orgsRoute);
|
|
107
|
+
app.use('/api/notifications', notificationsRoute);
|
|
108
|
+
app.use('/api/saved-searches', savedSearchesRoute);
|
|
109
|
+
app.use('/api/bookmarks', bookmarksRoute);
|
|
110
|
+
app.use('/api/metrics', metricsRoute);
|
|
111
|
+
|
|
112
|
+
// ── Registration + OAuth routes (must be before UI catch-all) ───────────────
|
|
113
|
+
app.use('/', registrationRoute);
|
|
114
|
+
|
|
115
|
+
// ── UI routes ──────────────────────────────────────────────────────────────
|
|
116
|
+
app.use('/', uiRoute.router);
|
|
117
|
+
|
|
118
|
+
// ── 404 / error ────────────────────────────────────────────────────────────
|
|
119
|
+
app.use((req, res) => {
|
|
120
|
+
if (req.path.startsWith('/api/')) { return res.status(404).json({ error: 'Not found' }); }
|
|
121
|
+
res.status(404).render('404', { title: '404', user: req.user||null });
|
|
122
|
+
});
|
|
123
|
+
app.use((err, _req, res, _next) => {
|
|
124
|
+
logger.error(`Unhandled: ${err.stack}`);
|
|
125
|
+
if (res.headersSent) { return; }
|
|
126
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
scheduleCleanup();
|
|
130
|
+
// Health monitor — alerts for RAM/CPU/disk
|
|
131
|
+
const healthMonitor = require('./lib/healthMonitor');
|
|
132
|
+
const SettingsService = require('./services/SettingsService');
|
|
133
|
+
healthMonitor.start(() => new SettingsService().get());
|
|
134
|
+
// Scheduled reports
|
|
135
|
+
const ReportService = require('./services/ReportService');
|
|
136
|
+
ReportService.startScheduler(() => new SettingsService().get());
|
|
137
|
+
|
|
138
|
+
// Auto-migrate flat data to orgs structure on first start
|
|
139
|
+
(async () => {
|
|
140
|
+
try {
|
|
141
|
+
const OrgSvc = require('./services/OrgService');
|
|
142
|
+
const result = await OrgSvc.migrateToDefaultOrg();
|
|
143
|
+
if (result.migrated) {
|
|
144
|
+
logger.info('[Startup] Auto-migrated existing data to default org');
|
|
145
|
+
}
|
|
146
|
+
} catch(e) { logger.warn('[Startup] Migration skipped: ' + e.message); }
|
|
147
|
+
})();
|
|
148
|
+
|
|
149
|
+
const server = app.listen(config.PORT, () => {
|
|
150
|
+
logger.info(`LogBoard → http://localhost:${config.PORT}`);
|
|
151
|
+
logger.info(`Env: ${config.NODE_ENV} | Log dir: ${config.LOG_BASE_DIR}`);
|
|
152
|
+
logger.info(`Data dir: ${config.DATA_DIR}`);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
let shuttingDown = false;
|
|
156
|
+
|
|
157
|
+
async function shutdown (sig) {
|
|
158
|
+
if (shuttingDown) { return; } shuttingDown=true;
|
|
159
|
+
logger.info(`${sig} received. Shutting down…`);
|
|
160
|
+
server.close(async () => {
|
|
161
|
+
try { await batchWriter.flushAll(); } catch (e) { logger.error(`Flush: ${e.message}`); }
|
|
162
|
+
closeAllStreams();
|
|
163
|
+
logger.info('Shutdown complete.');
|
|
164
|
+
process.exit(0);
|
|
165
|
+
});
|
|
166
|
+
setTimeout(() => { logger.error('Forced exit.'); process.exit(1); }, 10000).unref();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
170
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
171
|
+
process.on('uncaughtException', (e) => { logger.error(`Uncaught: ${e.stack}`); shutdown('uncaughtException'); });
|
|
172
|
+
process.on('unhandledRejection', (r) => { logger.error(`UnhandledRejection: ${r}`); });
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fsP = require('fs').promises;
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const config = require('../config');
|
|
6
|
+
const logger = require('../lib/logger');
|
|
7
|
+
|
|
8
|
+
const RULE_TYPES = ['error_rate', 'error_count', 'keyword_match', 'log_volume'];
|
|
9
|
+
|
|
10
|
+
class AlertRulesService {
|
|
11
|
+
constructor (org) {
|
|
12
|
+
this._org = org || null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
_file (cfgRef, orgAttr) {
|
|
16
|
+
return this._org && this._org[orgAttr] ? this._org[orgAttr] : cfgRef;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async _load () {
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(
|
|
22
|
+
await fsP.readFile(
|
|
23
|
+
this._file(config.ALERTS_FILE, 'alertsFile'),
|
|
24
|
+
'utf8',
|
|
25
|
+
),
|
|
26
|
+
);
|
|
27
|
+
} catch (e) {
|
|
28
|
+
if (e.code === 'ENOENT') {
|
|
29
|
+
return { rules: [], history: [] };
|
|
30
|
+
}
|
|
31
|
+
throw e;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async _save (d) {
|
|
36
|
+
await fsP.writeFile(
|
|
37
|
+
this._file(config.ALERTS_FILE, 'alertsFile'),
|
|
38
|
+
JSON.stringify(d, null, 2),
|
|
39
|
+
'utf8',
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async listRules () {
|
|
44
|
+
return (await this._load()).rules || [];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async getHistory (n = 50) {
|
|
48
|
+
return ((await this._load()).history || []).slice(-n).reverse();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async upsertRule (rule) {
|
|
52
|
+
if (!rule.name) {
|
|
53
|
+
throw Object.assign(new Error('name required'), { status: 400 });
|
|
54
|
+
}
|
|
55
|
+
if (!RULE_TYPES.includes(rule.type)) {
|
|
56
|
+
throw Object.assign(
|
|
57
|
+
new Error(
|
|
58
|
+
`invalid type: ${rule.type}. Valid: ${RULE_TYPES.join(', ')}`,
|
|
59
|
+
),
|
|
60
|
+
{ status: 400 },
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
const d = await this._load();
|
|
64
|
+
const id = rule.id || crypto.randomUUID();
|
|
65
|
+
const idx = (d.rules || []).findIndex((r) => r.id === id);
|
|
66
|
+
const entry = {
|
|
67
|
+
id,
|
|
68
|
+
name: rule.name,
|
|
69
|
+
type: rule.type,
|
|
70
|
+
service: rule.service || '',
|
|
71
|
+
threshold: Number(rule.threshold) || 5,
|
|
72
|
+
windowMinutes: Number(rule.windowMinutes) || 10,
|
|
73
|
+
cooldownMinutes: Number(rule.cooldownMinutes) || 30,
|
|
74
|
+
keyword: rule.keyword || '',
|
|
75
|
+
enabled: rule.enabled !== false,
|
|
76
|
+
// Per-rule delivery overrides (fall back to global settings if empty)
|
|
77
|
+
slackUrl: rule.slackUrl || '',
|
|
78
|
+
discordUrl: rule.discordUrl || '',
|
|
79
|
+
emailTo: rule.emailTo || '',
|
|
80
|
+
pagerdutyKey: rule.pagerdutyKey || '',
|
|
81
|
+
lastFiredAt: rule.lastFiredAt || null,
|
|
82
|
+
createdAt: rule.createdAt || new Date().toISOString(),
|
|
83
|
+
};
|
|
84
|
+
if (idx >= 0) {
|
|
85
|
+
d.rules[idx] = entry;
|
|
86
|
+
} else {
|
|
87
|
+
d.rules = [...(d.rules || []), entry];
|
|
88
|
+
}
|
|
89
|
+
await this._save(d);
|
|
90
|
+
return entry;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async deleteRule (id) {
|
|
94
|
+
const d = await this._load();
|
|
95
|
+
d.rules = (d.rules || []).filter((r) => r.id !== id);
|
|
96
|
+
await this._save(d);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Evaluate incoming log batch ────────────────────────────────────────────
|
|
100
|
+
async evaluate (parsedLogs, settings) {
|
|
101
|
+
const rules = (await this.listRules()).filter((r) => r.enabled);
|
|
102
|
+
if (!rules.length) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const now = Date.now();
|
|
106
|
+
for (const rule of rules) {
|
|
107
|
+
if (
|
|
108
|
+
rule.lastFiredAt
|
|
109
|
+
&& now - new Date(rule.lastFiredAt).getTime()
|
|
110
|
+
< rule.cooldownMinutes * 60_000
|
|
111
|
+
) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const subset = rule.service
|
|
115
|
+
? parsedLogs.filter((l) => (l.appName || '').startsWith(rule.service))
|
|
116
|
+
: parsedLogs;
|
|
117
|
+
let triggered = false,
|
|
118
|
+
triggerMsg = '';
|
|
119
|
+
if (rule.type === 'error_count') {
|
|
120
|
+
const n = subset.filter(
|
|
121
|
+
(l) => (l.level || '').toLowerCase() === 'error',
|
|
122
|
+
).length;
|
|
123
|
+
if (n >= rule.threshold) {
|
|
124
|
+
triggered = true;
|
|
125
|
+
triggerMsg = `${n} errors in batch (threshold: ${rule.threshold})`;
|
|
126
|
+
}
|
|
127
|
+
} else if (rule.type === 'error_rate') {
|
|
128
|
+
const n = subset.filter(
|
|
129
|
+
(l) => (l.level || '').toLowerCase() === 'error',
|
|
130
|
+
).length;
|
|
131
|
+
const pct = subset.length ? (n / subset.length) * 100 : 0;
|
|
132
|
+
if (pct >= rule.threshold) {
|
|
133
|
+
triggered = true;
|
|
134
|
+
triggerMsg = `${pct.toFixed(1)}% error rate (threshold: ${rule.threshold}%)`;
|
|
135
|
+
}
|
|
136
|
+
} else if (rule.type === 'keyword_match' && rule.keyword) {
|
|
137
|
+
const kw = rule.keyword.toLowerCase();
|
|
138
|
+
const n = subset.filter((l) =>
|
|
139
|
+
JSON.stringify(l).toLowerCase().includes(kw),
|
|
140
|
+
).length;
|
|
141
|
+
if (n >= rule.threshold) {
|
|
142
|
+
triggered = true;
|
|
143
|
+
triggerMsg = `${n} occurrences of "${rule.keyword}"`;
|
|
144
|
+
}
|
|
145
|
+
} else if (rule.type === 'log_volume') {
|
|
146
|
+
if (subset.length >= rule.threshold) {
|
|
147
|
+
triggered = true;
|
|
148
|
+
triggerMsg = `${subset.length} logs in batch (threshold: ${rule.threshold})`;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (triggered) {
|
|
152
|
+
await this._fire(rule, triggerMsg, settings);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── Fire: save history + notify all channels ───────────────────────────────
|
|
158
|
+
async _fire (rule, message, settings) {
|
|
159
|
+
const ts = new Date().toISOString();
|
|
160
|
+
const d = await this._load();
|
|
161
|
+
d.history = [
|
|
162
|
+
...(d.history || []),
|
|
163
|
+
{
|
|
164
|
+
ts,
|
|
165
|
+
ruleId: rule.id,
|
|
166
|
+
ruleName: rule.name,
|
|
167
|
+
service: rule.service || 'all',
|
|
168
|
+
message,
|
|
169
|
+
},
|
|
170
|
+
].slice(-500);
|
|
171
|
+
const idx = (d.rules || []).findIndex((r) => r.id === rule.id);
|
|
172
|
+
if (idx >= 0) {
|
|
173
|
+
d.rules[idx].lastFiredAt = ts;
|
|
174
|
+
}
|
|
175
|
+
await this._save(d);
|
|
176
|
+
logger.info(`[Alert] "${rule.name}" fired: ${message}`);
|
|
177
|
+
|
|
178
|
+
// In-app notification bell
|
|
179
|
+
try {
|
|
180
|
+
const N = require('./NotificationService');
|
|
181
|
+
await N.create({
|
|
182
|
+
type: 'alert',
|
|
183
|
+
title: `Alert: ${rule.name}`,
|
|
184
|
+
body: message,
|
|
185
|
+
ruleId: rule.id,
|
|
186
|
+
service: rule.service || 'all',
|
|
187
|
+
});
|
|
188
|
+
} catch {}
|
|
189
|
+
|
|
190
|
+
const p = {
|
|
191
|
+
rule: rule.name,
|
|
192
|
+
trigger: message,
|
|
193
|
+
service: rule.service || 'all',
|
|
194
|
+
ts,
|
|
195
|
+
host: os.hostname(),
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
// All channels in parallel — one failure never blocks others
|
|
199
|
+
await Promise.allSettled([
|
|
200
|
+
this._slack(rule.slackUrl || settings?.webhookUrl || '', p),
|
|
201
|
+
this._discord(rule.discordUrl || settings?.discordUrl || '', p),
|
|
202
|
+
this._email(rule.emailTo || settings?.reportEmail || '', settings, p),
|
|
203
|
+
this._pagerduty(
|
|
204
|
+
rule.pagerdutyKey || settings?.pagerdutyKey || '',
|
|
205
|
+
settings,
|
|
206
|
+
p,
|
|
207
|
+
),
|
|
208
|
+
]);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async _slack (url, p) {
|
|
212
|
+
if (!url) {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
await fetch(url, {
|
|
216
|
+
method: 'POST',
|
|
217
|
+
headers: { 'Content-Type': 'application/json' },
|
|
218
|
+
body: JSON.stringify({
|
|
219
|
+
text: `:rotating_light: *LogBoard Alert* — ${p.rule}`,
|
|
220
|
+
attachments: [
|
|
221
|
+
{
|
|
222
|
+
color: 'danger',
|
|
223
|
+
fields: [
|
|
224
|
+
{ title: 'Rule', value: p.rule, short: true },
|
|
225
|
+
{ title: 'Trigger', value: p.trigger, short: true },
|
|
226
|
+
{ title: 'Service', value: p.service, short: true },
|
|
227
|
+
{ title: 'Host', value: p.host, short: true },
|
|
228
|
+
{ title: 'Time', value: p.ts, short: true },
|
|
229
|
+
],
|
|
230
|
+
},
|
|
231
|
+
],
|
|
232
|
+
}),
|
|
233
|
+
signal: AbortSignal.timeout(5000),
|
|
234
|
+
}).catch((e) => logger.warn(`[Alert] Slack: ${e.message}`));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async _discord (url, p) {
|
|
238
|
+
if (!url) {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
await fetch(url, {
|
|
242
|
+
method: 'POST',
|
|
243
|
+
headers: { 'Content-Type': 'application/json' },
|
|
244
|
+
body: JSON.stringify({
|
|
245
|
+
username: 'LogBoard',
|
|
246
|
+
embeds: [
|
|
247
|
+
{
|
|
248
|
+
title: `🚨 Alert: ${p.rule}`,
|
|
249
|
+
description: p.trigger,
|
|
250
|
+
color: 0xef4444,
|
|
251
|
+
fields: [
|
|
252
|
+
{ name: 'Service', value: p.service, inline: true },
|
|
253
|
+
{ name: 'Host', value: p.host, inline: true },
|
|
254
|
+
],
|
|
255
|
+
timestamp: p.ts,
|
|
256
|
+
footer: { text: 'LogBoard Alert System' },
|
|
257
|
+
},
|
|
258
|
+
],
|
|
259
|
+
}),
|
|
260
|
+
signal: AbortSignal.timeout(5000),
|
|
261
|
+
}).catch((e) => logger.warn(`[Alert] Discord: ${e.message}`));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async _email (to, settings, p) {
|
|
265
|
+
if (!to || !settings?.smtpHost || !settings?.emailEnabled) {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
try {
|
|
269
|
+
const nm = require('nodemailer');
|
|
270
|
+
const tr = nm.createTransport({
|
|
271
|
+
host: settings.smtpHost,
|
|
272
|
+
port: Number(settings.smtpPort || 587),
|
|
273
|
+
secure: Number(settings.smtpPort) === 465,
|
|
274
|
+
auth: { user: settings.smtpUser, pass: settings.smtpPass },
|
|
275
|
+
});
|
|
276
|
+
await tr.sendMail({
|
|
277
|
+
from: settings.smtpFrom || 'logboard@example.com',
|
|
278
|
+
to,
|
|
279
|
+
subject: `[LogBoard Alert] ${p.rule} — ${p.service}`,
|
|
280
|
+
text: `Rule: ${p.rule}\nTrigger: ${p.trigger}\nService: ${p.service}\nHost: ${p.host}\nTime: ${p.ts}`,
|
|
281
|
+
html: `<h2 style="color:#ef4444">🚨 LogBoard Alert</h2>
|
|
282
|
+
<table cellpadding="8" style="font-family:sans-serif;font-size:14px;border-collapse:collapse;">
|
|
283
|
+
<tr><td><b>Rule</b></td><td>${p.rule}</td></tr>
|
|
284
|
+
<tr><td><b>Trigger</b></td><td>${p.trigger}</td></tr>
|
|
285
|
+
<tr><td><b>Service</b></td><td>${p.service}</td></tr>
|
|
286
|
+
<tr><td><b>Host</b></td><td>${p.host}</td></tr>
|
|
287
|
+
<tr><td><b>Time</b></td><td>${p.ts}</td></tr>
|
|
288
|
+
</table>`,
|
|
289
|
+
});
|
|
290
|
+
} catch (e) {
|
|
291
|
+
logger.warn(`[Alert] Email: ${e.message}`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async _pagerduty (key, settings, p) {
|
|
296
|
+
if (!key || !settings?.pagerdutyEnabled) {
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
await fetch('https://events.pagerduty.com/v2/enqueue', {
|
|
300
|
+
method: 'POST',
|
|
301
|
+
headers: { 'Content-Type': 'application/json' },
|
|
302
|
+
body: JSON.stringify({
|
|
303
|
+
routing_key: key,
|
|
304
|
+
event_action: 'trigger',
|
|
305
|
+
dedup_key: `logboard-${p.rule}-${new Date().toISOString().slice(0, 10)}`,
|
|
306
|
+
payload: {
|
|
307
|
+
summary: `${p.rule}: ${p.trigger}`.slice(0, 1024),
|
|
308
|
+
source: p.host,
|
|
309
|
+
severity: 'error',
|
|
310
|
+
timestamp: p.ts,
|
|
311
|
+
custom_details: {
|
|
312
|
+
rule: p.rule,
|
|
313
|
+
service: p.service,
|
|
314
|
+
trigger: p.trigger,
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
}),
|
|
318
|
+
signal: AbortSignal.timeout(8000),
|
|
319
|
+
}).catch((e) => logger.warn(`[Alert] PagerDuty: ${e.message}`));
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
module.exports = new AlertRulesService();
|