@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,54 @@
1
+ <%- include('partials/head', { title: 'Bookmarks' }) %>
2
+ <div class="app-shell">
3
+ <%- include('partials/sidebar') %>
4
+ <div class="main-area">
5
+ <header class="top-header">
6
+ <div class="page-title">Bookmarks</div>
7
+ </header>
8
+ <div class="page-content">
9
+ <div class="card">
10
+ <% if (!bookmarks || !bookmarks.length) { %>
11
+ <div class="empty-state" style="padding:50px 0;">
12
+ <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>
13
+ <p>No bookmarks yet</p>
14
+ <p style="font-size:11px;margin-top:4px;color:var(--text3)">Click the bookmark icon on any log line to save it here</p>
15
+ </div>
16
+ <% } else { %>
17
+ <% bookmarks.forEach(function(b){ %>
18
+ <div class="card" style="margin-bottom:10px;border-left:3px solid var(--accent);" id="bm-<%=b.id%>">
19
+ <div style="display:flex;align-items:flex-start;gap:10px;">
20
+ <div style="flex:1;min-width:0;">
21
+ <div style="font-family:'JetBrains Mono',monospace;font-size:11.5px;color:var(--text);margin-bottom:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"><%=b.line%></div>
22
+ <div style="font-size:11px;color:var(--text3);display:flex;gap:12px;flex-wrap:wrap;margin-bottom:8px;">
23
+ <span><strong><%=b.service%></strong></span>
24
+ <span><%=b.date%></span>
25
+ <span>Line <%=b.lineIndex%></span>
26
+ <span><%=new Date(b.createdAt).toLocaleString()%></span>
27
+ </div>
28
+ <div style="display:flex;align-items:center;gap:8px;">
29
+ <input type="text" class="form-input" style="flex:1;padding:5px 10px;font-size:12px;" placeholder="Add investigation note…" value="<%=b.note||''%>" id="note-<%=b.id%>" onblur="saveNote('<%=b.id%>',this.value)"/>
30
+ </div>
31
+ </div>
32
+ <div style="display:flex;gap:6px;flex-shrink:0;">
33
+ <a href="/logs?service=<%=encodeURIComponent(b.service)%>&date=<%=b.date%>" class="btn btn-secondary btn-xs">View</a>
34
+ <button class="btn btn-danger btn-xs" onclick="delBm('<%=b.id%>')">Delete</button>
35
+ </div>
36
+ </div>
37
+ </div>
38
+ <% }) %>
39
+ <% } %>
40
+ </div>
41
+ </div>
42
+ </div>
43
+ </div>
44
+ <script>
45
+ async function saveNote(id, note) {
46
+ const r=await fetch('/api/bookmarks/'+id+'/note',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({note})});
47
+ if(r.ok) toast('Note saved','success');
48
+ }
49
+ async function delBm(id) {
50
+ const r=await fetch('/api/bookmarks/'+id,{method:'DELETE'});
51
+ if(r.ok){const el=document.getElementById('bm-'+id);if(el){el.style.opacity='0';el.style.transition='opacity .2s';setTimeout(()=>el.remove(),200);}toast('Deleted','success');}
52
+ }
53
+ </script>
54
+ </body></html>
@@ -0,0 +1,162 @@
1
+ <%- include('partials/head', { title: 'My Dashboard' }) %>
2
+ <style>
3
+ .dash-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px;min-height:200px;}
4
+ .dash-widget{background:var(--surface);border:1.5px solid var(--border);border-radius:var(--radius-lg);padding:14px;position:relative;cursor:grab;}
5
+ .dash-widget:active{cursor:grabbing;}
6
+ .dash-widget.drag-over{border-color:var(--accent);background:var(--accent-dim);}
7
+ .w-title{font-size:11px;font-weight:600;color:var(--text3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:10px;display:flex;align-items:center;justify-content:space-between;}
8
+ .w-remove{opacity:0;background:none;border:none;color:var(--text3);cursor:pointer;padding:2px 4px;border-radius:3px;font-size:13px;transition:opacity .15s;}
9
+ .dash-widget:hover .w-remove{opacity:1;}
10
+ .dash-empty{border:1.5px dashed var(--border2);border-radius:var(--radius-lg);padding:30px;text-align:center;color:var(--text3);font-size:12px;cursor:pointer;transition:all .15s;}
11
+ .dash-empty:hover{border-color:var(--accent);color:var(--accent-l);}
12
+ </style>
13
+ <div class="app-shell">
14
+ <%- include('partials/sidebar') %>
15
+ <div class="main-area">
16
+ <header class="top-header">
17
+ <div class="page-title">My Dashboard</div>
18
+ <div class="header-actions">
19
+ <button class="btn btn-secondary btn-sm" onclick="openAddWidget()">
20
+ <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>
21
+ Add Widget
22
+ </button>
23
+ <button class="btn btn-primary btn-sm" id="save-btn" onclick="saveLayout()" style="display:none;">Save Layout</button>
24
+ </div>
25
+ </header>
26
+ <div class="page-content">
27
+ <div class="dash-grid" id="dash-grid">
28
+ <% if (!layout || !layout.length) { %>
29
+ <div class="dash-empty" onclick="openAddWidget()">
30
+ <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="margin:0 auto 8px;display:block;"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg>
31
+ <div style="font-weight:600;margin-bottom:4px;">Your dashboard is empty</div>
32
+ <div>Click to add your first widget</div>
33
+ </div>
34
+ <% } %>
35
+ <% if (layout) { layout.forEach(function(w){ %>
36
+ <div class="dash-widget" data-id="<%= w.id %>" draggable="true" ondragstart="dragStart(event,this)" ondragover="dragOver(event,this)" ondrop="drop(event,this)">
37
+ <div class="w-title">
38
+ <%= w.title %>
39
+ <button class="w-remove" onclick="removeWidget('<%= w.id %>')">✕</button>
40
+ </div>
41
+ <div id="w-content-<%= w.id %>">
42
+ <div style="text-align:center;color:var(--text3);font-size:11px;padding:16px 0;">Loading…</div>
43
+ </div>
44
+ </div>
45
+ <% }) } %>
46
+ </div>
47
+ </div>
48
+ </div>
49
+ </div>
50
+
51
+ <!-- Add widget modal -->
52
+ <div class="modal-overlay" id="add-modal">
53
+ <div class="modal-box">
54
+ <div class="modal-title">Add Widget</div>
55
+ <div class="modal-sub">Choose a widget to add to your dashboard</div>
56
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:14px;">
57
+ <% [['logs-today','Logs Today','Bar chart of today\'s log volume'],['errors-today','Errors Today','Error count for today'],['error-rate','Error Rate','% of logs that are errors'],['top-services','Top Services','Most active services'],['recent-errors','Recent Errors','Latest error log lines'],['hourly-chart','Hourly Volume','24-hour activity chart']].forEach(function(w){ %>
58
+ <div onclick="addWidget('<%=w[0]%>','<%=w[1]%>')" style="padding:12px;border:1px solid var(--border);border-radius:8px;cursor:pointer;transition:all .15s;" onmouseover="this.style.borderColor='var(--accent)'" onmouseout="this.style.borderColor='var(--border)'">
59
+ <div style="font-size:12px;font-weight:600;margin-bottom:3px;"><%=w[1]%></div>
60
+ <div style="font-size:11px;color:var(--text3);"><%=w[2]%></div>
61
+ </div>
62
+ <% }) %>
63
+ </div>
64
+ <button class="btn btn-secondary" onclick="closeModal('add-modal')">Cancel</button>
65
+ </div>
66
+ </div>
67
+
68
+ <script>
69
+ let layout = <%- JSON.stringify(layout||[]) %>;
70
+ let dirty = false;
71
+
72
+ function openAddWidget() { document.getElementById('add-modal').classList.add('open'); }
73
+ function closeModal(id) { document.getElementById(id).classList.remove('open'); }
74
+ document.querySelectorAll('.modal-overlay').forEach(m => m.addEventListener('click', e => { if(e.target===m) m.classList.remove('open'); }));
75
+
76
+ function addWidget(type, title) {
77
+ const id = type + '_' + Date.now();
78
+ layout.push({ id, type, title });
79
+ dirty = true;
80
+ document.getElementById('save-btn').style.display = '';
81
+ closeModal('add-modal');
82
+ renderWidgets();
83
+ }
84
+
85
+ function removeWidget(id) {
86
+ layout = layout.filter(w => w.id !== id);
87
+ dirty = true;
88
+ document.getElementById('save-btn').style.display = '';
89
+ renderWidgets();
90
+ }
91
+
92
+ function renderWidgets() {
93
+ const grid = document.getElementById('dash-grid');
94
+ grid.innerHTML = '';
95
+ if (!layout.length) {
96
+ grid.innerHTML = '<div class="dash-empty" onclick="openAddWidget()"><div style="font-weight:600;margin-bottom:4px;">Add your first widget</div><div>Click here</div></div>';
97
+ return;
98
+ }
99
+ layout.forEach(function(w) {
100
+ const el = document.createElement('div');
101
+ el.className = 'dash-widget';
102
+ el.dataset.id = w.id;
103
+ el.draggable = true;
104
+ el.setAttribute('ondragstart', 'dragStart(event,this)');
105
+ el.setAttribute('ondragover', 'dragOver(event,this)');
106
+ el.setAttribute('ondrop', 'drop(event,this)');
107
+ el.innerHTML = `<div class="w-title">${w.title}<button class="w-remove" onclick="removeWidget('${w.id}')">✕</button></div><div id="w-content-${w.id}"><div style="text-align:center;color:var(--text3);font-size:11px;padding:16px 0;">Loading…</div></div>`;
108
+ grid.appendChild(el);
109
+ loadWidgetData(w);
110
+ });
111
+ }
112
+
113
+ async function loadWidgetData(w) {
114
+ const el = document.getElementById('w-content-' + w.id);
115
+ if (!el) return;
116
+ try {
117
+ if (w.type === 'logs-today' || w.type === 'errors-today' || w.type === 'error-rate') {
118
+ const r = await fetch('/api/analytics/overview'); const d = await r.json();
119
+ if (w.type === 'logs-today') el.innerHTML = '<div style="font-size:36px;font-weight:700;text-align:center;padding:12px 0;">' + d.logsToday.toLocaleString() + '</div><div style="text-align:center;font-size:11px;color:var(--text3)">logs today</div>';
120
+ if (w.type === 'errors-today') el.innerHTML = '<div style="font-size:36px;font-weight:700;text-align:center;padding:12px 0;color:var(--red)">' + d.errorsToday.toLocaleString() + '</div><div style="text-align:center;font-size:11px;color:var(--text3)">errors today</div>';
121
+ if (w.type === 'error-rate') el.innerHTML = '<div style="font-size:36px;font-weight:700;text-align:center;padding:12px 0">' + d.errorRate + '%</div><div style="text-align:center;font-size:11px;color:var(--text3)">error rate</div>';
122
+ }
123
+ if (w.type === 'top-services') {
124
+ const r = await fetch('/api/analytics/top-services'); const d = await r.json();
125
+ el.innerHTML = (d.slice?.(0,5)||[]).map(s=>'<div style="display:flex;justify-content:space-between;padding:4px 0;font-size:12px;border-bottom:1px solid var(--border)"><span>'+s.appName+'</span><span style="color:var(--text3)">'+s.count+'</span></div>').join('') || '<div style="color:var(--text3);font-size:11px;text-align:center">No data</div>';
126
+ }
127
+ if (w.type === 'recent-errors') {
128
+ const r = await fetch('/api/analytics/recent-errors'); const d = await r.json();
129
+ el.innerHTML = (d.slice?.(0,4)||[]).map(e=>'<div style="padding:5px 0;border-bottom:1px solid var(--border);font-size:11px;font-family:JetBrains Mono,monospace;color:var(--red);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">'+(e.message||'').slice(0,80)+'</div>').join('') || '<div style="color:var(--text3);font-size:11px;text-align:center">No errors today</div>';
130
+ }
131
+ } catch { el.innerHTML = '<div style="color:var(--text3);font-size:11px;text-align:center;padding:12px">Failed to load</div>'; }
132
+ }
133
+
134
+ // Drag and drop
135
+ let draggingId = null;
136
+ function dragStart(e, el) { draggingId = el.dataset.id; e.dataTransfer.effectAllowed='move'; }
137
+ function dragOver(e, el) { e.preventDefault(); el.classList.add('drag-over'); }
138
+ function drop(e, el) {
139
+ e.preventDefault(); el.classList.remove('drag-over');
140
+ if (!draggingId || draggingId === el.dataset.id) return;
141
+ const fromIdx = layout.findIndex(w=>w.id===draggingId);
142
+ const toIdx = layout.findIndex(w=>w.id===el.dataset.id);
143
+ if (fromIdx < 0 || toIdx < 0) return;
144
+ const [item] = layout.splice(fromIdx, 1);
145
+ layout.splice(toIdx, 0, item);
146
+ dirty = true;
147
+ document.getElementById('save-btn').style.display = '';
148
+ renderWidgets();
149
+ }
150
+ document.querySelectorAll('.dash-widget').forEach(el => el.addEventListener('dragleave', ()=>el.classList.remove('drag-over')));
151
+
152
+ async function saveLayout() {
153
+ try {
154
+ const r = await fetch('/api/dashboard/layout', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({layout}) });
155
+ if (r.ok) { toast('Layout saved','success'); dirty=false; document.getElementById('save-btn').style.display='none'; }
156
+ } catch { toast('Save failed','error'); }
157
+ }
158
+
159
+ // Initial load
160
+ layout.forEach(loadWidgetData);
161
+ </script>
162
+ </body></html>
@@ -0,0 +1,186 @@
1
+ <%- include('partials/head', { title: 'Dashboard' }) %>
2
+ <meta id="dash-order-meta" content="<%= (typeof dashOrder !== 'undefined' && dashOrder) ? JSON.stringify(dashOrder) : 'null' %>"/>
3
+ <style>
4
+ .drag-section{position:relative;margin-bottom:12px;}
5
+ .drag-section.dragging{opacity:.45;}
6
+ .drag-section.drag-over::before{content:'';display:block;height:3px;background:var(--accent);border-radius:2px;margin-bottom:10px;}
7
+ .drag-handle{position:absolute;top:14px;right:14px;z-index:2;cursor:grab;color:var(--text3);padding:3px 4px;border-radius:4px;transition:color .15s,background .15s;line-height:0;}
8
+ .drag-handle:hover{color:var(--text2);background:var(--surface2);}
9
+ .drag-handle:active{cursor:grabbing;}
10
+ .drag-tip{font-size:11px;color:var(--text3);display:flex;align-items:center;gap:5px;margin-bottom:10px;}
11
+ </style>
12
+ <div class="app-shell">
13
+ <%- include('partials/sidebar') %>
14
+ <div class="main-area">
15
+ <header class="top-header">
16
+ <div class="page-title">Dashboard</div>
17
+ <div class="header-actions">
18
+ <span style="font-size:12px;color:var(--text3);">Today: <strong style="color:var(--text)"><%= today %></strong></span>
19
+ <button class="btn btn-secondary btn-sm" id="reset-btn" onclick="resetOrder()" style="display:none;" title="Reset layout">
20
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1,4 1,10 7,10"/><path d="M3.51 15a9 9 0 1 0 .49-3.5"/></svg>
21
+ </button>
22
+ <button class="btn btn-secondary btn-sm" onclick="location.reload()">
23
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23,4 23,10 17,10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
24
+ Refresh
25
+ </button>
26
+ </div>
27
+ </header>
28
+ <div class="page-content">
29
+ <% if(typeof renderError!=='undefined'&&renderError){%><div style="background:rgba(239,68,68,.12);border:1px solid rgba(239,68,68,.3);border-radius:8px;padding:12px 16px;color:#ff6b6b;margin-bottom:16px;">⚠ <%=renderError%></div><%}%>
30
+ <% if(new URLSearchParams(''+((typeof location!=='undefined'?location.search:''))).get&&false){} %>
31
+ <div id="denied-banner" style="display:none;background:rgba(245,158,11,.1);border:1px solid rgba(245,158,11,.25);border-radius:8px;padding:10px 14px;color:var(--yellow);font-size:12px;margin-bottom:14px;">You don't have access to that page.</div>
32
+ <% if(overview){ %>
33
+ <div class="drag-tip" id="drag-tip"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="9" cy="5" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="9" cy="19" r="1"/><circle cx="15" cy="5" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="19" r="1"/></svg>Drag sections to rearrange</div>
34
+ <div id="drag-board">
35
+ <!-- STATS -->
36
+ <div class="drag-section" data-section="stats">
37
+ <div class="drag-handle" title="Drag"><svg width="14" height="14" viewBox="0 0 24 24" fill="none"><circle cx="9" cy="5" r="1.3" fill="currentColor"/><circle cx="9" cy="12" r="1.3" fill="currentColor"/><circle cx="9" cy="19" r="1.3" fill="currentColor"/><circle cx="15" cy="5" r="1.3" fill="currentColor"/><circle cx="15" cy="12" r="1.3" fill="currentColor"/><circle cx="15" cy="19" r="1.3" fill="currentColor"/></svg></div>
38
+ <div class="stats-grid">
39
+ <% if(hasCard('stat-logs')){ %>
40
+ <div class="stat-card"><div class="stat-icon blue"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14,2 14,8 20,8"/></svg></div><div><div class="stat-value"><%=overview.logsToday.toLocaleString()%></div><div class="stat-label">Logs Today</div><canvas id="spark-logs" height="28" style="width:100%;margin-top:4px;opacity:.8;"></canvas></div></div>
41
+ <% } %>
42
+ <% if(hasCard('stat-errors')){ %>
43
+ <div class="stat-card"><div class="stat-icon red"><svg width="20" height="20" 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"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></div><div><div class="stat-value"><%=overview.errorsToday.toLocaleString()%></div><div class="stat-label">Errors Today</div><canvas id="spark-errors" height="28" style="width:100%;margin-top:4px;opacity:.8;"></canvas></div></div>
44
+ <% } %>
45
+ <% if(hasCard('stat-services')){ %>
46
+ <div class="stat-card"><div class="stat-icon green"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg></div><div><div class="stat-value"><%=overview.services%></div><div class="stat-label">Services</div><div class="stat-sub"><%=overview.totalStorage%></div></div></div>
47
+ <% } %>
48
+ <% if(hasCard('stat-errorrate')){ %>
49
+ <div class="stat-card"><div class="stat-icon purple"><svg width="20" height="20" 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></div><div><div class="stat-value"><%=overview.errorRate%>%</div><div class="stat-label">Error Rate</div><div class="stat-sub">Last 24h</div></div></div>
50
+ <% } %>
51
+ </div>
52
+ </div>
53
+ <!-- CHARTS -->
54
+ <% if(hasCard('chart-hourly')||hasCard('chart-services')){ %>
55
+ <div class="drag-section" data-section="charts">
56
+ <div class="drag-handle" title="Drag"><svg width="14" height="14" viewBox="0 0 24 24" fill="none"><circle cx="9" cy="5" r="1.3" fill="currentColor"/><circle cx="9" cy="12" r="1.3" fill="currentColor"/><circle cx="9" cy="19" r="1.3" fill="currentColor"/><circle cx="15" cy="5" r="1.3" fill="currentColor"/><circle cx="15" cy="12" r="1.3" fill="currentColor"/><circle cx="15" cy="19" r="1.3" fill="currentColor"/></svg></div>
57
+ <div class="grid-2">
58
+ <% if(hasCard('chart-hourly')){ %>
59
+ <div class="card"><div class="card-title"><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>Hourly Volume</div><div class="chart-wrap"><canvas id="hourlyChart"></canvas></div></div>
60
+ <% } %>
61
+ <% if(hasCard('chart-services')){ %>
62
+ <div class="card"><div class="card-title"><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>Top Services</div>
63
+ <% if(overview.serviceList&&overview.serviceList.length){ %>
64
+ <div style="display:flex;flex-direction:column;gap:8px;">
65
+ <% overview.serviceList.slice(0,6).forEach(function(svc){ %>
66
+ <div><div style="display:flex;justify-content:space-between;margin-bottom:3px;"><span style="font-size:12px;font-weight:500;color:var(--text);font-family:'JetBrains Mono',monospace"><%=svc.appName%></span><span style="font-size:12px;color:var(--text2)"><%=svc.today%></span></div><div class="progress-bar"><div class="progress-fill <%=svc.error>svc.today*.1?'red':''%>" style="width:<%=overview.logsToday?Math.round(svc.today/overview.logsToday*100):0%>%"></div></div></div>
67
+ <% }) %>
68
+ </div>
69
+ <% }else{ %><div class="empty-state" style="padding:24px 0;"><p>No logs today</p></div><% } %>
70
+ </div>
71
+ <% } %>
72
+ </div>
73
+ </div>
74
+ <% } %>
75
+ <!-- ERRORS -->
76
+ <% if(hasCard('recent-errors')){ %>
77
+ <div class="drag-section" data-section="errors">
78
+ <div class="drag-handle" title="Drag"><svg width="14" height="14" viewBox="0 0 24 24" fill="none"><circle cx="9" cy="5" r="1.3" fill="currentColor"/><circle cx="9" cy="12" r="1.3" fill="currentColor"/><circle cx="9" cy="19" r="1.3" fill="currentColor"/><circle cx="15" cy="5" r="1.3" fill="currentColor"/><circle cx="15" cy="12" r="1.3" fill="currentColor"/><circle cx="15" cy="19" r="1.3" fill="currentColor"/></svg></div>
79
+ <div class="card">
80
+ <div class="card-title" style="justify-content:space-between;"><span style="display:flex;align-items:center;gap:8px;"><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"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>Recent Errors</span><a href="/logs?level=error&date=<%=today%>" class="btn btn-secondary btn-xs">View All</a></div>
81
+ <% if(errors&&errors.length){ %>
82
+ <div class="log-list">
83
+ <% errors.forEach(function(e){ %>
84
+ <div class="log-line level-error" onclick="this.classList.toggle('expanded');this.nextElementSibling.style.display=this.classList.contains('expanded')?'':'none'">
85
+ <span class="log-ts"><%=e.ts?e.ts.replace('T',' ').slice(0,19):''%></span>
86
+ <span class="badge badge-error" style="margin-top:1px;">error</span>
87
+ <span class="log-body truncate" style="color:var(--text)"><%=e.message||e._raw||JSON.stringify(e)%></span>
88
+ <span style="font-size:11px;color:var(--text3);white-space:nowrap"><%=e._appName||e.appName||''%></span>
89
+ </div>
90
+ <div class="log-expanded" style="display:none"><pre class="log-json"><%=JSON.stringify(e,null,2)%></pre></div>
91
+ <% }) %>
92
+ </div>
93
+ <% }else{ %><div class="empty-state" style="padding:24px 0;"><svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22,4 12,14.01 9,11.01"/></svg><p>No errors today 🎉</p></div><% } %>
94
+ </div>
95
+ </div>
96
+ <% } %>
97
+ </div>
98
+ <% }else{ %>
99
+ <div class="card"><div class="empty-state"><svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14,2 14,8 20,8"/></svg><p style="margin-top:12px;">No logs yet. Start ingesting via the API.</p><div style="margin-top:12px;font-family:'JetBrains Mono',monospace;font-size:11px;background:var(--surface2);padding:10px 14px;border-radius:6px;text-align:left;display:inline-block;color:var(--text2)">curl -X POST http://localhost:9900/api/logs \<br>&nbsp;&nbsp;-H "X-Api-Key: blq_your_key" \<br>&nbsp;&nbsp;-H "Content-Type: application/json" \<br>&nbsp;&nbsp;-d '{"logs":["..."]}'</div></div></div>
100
+ <% } %>
101
+ </div>
102
+ </div>
103
+ </div>
104
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
105
+ <script>
106
+ const hourlyData=<%- JSON.stringify(hourly?hourly.hours:[]) %>;
107
+ if(hourlyData.length&&document.getElementById('hourlyChart')){
108
+ const isDark=document.documentElement.dataset.theme!=='light';
109
+ const gc=isDark?'rgba(255,255,255,.05)':'rgba(0,0,0,.06)';
110
+ const tc=isDark?'#5a5a78':'#9494b8';
111
+ new Chart(document.getElementById('hourlyChart'),{type:'bar',data:{labels:hourlyData.map(h=>h.hour+':00'),datasets:[{label:'info',data:hourlyData.map(h=>h.info),backgroundColor:'rgba(59,130,246,.5)',stack:'s'},{label:'warn',data:hourlyData.map(h=>h.warn),backgroundColor:'rgba(245,158,11,.5)',stack:'s'},{label:'error',data:hourlyData.map(h=>h.error),backgroundColor:'rgba(239,68,68,.7)',stack:'s'},{label:'debug',data:hourlyData.map(h=>h.debug),backgroundColor:'rgba(107,114,128,.4)',stack:'s'}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{display:false}},scales:{x:{stacked:true,ticks:{color:tc,font:{size:10},maxTicksLimit:12},grid:{color:gc}},y:{stacked:true,ticks:{color:tc,font:{size:10}},grid:{color:gc}}}}});
112
+ }
113
+ if(new URLSearchParams(location.search).get('denied')){document.getElementById('denied-banner').style.display='block';history.replaceState(null,'','/dashboard');}
114
+ const board=document.getElementById('drag-board');
115
+ function getSecs(){return board?[...board.querySelectorAll('.drag-section')]:[]; }
116
+
117
+ // Server-side order persistence (works across browsers/devices)
118
+ async function saveOrder(){
119
+ const order = getSecs().map(s=>s.dataset.section);
120
+ try {
121
+ await fetch('/api/settings/dashboard/layout',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({order})});
122
+ document.getElementById('reset-btn').style.display='';
123
+ } catch {}
124
+ }
125
+ async function resetOrder(){
126
+ try {
127
+ await fetch('/api/settings/dashboard/layout',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({order:[]})});
128
+ location.reload();
129
+ } catch { location.reload(); }
130
+ }
131
+
132
+ // Load saved order from server on page load
133
+ const saved = (function(){
134
+ try {
135
+ const meta = document.getElementById('dash-order-meta');
136
+ if (meta && meta.content) return JSON.parse(meta.content);
137
+ } catch {}
138
+ return null;
139
+ })();
140
+ if(saved&&board){saved.forEach(id=>{const el=board.querySelector('[data-section="'+id+'"]');if(el)board.appendChild(el);});document.getElementById('reset-btn').style.display='';}
141
+ if(localStorage.getItem('logboard_dragged')){const t=document.getElementById('drag-tip');if(t)t.style.display='none';}
142
+ let dragEl=null;
143
+ getSecs().forEach(sec=>{
144
+ const h=sec.querySelector('.drag-handle');
145
+ if(h){h.addEventListener('mousedown',()=>sec.draggable=true);h.addEventListener('mouseup',()=>sec.draggable=false);}
146
+ sec.addEventListener('dragstart',e=>{dragEl=sec;sec.classList.add('dragging');e.dataTransfer.effectAllowed='move';localStorage.setItem('logboard_dragged','1');const t=document.getElementById('drag-tip');if(t)t.style.display='none';});
147
+ sec.addEventListener('dragend',()=>{sec.classList.remove('dragging');getSecs().forEach(s=>s.classList.remove('drag-over'));sec.draggable=false;saveOrder();dragEl=null;});
148
+ sec.addEventListener('dragover',e=>{e.preventDefault();if(!dragEl||dragEl===sec)return;getSecs().forEach(s=>s.classList.remove('drag-over'));sec.classList.add('drag-over');const rect=sec.getBoundingClientRect();if(e.clientY<rect.top+rect.height/2)board.insertBefore(dragEl,sec);else board.insertBefore(dragEl,sec.nextSibling);});
149
+ sec.addEventListener('dragleave',()=>sec.classList.remove('drag-over'));
150
+ sec.addEventListener('drop',e=>{e.preventDefault();sec.classList.remove('drag-over');});
151
+ });
152
+
153
+ // ── Sparklines ────────────────────────────────────────────────────────────
154
+ const sparkTrend = <%- JSON.stringify(trend||[]) %>;
155
+ function drawSparkline(canvasId, data, color) {
156
+ const canvas = document.getElementById(canvasId);
157
+ if (!canvas || !data.length) return;
158
+ const ctx = canvas.getContext('2d');
159
+ canvas.width = canvas.offsetWidth || 200;
160
+ canvas.height = 28;
161
+ const w = canvas.width, h = canvas.height;
162
+ const max = Math.max(...data, 1);
163
+ const min = Math.min(...data);
164
+ ctx.clearRect(0, 0, w, h);
165
+ const pts = data.map((v, i) => ({ x: (i / (data.length - 1)) * w, y: h - ((v - min) / (max - min + 0.01)) * (h - 4) - 2 }));
166
+ // Fill
167
+ ctx.beginPath();
168
+ ctx.moveTo(pts[0].x, h);
169
+ pts.forEach(p => ctx.lineTo(p.x, p.y));
170
+ ctx.lineTo(pts[pts.length-1].x, h);
171
+ ctx.closePath();
172
+ ctx.fillStyle = color.replace('1)', '.15)');
173
+ ctx.fill();
174
+ // Line
175
+ ctx.beginPath();
176
+ pts.forEach((p, i) => i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y));
177
+ ctx.strokeStyle = color;
178
+ ctx.lineWidth = 1.5;
179
+ ctx.stroke();
180
+ }
181
+ if (sparkTrend.length) {
182
+ drawSparkline('spark-logs', sparkTrend.map(d=>d.total||0), 'rgba(59,130,246,1)');
183
+ drawSparkline('spark-errors', sparkTrend.map(d=>d.error||0), 'rgba(239,68,68,1)');
184
+ }
185
+ </script>
186
+ </body></html>
package/views/diff.ejs ADDED
@@ -0,0 +1,98 @@
1
+ <%- include('partials/head', { title: 'Log Diff' }) %>
2
+ <div class="app-shell">
3
+ <%- include('partials/sidebar') %>
4
+ <div class="main-area">
5
+ <header class="top-header">
6
+ <div class="page-title">Log Diff</div>
7
+ </header>
8
+ <div class="page-content">
9
+ <div class="card" style="margin-bottom:12px;">
10
+ <form method="GET" action="/diff" style="display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap;">
11
+ <div class="form-group" style="margin:0;min-width:160px;flex:1.2">
12
+ <label class="form-label">Service</label>
13
+ <select name="service" class="form-select">
14
+ <option value="">Select service…</option>
15
+ <% services.forEach(function(s){ %><option value="<%= s.appName %>" <%= service===s.appName?'selected':'' %>><%= s.appName %></option><% }) %>
16
+ </select>
17
+ </div>
18
+ <div class="form-group" style="margin:0">
19
+ <label class="form-label">Date A (Before)</label>
20
+ <input type="date" name="dateA" class="form-input" value="<%= dateA %>" max="<%= today %>"/>
21
+ </div>
22
+ <div class="form-group" style="margin:0">
23
+ <label class="form-label">Date B (After)</label>
24
+ <input type="date" name="dateB" class="form-input" value="<%= dateB %>" max="<%= today %>"/>
25
+ </div>
26
+ <button type="submit" class="btn btn-primary">Compare</button>
27
+ </form>
28
+ </div>
29
+
30
+ <% if (!diffData) { %>
31
+ <div class="card"><div class="empty-state" style="padding:50px 0;">
32
+ <svg width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
33
+ <p>Select a service and two dates to compare error patterns</p>
34
+ </div></div>
35
+ <% } else { %>
36
+ <!-- Summary -->
37
+ <div class="stats-grid" style="margin-bottom:14px;">
38
+ <div class="stat-card">
39
+ <div><div style="font-size:11px;color:var(--text3);margin-bottom:4px;">Date A — <%= diffData.dateA %></div>
40
+ <div class="stat-value"><%= diffData.totalA.toLocaleString() %></div><div class="stat-label">Total Logs</div>
41
+ <div class="stat-sub" style="color:var(--red)"><%= diffData.errorsA %> errors</div>
42
+ </div>
43
+ </div>
44
+ <div class="stat-card">
45
+ <div><div style="font-size:11px;color:var(--text3);margin-bottom:4px;">Date B — <%= diffData.dateB %></div>
46
+ <div class="stat-value"><%= diffData.totalB.toLocaleString() %></div><div class="stat-label">Total Logs</div>
47
+ <div class="stat-sub" style="color:var(--red)"><%= diffData.errorsB %> errors</div>
48
+ </div>
49
+ </div>
50
+ <div class="stat-card">
51
+ <% var delta = diffData.errorsB - diffData.errorsA; %>
52
+ <div class="stat-icon <%=delta>0?'red':delta<0?'green':'blue'%>">
53
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="<%=delta>0?'23,18 13.5,8.5 8.5,13.5 1,6':'23,6 13.5,15.5 8.5,10.5 1,18'%>"/></svg>
54
+ </div>
55
+ <div>
56
+ <div class="stat-value" style="color:<%=delta>0?'var(--red)':delta<0?'var(--green)':'var(--text)'%>">
57
+ <%=delta>0?'+':'%><%= delta %>
58
+ </div>
59
+ <div class="stat-label">Error Delta</div>
60
+ </div>
61
+ </div>
62
+ </div>
63
+
64
+ <!-- Diff table -->
65
+ <div class="card">
66
+ <div style="font-weight:600;font-size:13px;margin-bottom:12px;">Error Pattern Comparison</div>
67
+ <% if (!diffData.rows || !diffData.rows.length) { %>
68
+ <div style="text-align:center;padding:30px;color:var(--text3);font-size:12px;">No error patterns found in either date</div>
69
+ <% } else { %>
70
+ <div class="table-wrap">
71
+ <table>
72
+ <thead><tr><th style="width:40%">Error Pattern</th><th>A (<%= diffData.dateA %>)</th><th>B (<%= diffData.dateB %>)</th><th>Δ Change</th><th>%</th></tr></thead>
73
+ <tbody>
74
+ <% diffData.rows.forEach(function(row){ %>
75
+ <tr>
76
+ <td style="font-family:'JetBrains Mono',monospace;font-size:11px;max-width:300px;"><%= row.msg.slice(0,120) %></td>
77
+ <td style="text-align:center;font-size:12px;"><%= row.countA %></td>
78
+ <td style="text-align:center;font-size:12px;"><%= row.countB %></td>
79
+ <td style="text-align:center;">
80
+ <span style="font-weight:600;font-size:12px;color:<%=row.delta>0?'var(--red)':row.delta<0?'var(--green)':'var(--text3)'%>">
81
+ <%=row.delta>0?'+':'%><%= row.delta %>
82
+ </span>
83
+ </td>
84
+ <td style="text-align:center;font-size:11px;color:var(--text3);">
85
+ <%=row.pct===null?'new':row.pct>0?'+'+row.pct+'%':row.pct+'%'%>
86
+ </td>
87
+ </tr>
88
+ <% }) %>
89
+ </tbody>
90
+ </table>
91
+ </div>
92
+ <% } %>
93
+ </div>
94
+ <% } %>
95
+ </div>
96
+ </div>
97
+ </div>
98
+ </body></html>