@logboard/cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/.env.example +37 -0
  2. package/README.md +200 -0
  3. package/bin/logboard +536 -0
  4. package/client/logger.js +309 -0
  5. package/config/index.js +142 -0
  6. package/config.js +2 -0
  7. package/controllers/AnalyticsController.js +46 -0
  8. package/controllers/ApiAnalyticsController.js +129 -0
  9. package/controllers/ApiKeyController.js +58 -0
  10. package/controllers/AuthController.js +131 -0
  11. package/controllers/HealthController.js +56 -0
  12. package/controllers/LogController.js +197 -0
  13. package/controllers/OrgController.js +152 -0
  14. package/controllers/RoleConfigController.js +20 -0
  15. package/controllers/SettingsController.js +39 -0
  16. package/controllers/StreamController.js +55 -0
  17. package/controllers/UiController.js +789 -0
  18. package/controllers/UserController.js +79 -0
  19. package/lib/batchWriter.js +57 -0
  20. package/lib/cleanup.js +67 -0
  21. package/lib/ejs.js +103 -0
  22. package/lib/emitter.js +5 -0
  23. package/lib/healthMonitor.js +245 -0
  24. package/lib/logger.js +21 -0
  25. package/lib/streams.js +32 -0
  26. package/lib/theme.js +77 -0
  27. package/lib/userStore.js +13 -0
  28. package/lib/utils.js +44 -0
  29. package/middleware/apiKey.js +82 -0
  30. package/middleware/auth.js +55 -0
  31. package/middleware/ipWhitelist.js +59 -0
  32. package/middleware/org.js +85 -0
  33. package/middleware/pageAccess.js +20 -0
  34. package/middleware/rateLimit.js +29 -0
  35. package/middleware/roles.js +11 -0
  36. package/package.json +77 -0
  37. package/routes/alerts.js +18 -0
  38. package/routes/analytics.js +26 -0
  39. package/routes/api-analytics.js +30 -0
  40. package/routes/api-keys.js +12 -0
  41. package/routes/archive.js +91 -0
  42. package/routes/audit.js +50 -0
  43. package/routes/auth.js +22 -0
  44. package/routes/bookmarks.js +13 -0
  45. package/routes/health.js +11 -0
  46. package/routes/logs.js +88 -0
  47. package/routes/metrics.js +66 -0
  48. package/routes/notifications.js +14 -0
  49. package/routes/orgs.js +98 -0
  50. package/routes/registration.js +202 -0
  51. package/routes/role-config.js +97 -0
  52. package/routes/saved-searches.js +12 -0
  53. package/routes/server.js +151 -0
  54. package/routes/settings.js +28 -0
  55. package/routes/status.js +21 -0
  56. package/routes/stream.js +11 -0
  57. package/routes/super.js +129 -0
  58. package/routes/ui.js +120 -0
  59. package/routes/users.js +13 -0
  60. package/server.js +172 -0
  61. package/services/AlertRulesService.js +323 -0
  62. package/services/AnalyticsService.js +665 -0
  63. package/services/ApiAnalyticsService.js +471 -0
  64. package/services/ApiKeyService.js +166 -0
  65. package/services/AuditService.js +249 -0
  66. package/services/AuthService.js +234 -0
  67. package/services/BookmarkService.js +49 -0
  68. package/services/GlobalSettingsService.js +44 -0
  69. package/services/LogService.js +1066 -0
  70. package/services/MetricsService.js +116 -0
  71. package/services/NotificationService.js +70 -0
  72. package/services/OrgService.js +217 -0
  73. package/services/ReportService.js +247 -0
  74. package/services/RoleConfigService.js +201 -0
  75. package/services/SavedSearchService.js +63 -0
  76. package/services/SettingsService.js +220 -0
  77. package/services/UserService.js +121 -0
  78. package/setup.js +132 -0
  79. package/views/404.ejs +8 -0
  80. package/views/alerts.ejs +190 -0
  81. package/views/analytics.ejs +209 -0
  82. package/views/api-analytics.ejs +660 -0
  83. package/views/api-keys.ejs +150 -0
  84. package/views/archive.ejs +123 -0
  85. package/views/audit.ejs +314 -0
  86. package/views/bookmarks.ejs +54 -0
  87. package/views/custom-dashboard.ejs +162 -0
  88. package/views/dashboard.ejs +186 -0
  89. package/views/diff.ejs +98 -0
  90. package/views/health.ejs +269 -0
  91. package/views/heatmap.ejs +126 -0
  92. package/views/insights.ejs +334 -0
  93. package/views/invite.ejs +74 -0
  94. package/views/live.ejs +299 -0
  95. package/views/login.ejs +64 -0
  96. package/views/logo.png +0 -0
  97. package/views/logs.ejs +754 -0
  98. package/views/notifications.ejs +58 -0
  99. package/views/partials/head.ejs +282 -0
  100. package/views/partials/sidebar.ejs +168 -0
  101. package/views/register.ejs +100 -0
  102. package/views/roles.ejs +279 -0
  103. package/views/saved-searches.ejs +51 -0
  104. package/views/service-map.ejs +142 -0
  105. package/views/settings.ejs +1159 -0
  106. package/views/sidebar.ejs +129 -0
  107. package/views/status.ejs +100 -0
  108. package/views/super-admin-admins.ejs +58 -0
  109. package/views/super-admin-analytics.ejs +49 -0
  110. package/views/super-admin-orgs.ejs +310 -0
  111. package/views/super-admin-profile.ejs +77 -0
  112. package/views/super-admin-settings.ejs +108 -0
  113. package/views/super-admin-system.ejs +46 -0
  114. package/views/users.ejs +153 -0
@@ -0,0 +1,116 @@
1
+ 'use strict';
2
+ const fsP = require('fs').promises;
3
+ const path = require('path');
4
+ const config = require('../config');
5
+
6
+ function getToday () {
7
+ return new Date().toISOString().slice(0, 10);
8
+ }
9
+
10
+ async function ensureDir (d) {
11
+ try {
12
+ await fsP.mkdir(d, { recursive: true });
13
+ } catch {}
14
+ }
15
+
16
+ class MetricsService {
17
+ constructor (org) {
18
+ this._org = org || null;
19
+ this._baseDir = org
20
+ ? path.join(org.dataDir, 'metrics')
21
+ : path.join(config.DATA_DIR, 'metrics');
22
+ }
23
+
24
+ _appDir (appName) {
25
+ return path.join(this._baseDir, appName);
26
+ }
27
+
28
+ _file (appName, date) {
29
+ return path.join(this._appDir(appName), `${date}.ndjson`);
30
+ }
31
+
32
+ // Store a metrics snapshot
33
+ async ingest (appName, payload) {
34
+ if (!appName || typeof appName !== 'string') { throw new Error('appName required'); }
35
+ const dir = this._appDir(appName);
36
+ await ensureDir(dir);
37
+ const line
38
+ = `${JSON.stringify({
39
+ ...payload,
40
+ ts: payload.ts || new Date().toISOString(),
41
+ appName,
42
+ }) }\n`;
43
+ await fsP.appendFile(this._file(appName, getToday()), line, 'utf8');
44
+ }
45
+
46
+ // Get time-series for a service over a range
47
+ async getTimeSeries (appName, rangeMinutes = 1440) {
48
+ const now = Date.now();
49
+ const cutoff = now - rangeMinutes * 60 * 1000;
50
+ const dates = this._datesForRange(rangeMinutes);
51
+ const rows = [];
52
+ for (const date of dates) {
53
+ try {
54
+ const content = await fsP.readFile(this._file(appName, date), 'utf8');
55
+ for (const line of content.split('\n').filter(Boolean)) {
56
+ try {
57
+ const obj = JSON.parse(line);
58
+ if (new Date(obj.ts).getTime() >= cutoff) { rows.push(obj); }
59
+ } catch {}
60
+ }
61
+ } catch {}
62
+ }
63
+ return rows.sort((a, b) => new Date(a.ts) - new Date(b.ts));
64
+ }
65
+
66
+ // Get latest snapshot per service (for status cards)
67
+ async getLatestAll (allowedApps = []) {
68
+ const result = [];
69
+ try {
70
+ const dirs = await fsP.readdir(this._baseDir);
71
+ const apps = allowedApps.length
72
+ ? dirs.filter((d) => allowedApps.includes(d))
73
+ : dirs;
74
+ for (const appName of apps) {
75
+ try {
76
+ const latest = await this._getLatest(appName);
77
+ if (latest) { result.push({ appName, ...latest }); }
78
+ } catch {}
79
+ }
80
+ } catch {}
81
+ return result;
82
+ }
83
+
84
+ async _getLatest (appName) {
85
+ const dates = this._datesForRange(2880); // last 2 days
86
+ for (const date of dates.reverse()) {
87
+ try {
88
+ const content = await fsP.readFile(this._file(appName, date), 'utf8');
89
+ const lines = content.split('\n').filter(Boolean);
90
+ if (lines.length) { return JSON.parse(lines[lines.length - 1]); }
91
+ } catch {}
92
+ }
93
+ return null;
94
+ }
95
+
96
+ // List services that have metrics
97
+ async listServices () {
98
+ try {
99
+ return await fsP.readdir(this._baseDir);
100
+ } catch {
101
+ return [];
102
+ }
103
+ }
104
+
105
+ _datesForRange (rangeMinutes) {
106
+ const days = Math.ceil(rangeMinutes / 1440) + 1;
107
+ const dates = [];
108
+ for (let i = days - 1; i >= 0; i--) {
109
+ const d = new Date(Date.now() - i * 86400000);
110
+ dates.push(d.toISOString().slice(0, 10));
111
+ }
112
+ return dates;
113
+ }
114
+ }
115
+
116
+ module.exports = MetricsService;
@@ -0,0 +1,70 @@
1
+ 'use strict';
2
+ const fsP = require('fs').promises;
3
+ const path = require('path');
4
+ const crypto = require('crypto');
5
+ const config = require('../config');
6
+
7
+ const FILE = path.join(config.DATA_DIR, 'notifications.json');
8
+
9
+ class NotificationService {
10
+ async _load () {
11
+ try {
12
+ return JSON.parse(await fsP.readFile(FILE, 'utf8'));
13
+ } catch {
14
+ return [];
15
+ }
16
+ }
17
+
18
+ async _save (arr) {
19
+ await fsP.writeFile(FILE, JSON.stringify(arr.slice(-500), null, 2), 'utf8');
20
+ }
21
+
22
+ /** Create a new in-app notification */
23
+ async create (data) {
24
+ const arr = await this._load();
25
+ const notif = {
26
+ id: crypto.randomUUID(),
27
+ ts: new Date().toISOString(),
28
+ type: data.type || 'info', // alert | report | system | info
29
+ title: data.title || '',
30
+ body: data.body || '',
31
+ ruleId: data.ruleId || null,
32
+ service: data.service || null,
33
+ read: false,
34
+ };
35
+ arr.unshift(notif);
36
+ await this._save(arr);
37
+ return notif;
38
+ }
39
+
40
+ /** Get notifications (all or unread) */
41
+ async getAll (limit = 100, unreadOnly = false) {
42
+ const arr = await this._load();
43
+ return (unreadOnly ? arr.filter((n) => !n.read) : arr).slice(0, limit);
44
+ }
45
+
46
+ /** Count unread */
47
+ async countUnread () {
48
+ const arr = await this._load();
49
+ return arr.filter((n) => !n.read).length;
50
+ }
51
+
52
+ /** Mark one or all as read */
53
+ async markRead (id = null) {
54
+ const arr = await this._load();
55
+ arr.forEach((n) => {
56
+ if (!id || n.id === id) {
57
+ n.read = true;
58
+ }
59
+ });
60
+ await this._save(arr);
61
+ }
62
+
63
+ /** Delete a notification */
64
+ async delete (id) {
65
+ const arr = (await this._load()).filter((n) => n.id !== id);
66
+ await this._save(arr);
67
+ }
68
+ }
69
+
70
+ module.exports = new NotificationService();
@@ -0,0 +1,217 @@
1
+ 'use strict';
2
+ const fsP = require('fs').promises;
3
+ const path = require('path');
4
+ const crypto = require('crypto');
5
+ const config = require('../config');
6
+
7
+ const ORGS_FILE = () => path.join(config.DATA_DIR, 'orgs.json');
8
+ async function ensureDir(d) { try { await fsP.mkdir(d, { recursive: true }); } catch {} }
9
+
10
+ function slugify(name) {
11
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32);
12
+ }
13
+
14
+ class OrgService {
15
+
16
+ orgDataDir(slug) { return path.join(config.DATA_DIR, 'orgs', slug); }
17
+ orgLogsDir(slug) { return path.join(config.LOG_BASE_DIR, slug); }
18
+
19
+ // Returns all file paths for an org — passed to every service as `req.org`
20
+ orgPaths(slug) {
21
+ const d = this.orgDataDir(slug);
22
+ return {
23
+ slug,
24
+ name: null, // filled by getOrg()
25
+ dataDir: d,
26
+ logsDir: this.orgLogsDir(slug),
27
+ usersFile: path.join(d, 'users.json'),
28
+ settingsFile: path.join(d, 'settings.json'),
29
+ rolesFile: path.join(d, 'role-config.json'),
30
+ apiKeysFile: path.join(d, 'api-keys.json'),
31
+ auditFile: path.join(d, 'audit.ndjson'),
32
+ alertsFile: path.join(d, 'alerts.json'),
33
+ bookmarksFile: path.join(d, 'bookmarks.json'),
34
+ savedSearchesFile: path.join(d, 'saved-searches.json'),
35
+ notificationsFile: path.join(d, 'notifications.json'),
36
+ invitesFile: path.join(d, 'invites.json'),
37
+ };
38
+ }
39
+
40
+ // ── Registry ──────────────────────────────────────────────────────────────
41
+ async getOrgs() {
42
+ try { return JSON.parse(await fsP.readFile(ORGS_FILE(), 'utf8')); }
43
+ catch (e) { if (e.code === 'ENOENT') return {}; throw e; }
44
+ }
45
+
46
+ async saveOrgs(orgs) {
47
+ await ensureDir(path.dirname(ORGS_FILE()));
48
+ await fsP.writeFile(ORGS_FILE(), JSON.stringify(orgs, null, 2), 'utf8');
49
+ }
50
+
51
+ async getOrg(slug) {
52
+ if (!slug || typeof slug !== 'string') return null;
53
+ const orgs = await this.getOrgs();
54
+ if (!orgs[slug]) return null;
55
+ const p = this.orgPaths(slug);
56
+ p.name = orgs[slug].name;
57
+ return { ...orgs[slug], ...p };
58
+ }
59
+
60
+ // ── Create ────────────────────────────────────────────────────────────────
61
+ async create({ name, ownerUsername, ownerPassword, plan = 'free' }) {
62
+ if (!name || typeof name !== 'string') throw Object.assign(new Error('Org name required'), { status: 400 });
63
+ const slug = slugify(name);
64
+ if (!slug) throw Object.assign(new Error('Invalid org name'), { status: 400 });
65
+ const orgs = await this.getOrgs();
66
+ if (orgs[slug]) throw Object.assign(new Error(`Org "${slug}" already exists`), { status: 409 });
67
+
68
+ const p = this.orgPaths(slug);
69
+ await ensureDir(p.dataDir);
70
+ await ensureDir(p.logsDir);
71
+
72
+ await fsP.writeFile(p.settingsFile, JSON.stringify({
73
+ appName: name, appLogoUrl: '/public/logo.png', faviconUrl: '',
74
+ themeId: 'dark-indigo', themeMode: 'dark', accentR: 99, accentG: 102, accentB: 241,
75
+ retentionDays: 7, enableStream: true, alertsEnabled: true,
76
+ alertRamPct: 85, alertCpuPct: 90, alertDiskPct: 90,
77
+ }, null, 2), 'utf8');
78
+
79
+ await fsP.writeFile(p.rolesFile, JSON.stringify({
80
+ admin: { label: 'Administrator', color: '#6366f1', isBuiltIn: true, pages: [], cards: [] },
81
+ viewer: { label: 'Viewer', color: '#22c55e', isBuiltIn: true, pages: ['logs','live'], cards: [] },
82
+ }, null, 2), 'utf8');
83
+
84
+ if (ownerUsername && ownerPassword) {
85
+ const bcrypt = require('bcryptjs');
86
+ const hash = await bcrypt.hash(ownerPassword, 10);
87
+ await fsP.writeFile(p.usersFile, JSON.stringify({
88
+ [ownerUsername]: { username: ownerUsername, password: hash, role: 'admin', createdAt: new Date().toISOString() }
89
+ }, null, 2), 'utf8');
90
+ } else {
91
+ await fsP.writeFile(p.usersFile, '{}', 'utf8');
92
+ }
93
+
94
+ const org = { slug, name, plan, createdAt: new Date().toISOString(), ownerUsername: ownerUsername || null };
95
+ orgs[slug] = org;
96
+ await this.saveOrgs(orgs);
97
+ return { ...org, ...p };
98
+ }
99
+
100
+ // ── Delete ────────────────────────────────────────────────────────────────
101
+ async delete(slug) {
102
+ const orgs = await this.getOrgs();
103
+ if (!orgs[slug]) throw Object.assign(new Error('Org not found'), { status: 404 });
104
+ delete orgs[slug];
105
+ await this.saveOrgs(orgs);
106
+ // Data dirs kept on disk — super-admin must manually remove
107
+ }
108
+
109
+ // ── Invites ───────────────────────────────────────────────────────────────
110
+ async createInvite(orgSlug, email, role = 'viewer', invitedBy = 'admin') {
111
+ const org = await this.getOrg(orgSlug);
112
+ if (!org) throw Object.assign(new Error('Org not found'), { status: 404 });
113
+ const token = crypto.randomBytes(24).toString('hex');
114
+ const invites = await this._readInvites(org.invitesFile);
115
+ invites[token] = {
116
+ email, role, invitedBy, orgSlug,
117
+ createdAt: new Date().toISOString(),
118
+ expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
119
+ };
120
+ await fsP.writeFile(org.invitesFile, JSON.stringify(invites, null, 2), 'utf8');
121
+ return { token, email, role, orgSlug };
122
+ }
123
+
124
+ async getInvite(token) {
125
+ // Search all orgs for this token
126
+ const orgs = await this.getOrgs();
127
+ for (const slug of Object.keys(orgs)) {
128
+ const p = this.orgPaths(slug);
129
+ const invites = await this._readInvites(p.invitesFile);
130
+ const inv = invites[token];
131
+ if (inv && new Date(inv.expiresAt) > new Date()) return { ...inv, token };
132
+ }
133
+ return null;
134
+ }
135
+
136
+ async acceptInvite(token, username, password) {
137
+ const inv = await this.getInvite(token);
138
+ if (!inv) throw Object.assign(new Error('Invite not found or expired'), { status: 404 });
139
+
140
+ const org = await this.getOrg(inv.orgSlug);
141
+ const bcrypt = require('bcryptjs');
142
+ let users = {};
143
+ try { users = JSON.parse(await fsP.readFile(org.usersFile, 'utf8')); } catch {}
144
+ if (users[username]) throw Object.assign(new Error('Username taken in this org'), { status: 409 });
145
+
146
+ users[username] = {
147
+ username, role: inv.role, email: inv.email,
148
+ password: await bcrypt.hash(password, 10),
149
+ createdAt: new Date().toISOString(),
150
+ };
151
+ await fsP.writeFile(org.usersFile, JSON.stringify(users, null, 2), 'utf8');
152
+
153
+ // Consume invite
154
+ const invites = await this._readInvites(org.invitesFile);
155
+ delete invites[token];
156
+ await fsP.writeFile(org.invitesFile, JSON.stringify(invites, null, 2), 'utf8');
157
+
158
+ return { username, role: inv.role, orgSlug: inv.orgSlug };
159
+ }
160
+
161
+ async _readInvites(file) {
162
+ try { return JSON.parse(await fsP.readFile(file, 'utf8')); } catch { return {}; }
163
+ }
164
+
165
+ // ── Migration: flat data → data/orgs/default/ ────────────────────────────
166
+ async migrateToDefaultOrg() {
167
+ const orgs = await this.getOrgs();
168
+ if (orgs['default']) return { skipped: true, message: 'Already migrated' };
169
+
170
+ const p = this.orgPaths('default');
171
+ await ensureDir(p.dataDir);
172
+ await ensureDir(p.logsDir);
173
+
174
+ const fileMoves = [
175
+ ['users.json', p.usersFile],
176
+ ['settings.json', p.settingsFile],
177
+ ['role-config.json', p.rolesFile],
178
+ ['api-keys.json', p.apiKeysFile],
179
+ ['audit.ndjson', p.auditFile],
180
+ ['alerts.json', p.alertsFile],
181
+ ['bookmarks.json', p.bookmarksFile],
182
+ ['saved-searches.json', p.savedSearchesFile],
183
+ ['notifications.json', p.notificationsFile],
184
+ ];
185
+
186
+ const moved = [], skipped = [];
187
+ for (const [filename, dst] of fileMoves) {
188
+ const src = path.join(config.DATA_DIR, filename);
189
+ try {
190
+ const content = await fsP.readFile(src, 'utf8');
191
+ await fsP.writeFile(dst, content, 'utf8');
192
+ await fsP.rename(src, src + '.bak');
193
+ moved.push(filename);
194
+ } catch (e) { if (e.code !== 'ENOENT') skipped.push(filename + ':' + e.message); }
195
+ }
196
+
197
+ // Move existing service log dirs into logs/default/
198
+ try {
199
+ const logDirs = await fsP.readdir(config.LOG_BASE_DIR);
200
+ for (const d of logDirs) {
201
+ if (['default', '_archive'].includes(d)) continue;
202
+ const src = path.join(config.LOG_BASE_DIR, d);
203
+ const dst = path.join(p.logsDir, d);
204
+ try {
205
+ const st = await fsP.stat(src);
206
+ if (st.isDirectory()) { await ensureDir(p.logsDir); await fsP.rename(src, dst); }
207
+ } catch {}
208
+ }
209
+ } catch {}
210
+
211
+ orgs['default'] = { slug: 'default', name: 'Default', plan: 'free', createdAt: new Date().toISOString(), ownerUsername: 'admin', migratedAt: new Date().toISOString() };
212
+ await this.saveOrgs(orgs);
213
+ return { migrated: true, moved, skipped };
214
+ }
215
+ }
216
+
217
+ module.exports = new OrgService();
@@ -0,0 +1,247 @@
1
+ 'use strict';
2
+ const os = require('os');
3
+ const logger = require('../lib/logger');
4
+
5
+ class ReportService {
6
+ /**
7
+ * Build the report payload (used for both email and in-app)
8
+ */
9
+ async buildReport (settings) {
10
+ const AnalyticsService = require('./AnalyticsService');
11
+ const svc = new AnalyticsService();
12
+ const today = new Date().toISOString().slice(0, 10);
13
+ // Yesterday
14
+ const yd = new Date();
15
+ yd.setDate(yd.getDate() - 1);
16
+ const yesterday = yd.toISOString().slice(0, 10);
17
+
18
+ let overview = null,
19
+ trend = null,
20
+ topSvcs = [],
21
+ recentErrors = [];
22
+ try {
23
+ overview = await svc.getOverview();
24
+ } catch {}
25
+ try {
26
+ trend = await svc.getDailyTrend(null, 7);
27
+ } catch {}
28
+ try {
29
+ topSvcs = await svc.getTopServices(yesterday, 10);
30
+ } catch {}
31
+ try {
32
+ recentErrors = await svc.getRecentErrors(5);
33
+ } catch {}
34
+
35
+ return {
36
+ today,
37
+ yesterday,
38
+ overview,
39
+ trend,
40
+ topSvcs,
41
+ recentErrors,
42
+ host: os.hostname(),
43
+ generatedAt: new Date().toISOString(),
44
+ };
45
+ }
46
+
47
+ /** Format report as plain text */
48
+ _formatText (report) {
49
+ const lines = [
50
+ `LogBoard Daily Report — ${report.yesterday}`,
51
+ '='.repeat(50),
52
+ '',
53
+ ];
54
+ if (report.overview) {
55
+ lines.push(
56
+ `Logs Today: ${(report.overview.logsToday || 0).toLocaleString()}`,
57
+ );
58
+ lines.push(
59
+ `Errors Today: ${(report.overview.errorsToday || 0).toLocaleString()}`,
60
+ );
61
+ lines.push(`Error Rate: ${report.overview.errorRate || 0}%`);
62
+ lines.push(`Services: ${report.overview.services || 0}`);
63
+ lines.push('');
64
+ }
65
+ if (report.topSvcs?.length) {
66
+ lines.push('Top Services:');
67
+ report.topSvcs
68
+ .slice(0, 5)
69
+ .forEach((s) => lines.push(` ${s.appName}: ${s.count} logs`));
70
+ lines.push('');
71
+ }
72
+ if (report.recentErrors?.length) {
73
+ lines.push('Recent Errors:');
74
+ report.recentErrors
75
+ .slice(0, 5)
76
+ .forEach((e) =>
77
+ lines.push(` [${e.appName}] ${(e.message || '').slice(0, 80)}`),
78
+ );
79
+ lines.push('');
80
+ }
81
+ lines.push(`Generated: ${report.generatedAt}`);
82
+ lines.push(`Host: ${report.host}`);
83
+ return lines.join('\n');
84
+ }
85
+
86
+ /** Format report as HTML */
87
+ _formatHTML (report) {
88
+ const errRate = report.overview?.errorRate || 0;
89
+ const rateColor
90
+ = errRate > 10 ? '#ef4444' : errRate > 3 ? '#f59e0b' : '#22c55e';
91
+ return `<!DOCTYPE html><html><head><meta charset="UTF-8"/><style>
92
+ body{font-family:'Inter',sans-serif;background:#0d0d14;color:#e2e2f2;padding:30px;margin:0;}
93
+ .card{background:#13131e;border:1px solid #2a2a45;border-radius:12px;padding:20px;margin-bottom:16px;}
94
+ .title{font-size:22px;font-weight:700;margin-bottom:4px;}
95
+ .sub{font-size:13px;color:#5a5a78;margin-bottom:20px;}
96
+ .stat{display:inline-block;margin-right:32px;margin-bottom:12px;}
97
+ .stat-val{font-size:28px;font-weight:700;}
98
+ .stat-lbl{font-size:11px;color:#5a5a78;text-transform:uppercase;letter-spacing:.5px;}
99
+ table{width:100%;border-collapse:collapse;font-size:13px;}
100
+ td,th{padding:8px 10px;text-align:left;border-bottom:1px solid #2a2a45;}
101
+ th{font-size:10px;text-transform:uppercase;letter-spacing:.5px;color:#5a5a78;font-weight:600;}
102
+ .badge{display:inline-block;padding:2px 8px;border-radius:4px;font-size:10px;font-weight:600;}
103
+ </style></head><body>
104
+ <div class="title">📊 LogBoard Daily Report</div>
105
+ <div class="sub">${report.yesterday} • Generated ${new Date(report.generatedAt).toLocaleString()} • ${report.host}</div>
106
+ ${
107
+ report.overview
108
+ ? `<div class="card">
109
+ <div class="stat">
110
+ <div class="stat-val">${(report.overview.logsToday || 0).toLocaleString()}</div>
111
+ <div class="stat-lbl">Logs Today</div>
112
+ </div>
113
+ <div class="stat">
114
+ <div class="stat-val" style="color:#ef4444">${(report.overview.errorsToday || 0).toLocaleString()}</div>
115
+ <div class="stat-lbl">Errors Today</div>
116
+ </div>
117
+ <div class="stat">
118
+ <div class="stat-val" style="color:${rateColor}">${errRate}%</div><div class="stat-lbl">Error Rate</div>
119
+ </div>
120
+ <div class="stat">
121
+ <div class="stat-val">${report.overview.services || 0}</div><div class="stat-lbl">Services</div>
122
+ </div>
123
+ </div>`
124
+ : ''
125
+ }
126
+ ${
127
+ report.topSvcs?.length
128
+ ? `<div class="card"><table>
129
+ <thead><tr><th>Service</th><th>Logs</th></tr></thead>
130
+ <tbody>${report.topSvcs
131
+ .slice(0, 8)
132
+ .map((s) => `<tr><td>${s.appName}</td><td>${s.count}</td></tr>`)
133
+ .join('')}</tbody>
134
+ </table></div>`
135
+ : ''
136
+ }
137
+ ${
138
+ report.recentErrors?.length
139
+ ? `<div class="card"><table>
140
+ <thead><tr><th>Service</th><th>Recent Error</th></tr></thead>
141
+ <tbody>${report.recentErrors
142
+ .slice(0, 5)
143
+ .map(
144
+ (e) =>
145
+ `<tr>
146
+ <td style="color:#818cf8">${e.appName || ''}</td>
147
+ <td style="font-family:monospace;font-size:11px">${(e.message || '').slice(0, 120)}</td>
148
+ </tr>`,
149
+ )
150
+ .join('')}</tbody>
151
+ </table></div>`
152
+ : ''
153
+ }
154
+ </body></html>`;
155
+ }
156
+
157
+ /** Send report email */
158
+ async sendEmail (settings) {
159
+ if (
160
+ !settings?.reportEmail
161
+ || !settings?.smtpHost
162
+ || !settings?.emailEnabled
163
+ ) {
164
+ return false;
165
+ }
166
+ try {
167
+ const nodemailer = require('nodemailer');
168
+ const report = await this.buildReport(settings);
169
+ const transport = nodemailer.createTransport({
170
+ host: settings.smtpHost,
171
+ port: Number(settings.smtpPort || 587),
172
+ secure: Number(settings.smtpPort) === 465,
173
+ auth: { user: settings.smtpUser, pass: settings.smtpPass },
174
+ });
175
+ await transport.sendMail({
176
+ from: settings.smtpFrom || 'logboard@example.com',
177
+ to: settings.reportEmail,
178
+ subject: `[LogBoard] Daily Report — ${report.yesterday}`,
179
+ text: this._formatText(report),
180
+ html: this._formatHTML(report),
181
+ });
182
+ logger.info(`[Report] Daily report sent to ${settings.reportEmail}`);
183
+ // Record last sent time
184
+ try {
185
+ const SettingsService = require('./SettingsService');
186
+ await new SettingsService().save({
187
+ lastReportAt: new Date().toISOString(),
188
+ });
189
+ } catch {}
190
+
191
+ // Also create in-app notification
192
+ const N = require('./NotificationService');
193
+ await N.create({
194
+ type: 'report',
195
+ title: `Daily Report — ${report.yesterday}`,
196
+ body: `${report.overview?.logsToday || 0} logs, ${report.overview?.errorsToday || 0} errors`,
197
+ });
198
+ return true;
199
+ } catch (e) {
200
+ logger.warn(`[Report] Email failed: ${e.message}`);
201
+ return false;
202
+ }
203
+ }
204
+
205
+ /** Start the cron scheduler */
206
+ startScheduler (getSettings) {
207
+ try {
208
+ const cron = require('node-cron');
209
+ // Daily at 08:00 server time
210
+ cron.schedule('0 8 * * *', async () => {
211
+ try {
212
+ const settings
213
+ = typeof getSettings === 'function'
214
+ ? await getSettings()
215
+ : getSettings;
216
+ if (settings?.reportEnabled) {
217
+ await this.sendEmail(settings);
218
+ }
219
+ } catch (e) {
220
+ logger.warn(`[Report] Scheduler error: ${e.message}`);
221
+ }
222
+ });
223
+ // Weekly on Monday 08:00
224
+ cron.schedule('0 8 * * 1', async () => {
225
+ try {
226
+ const settings
227
+ = typeof getSettings === 'function'
228
+ ? await getSettings()
229
+ : getSettings;
230
+ if (
231
+ settings?.reportEnabled
232
+ && settings?.reportSchedule === 'weekly'
233
+ ) {
234
+ await this.sendEmail(settings);
235
+ }
236
+ } catch (e) {
237
+ logger.warn(`[Report] Weekly scheduler error: ${e.message}`);
238
+ }
239
+ });
240
+ logger.info('[Report] Scheduler started (daily @ 08:00)');
241
+ } catch (e) {
242
+ logger.warn(`[Report] node-cron not available: ${e.message}`);
243
+ }
244
+ }
245
+ }
246
+
247
+ module.exports = new ReportService();