@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,150 @@
1
+ <%- include('partials/head', { title: 'API Keys' }) %>
2
+ <div class="app-shell">
3
+ <%- include('partials/sidebar') %>
4
+ <div class="main-area">
5
+ <header class="top-header">
6
+ <div class="page-title">API Keys</div>
7
+ <div class="header-actions">
8
+ <button class="btn btn-primary btn-sm" onclick="openCreateModal()">
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 Key
11
+ </button>
12
+ </div>
13
+ </header>
14
+ <div class="page-content">
15
+ <div class="card" style="margin-bottom:12px;padding:12px 16px;background:rgba(245,158,11,.06);border:1px solid rgba(245,158,11,.2);">
16
+ <div style="display:flex;gap:10px;align-items:flex-start;">
17
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--yellow)" stroke-width="2" style="flex-shrink:0;margin-top:1px;"><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>
18
+ <div style="font-size:12px;color:var(--text2);line-height:1.7;">API keys use a <code style="font-family:'JetBrains Mono',monospace;background:var(--surface3);padding:1px 5px;border-radius:3px;">blq_</code> prefix and are stored as SHA-256 hashes. The raw key is shown <strong>once</strong> on creation — copy it immediately. Use via <code style="font-family:'JetBrains Mono',monospace;background:var(--surface3);padding:1px 5px;border-radius:3px;">X-Api-Key: blq_...</code> header.</div>
19
+ </div>
20
+ </div>
21
+ <div class="card">
22
+ <% if(!keys||!keys.length){ %>
23
+ <div class="empty-state" style="padding:40px 0;">
24
+ <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>
25
+ <p style="margin-top:12px;">No API keys yet. Create one to start ingesting logs.</p>
26
+ </div>
27
+ <% }else{ %>
28
+ <div class="table-wrap">
29
+ <table>
30
+ <thead><tr><th>Name</th><th>Key Prefix</th><th>Scopes</th><th>Expires</th><th>Last Used</th><th>Status</th><th>Actions</th></tr></thead>
31
+ <tbody id="keys-tbody">
32
+ <% keys.forEach(function(k){ %>
33
+ <tr id="key-row-<%=k.id%>" style="<%=!k.active?'opacity:.5;':''%>">
34
+ <td style="font-weight:600;color:var(--text);"><%=k.name%></td>
35
+ <td><code style="font-family:'JetBrains Mono',monospace;font-size:12px;color:var(--accent-l);"><%=k.keyPrefix%></code></td>
36
+ <td><div style="display:flex;gap:4px;flex-wrap:wrap;"><%- (k.scopes||[]).map(s=>'<span class="badge badge-info" style="font-size:9px;">'+s+'</span>').join('') %></div></td>
37
+ <td style="font-size:12px;color:var(--text3);"><%=k.expiresAt?new Date(k.expiresAt).toLocaleDateString():'Never'%></td>
38
+ <td style="font-size:11px;color:var(--text3);"><%=k.lastUsedAt?new Date(k.lastUsedAt).toLocaleString():'Never'%></td>
39
+ <td><span class="badge <%=k.active?'badge-green':'badge-debug'%>"><%=k.active?'active':'revoked'%></span></td>
40
+ <td>
41
+ <div style="display:flex;gap:6px;">
42
+ <% if(k.active){ %><button class="btn btn-danger btn-xs" onclick="revokeKey('<%=k.id%>')">Revoke</button><% } %>
43
+ <button class="btn btn-secondary btn-xs" onclick="deleteKey('<%=k.id%>')">Delete</button>
44
+ </div>
45
+ </td>
46
+ </tr>
47
+ <% }) %>
48
+ </tbody>
49
+ </table>
50
+ </div>
51
+ <% } %>
52
+ </div>
53
+ </div>
54
+ </div>
55
+ </div>
56
+
57
+ <!-- Create Key Modal -->
58
+ <div class="modal-overlay" id="create-modal">
59
+ <div class="modal-box">
60
+ <div class="modal-title">Create API Key</div>
61
+ <div class="modal-sub">Keys are shown only once. Store the raw key securely.</div>
62
+ <div class="form-group"><label class="form-label">Key Name</label><input type="text" id="key-name" class="form-input" placeholder="e.g. production-server"/></div>
63
+ <div class="form-group">
64
+ <label class="form-label">Scopes <span style="font-weight:400;color:var(--text3);">— select all that apply</span></label>
65
+ <div class="scope-grid" id="scope-grid">
66
+ <% SCOPES.forEach(function(s){ %>
67
+ <div class="scope-pill <%=s==='logs:write'?'checked':''%>" onclick="toggleScope(this)" data-scope="<%=s%>" style="cursor:pointer;user-select:none;">
68
+ <input type="checkbox" value="<%=s%>" <%=s==='logs:write'?'checked':''%> style="display:none;"/><%=s%>
69
+ </div>
70
+ <% }) %>
71
+ </div>
72
+ </div>
73
+ <div style="font-size:11px;color:var(--text3);margin-top:6px;margin-bottom:2px;line-height:1.7;">
74
+ <span style="color:var(--text2);font-weight:500;">logs:write</span> — send logs in (SDK / your app)&nbsp;&nbsp;
75
+ <span style="color:var(--text2);font-weight:500;">logs:read</span> — search &amp; read logs via API&nbsp;&nbsp;
76
+ <span style="color:var(--text2);font-weight:500;">analytics:read</span> — charts &amp; stats&nbsp;&nbsp;
77
+ <span style="color:var(--text2);font-weight:500;">health:read</span> — server health status&nbsp;&nbsp;
78
+ <span style="color:var(--text2);font-weight:500;">stream:read</span> — live SSE stream
79
+ </div>
80
+ <div class="form-group"><label class="form-label">Expires At <span style="color:var(--text3);font-weight:400">(optional)</span></label><input type="date" id="key-expires" class="form-input"/></div>
81
+ <div id="create-err" style="display:none;color:var(--red);font-size:12px;margin-bottom:12px;"></div>
82
+ <div style="display:flex;gap:8px;">
83
+ <button class="btn btn-primary" style="flex:1;" onclick="createKey()">Create Key</button>
84
+ <button class="btn btn-secondary" onclick="closeModal('create-modal')">Cancel</button>
85
+ </div>
86
+ </div>
87
+ </div>
88
+
89
+ <!-- Key reveal Modal -->
90
+ <div class="modal-overlay" id="reveal-modal">
91
+ <div class="modal-box">
92
+ <div class="modal-title">✅ Key Created — Copy Now</div>
93
+ <div class="modal-sub" style="color:var(--red);font-weight:500;">This key will NOT be shown again.</div>
94
+ <div style="margin:16px 0;background:var(--surface3);border-radius:8px;padding:14px;border:1px solid var(--border);">
95
+ <div style="font-family:'JetBrains Mono',monospace;font-size:13px;color:var(--accent-l);word-break:break-all;margin-bottom:10px;" id="raw-key-display"></div>
96
+ <button class="btn btn-secondary btn-sm" onclick="copyRawKey()">
97
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
98
+ Copy Key
99
+ </button>
100
+ </div>
101
+ <button class="btn btn-primary w-full" style="width:100%;justify-content:center;" onclick="closeModal('reveal-modal');location.reload()">Done — I've copied it</button>
102
+ </div>
103
+ </div>
104
+
105
+ <script>
106
+ let _rawKey='';
107
+ function toggleScope(label){label.classList.toggle('checked');label.querySelector('input').checked=label.classList.contains('checked');}
108
+ function openCreateModal(){document.getElementById('key-name').value='';document.getElementById('key-expires').value='';document.getElementById('create-err').style.display='none';document.getElementById('create-modal').classList.add('open');}
109
+ function closeModal(id){document.getElementById(id).classList.remove('open');}
110
+ document.querySelectorAll('.modal-overlay').forEach(m=>m.addEventListener('click',e=>{if(e.target===m)m.classList.remove('open');}));
111
+ document.addEventListener('keydown',e=>{if(e.key==='Escape')document.querySelectorAll('.modal-overlay.open').forEach(m=>m.classList.remove('open'));});
112
+
113
+ async function createKey(){
114
+ const name=document.getElementById('key-name').value.trim();
115
+ const scopes=[...document.querySelectorAll('#scope-grid input:checked')].map(i=>i.value);
116
+ const exp=document.getElementById('key-expires').value||null;
117
+ const errEl=document.getElementById('create-err');errEl.style.display='none';
118
+ if(!name){errEl.textContent='Key name required';errEl.style.display='';return;}
119
+ if(!scopes.length){errEl.textContent='Select at least one scope';errEl.style.display='';return;}
120
+ try{
121
+ const r=await fetch('/api/api-keys',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,scopes,expiresAt:exp})});
122
+ const d=await r.json();
123
+ if(!r.ok){errEl.textContent=d.error;errEl.style.display='';return;}
124
+ _rawKey=d.key;
125
+ document.getElementById('raw-key-display').textContent=d.key;
126
+ closeModal('create-modal');
127
+ document.getElementById('reveal-modal').classList.add('open');
128
+ }catch(e){errEl.textContent='Network error';errEl.style.display='';}
129
+ }
130
+
131
+ function copyRawKey(){navigator.clipboard.writeText(_rawKey).then(()=>toast('Key copied!','success'));}
132
+
133
+ async function revokeKey(id){
134
+ if(!confirm('Revoke this key? It will stop working immediately.'))return;
135
+ const r=await fetch('/api/api-keys/'+id+'/revoke',{method:'POST'});
136
+ const d=await r.json();
137
+ if(!r.ok){toast(d.error,'error');return;}
138
+ toast('Key revoked','success');location.reload();
139
+ }
140
+
141
+ async function deleteKey(id){
142
+ if(!confirm('Permanently delete this key?'))return;
143
+ const r=await fetch('/api/api-keys/'+id,{method:'DELETE'});
144
+ const d=await r.json();
145
+ if(!r.ok){toast(d.error,'error');return;}
146
+ toast('Key deleted','success');
147
+ const row=document.getElementById('key-row-'+id);if(row)row.remove();
148
+ }
149
+ </script>
150
+ </body></html>
@@ -0,0 +1,123 @@
1
+ <%- include('partials/head', { title: 'Log Archive' }) %>
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 Archive</div>
7
+ <div class="header-actions">
8
+ <button class="btn btn-primary btn-sm" onclick="openCreateModal()">
9
+ <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="21,8 21,21 3,21 3,8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg>
10
+ Create Archive
11
+ </button>
12
+ </div>
13
+ </header>
14
+ <div class="page-content">
15
+ <div class="card" style="margin-bottom:12px;padding:12px 16px;background:rgba(59,130,246,.06);border:1px solid rgba(59,130,246,.2);">
16
+ <div style="font-size:12px;color:var(--text2);line-height:1.7;">
17
+ Archives are stored in <code style="font-family:'JetBrains Mono',monospace;background:var(--surface3);padding:1px 5px;border-radius:3px;">logs/_archive/</code>.
18
+ Creating an archive compresses selected logs into a <code style="font-family:'JetBrains Mono',monospace;background:var(--surface3);padding:1px 5px;border-radius:3px;">.tar.gz</code> file for download or long-term storage.
19
+ Archiving does <strong>not</strong> delete the originals — use Settings retention to control deletion.
20
+ </div>
21
+ </div>
22
+
23
+ <div class="card">
24
+ <% if(!archives||!archives.length){ %>
25
+ <div class="empty-state" style="padding:40px 0;">
26
+ <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><polyline points="21,8 21,21 3,21 3,8"/><rect x="1" y="3" width="22" height="5"/></svg>
27
+ <p>No archives yet. Create one to compress and store old logs.</p>
28
+ </div>
29
+ <% } else { %>
30
+ <div class="table-wrap">
31
+ <table>
32
+ <thead><tr><th>Filename</th><th>Size</th><th>Created</th><th>Actions</th></tr></thead>
33
+ <tbody>
34
+ <% archives.forEach(function(a){ %>
35
+ <tr>
36
+ <td><code style="font-family:'JetBrains Mono',monospace;font-size:12px;color:var(--accent-l);"><%=a.name%></code></td>
37
+ <td style="font-size:12px;color:var(--text2);"><%=(a.size/1024/1024).toFixed(2)%> MB</td>
38
+ <td style="font-size:11px;color:var(--text3);"><%=new Date(a.created).toLocaleString()%></td>
39
+ <td>
40
+ <div style="display:flex;gap:6px;">
41
+ <a href="/api/archive/download/<%=encodeURIComponent(a.name)%>" class="btn btn-secondary btn-xs">
42
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7,10 12,15 17,10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
43
+ Download
44
+ </a>
45
+ <button class="btn btn-danger btn-xs" onclick="deleteArchive('<%=a.name%>')">Delete</button>
46
+ </div>
47
+ </td>
48
+ </tr>
49
+ <% }) %>
50
+ </tbody>
51
+ </table>
52
+ </div>
53
+ <% } %>
54
+ </div>
55
+ </div>
56
+ </div>
57
+ </div>
58
+
59
+ <!-- Create archive modal -->
60
+ <div class="modal-overlay" id="create-modal">
61
+ <div class="modal-box">
62
+ <div class="modal-title">Create Archive</div>
63
+ <div class="modal-sub">Compress log files in a date range into a .tar.gz file.</div>
64
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
65
+ <div class="form-group"><label class="form-label">Start Date</label><input type="date" id="arc-start" class="form-input"/></div>
66
+ <div class="form-group"><label class="form-label">End Date</label><input type="date" id="arc-end" class="form-input" value="<%= new Date().toISOString().slice(0,10) %>"/></div>
67
+ </div>
68
+ <div class="form-group">
69
+ <label class="form-label">Services <span style="color:var(--text3);font-weight:400">(leave blank for all)</span></label>
70
+ <select id="arc-services" class="form-select" multiple style="height:120px;">
71
+ <% logServices.forEach(function(s){ %><option value="<%=s.appName%>"><%=s.appName%></option><% }) %>
72
+ </select>
73
+ </div>
74
+ <div id="arc-status" style="display:none;font-size:12px;color:var(--accent-l);margin-bottom:8px;">Creating archive…</div>
75
+ <div id="arc-err" style="display:none;color:var(--red);font-size:12px;margin-bottom:8px;"></div>
76
+ <div style="display:flex;gap:8px;">
77
+ <button class="btn btn-primary" style="flex:1;" id="arc-btn" onclick="createArchive()">Create Archive</button>
78
+ <button class="btn btn-secondary" onclick="closeModal('create-modal')">Cancel</button>
79
+ </div>
80
+ </div>
81
+ </div>
82
+
83
+ <script>
84
+ function openCreateModal() {
85
+ const s = new Date(); s.setDate(s.getDate() - 7);
86
+ document.getElementById('arc-start').value = s.toISOString().slice(0,10);
87
+ document.getElementById('arc-err').style.display = 'none';
88
+ document.getElementById('arc-status').style.display = 'none';
89
+ document.getElementById('create-modal').classList.add('open');
90
+ }
91
+ function closeModal(id) { document.getElementById(id).classList.remove('open'); }
92
+ document.querySelectorAll('.modal-overlay').forEach(m => m.addEventListener('click', e => { if (e.target === m) m.classList.remove('open'); }));
93
+
94
+ async function createArchive() {
95
+ const start = document.getElementById('arc-start').value;
96
+ const end = document.getElementById('arc-end').value;
97
+ const services = [...document.getElementById('arc-services').selectedOptions].map(o => o.value);
98
+ const errEl = document.getElementById('arc-err');
99
+ const statEl = document.getElementById('arc-status');
100
+ const btn = document.getElementById('arc-btn');
101
+ errEl.style.display = 'none';
102
+ if (!start || !end) { errEl.textContent = 'Start and end date required'; errEl.style.display = ''; return; }
103
+ btn.disabled = true; statEl.style.display = '';
104
+ try {
105
+ const r = await fetch('/api/archive/create', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ startDate: start, endDate: end, services }) });
106
+ const d = await r.json();
107
+ if (!r.ok) { errEl.textContent = d.error; errEl.style.display = ''; statEl.style.display = 'none'; btn.disabled = false; return; }
108
+ toast('Archive created: ' + d.filename + ' (' + (d.size/1024/1024).toFixed(2) + ' MB)', 'success');
109
+ closeModal('create-modal');
110
+ location.reload();
111
+ } catch(e) { errEl.textContent = 'Network error'; errEl.style.display = ''; statEl.style.display = 'none'; btn.disabled = false; }
112
+ }
113
+
114
+ async function deleteArchive(name) {
115
+ if (!confirm('Delete archive "' + name + '"?')) return;
116
+ const r = await fetch('/api/archive/' + encodeURIComponent(name), { method: 'DELETE' });
117
+ const d = await r.json();
118
+ if (!r.ok) { toast(d.error, 'error'); return; }
119
+ toast('Archive deleted', 'success');
120
+ location.reload();
121
+ }
122
+ </script>
123
+ </body></html>
@@ -0,0 +1,314 @@
1
+ <%- include('partials/head', { title: 'Audit Log' }) %>
2
+ <div class="app-shell">
3
+ <%- include('partials/sidebar') %>
4
+ <div class="main-area">
5
+ <header class="top-header">
6
+ <div class="page-title">Audit Log</div>
7
+ <div class="header-actions">
8
+ <div class="mode-tabs">
9
+ <button class="mode-tab active" id="tab-log" onclick="switchTab('log')">Log</button>
10
+ <button class="mode-tab" id="tab-analytics" onclick="switchTab('analytics')">Analytics</button>
11
+ </div>
12
+ <button class="btn btn-danger btn-sm" onclick="clearAudit()">Clear All</button>
13
+ </div>
14
+ </header>
15
+ <div class="page-content">
16
+
17
+ <!-- ── LOG TAB ─────────────────────────────────────────────────────── -->
18
+ <div id="pane-log">
19
+ <div class="card" style="margin-bottom:12px;">
20
+ <form method="GET" action="/audit" style="display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap;">
21
+ <div class="form-group" style="flex:1;min-width:200px;margin:0">
22
+ <label class="form-label">Search</label>
23
+ <div class="search-wrap">
24
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
25
+ <input type="text" name="q" class="form-input search-input" placeholder="Filter by actor, action, target…" value="<%= q||'' %>"/>
26
+ </div>
27
+ </div>
28
+ <div class="form-group" style="margin:0">
29
+ <label class="form-label">Action</label>
30
+ <select name="action" class="form-select" style="width:160px;">
31
+ <option value="">All Actions</option>
32
+ <% ['login','logout','page_view','log_view','settings_update','role_change',
33
+ 'api_key_create','api_key_delete','user_create','user_delete',
34
+ 'alert_rule_create','alert_rule_delete','role_apps_update'].forEach(function(a){ %>
35
+ <option value="<%= a %>" <%=action===a?'selected':'' %>><%= a.replace(/_/g,' ') %></option>
36
+ <% }) %>
37
+ </select>
38
+ </div>
39
+ <button type="submit" class="btn btn-primary btn-sm">Filter</button>
40
+ <a href="/audit" class="btn btn-secondary btn-sm">Reset</a>
41
+ </form>
42
+ </div>
43
+
44
+ <div class="card">
45
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;">
46
+ <div style="font-size:12px;color:var(--text2);">
47
+ Showing <strong style="color:var(--text)"><%= entries.length %></strong> of <strong style="color:var(--text)"><%= total %></strong> entries
48
+ </div>
49
+ </div>
50
+ <% if(!entries.length){ %>
51
+ <div class="empty-state" style="padding:40px 0;">
52
+ <svg width="40" height="40" 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"/></svg>
53
+ <p>No audit entries yet</p>
54
+ </div>
55
+ <% } else { %>
56
+ <div class="table-wrap">
57
+ <table>
58
+ <thead><tr><th>Time</th><th>Actor</th><th>Action</th><th>Target</th><th>IP</th></tr></thead>
59
+ <tbody>
60
+ <% entries.forEach(function(e){ %>
61
+ <tr>
62
+ <td style="font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--text3);white-space:nowrap;">
63
+ <%= e.ts ? new Date(e.ts).toLocaleString() : '—' %>
64
+ </td>
65
+ <td><span style="font-weight:600;color:var(--accent-l);font-family:'JetBrains Mono',monospace;font-size:12px;"><%= e.actor||'—' %></span></td>
66
+ <td>
67
+ <% var ac=e.action||''; %>
68
+ <span class="badge <%=
69
+ ac.includes('delete')||ac.includes('fail') ? 'badge-error' :
70
+ ac.includes('create')||ac.includes('enable') ? 'badge-green' :
71
+ ac==='login'||ac==='logout' ? 'badge-info' :
72
+ ac==='page_view'||ac==='log_view' ? 'badge-debug' :
73
+ 'badge-debug'
74
+ %>" style="font-size:10px;text-transform:none;"><%= ac.replace(/_/g,' ') %></span>
75
+ </td>
76
+ <td style="font-family:'JetBrains Mono',monospace;font-size:12px;color:var(--text2);max-width:200px;" class="truncate">
77
+ <%= e.target||'—' %>
78
+ <% if(e.durationSec&&e.durationSec>0){%><span style="font-size:10px;color:var(--text3);margin-left:6px;"><%=e.durationSec%>s</span><%}%>
79
+ </td>
80
+ <td style="font-size:11px;color:var(--text3);font-family:'JetBrains Mono',monospace;"><%= e.ip||'—' %></td>
81
+ </tr>
82
+ <% }) %>
83
+ </tbody>
84
+ </table>
85
+ </div>
86
+ <% } %>
87
+ </div>
88
+ </div>
89
+
90
+ <!-- ── ANALYTICS TAB ────────────────────────────────────────────────── -->
91
+ <div id="pane-analytics" style="display:none;">
92
+ <div style="display:flex;align-items:center;gap:10px;margin-bottom:12px;">
93
+ <span style="font-size:12px;color:var(--text2);">Period:</span>
94
+ <select id="period-sel" class="form-select" style="width:120px;" onchange="loadAnalytics(this.value)">
95
+ <option value="7">Last 7 days</option>
96
+ <option value="14">Last 14 days</option>
97
+ <option value="30">Last 30 days</option>
98
+ <option value="90">Last 90 days</option>
99
+ </select>
100
+ <span id="analytics-loading" style="font-size:11px;color:var(--text3);display:none;">Loading…</span>
101
+ </div>
102
+
103
+ <!-- Summary cards -->
104
+ <div class="stats-grid" style="margin-bottom:14px;" id="a-summary">
105
+ <div class="stat-card"><div><div class="stat-value" id="a-total">—</div><div class="stat-label">Total Events</div></div></div>
106
+ <div class="stat-card"><div><div class="stat-value" id="a-users">—</div><div class="stat-label">Unique Users</div></div></div>
107
+ <div class="stat-card"><div><div class="stat-value" id="a-settings">—</div><div class="stat-label">Settings Changes</div></div></div>
108
+ <div class="stat-card"><div><div class="stat-value" id="a-logins">—</div><div class="stat-label">Logins (period)</div></div></div>
109
+ </div>
110
+
111
+ <div style="display:grid;grid-template-columns:1.5fr 1fr;gap:12px;margin-bottom:12px;">
112
+ <!-- Activity chart -->
113
+ <div class="card">
114
+ <div class="card-title" style="margin-bottom:12px;">Daily Activity</div>
115
+ <canvas id="activity-chart" height="120"></canvas>
116
+ </div>
117
+ <!-- Top users -->
118
+ <div class="card">
119
+ <div class="card-title" style="margin-bottom:10px;">Most Active Users</div>
120
+ <div id="top-users-list"></div>
121
+ </div>
122
+ </div>
123
+
124
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px;">
125
+ <!-- Top pages -->
126
+ <div class="card">
127
+ <div class="card-title" style="margin-bottom:10px;">
128
+ Most Visited Pages
129
+ <span style="font-size:10px;font-weight:400;color:var(--text3);margin-left:6px;">with avg time on page</span>
130
+ </div>
131
+ <div id="top-pages-list"></div>
132
+ </div>
133
+ <!-- Top services accessed -->
134
+ <div class="card">
135
+ <div class="card-title" style="margin-bottom:10px;">Most Viewed Services</div>
136
+ <div id="top-services-list"></div>
137
+ </div>
138
+ </div>
139
+
140
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
141
+ <!-- Action breakdown -->
142
+ <div class="card">
143
+ <div class="card-title" style="margin-bottom:10px;">Action Breakdown</div>
144
+ <div id="top-actions-list"></div>
145
+ </div>
146
+ <!-- Security events -->
147
+ <div class="card">
148
+ <div class="card-title" style="margin-bottom:10px;">
149
+ Security Events
150
+ <span style="font-size:10px;font-weight:400;color:var(--text3);margin-left:6px;">logins, key changes, role changes</span>
151
+ </div>
152
+ <div id="security-list" style="max-height:260px;overflow-y:auto;"></div>
153
+ </div>
154
+ </div>
155
+ </div>
156
+
157
+ </div>
158
+ </div>
159
+ </div>
160
+
161
+ <style>
162
+ .mode-tabs{display:flex;gap:0;background:var(--surface2);border:1px solid var(--border);border-radius:6px;overflow:hidden;}
163
+ .mode-tab{padding:5px 14px;font-size:11px;font-weight:500;cursor:pointer;border:none;background:none;color:var(--text3);transition:all .15s;}
164
+ .mode-tab.active{background:var(--accent);color:#fff;}
165
+ .bar-wrap{display:flex;align-items:center;gap:8px;margin-bottom:5px;}
166
+ .bar-label{font-size:11px;color:var(--text2);width:130px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex-shrink:0;}
167
+ .bar-track{flex:1;height:6px;background:var(--surface3);border-radius:3px;overflow:hidden;}
168
+ .bar-fill{height:100%;border-radius:3px;background:var(--accent);transition:width .4s;}
169
+ .bar-count{font-size:11px;color:var(--text3);min-width:30px;text-align:right;}
170
+ </style>
171
+
172
+ <script>
173
+ function switchTab(t) {
174
+ ['log','analytics'].forEach(n => {
175
+ document.getElementById('pane-'+n).style.display = n===t ? '' : 'none';
176
+ document.getElementById('tab-'+n).classList.toggle('active', n===t);
177
+ });
178
+ if (t==='analytics') loadAnalytics(document.getElementById('period-sel').value);
179
+ }
180
+
181
+ async function loadAnalytics(days) {
182
+ const loading = document.getElementById('analytics-loading');
183
+ loading.style.display = '';
184
+ try {
185
+ const r = await fetch('/api/audit/analytics?days='+days);
186
+ const d = await r.json();
187
+ loading.style.display = 'none';
188
+
189
+ // Summary
190
+ document.getElementById('a-total').textContent = d.totalEvents.toLocaleString();
191
+ document.getElementById('a-users').textContent = d.uniqueUsers;
192
+ document.getElementById('a-settings').textContent = d.settingsChanges;
193
+ document.getElementById('a-logins').textContent = (d.loginTimeline||[]).reduce((s,l)=>s+l.logins,0);
194
+
195
+ // Activity bar chart (canvas)
196
+ drawBarChart('activity-chart', d.dailyActivity, 'date', 'count');
197
+
198
+ // Top users
199
+ renderBars('top-users-list', d.topUsers, 'actor', 'count');
200
+ // Top pages
201
+ renderPagesWithTime('top-pages-list', d.topPages);
202
+ // Top services
203
+ renderBars('top-services-list', d.topServices, 'service', 'views');
204
+ // Action breakdown
205
+ renderBars('top-actions-list', d.topActions, 'action', 'count', a=>a.replace(/_/g,' '));
206
+ // Security events
207
+ renderSecurityEvents('security-list', d.securityEvents||[]);
208
+ } catch(e) {
209
+ loading.style.display = 'none';
210
+ console.error('Analytics error', e);
211
+ }
212
+ }
213
+
214
+ function drawBarChart(canvasId, data, labelKey, valueKey) {
215
+ const canvas = document.getElementById(canvasId);
216
+ if (!canvas) return;
217
+ const ctx = canvas.getContext('2d');
218
+ const w = canvas.offsetWidth || 400;
219
+ const h = canvas.height;
220
+ canvas.width = w;
221
+ ctx.clearRect(0,0,w,h);
222
+
223
+ if (!data || !data.length) return;
224
+ const max = Math.max(...data.map(d=>d[valueKey]), 1);
225
+ const bw = Math.max(4, (w - data.length*2) / data.length);
226
+ const pad = 20;
227
+
228
+ // X axis line
229
+ ctx.strokeStyle = getComputedStyle(document.documentElement).getPropertyValue('--border') || '#2a2a45';
230
+ ctx.beginPath(); ctx.moveTo(0,h-pad); ctx.lineTo(w,h-pad); ctx.stroke();
231
+
232
+ // bars
233
+ const accentR = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--accent').match(/\d+/)?.[0]||'99');
234
+ const accentG = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--accent').match(/\d+/)?.[1]||'102');
235
+ const accentB = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--accent').match(/\d+/)?.[2]||'241');
236
+ ctx.fillStyle = `rgba(${accentR},${accentG},${accentB},0.7)`;
237
+
238
+ data.forEach((item, i) => {
239
+ const bh = item[valueKey] / max * (h - pad - 4);
240
+ const x = i * (bw + 2);
241
+ const y = h - pad - bh;
242
+ ctx.fillRect(x, y, bw, bh);
243
+ // Date label every ~4 bars
244
+ if (data.length <= 14 || i % Math.ceil(data.length/7) === 0) {
245
+ ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--text3') || '#5a5a78';
246
+ ctx.font = '9px Inter,system-ui,sans-serif';
247
+ ctx.fillText(item[labelKey].slice(5), x, h-4);
248
+ ctx.fillStyle = `rgba(${accentR},${accentG},${accentB},0.7)`;
249
+ }
250
+ });
251
+ }
252
+
253
+ function renderBars(containerId, data, labelKey, countKey, labelFmt) {
254
+ const el = document.getElementById(containerId);
255
+ if (!el) return;
256
+ if (!data || !data.length) { el.innerHTML='<div style="color:var(--text3);font-size:12px;text-align:center;padding:12px">No data</div>'; return; }
257
+ const max = Math.max(...data.map(d=>d[countKey]),1);
258
+ el.innerHTML = data.slice(0,8).map(item =>
259
+ '<div class="bar-wrap">'
260
+ +'<div class="bar-label" title="'+(labelFmt?labelFmt(item[labelKey]):item[labelKey])+'">'+escHtml(labelFmt?labelFmt(item[labelKey]):item[labelKey])+'</div>'
261
+ +'<div class="bar-track"><div class="bar-fill" style="width:'+Math.round(item[countKey]/max*100)+'%"></div></div>'
262
+ +'<div class="bar-count">'+item[countKey]+'</div>'
263
+ +'</div>'
264
+ ).join('');
265
+ }
266
+
267
+ function renderPagesWithTime(containerId, data) {
268
+ const el = document.getElementById(containerId);
269
+ if (!el) return;
270
+ if (!data || !data.length) { el.innerHTML='<div style="color:var(--text3);font-size:12px;text-align:center;padding:12px">No data</div>'; return; }
271
+ const max = Math.max(...data.map(d=>d.views),1);
272
+ el.innerHTML = data.slice(0,8).map(item =>
273
+ '<div class="bar-wrap">'
274
+ +'<div class="bar-label" title="'+item.page+'">'+escHtml(item.page)+'</div>'
275
+ +'<div class="bar-track"><div class="bar-fill" style="width:'+Math.round(item.views/max*100)+'%"></div></div>'
276
+ +'<div class="bar-count">'+item.views+'</div>'
277
+ +'<div style="font-size:10px;color:var(--text3);min-width:40px;text-align:right;">'+(item.avgDurationSec>0?item.avgDurationSec+'s':'')+'</div>'
278
+ +'</div>'
279
+ ).join('');
280
+ }
281
+
282
+ function renderSecurityEvents(containerId, events) {
283
+ const el = document.getElementById(containerId);
284
+ if (!el) return;
285
+ if (!events.length) { el.innerHTML='<div style="color:var(--text3);font-size:12px;text-align:center;padding:12px">No security events</div>'; return; }
286
+ el.innerHTML = events.map(e =>
287
+ '<div style="padding:6px 0;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:8px;">'
288
+ +'<span class="badge badge-'+(e.action?.includes('fail')?'error':e.action?.includes('delete')?'error':'info')+'" style="font-size:9px;text-transform:none;">'+escHtml(e.action?.replace(/_/g,' ')||'')+'</span>'
289
+ +'<span style="font-size:12px;color:var(--accent-l);">'+escHtml(e.actor||'')+'</span>'
290
+ +'<span style="font-size:11px;color:var(--text2);">'+escHtml(e.target||'')+'</span>'
291
+ +'<span style="font-size:10px;color:var(--text3);margin-left:auto;white-space:nowrap;">'+new Date(e.ts).toLocaleString()+'</span>'
292
+ +'</div>'
293
+ ).join('');
294
+ }
295
+
296
+ async function clearAudit() {
297
+ if (!confirm('Clear all audit log entries? This cannot be undone.')) return;
298
+ const r = await fetch('/api/audit', { method:'DELETE' });
299
+ const d = await r.json();
300
+ if (!r.ok) { toast(d.error,'error'); return; }
301
+ toast('Audit log cleared','success');
302
+ location.reload();
303
+ }
304
+
305
+ function escHtml(s){ return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
306
+
307
+ // Redraw charts on resize
308
+ window.addEventListener('resize', () => {
309
+ if (document.getElementById('pane-analytics').style.display !== 'none') {
310
+ loadAnalytics(document.getElementById('period-sel').value);
311
+ }
312
+ });
313
+ </script>
314
+ </body></html>