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