@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,279 @@
1
+ <%- include('partials/head', { title: 'Role Config' }) %>
2
+ <style>
3
+ .role-card { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius-lg); padding:18px; margin-bottom:14px; }
4
+ .role-card-header { display:flex; align-items:center; gap:10px; margin-bottom:16px; }
5
+ .role-dot { width:13px; height:13px; border-radius:50%; flex-shrink:0; }
6
+ .role-label { font-size:14px; font-weight:700; color:var(--text); }
7
+ .role-id { font-size:11px; color:var(--text3); font-family:'JetBrains Mono',monospace; margin-top:1px; }
8
+ .builtin-badge { font-size:9px; padding:1px 7px; background:rgba(99,102,241,.15); color:var(--accent-l); border-radius:3px; font-weight:600; letter-spacing:.3px; text-transform:uppercase; }
9
+ .perm-section { margin-bottom:14px; }
10
+ .perm-section-label { font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.7px; color:var(--text3); margin-bottom:8px; display:flex; align-items:center; gap:6px; }
11
+ .perm-section-label::after { content:''; flex:1; height:1px; background:var(--border); }
12
+ .perm-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(175px,1fr)); gap:6px; }
13
+ .perm-item {
14
+ display:flex; align-items:center; gap:8px; padding:7px 11px;
15
+ border-radius:7px; background:var(--surface2); border:1px solid var(--border);
16
+ font-size:12px; color:var(--text2); cursor:pointer; transition:all .15s; user-select:none;
17
+ }
18
+ .perm-item:hover { border-color:var(--border2); color:var(--text); background:var(--surface3); }
19
+ .perm-item.on { border-color:var(--accent); background:var(--accent-dim); color:var(--accent-l); }
20
+ .perm-item.on .perm-check { background:var(--accent); border-color:var(--accent); }
21
+ .perm-item.on .perm-check::after { content:''; display:block; width:7px; height:7px; background:#fff; border-radius:1px; clip-path:polygon(14% 44%,0 65%,50% 100%,100% 16%,80% 0,43% 62%); }
22
+ .perm-check { width:15px; height:15px; border-radius:3px; border:1.5px solid var(--border2); flex-shrink:0; transition:all .15s; display:flex; align-items:center; justify-content:center; }
23
+ .new-role-form { background:var(--surface2); border:1px dashed var(--border2); border-radius:var(--radius-lg); padding:18px; margin-bottom:14px; }
24
+ .save-indicator { font-size:11px; color:var(--text3); display:none; margin-left:auto; }
25
+ .save-indicator.saving { display:block; color:var(--accent-l); }
26
+ .save-indicator.saved { display:block; color:var(--green); }
27
+ </style>
28
+ <div class="app-shell">
29
+ <%- include('partials/sidebar') %>
30
+ <div class="main-area">
31
+ <header class="top-header">
32
+ <div class="page-title">Role Configuration</div>
33
+ <div class="header-actions">
34
+ <span style="font-size:12px;color:var(--text3);">Page &amp; card permissions take effect immediately</span>
35
+ <button class="btn btn-secondary btn-sm" onclick="toggleNewForm()">
36
+ <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>
37
+ New Role
38
+ </button>
39
+ </div>
40
+ </header>
41
+ <div class="page-content">
42
+
43
+ <!-- New Role Form -->
44
+ <div class="new-role-form" id="new-role-form" style="display:none;">
45
+ <div style="font-size:13px;font-weight:600;color:var(--text);margin-bottom:14px;">Create Custom Role</div>
46
+ <div style="display:grid;grid-template-columns:1fr 1fr 1fr auto;gap:10px;align-items:end;margin-bottom:14px;">
47
+ <div class="form-group" style="margin:0"><label class="form-label">Role ID</label><input type="text" id="nr-id" class="form-input" placeholder="e.g. operator"/></div>
48
+ <div class="form-group" style="margin:0"><label class="form-label">Display Label</label><input type="text" id="nr-label" class="form-input" placeholder="e.g. Operator"/></div>
49
+ <div class="form-group" style="margin:0"><label class="form-label">Badge Color</label>
50
+ <div style="display:flex;gap:6px;align-items:center;">
51
+ <input type="color" id="nr-color" value="#6366f1" style="width:38px;height:36px;border:none;background:none;cursor:pointer;border-radius:4px;padding:0;"/>
52
+ <span id="nr-color-preview" style="font-size:12px;color:var(--text2);">#6366f1</span>
53
+ </div>
54
+ </div>
55
+ <div style="display:flex;gap:8px;">
56
+ <button class="btn btn-primary btn-sm" onclick="createRole()">Create</button>
57
+ <button class="btn btn-secondary btn-sm" onclick="toggleNewForm()">Cancel</button>
58
+ </div>
59
+ </div>
60
+ </div>
61
+
62
+ <!-- Role Cards -->
63
+ <% const roleNames = Object.keys(rolesData); %>
64
+ <% roleNames.forEach(function(roleName) { %>
65
+ <% const role = rolesData[roleName]; %>
66
+ <div class="role-card" id="role-card-<%=roleName%>">
67
+ <div class="role-card-header">
68
+ <div class="role-dot" id="dot-<%=roleName%>" style="background:<%=role.color||'#6b7280'%>"></div>
69
+ <div style="flex:1;">
70
+ <div class="role-label" id="label-display-<%=roleName%>"><%=role.label||roleName%></div>
71
+ <div class="role-id"><%=roleName%></div>
72
+ </div>
73
+ <span id="save-ind-<%=roleName%>" class="save-indicator"></span>
74
+ <% if (role.isBuiltIn) { %><span class="builtin-badge">built-in</span><% } %>
75
+ <% if (!role.isBuiltIn) { %>
76
+ <button class="btn btn-danger btn-xs" onclick="deleteRole('<%=roleName%>')">Delete</button>
77
+ <% } %>
78
+
79
+ <!-- Log Access (per-service) -->
80
+ <div class="perm-section">
81
+ <div class="perm-section-label">Log Access <span style="font-size:9px;font-weight:400;text-transform:none;color:var(--text3);">(empty = all services)</span></div>
82
+ <div style="margin-bottom:8px;font-size:11px;color:var(--text2);">
83
+ Restrict this role to specific services. Leave blank to allow all.
84
+ </div>
85
+ <div id="apps-<%=roleName%>" style="display:flex;gap:6px;flex-wrap:wrap;min-height:28px;margin-bottom:8px;">
86
+ <% (role.allowedApps||[]).forEach(function(app){ %>
87
+ <span class="chip active" style="font-size:11px;" data-app="<%=app%>" onclick="removeApp('<%=roleName%>','<%=app%>')">
88
+ <%=app%> <span style="margin-left:3px;opacity:.6;cursor:pointer;">✕</span>
89
+ </span>
90
+ <% }) %>
91
+ </div>
92
+ <div style="display:flex;gap:6px;align-items:center;">
93
+ <input type="text" id="app-input-<%=roleName%>" class="form-input" style="width:200px;padding:5px 10px;font-size:12px;" placeholder="appName, e.g. payment-service" onkeydown="if(event.key==='Enter'){event.preventDefault();addApp('<%=roleName%>');}"/>
94
+ <button type="button" class="btn btn-secondary btn-xs" onclick="addApp('<%=roleName%>')">+ Add</button>
95
+ </div>
96
+ </div>
97
+ <button class="btn btn-primary btn-sm" onclick="saveRole('<%=roleName%>')">
98
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17,21 17,13 7,13 7,21"/><polyline points="7,3 7,8 15,8"/></svg>
99
+ Save
100
+ </button>
101
+ </div>
102
+
103
+ <!-- Color + label editors (for built-in and custom) -->
104
+ <div style="display:flex;gap:10px;align-items:center;margin-bottom:14px;padding:10px 12px;background:var(--surface2);border-radius:8px;border:1px solid var(--border);">
105
+ <div style="display:flex;align-items:center;gap:8px;flex:1;">
106
+ <label style="font-size:11px;color:var(--text3);font-weight:500;">Label:</label>
107
+ <input type="text" class="form-input" id="label-<%=roleName%>" value="<%=role.label||roleName%>" style="max-width:200px;padding:5px 10px;"/>
108
+ </div>
109
+ <div style="display:flex;align-items:center;gap:8px;">
110
+ <label style="font-size:11px;color:var(--text3);font-weight:500;">Color:</label>
111
+ <input type="color" id="color-<%=roleName%>" value="<%=role.color||'#6b7280'%>" style="width:32px;height:30px;border:none;background:none;cursor:pointer;border-radius:4px;padding:0;" oninput="document.getElementById('dot-<%=roleName%>').style.background=this.value"/>
112
+ </div>
113
+ </div>
114
+
115
+ <!-- Pages -->
116
+ <div class="perm-section">
117
+ <div class="perm-section-label">Pages</div>
118
+ <div class="perm-grid">
119
+ <% allPages.forEach(function(page) { %>
120
+ <% const isOn = (role.pages||[]).includes(page.id); %>
121
+ <div class="perm-item <%=isOn?'on':''%>" data-role="<%=roleName%>" data-type="page" data-id="<%=page.id%>" onclick="togglePerm(this)">
122
+ <div class="perm-check"></div>
123
+ <span><%=page.label%></span>
124
+ <% if (page.adminOnly) { %><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-left:auto;opacity:.4"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg><% } %>
125
+ </div>
126
+ <% }) %>
127
+ </div>
128
+ </div>
129
+
130
+ <!-- Cards (grouped by section) -->
131
+ <%
132
+ const sections = [...new Set(allCards.map(c=>c.section))];
133
+ %>
134
+ <% sections.forEach(function(section) { %>
135
+ <div class="perm-section">
136
+ <div class="perm-section-label"><%=section%></div>
137
+ <div class="perm-grid">
138
+ <% allCards.filter(c=>c.section===section).forEach(function(card) { %>
139
+ <% const isOn = (role.cards||[]).includes(card.id); %>
140
+ <div class="perm-item <%=isOn?'on':''%>" data-role="<%=roleName%>" data-type="card" data-id="<%=card.id%>" onclick="togglePerm(this)">
141
+ <div class="perm-check"></div>
142
+ <span><%=card.label%></span>
143
+ </div>
144
+ <% }) %>
145
+ </div>
146
+ </div>
147
+ <% }) %>
148
+ </div>
149
+ <% }) %>
150
+
151
+ </div>
152
+ </div>
153
+ </div>
154
+
155
+ <script>
156
+ // ── Toggle perm item ────────────────────────────────────────────────────────
157
+ function togglePerm(el) {
158
+ el.classList.toggle('on');
159
+ }
160
+
161
+ function getPerms(roleName, type) {
162
+ return [...document.querySelectorAll(
163
+ '[data-role="'+roleName+'"][data-type="'+type+'"].on'
164
+ )].map(el => el.dataset.id);
165
+ }
166
+
167
+ function toggleNewForm() {
168
+ const f = document.getElementById('new-role-form');
169
+ f.style.display = f.style.display === 'none' ? '' : 'none';
170
+ }
171
+
172
+ // Color preview in "new role" form
173
+ const nrColorInput = document.getElementById('nr-color');
174
+ if (nrColorInput) {
175
+ nrColorInput.addEventListener('input', function() {
176
+ const preview = document.getElementById('nr-color-preview');
177
+ if (preview) preview.textContent = this.value;
178
+ });
179
+ }
180
+
181
+ // ── Save role ────────────────────────────────────────────────────────────────
182
+ async function saveRole(name) {
183
+ const pages = getPerms(name, 'page');
184
+ if (pages.length === 0) {
185
+ toast('Select at least 1 page permission before saving', 'error');
186
+ return;
187
+ }
188
+ const cards = getPerms(name, 'card');
189
+ const label = document.getElementById('label-' + name)?.value || name;
190
+ const color = document.getElementById('color-' + name)?.value || '#6b7280';
191
+ const allowedApps = getApps(name);
192
+
193
+ const ind = document.getElementById('save-ind-' + name);
194
+ if (ind) { ind.textContent = 'Saving…'; ind.className = 'save-indicator saving'; }
195
+
196
+ try {
197
+ const r = await fetch('/api/role-config/' + encodeURIComponent(name), {
198
+ method: 'PUT',
199
+ headers: { 'Content-Type': 'application/json' },
200
+ body: JSON.stringify({ label, color, pages, cards, allowedApps }),
201
+ });
202
+ const d = await r.json();
203
+ if (!r.ok) { toast(d.error || 'Save failed', 'error'); if(ind){ind.textContent='Failed';ind.className='save-indicator';} return; }
204
+
205
+ // Update dot color immediately
206
+ const dot = document.getElementById('dot-' + name);
207
+ if (dot) dot.style.background = color;
208
+ const labelDisplay = document.getElementById('label-display-' + name);
209
+ if (labelDisplay) labelDisplay.textContent = label;
210
+
211
+ if (ind) { ind.textContent = 'Saved ✓'; ind.className = 'save-indicator saved'; setTimeout(()=>{ind.style.display='none';ind.className='save-indicator';},2500); }
212
+ toast('"' + name + '" permissions saved — takes effect on next page load', 'success');
213
+ } catch(e) {
214
+ toast('Network error', 'error');
215
+ if (ind) { ind.textContent='Error'; ind.className='save-indicator'; }
216
+ }
217
+ }
218
+
219
+ // ── Create role ──────────────────────────────────────────────────────────────
220
+ async function createRole() {
221
+ const id = document.getElementById('nr-id').value.trim();
222
+ const label = document.getElementById('nr-label').value.trim();
223
+ const color = document.getElementById('nr-color').value;
224
+ if (!id) { toast('Role ID is required', 'error'); return; }
225
+
226
+ const r = await fetch('/api/role-config/' + encodeURIComponent(id), {
227
+ method: 'PUT',
228
+ headers: { 'Content-Type': 'application/json' },
229
+ body: JSON.stringify({ label: label || id, color, pages:[], cards:[] }),
230
+ });
231
+ const d = await r.json();
232
+ if (!r.ok) { toast(d.error || 'Create failed', 'error'); return; }
233
+ toast('Role "' + id + '" created', 'success');
234
+ location.reload();
235
+ }
236
+
237
+ // ── Delete role ──────────────────────────────────────────────────────────────
238
+ async function deleteRole(name) {
239
+ if (!confirm('Delete role "' + name + '"?\nUsers assigned this role will keep the role name but lose all page access until reassigned.')) return;
240
+ const r = await fetch('/api/role-config/' + encodeURIComponent(name), { method: 'DELETE' });
241
+ const d = await r.json();
242
+ if (!r.ok) { toast(d.error || 'Delete failed', 'error'); return; }
243
+ toast('Role "' + name + '" deleted', 'success');
244
+ const card = document.getElementById('role-card-' + name);
245
+ if (card) { card.style.opacity='0'; card.style.transition='opacity .2s'; setTimeout(()=>card.remove(),200); }
246
+ }
247
+
248
+ // ── Per-service log access control ────────────────────────────────────────
249
+ function addApp(roleName) {
250
+ const input = document.getElementById('app-input-'+roleName);
251
+ const val = input.value.trim();
252
+ if (!val) return;
253
+ const container = document.getElementById('apps-'+roleName);
254
+ // Prevent duplicates
255
+ if ([...container.querySelectorAll('.chip')].some(c => c.dataset.app === val)) {
256
+ toast('Already added', 'error'); return;
257
+ }
258
+ const chip = document.createElement('span');
259
+ chip.className = 'chip active';
260
+ chip.dataset.app = val;
261
+ chip.style.fontSize = '11px';
262
+ chip.innerHTML = val + ' <span style="margin-left:3px;opacity:.6;cursor:pointer;" onclick="removeApp(\''+roleName+'\',\''+val+'\')">✕</span>';
263
+ container.appendChild(chip);
264
+ input.value = '';
265
+ }
266
+
267
+ function removeApp(roleName, appName) {
268
+ const container = document.getElementById('apps-'+roleName);
269
+ const chips = container.querySelectorAll('.chip');
270
+ chips.forEach(c => { if (c.dataset.app === appName || c.textContent.replace('✕','').trim() === appName) c.remove(); });
271
+ }
272
+
273
+ function getApps(roleName) {
274
+ const container = document.getElementById('apps-'+roleName);
275
+ if (!container) return [];
276
+ return [...container.querySelectorAll('.chip')].map(c => (c.dataset.app || c.textContent.replace('✕','').trim()).trim()).filter(Boolean);
277
+ }
278
+ </script>
279
+ </body></html>
@@ -0,0 +1,51 @@
1
+ <%- include('partials/head', { title: 'Saved Searches' }) %>
2
+ <div class="app-shell">
3
+ <%- include('partials/sidebar') %>
4
+ <div class="main-area">
5
+ <header class="top-header">
6
+ <div class="page-title">Saved Searches</div>
7
+ </header>
8
+ <div class="page-content">
9
+ <div class="card">
10
+ <% if (!searches || !searches.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 saved searches yet</p>
14
+ <p style="font-size:11px;margin-top:4px;color:var(--text3)">Go to Logs, search for something, then click <strong>Save Search</strong> in the header</p>
15
+ </div>
16
+ <% } else { %>
17
+ <div class="table-wrap">
18
+ <table>
19
+ <thead><tr><th>Name</th><th>Service</th><th>Level</th><th>Query</th><th>Date</th><th>Created</th><th></th></tr></thead>
20
+ <tbody>
21
+ <% searches.forEach(function(s){ %>
22
+ <tr>
23
+ <td style="font-weight:600;"><a href="<%= '/logs?'+[s.service?'service='+encodeURIComponent(s.service):'',s.date?'date='+s.date:'',s.level?'level='+s.level:'',s.q?'q='+encodeURIComponent(s.q):'',s.fromDate?'fromDate='+s.fromDate:'',s.toDate?'toDate='+s.toDate:''].filter(Boolean).join('&') %>" style="color:var(--accent-l);text-decoration:none;"><%=s.name%></a></td>
24
+ <td style="font-family:'JetBrains Mono',monospace;font-size:11px;"><%=s.service||'—'%></td>
25
+ <td><%if(s.level){%><span class="badge badge-<%=s.level==='error'?'error':s.level==='warn'?'warn':s.level==='info'?'info':'debug'%>" style="font-size:9px;"><%=s.level%></span><%}else{%><span style="color:var(--text3)">—</span><%}%></td>
26
+ <td style="font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--text2);"><%=s.q||'—'%></td>
27
+ <td style="font-size:11px;color:var(--text2);"><%=s.fromDate?s.fromDate+'→'+s.toDate:s.date||'—'%></td>
28
+ <td style="font-size:11px;color:var(--text3);"><%=new Date(s.createdAt).toLocaleDateString()%></td>
29
+ <td>
30
+ <div style="display:flex;gap:6px;">
31
+ <a href="<%= '/logs?'+[s.service?'service='+encodeURIComponent(s.service):'',s.date?'date='+s.date:'',s.level?'level='+s.level:'',s.q?'q='+encodeURIComponent(s.q):''].filter(Boolean).join('&') %>" class="btn btn-primary btn-xs">Run</a>
32
+ <button class="btn btn-danger btn-xs" onclick="del('<%=s.id%>',this)">Delete</button>
33
+ </div>
34
+ </td>
35
+ </tr>
36
+ <% }) %>
37
+ </tbody>
38
+ </table>
39
+ </div>
40
+ <% } %>
41
+ </div>
42
+ </div>
43
+ </div>
44
+ </div>
45
+ <script>
46
+ async function del(id, btn) {
47
+ const r=await fetch('/api/saved-searches/'+id,{method:'DELETE'});
48
+ if(r.ok){btn.closest('tr').remove();toast('Deleted','success');}
49
+ }
50
+ </script>
51
+ </body></html>
@@ -0,0 +1,142 @@
1
+ <%- include('partials/head', { title: 'Service Map' }) %>
2
+ <style>
3
+ #map-svg { width:100%; min-height:480px; background:var(--surface2); border-radius:var(--radius); }
4
+ .node { cursor:pointer; }
5
+ .node circle { transition:r .15s, fill .15s; }
6
+ .node:hover circle { r:24; }
7
+ .node text { font-family:'Inter',sans-serif; font-size:11px; fill:var(--text); pointer-events:none; }
8
+ .edge { stroke:var(--border2); stroke-width:1.5; fill:none; marker-end:url(#arrow); }
9
+ .edge-label { font-family:'Inter',sans-serif; font-size:9px; fill:var(--text3); }
10
+ </style>
11
+ <div class="app-shell">
12
+ <%- include('partials/sidebar') %>
13
+ <div class="main-area">
14
+ <header class="top-header">
15
+ <div class="page-title">Service Map</div>
16
+ <div class="header-actions">
17
+ <input type="date" class="form-input" id="map-date" value="<%= date %>" max="<%= today %>" style="width:150px;" onchange="location='?date='+this.value"/>
18
+ <button class="btn btn-secondary btn-sm" onclick="location.reload()">
19
+ <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,11 16,11"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 11"/></svg>
20
+ Refresh
21
+ </button>
22
+ </div>
23
+ </header>
24
+ <div class="page-content">
25
+ <% if (!mapData || !mapData.nodes || !mapData.nodes.length) { %>
26
+ <div class="card"><div class="empty-state" style="padding:60px 0;">
27
+ <svg width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
28
+ <p>No services found for <strong><%= date %></strong></p>
29
+ <p style="font-size:11px;margin-top:4px;color:var(--text3)">Ingest logs with matching service names to see dependency links</p>
30
+ </div></div>
31
+ <% } else { %>
32
+ <div class="card" style="padding:0;overflow:hidden;">
33
+ <div style="padding:14px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;">
34
+ <div style="font-size:12px;color:var(--text2);"><strong><%= mapData.nodes.length %></strong> services · <strong><%= mapData.edges.length %></strong> connections on <%= date %></div>
35
+ <div style="font-size:11px;color:var(--text3);">Edge weight = co-occurrence count in logs</div>
36
+ </div>
37
+ <svg id="map-svg" viewBox="0 0 900 500" xmlns="http://www.w3.org/2000/svg">
38
+ <defs>
39
+ <marker id="arrow" markerWidth="8" markerHeight="8" refX="8" refY="3" orient="auto">
40
+ <path d="M0,0 L0,6 L8,3 z" fill="var(--border2)"/>
41
+ </marker>
42
+ </defs>
43
+ <script type="text/javascript">
44
+ // Simple force-directed layout using spring simulation
45
+ const nodes = <%- JSON.stringify(mapData.nodes) %>;
46
+ const edges = <%- JSON.stringify(mapData.edges) %>;
47
+ const W=900, H=500, cx=W/2, cy=H/2;
48
+ const r=18, pad=80;
49
+
50
+ // Initialize positions in a circle
51
+ nodes.forEach(function(n,i){
52
+ const angle = (2*Math.PI*i)/nodes.length;
53
+ const radius = Math.min(W,H)/2 - pad;
54
+ n.x = cx + radius*Math.cos(angle);
55
+ n.y = cy + radius*Math.sin(angle);
56
+ n.vx=0; n.vy=0;
57
+ });
58
+
59
+ // Simple spring iterations
60
+ for(let iter=0;iter<200;iter++){
61
+ // Repulsion
62
+ for(let i=0;i<nodes.length;i++) for(let j=i+1;j<nodes.length;j++){
63
+ const dx=nodes[j].x-nodes[i].x, dy=nodes[j].y-nodes[i].y;
64
+ const dist=Math.sqrt(dx*dx+dy*dy)||1;
65
+ const force=4000/(dist*dist);
66
+ nodes[i].vx-=force*dx/dist; nodes[i].vy-=force*dy/dist;
67
+ nodes[j].vx+=force*dx/dist; nodes[j].vy+=force*dy/dist;
68
+ }
69
+ // Attraction along edges
70
+ edges.forEach(function(e){
71
+ const s=nodes.find(n=>n.id===e.source), t=nodes.find(n=>n.id===e.target);
72
+ if(!s||!t) return;
73
+ const dx=t.x-s.x, dy=t.y-s.y, dist=Math.sqrt(dx*dx+dy*dy)||1;
74
+ const force=0.02*(dist-120);
75
+ s.vx+=force*dx/dist; s.vy+=force*dy/dist;
76
+ t.vx-=force*dx/dist; t.vy-=force*dy/dist;
77
+ });
78
+ // Center gravity
79
+ nodes.forEach(function(n){
80
+ n.vx+=(cx-n.x)*0.003; n.vy+=(cy-n.y)*0.003;
81
+ n.x+=n.vx*0.6; n.y+=n.vy*0.6; n.vx*=0.7; n.vy*=0.7;
82
+ n.x=Math.max(r+10,Math.min(W-r-10,n.x));
83
+ n.y=Math.max(r+10,Math.min(H-r-10,n.y));
84
+ });
85
+ }
86
+
87
+ // Render edges
88
+ const svgNS='http://www.w3.org/2000/svg';
89
+ const svg=document.getElementById('map-svg');
90
+ edges.forEach(function(e){
91
+ const s=nodes.find(n=>n.id===e.source), t=nodes.find(n=>n.id===e.target);
92
+ if(!s||!t) return;
93
+ const dx=t.x-s.x,dy=t.y-s.y,dist=Math.sqrt(dx*dx+dy*dy);
94
+ const ex=t.x-(dx/dist)*(r+10), ey=t.y-(dy/dist)*(r+10);
95
+ const l=document.createElementNS(svgNS,'line');
96
+ l.setAttribute('x1',s.x); l.setAttribute('y1',s.y);
97
+ l.setAttribute('x2',ex); l.setAttribute('y2',ey);
98
+ l.setAttribute('class','edge');
99
+ const opacity=Math.min(1,0.2+e.weight/50);
100
+ l.setAttribute('stroke-opacity',opacity);
101
+ svg.appendChild(l);
102
+ if(e.weight>1){
103
+ const lbl=document.createElementNS(svgNS,'text');
104
+ lbl.setAttribute('x',(s.x+t.x)/2); lbl.setAttribute('y',(s.y+t.y)/2-4);
105
+ lbl.setAttribute('class','edge-label'); lbl.setAttribute('text-anchor','middle');
106
+ lbl.textContent=e.weight;
107
+ svg.appendChild(lbl);
108
+ }
109
+ });
110
+
111
+ // Render nodes
112
+ const COLORS=['#6366f1','#8b5cf6','#06b6d4','#10b981','#f59e0b','#ef4444','#ec4899','#14b8a6','#f97316','#84cc16'];
113
+ nodes.forEach(function(n,i){
114
+ const g=document.createElementNS(svgNS,'g');
115
+ g.setAttribute('class','node');
116
+ g.setAttribute('transform','translate('+n.x+','+n.y+')');
117
+ const c=document.createElementNS(svgNS,'circle');
118
+ c.setAttribute('r',r); c.setAttribute('fill',COLORS[i%COLORS.length]);
119
+ c.setAttribute('fill-opacity','0.85');
120
+ const initials=n.label.split(/[-_]/).map(s=>s[0]).join('').slice(0,2).toUpperCase();
121
+ const t=document.createElementNS(svgNS,'text');
122
+ t.setAttribute('text-anchor','middle'); t.setAttribute('dy','0.35em');
123
+ t.setAttribute('fill','#fff'); t.setAttribute('font-weight','600'); t.setAttribute('font-size','11');
124
+ t.textContent=initials;
125
+ const lbl=document.createElementNS(svgNS,'text');
126
+ lbl.setAttribute('text-anchor','middle'); lbl.setAttribute('dy',r+14);
127
+ lbl.setAttribute('font-size','10'); lbl.setAttribute('fill','var(--text2)');
128
+ lbl.textContent=n.label.slice(0,20);
129
+ g.appendChild(c); g.appendChild(t); g.appendChild(lbl);
130
+ g.setAttribute('title',n.label);
131
+ g.style.cursor='pointer';
132
+ g.onclick=function(){location='/logs?service='+encodeURIComponent(n.id);};
133
+ svg.appendChild(g);
134
+ });
135
+ </script>
136
+ </svg>
137
+ </div>
138
+ <% } %>
139
+ </div>
140
+ </div>
141
+ </div>
142
+ </body></html>