@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
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();