@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/setup.js ADDED
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env node
2
+ /* eslint-disable no-console */
3
+ 'use strict';
4
+ /**
5
+ * LogBoard — First-run setup
6
+ * Creates data/ directory with default users.json, settings.json, and .env
7
+ * Run once: npm run setup
8
+ */
9
+ const bcrypt = require('bcryptjs');
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const crypto = require('crypto');
13
+
14
+ const DATA_DIR = path.join(__dirname, 'data');
15
+ const USERS_FILE = path.join(DATA_DIR, 'users.json');
16
+ const SETTINGS_FILE= path.join(DATA_DIR, 'settings.json');
17
+ const ROLES_FILE = path.join(DATA_DIR, 'role-config.json');
18
+ const API_KEYS_FILE= path.join(DATA_DIR, 'api-keys.json');
19
+ const AUDIT_FILE = path.join(DATA_DIR, 'audit.ndjson');
20
+ const ALERTS_FILE = path.join(DATA_DIR, 'alerts.json');
21
+ const ENV_FILE = path.join(__dirname, '.env');
22
+
23
+ async function main () {
24
+ console.log('\n ╔══════════════════════════════════╗');
25
+ console.log(' ║ LogBoard — First-Run Setup ║');
26
+ console.log(' ╚══════════════════════════════════╝\n');
27
+
28
+ // Ensure data directory exists
29
+ if (!fs.existsSync(DATA_DIR)) {
30
+ fs.mkdirSync(DATA_DIR, { recursive: true });
31
+ console.log('✅ Created data/');
32
+ }
33
+
34
+ // users.json
35
+ if (fs.existsSync(USERS_FILE)) {
36
+ console.log('⚠️ data/users.json already exists — skipping.');
37
+ } else {
38
+ const [adminHash, viewerHash] = await Promise.all([
39
+ bcrypt.hash('admin123', 12),
40
+ bcrypt.hash('viewer123', 12),
41
+ ]);
42
+ fs.writeFileSync(USERS_FILE, JSON.stringify({
43
+ admin: { password: adminHash, role: 'admin', createdAt: new Date().toISOString() },
44
+ viewer: { password: viewerHash, role: 'viewer', createdAt: new Date().toISOString() },
45
+ }, null, 2));
46
+ console.log('✅ data/users.json created');
47
+ console.log(' admin / admin123 (role: admin)');
48
+ console.log(' viewer / viewer123 (role: viewer)');
49
+ console.log(' ⚠️ Change these passwords on first login!\n');
50
+ }
51
+
52
+ // settings.json
53
+ if (!fs.existsSync(SETTINGS_FILE)) {
54
+ fs.writeFileSync(SETTINGS_FILE, JSON.stringify({
55
+ retentionDays: 7,
56
+ webhookUrl: '',
57
+ enableStream: true,
58
+ ipWhitelist: [],
59
+ }, null, 2));
60
+ console.log('✅ data/settings.json created');
61
+ }
62
+
63
+ // role-config.json (empty — built-in roles are in-memory)
64
+ if (!fs.existsSync(ROLES_FILE)) {
65
+ fs.writeFileSync(ROLES_FILE, JSON.stringify({}, null, 2));
66
+ console.log('✅ data/role-config.json created');
67
+ }
68
+
69
+ // api-keys.json
70
+ if (!fs.existsSync(API_KEYS_FILE)) {
71
+ fs.writeFileSync(API_KEYS_FILE, JSON.stringify([], null, 2));
72
+ console.log('✅ data/api-keys.json created');
73
+ }
74
+ // audit.ndjson
75
+ if (!fs.existsSync(AUDIT_FILE)) {
76
+ fs.writeFileSync(AUDIT_FILE, '', 'utf8');
77
+ console.log('✅ data/audit.ndjson created');
78
+ }
79
+ // alerts.json
80
+ if (!fs.existsSync(ALERTS_FILE)) {
81
+ fs.writeFileSync(ALERTS_FILE, JSON.stringify({ rules: [], history: [] }, null, 2));
82
+ console.log('✅ data/alerts.json created');
83
+ }
84
+
85
+ // .env
86
+ if (fs.existsSync(ENV_FILE)) {
87
+ console.log('⚠️ .env already exists — skipping.');
88
+ } else {
89
+ const jwtSecret = crypto.randomBytes(32).toString('hex');
90
+ const apiKey = `blq_${ crypto.randomBytes(32).toString('hex')}`;
91
+ fs.writeFileSync(ENV_FILE, `${[
92
+ '# LogBoard Configuration',
93
+ '# Generated by: npm run setup',
94
+ '',
95
+ 'NODE_ENV=development',
96
+ 'PORT=9900',
97
+ '',
98
+ '# Security — keep these secret!',
99
+ `JWT_SECRET=${ jwtSecret}`,
100
+ `API_KEY=${ apiKey } # Legacy single key — use API key management UI instead`,
101
+ '',
102
+ '# Storage',
103
+ 'DATA_DIR=./data',
104
+ 'LOG_BASE_DIR=./logs',
105
+ 'RETENTION_DAYS=7',
106
+ '',
107
+ '# Features',
108
+ 'ENABLE_STREAM=true',
109
+ 'JWT_EXPIRES_IN=24h',
110
+ '',
111
+ '# CORS (comma-separated)',
112
+ 'CORS_ORIGINS=http://localhost:9900',
113
+ '',
114
+ '# Optional webhook for error alerts',
115
+ 'WEBHOOK_URL=',
116
+ ].join('\n') }\n`);
117
+ console.log('✅ .env created with generated secrets');
118
+ console.log(` API_KEY=${ apiKey.slice(0, 20) }…\n`);
119
+ }
120
+
121
+ // logs directory
122
+ if (!fs.existsSync(path.join(__dirname, 'logs'))) {
123
+ fs.mkdirSync(path.join(__dirname, 'logs'), { recursive: true });
124
+ console.log('✅ logs/ directory created');
125
+ }
126
+
127
+ console.log('\n 🚀 Setup complete! Start LogBoard:');
128
+ console.log(' npm start\n');
129
+ console.log(' Open: http://localhost:9900\n');
130
+ }
131
+
132
+ main().catch((err) => { console.error('Setup error:', err.message); process.exit(1); });
package/views/404.ejs ADDED
@@ -0,0 +1,8 @@
1
+ <%- include('partials/head', { title: '404' }) %>
2
+ <div style="min-height:100vh;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:16px;text-align:center;padding:20px;">
3
+ <div style="font-size:80px;line-height:1;">🔍</div>
4
+ <div style="font-size:28px;font-weight:700;color:var(--text);">404 — Page Not Found</div>
5
+ <div style="color:var(--text2);max-width:340px;">The page you're looking for doesn't exist or has been moved.</div>
6
+ <a href="/dashboard" class="btn btn-primary" style="margin-top:8px;">Go to Dashboard</a>
7
+ </div>
8
+ </body></html>
@@ -0,0 +1,190 @@
1
+ <%- include('partials/head', { title: 'Alert Rules' }) %>
2
+ <div class="app-shell">
3
+ <%- include('partials/sidebar') %>
4
+ <div class="main-area">
5
+ <header class="top-header">
6
+ <div class="page-title">Alert Rules</div>
7
+ <div class="header-actions">
8
+ <button class="btn btn-primary btn-sm" onclick="openRuleModal()">
9
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
10
+ New Rule
11
+ </button>
12
+ </div>
13
+ </header>
14
+ <div class="page-content">
15
+ <div style="display:grid;grid-template-columns:1.3fr 1fr;gap:12px;">
16
+ <!-- Rules list -->
17
+ <div>
18
+ <div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.6px;color:var(--text3);margin-bottom:8px;">Rules (<%= rules.length %>)</div>
19
+ <% if(!rules.length){ %>
20
+ <div class="card"><div class="empty-state" style="padding:36px 0;">
21
+ <svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
22
+ <p style="margin-top:10px;">No alert rules yet</p>
23
+ </div></div>
24
+ <% } %>
25
+ <% rules.forEach(function(rule){ %>
26
+ <div class="card" style="margin-bottom:10px;border-left:3px solid <%=rule.enabled?'var(--accent)':'var(--border2)'%>;">
27
+ <div style="display:flex;align-items:flex-start;justify-content:space-between;gap:10px;">
28
+ <div style="flex:1;min-width:0;">
29
+ <div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
30
+ <span style="font-weight:600;font-size:13px;color:var(--text);"><%=rule.name%></span>
31
+ <span class="badge <%=rule.enabled?'badge-green':'badge-debug'%>"><%=rule.enabled?'active':'disabled'%></span>
32
+ <span class="badge badge-info" style="font-size:9px;"><%=rule.type%></span>
33
+ </div>
34
+ <div style="font-size:11px;color:var(--text2);line-height:1.7;">
35
+ <%if(rule.service){%><span style="font-family:'JetBrains Mono',monospace;color:var(--accent-l);"><%=rule.service%></span> · <%}%>
36
+ Threshold: <strong><%=rule.threshold%></strong> · Window: <%=rule.windowMinutes%>min · Cooldown: <%=rule.cooldownMinutes%>min
37
+ <%if(rule.keyword){%> · Keyword: "<strong><%=rule.keyword%></strong>"<%}%>
38
+ </div>
39
+ <%if(rule.lastFiredAt){%>
40
+ <div style="font-size:10px;color:var(--text3);margin-top:3px;">Last fired: <%=new Date(rule.lastFiredAt).toLocaleString()%></div>
41
+ <%}%>
42
+ </div>
43
+ <div style="display:flex;gap:6px;flex-shrink:0;">
44
+ <button class="btn btn-secondary btn-xs" onclick="editRule(<%- JSON.stringify(rule) %>)">Edit</button>
45
+ <button class="btn btn-danger btn-xs" onclick="deleteRule('<%=rule.id%>','<%=rule.name%>')">Delete</button>
46
+ </div>
47
+ </div>
48
+ </div>
49
+ <% }) %>
50
+ </div>
51
+
52
+ <!-- Trigger history -->
53
+ <div>
54
+ <div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.6px;color:var(--text3);margin-bottom:8px;">Trigger History</div>
55
+ <div class="card" style="max-height:600px;overflow-y:auto;">
56
+ <% if(!history.length){ %>
57
+ <div style="text-align:center;padding:30px;color:var(--text3);font-size:12px;">No alerts fired yet</div>
58
+ <% } %>
59
+ <% history.forEach(function(h){ %>
60
+ <div style="padding:8px 0;border-bottom:1px solid var(--border);display:flex;align-items:flex-start;gap:8px;">
61
+ <div class="dot dot-red" style="margin-top:5px;flex-shrink:0;"></div>
62
+ <div>
63
+ <div style="font-size:12px;font-weight:500;color:var(--text);"><%=h.ruleName%></div>
64
+ <div style="font-size:11px;color:var(--text2);margin:2px 0;"><%=h.message%></div>
65
+ <div style="font-size:10px;color:var(--text3);"><%=new Date(h.ts).toLocaleString()%></div>
66
+ </div>
67
+ </div>
68
+ <% }) %>
69
+ </div>
70
+ </div>
71
+ </div>
72
+ </div>
73
+ </div>
74
+ </div>
75
+
76
+ <!-- Rule modal -->
77
+ <div class="modal-overlay" id="rule-modal">
78
+ <div class="modal-box" style="max-width:560px;">
79
+ <div class="modal-title" id="modal-title">New Alert Rule</div>
80
+ <div class="modal-sub">Alert fires when the condition is met and sends to Slack webhook.</div>
81
+ <input type="hidden" id="rule-id"/>
82
+
83
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
84
+ <div class="form-group" style="grid-column:span 2"><label class="form-label">Rule Name</label><input type="text" id="rule-name" class="form-input" placeholder="e.g. High error rate on API"/></div>
85
+ <div class="form-group"><label class="form-label">Type</label>
86
+ <select id="rule-type" class="form-select" onchange="updateTypeHints()">
87
+ <option value="error_rate">Error Rate (%)</option>
88
+ <option value="error_count">Error Count</option>
89
+ <option value="log_volume">Log Volume</option>
90
+ <option value="keyword_match">Keyword Match</option>
91
+ </select>
92
+ </div>
93
+ <div class="form-group"><label class="form-label">Service (optional)</label>
94
+ <select id="rule-service" class="form-select">
95
+ <option value="">All services</option>
96
+ <% logServices.forEach(function(s){ %><option value="<%=s.appName%>"><%=s.appName%></option><% }) %>
97
+ </select>
98
+ </div>
99
+ <div class="form-group"><label class="form-label" id="thresh-label">Threshold (%)</label><input type="number" id="rule-threshold" class="form-input" value="5" min="1"/></div>
100
+ <div class="form-group"><label class="form-label">Window (min)</label><input type="number" id="rule-window" class="form-input" value="10" min="1"/></div>
101
+ <div class="form-group"><label class="form-label">Cooldown (min)</label><input type="number" id="rule-cooldown" class="form-input" value="30" min="1"/></div>
102
+ <div class="form-group" id="kw-group" style="display:none"><label class="form-label">Keyword</label><input type="text" id="rule-keyword" class="form-input" placeholder="e.g. OutOfMemory"/></div>
103
+ <div class="form-group" style="grid-column:span 2"><label class="form-label">Slack Webhook URL <span style="color:var(--text3);font-weight:400">(blank = use global)</span></label><input type="text" id="rule-slack" class="form-input" placeholder="https://hooks.slack.com/services/…"/></div>
104
+ <div class="form-group"><label class="form-label">Discord Webhook <span style="color:var(--text3);font-weight:400">(optional)</span></label><input type="text" id="rule-discord" class="form-input" placeholder="https://discord.com/api/webhooks/…"/></div>
105
+ <div class="form-group"><label class="form-label">Email To <span style="color:var(--text3);font-weight:400">(optional)</span></label><input type="email" id="rule-email" class="form-input" placeholder="oncall@company.com"/></div>
106
+ <div class="form-group" style="display:flex;align-items:center;gap:8px;">
107
+ <label class="toggle"><input type="checkbox" id="rule-enabled" checked/><span class="toggle-slider"></span></label>
108
+ <span style="font-size:12px;color:var(--text2)">Enabled</span>
109
+ </div>
110
+ </div>
111
+
112
+ <div id="rule-err" style="display:none;color:var(--red);font-size:12px;margin-bottom:10px;"></div>
113
+ <div style="display:flex;gap:8px;margin-top:4px;">
114
+ <button class="btn btn-primary" style="flex:1;" onclick="saveRule()">Save Rule</button>
115
+ <button class="btn btn-secondary" onclick="closeModal('rule-modal')">Cancel</button>
116
+ </div>
117
+ </div>
118
+ </div>
119
+
120
+ <script>
121
+ function openRuleModal(rule) {
122
+ const m = document.getElementById('rule-modal');
123
+ document.getElementById('modal-title').textContent = rule ? 'Edit Rule' : 'New Alert Rule';
124
+ document.getElementById('rule-id').value = rule?.id || '';
125
+ document.getElementById('rule-name').value = rule?.name || '';
126
+ document.getElementById('rule-type').value = rule?.type || 'error_rate';
127
+ document.getElementById('rule-service').value = rule?.service || '';
128
+ document.getElementById('rule-threshold').value = rule?.threshold ?? 5;
129
+ document.getElementById('rule-window').value = rule?.windowMinutes ?? 10;
130
+ document.getElementById('rule-cooldown').value = rule?.cooldownMinutes ?? 30;
131
+ document.getElementById('rule-keyword').value = rule?.keyword || '';
132
+ document.getElementById('rule-slack').value = rule?.slackUrl || '';
133
+ if(document.getElementById('rule-discord')) document.getElementById('rule-discord').value = rule?.discordUrl || '';
134
+ if(document.getElementById('rule-email')) document.getElementById('rule-email').value = rule?.emailTo || '';
135
+ document.getElementById('rule-enabled').checked = rule?.enabled !== false;
136
+ document.getElementById('rule-err').style.display = 'none';
137
+ updateTypeHints();
138
+ m.classList.add('open');
139
+ }
140
+ function editRule(rule) { openRuleModal(rule); }
141
+ function closeModal(id) { document.getElementById(id).classList.remove('open'); }
142
+ document.querySelectorAll('.modal-overlay').forEach(m => m.addEventListener('click', e => { if (e.target === m) m.classList.remove('open'); }));
143
+ document.addEventListener('keydown', e => { if (e.key === 'Escape') document.querySelectorAll('.modal-overlay.open').forEach(m => m.classList.remove('open')); });
144
+
145
+ function updateTypeHints() {
146
+ const t = document.getElementById('rule-type').value;
147
+ document.getElementById('thresh-label').textContent = t === 'error_rate' ? 'Threshold (%)' : t === 'keyword_match' ? 'Min matches' : 'Threshold (count)';
148
+ document.getElementById('kw-group').style.display = t === 'keyword_match' ? '' : 'none';
149
+ }
150
+
151
+ async function saveRule() {
152
+ const id = document.getElementById('rule-id').value;
153
+ const body = {
154
+ name: document.getElementById('rule-name').value.trim(),
155
+ type: document.getElementById('rule-type').value,
156
+ service: document.getElementById('rule-service').value,
157
+ threshold: Number(document.getElementById('rule-threshold').value),
158
+ windowMinutes: Number(document.getElementById('rule-window').value),
159
+ cooldownMinutes: Number(document.getElementById('rule-cooldown').value),
160
+ keyword: document.getElementById('rule-keyword').value.trim(),
161
+ slackUrl: document.getElementById('rule-slack').value.trim(),
162
+ discordUrl: document.getElementById('rule-discord')?.value?.trim()||'',
163
+ emailTo: document.getElementById('rule-email')?.value?.trim()||'',
164
+ enabled: document.getElementById('rule-enabled').checked,
165
+ id: id || undefined,
166
+ };
167
+ const errEl = document.getElementById('rule-err');
168
+ errEl.style.display = 'none';
169
+ if (!body.name) { errEl.textContent = 'Name is required'; errEl.style.display = ''; return; }
170
+ try {
171
+ const url = id ? '/api/alerts/rules/' + id : '/api/alerts/rules';
172
+ const r = await fetch(url, { method: id ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
173
+ const d = await r.json();
174
+ if (!r.ok) { errEl.textContent = d.error; errEl.style.display = ''; return; }
175
+ toast('Rule saved', 'success');
176
+ closeModal('rule-modal');
177
+ location.reload();
178
+ } catch(e) { errEl.textContent = 'Network error'; errEl.style.display = ''; }
179
+ }
180
+
181
+ async function deleteRule(id, name) {
182
+ if (!confirm('Delete rule "' + name + '"?')) return;
183
+ const r = await fetch('/api/alerts/rules/' + id, { method: 'DELETE' });
184
+ const d = await r.json();
185
+ if (!r.ok) { toast(d.error, 'error'); return; }
186
+ toast('Rule deleted', 'success');
187
+ location.reload();
188
+ }
189
+ </script>
190
+ </body></html>
@@ -0,0 +1,209 @@
1
+ <%- include('partials/head', { title: 'Analytics' }) %>
2
+ <div class="app-shell">
3
+ <%- include('partials/sidebar') %>
4
+ <div class="main-area">
5
+ <header class="top-header">
6
+ <div class="page-title">Analytics</div>
7
+ </header>
8
+
9
+ <div class="page-content">
10
+ <!-- Filters -->
11
+ <div class="card" style="margin-bottom:12px;">
12
+ <form method="GET" action="/analytics">
13
+ <div class="form-row">
14
+ <div class="form-group" style="min-width:180px;flex:1.5">
15
+ <label class="form-label">Service</label>
16
+ <select name="service" class="form-select" onchange="this.form.submit()">
17
+ <option value="">All Services</option>
18
+ <% services.forEach(function(s) { %>
19
+ <option value="<%= s.appName %>" <%= selected.service===s.appName?'selected':'' %>><%= s.appName %></option>
20
+ <% }) %>
21
+ </select>
22
+ </div>
23
+ <div class="form-group" style="min-width:150px;">
24
+ <label class="form-label">Date</label>
25
+ <input type="date" name="date" class="form-input" value="<%= selected.date %>" max="<%= today %>" onchange="this.form.submit()"/>
26
+ </div>
27
+ <div class="form-group" style="display:flex;align-items:flex-end;">
28
+ <button type="submit" class="btn btn-primary">Apply</button>
29
+ </div>
30
+ </div>
31
+ </form>
32
+ </div>
33
+
34
+ <!-- Level breakdown summary -->
35
+ <% if (breakdown) { %>
36
+ <div class="stats-grid" style="margin-bottom:12px;">
37
+ <div class="stat-card">
38
+ <div class="stat-icon blue"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg></div>
39
+ <div><div class="stat-value"><%= breakdown.total.toLocaleString() %></div><div class="stat-label">Total Logs</div><div class="stat-sub"><%= selected.date %></div></div>
40
+ </div>
41
+ <div class="stat-card">
42
+ <div class="stat-icon red"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></div>
43
+ <div><div class="stat-value"><%= breakdown.error.toLocaleString() %></div><div class="stat-label">Errors</div><div class="stat-sub"><%= breakdown.total ? ((breakdown.error/breakdown.total*100).toFixed(1))+'%' : '0%' %> of total</div></div>
44
+ </div>
45
+ <div class="stat-card">
46
+ <div class="stat-icon yellow"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></div>
47
+ <div><div class="stat-value"><%= breakdown.warn.toLocaleString() %></div><div class="stat-label">Warnings</div><div class="stat-sub"><%= breakdown.total ? ((breakdown.warn/breakdown.total*100).toFixed(1))+'%' : '0%' %> of total</div></div>
48
+ </div>
49
+ <div class="stat-card">
50
+ <div class="stat-icon green"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22,12 18,12 15,21 9,3 6,12 2,12"/></svg></div>
51
+ <div><div class="stat-value"><%= breakdown.info.toLocaleString() %></div><div class="stat-label">Info</div><div class="stat-sub"><%= breakdown.debug %> debug</div></div>
52
+ </div>
53
+ </div>
54
+ <% } %>
55
+
56
+ <div class="grid-2" style="margin-bottom:12px;">
57
+ <!-- Hourly chart -->
58
+ <div class="card span-2">
59
+ <div class="card-title">
60
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
61
+ Log Volume by Hour
62
+ <span style="margin-left:auto;font-size:11px;color:var(--text3);font-weight:400"><%= selected.date %> · <%= selected.service || 'all services' %></span>
63
+ </div>
64
+ <div class="chart-wrap-lg">
65
+ <canvas id="hourlyChart"></canvas>
66
+ </div>
67
+ </div>
68
+ </div>
69
+
70
+ <div class="grid-2" style="margin-bottom:12px;">
71
+ <!-- Level donut -->
72
+ <div class="card">
73
+ <div class="card-title">
74
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="4"/></svg>
75
+ Level Distribution
76
+ </div>
77
+ <div class="chart-wrap">
78
+ <canvas id="levelChart"></canvas>
79
+ </div>
80
+ </div>
81
+
82
+ <!-- 7-day trend -->
83
+ <div class="card">
84
+ <div class="card-title">
85
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22,12 18,12 15,21 9,3 6,12 2,12"/></svg>
86
+ 7-Day Trend
87
+ </div>
88
+ <div class="chart-wrap">
89
+ <canvas id="trendChart"></canvas>
90
+ </div>
91
+ </div>
92
+ </div>
93
+
94
+ <!-- Top services -->
95
+ <% if (topSvcs && topSvcs.length) { %>
96
+ <div class="card">
97
+ <div class="card-title">
98
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12,2 15.09,8.26 22,9.27 17,14.14 18.18,21.02 12,17.77 5.82,21.02 7,14.14 2,9.27 8.91,8.26"/></svg>
99
+ Top Services — <span style="font-weight:400;color:var(--text2)"><%= selected.date %></span>
100
+ </div>
101
+ <div class="table-wrap">
102
+ <table>
103
+ <thead>
104
+ <tr><th>Service</th><th>Total</th><th>Errors</th><th>Warnings</th><th>Info</th><th>Error Rate</th><th>Volume</th></tr>
105
+ </thead>
106
+ <tbody>
107
+ <% var maxTotal = topSvcs[0] ? topSvcs[0].total : 1; %>
108
+ <% topSvcs.forEach(function(svc) { %>
109
+ <tr>
110
+ <td><a href="/logs?service=<%= svc.appName %>&date=<%= selected.date %>" style="font-family:'JetBrains Mono',monospace;font-size:12px;font-weight:500"><%= svc.appName %></a></td>
111
+ <td><strong><%= svc.total.toLocaleString() %></strong></td>
112
+ <td><span class="badge badge-error"><%= svc.error %></span></td>
113
+ <td><span class="badge badge-warn"><%= svc.warn %></span></td>
114
+ <td><span class="badge badge-info"><%= svc.info %></span></td>
115
+ <td style="color:<%= svc.total && svc.error/svc.total > 0.1 ? 'var(--red)' : 'var(--text2)' %>">
116
+ <%= svc.total ? (svc.error/svc.total*100).toFixed(1) : '0.0' %>%
117
+ </td>
118
+ <td style="min-width:120px;">
119
+ <div class="progress-bar" style="margin-top:4px;">
120
+ <div class="progress-fill <%= svc.error/svc.total > 0.1 ? 'red' : '' %>" style="width:<%= Math.round(svc.total/maxTotal*100) %>%"></div>
121
+ </div>
122
+ </td>
123
+ </tr>
124
+ <% }) %>
125
+ </tbody>
126
+ </table>
127
+ </div>
128
+ </div>
129
+ <% } %>
130
+ </div>
131
+ </div>
132
+ </div>
133
+
134
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
135
+ <script>
136
+ const isDark = document.documentElement.dataset.theme !== 'light';
137
+ const grid = isDark ? 'rgba(255,255,255,.05)' : 'rgba(0,0,0,.06)';
138
+ const tickColor = isDark ? '#5a5a78' : '#9494b8';
139
+
140
+ const hourlyRaw = <%- JSON.stringify(hourly ? hourly.hours : []) %>;
141
+ const breakdownR = <%- JSON.stringify(breakdown || {error:0,warn:0,info:0,debug:0}) %>;
142
+ const trendRaw = <%- JSON.stringify(trend || []) %>;
143
+
144
+ // ── Hourly stacked bar ─────────────────────────────────────────────────────
145
+ if (hourlyRaw.length) {
146
+ new Chart(document.getElementById('hourlyChart'), {
147
+ type: 'bar',
148
+ data: {
149
+ labels: hourlyRaw.map(h => h.hour + ':00'),
150
+ datasets: [
151
+ { label: 'Error', data: hourlyRaw.map(h => h.error), backgroundColor: 'rgba(239,68,68,.75)', stack: 's' },
152
+ { label: 'Warn', data: hourlyRaw.map(h => h.warn), backgroundColor: 'rgba(245,158,11,.65)', stack: 's' },
153
+ { label: 'Info', data: hourlyRaw.map(h => h.info), backgroundColor: 'rgba(59,130,246,.6)', stack: 's' },
154
+ { label: 'Debug', data: hourlyRaw.map(h => h.debug), backgroundColor: 'rgba(107,114,128,.4)', stack: 's' },
155
+ ]
156
+ },
157
+ options: {
158
+ responsive: true, maintainAspectRatio: false,
159
+ plugins: { legend: { position: 'top', labels: { color: tickColor, font: { size: 11 }, boxWidth: 12 } } },
160
+ scales: {
161
+ x: { stacked: true, ticks: { color: tickColor, font: { size: 10 } }, grid: { color: grid } },
162
+ y: { stacked: true, ticks: { color: tickColor, font: { size: 10 } }, grid: { color: grid } }
163
+ }
164
+ }
165
+ });
166
+ }
167
+
168
+ // ── Level donut ────────────────────────────────────────────────────────────
169
+ new Chart(document.getElementById('levelChart'), {
170
+ type: 'doughnut',
171
+ data: {
172
+ labels: ['Error', 'Warn', 'Info', 'Debug'],
173
+ datasets: [{
174
+ data: [breakdownR.error, breakdownR.warn, breakdownR.info, breakdownR.debug],
175
+ backgroundColor: ['rgba(239,68,68,.85)','rgba(245,158,11,.85)','rgba(59,130,246,.85)','rgba(107,114,128,.75)'],
176
+ borderWidth: 2,
177
+ borderColor: isDark ? '#13131e' : '#fff',
178
+ }]
179
+ },
180
+ options: {
181
+ responsive: true, maintainAspectRatio: false,
182
+ plugins: { legend: { position: 'right', labels: { color: tickColor, font: { size: 11 }, boxWidth: 12 } } },
183
+ cutout: '65%'
184
+ }
185
+ });
186
+
187
+ // ── 7-day trend line ───────────────────────────────────────────────────────
188
+ if (trendRaw.length) {
189
+ new Chart(document.getElementById('trendChart'), {
190
+ type: 'line',
191
+ data: {
192
+ labels: trendRaw.map(d => d.date.slice(5)),
193
+ datasets: [
194
+ { label: 'Total', data: trendRaw.map(d => d.total), borderColor: 'rgba(99,102,241,.9)', backgroundColor: 'rgba(99,102,241,.1)', tension: .3, fill: true, pointRadius: 3 },
195
+ { label: 'Error', data: trendRaw.map(d => d.error), borderColor: 'rgba(239,68,68,.8)', backgroundColor: 'transparent', tension: .3, pointRadius: 3 },
196
+ ]
197
+ },
198
+ options: {
199
+ responsive: true, maintainAspectRatio: false,
200
+ plugins: { legend: { labels: { color: tickColor, font: { size: 11 }, boxWidth: 12 } } },
201
+ scales: {
202
+ x: { ticks: { color: tickColor, font: { size: 10 } }, grid: { color: grid } },
203
+ y: { ticks: { color: tickColor, font: { size: 10 } }, grid: { color: grid }, beginAtZero: true }
204
+ }
205
+ }
206
+ });
207
+ }
208
+ </script>
209
+ </body></html>