@logboard/cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/.env.example +37 -0
  2. package/README.md +200 -0
  3. package/bin/logboard +536 -0
  4. package/client/logger.js +309 -0
  5. package/config/index.js +142 -0
  6. package/config.js +2 -0
  7. package/controllers/AnalyticsController.js +46 -0
  8. package/controllers/ApiAnalyticsController.js +129 -0
  9. package/controllers/ApiKeyController.js +58 -0
  10. package/controllers/AuthController.js +131 -0
  11. package/controllers/HealthController.js +56 -0
  12. package/controllers/LogController.js +197 -0
  13. package/controllers/OrgController.js +152 -0
  14. package/controllers/RoleConfigController.js +20 -0
  15. package/controllers/SettingsController.js +39 -0
  16. package/controllers/StreamController.js +55 -0
  17. package/controllers/UiController.js +789 -0
  18. package/controllers/UserController.js +79 -0
  19. package/lib/batchWriter.js +57 -0
  20. package/lib/cleanup.js +67 -0
  21. package/lib/ejs.js +103 -0
  22. package/lib/emitter.js +5 -0
  23. package/lib/healthMonitor.js +245 -0
  24. package/lib/logger.js +21 -0
  25. package/lib/streams.js +32 -0
  26. package/lib/theme.js +77 -0
  27. package/lib/userStore.js +13 -0
  28. package/lib/utils.js +44 -0
  29. package/middleware/apiKey.js +82 -0
  30. package/middleware/auth.js +55 -0
  31. package/middleware/ipWhitelist.js +59 -0
  32. package/middleware/org.js +85 -0
  33. package/middleware/pageAccess.js +20 -0
  34. package/middleware/rateLimit.js +29 -0
  35. package/middleware/roles.js +11 -0
  36. package/package.json +77 -0
  37. package/routes/alerts.js +18 -0
  38. package/routes/analytics.js +26 -0
  39. package/routes/api-analytics.js +30 -0
  40. package/routes/api-keys.js +12 -0
  41. package/routes/archive.js +91 -0
  42. package/routes/audit.js +50 -0
  43. package/routes/auth.js +22 -0
  44. package/routes/bookmarks.js +13 -0
  45. package/routes/health.js +11 -0
  46. package/routes/logs.js +88 -0
  47. package/routes/metrics.js +66 -0
  48. package/routes/notifications.js +14 -0
  49. package/routes/orgs.js +98 -0
  50. package/routes/registration.js +202 -0
  51. package/routes/role-config.js +97 -0
  52. package/routes/saved-searches.js +12 -0
  53. package/routes/server.js +151 -0
  54. package/routes/settings.js +28 -0
  55. package/routes/status.js +21 -0
  56. package/routes/stream.js +11 -0
  57. package/routes/super.js +129 -0
  58. package/routes/ui.js +120 -0
  59. package/routes/users.js +13 -0
  60. package/server.js +172 -0
  61. package/services/AlertRulesService.js +323 -0
  62. package/services/AnalyticsService.js +665 -0
  63. package/services/ApiAnalyticsService.js +471 -0
  64. package/services/ApiKeyService.js +166 -0
  65. package/services/AuditService.js +249 -0
  66. package/services/AuthService.js +234 -0
  67. package/services/BookmarkService.js +49 -0
  68. package/services/GlobalSettingsService.js +44 -0
  69. package/services/LogService.js +1066 -0
  70. package/services/MetricsService.js +116 -0
  71. package/services/NotificationService.js +70 -0
  72. package/services/OrgService.js +217 -0
  73. package/services/ReportService.js +247 -0
  74. package/services/RoleConfigService.js +201 -0
  75. package/services/SavedSearchService.js +63 -0
  76. package/services/SettingsService.js +220 -0
  77. package/services/UserService.js +121 -0
  78. package/setup.js +132 -0
  79. package/views/404.ejs +8 -0
  80. package/views/alerts.ejs +190 -0
  81. package/views/analytics.ejs +209 -0
  82. package/views/api-analytics.ejs +660 -0
  83. package/views/api-keys.ejs +150 -0
  84. package/views/archive.ejs +123 -0
  85. package/views/audit.ejs +314 -0
  86. package/views/bookmarks.ejs +54 -0
  87. package/views/custom-dashboard.ejs +162 -0
  88. package/views/dashboard.ejs +186 -0
  89. package/views/diff.ejs +98 -0
  90. package/views/health.ejs +269 -0
  91. package/views/heatmap.ejs +126 -0
  92. package/views/insights.ejs +334 -0
  93. package/views/invite.ejs +74 -0
  94. package/views/live.ejs +299 -0
  95. package/views/login.ejs +64 -0
  96. package/views/logo.png +0 -0
  97. package/views/logs.ejs +754 -0
  98. package/views/notifications.ejs +58 -0
  99. package/views/partials/head.ejs +282 -0
  100. package/views/partials/sidebar.ejs +168 -0
  101. package/views/register.ejs +100 -0
  102. package/views/roles.ejs +279 -0
  103. package/views/saved-searches.ejs +51 -0
  104. package/views/service-map.ejs +142 -0
  105. package/views/settings.ejs +1159 -0
  106. package/views/sidebar.ejs +129 -0
  107. package/views/status.ejs +100 -0
  108. package/views/super-admin-admins.ejs +58 -0
  109. package/views/super-admin-analytics.ejs +49 -0
  110. package/views/super-admin-orgs.ejs +310 -0
  111. package/views/super-admin-profile.ejs +77 -0
  112. package/views/super-admin-settings.ejs +108 -0
  113. package/views/super-admin-system.ejs +46 -0
  114. package/views/users.ejs +153 -0
package/views/logs.ejs ADDED
@@ -0,0 +1,754 @@
1
+ <%- include('partials/head', { title: 'Logs' }) %>
2
+ <%
3
+ // Server‑side helper: unescape common escape sequences for display
4
+ function unescapeLog(str) {
5
+ if (!str) return '';
6
+ return str
7
+ .replace(/\\n/g, '\n')
8
+ .replace(/\\r/g, '\r')
9
+ .replace(/\\t/g, '\t')
10
+ .replace(/\\"/g, '"')
11
+ .replace(/\\\\/g, '\\');
12
+ }
13
+
14
+ // Ensure fromTime and toTime have default values (if not provided)
15
+ var fromTime = selected.fromTime || '00:00';
16
+ var toTime = selected.toTime || '23:59';
17
+ // For single date, ensure date has a value (today)
18
+ var singleDate = selected.date || (selected.fromDate ? '' : (new Date().toISOString().slice(0,10)));
19
+ %>
20
+ <style>
21
+ .time-range-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:8px 0 4px;border-top:1px solid var(--border);margin-top:8px;}
22
+ .date-range-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:6px 0 0;margin-top:4px;}
23
+ .log-stats-bar{display:flex;align-items:center;gap:12px;padding:8px 12px;background:var(--surface2);border-radius:var(--radius);margin-bottom:10px;border:1px solid var(--border);flex-wrap:wrap;}
24
+ .field-pills{display:flex;gap:4px;flex-wrap:wrap;margin-top:5px;}
25
+ .field-pill{font-size:10px;padding:1px 7px;border-radius:3px;background:var(--surface3);border:1px solid var(--border);color:var(--text2);cursor:pointer;font-family:'JetBrains Mono',monospace;}
26
+ .field-pill:hover{border-color:var(--accent);color:var(--accent-l);}
27
+ .field-pill .k{color:var(--text3);}
28
+ .trace-link{color:var(--accent-l);text-decoration:underline;cursor:pointer;font-family:'JetBrains Mono',monospace;font-size:11px;}
29
+ .modal-lg{display:none;position:fixed;inset:0;z-index:1000;background:rgba(0,0,0,.6);align-items:center;justify-content:center;backdrop-filter:blur(4px);}
30
+ .modal-lg.open{display:flex;}
31
+ .modal-inner{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);width:min(720px,96vw);max-height:80vh;overflow:hidden;display:flex;flex-direction:column;}
32
+ .modal-hdr{padding:14px 18px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;font-weight:600;font-size:13px;}
33
+ .modal-bdy{overflow-y:auto;padding:8px 0;}
34
+ .ctx-line{padding:5px 18px;font-family:'JetBrains Mono',monospace;font-size:11.5px;line-height:1.6;cursor:pointer;transition:background .1s;}
35
+ .ctx-line:hover{background:var(--surface2);}
36
+ .ctx-line.focus{background:rgba(99,102,241,.12);border-left:3px solid var(--accent);padding-left:15px;}
37
+ .ctx-line.level-error{border-left-color:var(--red);}
38
+ .ctx-line.level-warn{border-left-color:var(--yellow);}
39
+ .ss-chips{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px;}
40
+ .ss-chip{display:inline-flex;align-items:center;gap:5px;padding:3px 10px;border-radius:14px;background:var(--accent-dim);border:1px solid var(--accent);color:var(--accent-l);font-size:11px;font-weight:500;cursor:pointer;transition:all .15s;}
41
+ .ss-chip:hover{background:rgba(99,102,241,.25);}
42
+ .ss-chip .del{opacity:0;font-size:12px;margin-left:2px;line-height:1;}
43
+ .ss-chip:hover .del{opacity:.6;}
44
+ .mode-tabs{display:flex;gap:0;background:var(--surface2);border:1px solid var(--border);border-radius:6px;overflow:hidden;width:fit-content;}
45
+ .mode-tab{padding:5px 14px;font-size:11px;font-weight:500;cursor:pointer;border:none;background:none;color:var(--text3);transition:all .15s;}
46
+ .mode-tab.active{background:var(--accent);color:#fff;}
47
+ .time-picker-group{display:flex;align-items:center;gap:6px;background:var(--surface2);padding:2px 8px;border-radius:var(--radius);border:1px solid var(--border);}
48
+ .time-picker-group select{background:var(--surface);border:1px solid var(--border);border-radius:4px;padding:4px 6px;font-size:12px;color:var(--text);}
49
+ .quick-time-group{display:flex;gap:6px;align-items:center;flex-wrap:wrap;}
50
+ .quick-time-btn{padding:2px 8px;font-size:10px;background:var(--surface2);border:1px solid var(--border);border-radius:12px;cursor:pointer;transition:all .15s;color:var(--text2);}
51
+ .quick-time-btn:hover{background:var(--accent-dim);border-color:var(--accent);color:var(--accent-l);}
52
+ .log-json { white-space: pre-wrap; word-break: break-word; font-family: 'JetBrains Mono', monospace; font-size: 11px; margin: 0; background: var(--surface3); padding: 8px 12px; border-radius: 4px; }
53
+ .toast { position: fixed; bottom: 20px; right: 20px; z-index: 9999; padding: 10px 20px; border-radius: 8px; font-size: 13px; font-weight: 500; box-shadow: 0 4px 12px rgba(0,0,0,0.2); }
54
+ .toast-info { background: #3b82f6; color: #fff; }
55
+ .toast-error { background: #ef4444; color: #fff; }
56
+ .toast-success { background: #22c55e; color: #fff; }
57
+ </style>
58
+
59
+ <div class="app-shell">
60
+ <%- include('partials/sidebar') %>
61
+ <div class="main-area">
62
+ <header class="top-header">
63
+ <div class="page-title">Logs</div>
64
+ <div class="header-actions">
65
+ <button class="btn btn-secondary btn-sm" onclick="openTraceModal()">
66
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><polyline points="21,21 16.65,16.65"/></svg>
67
+ Trace ID
68
+ </button>
69
+ <% if (selected.service && (selected.date || selected.fromDate)) { %>
70
+ <button class="btn btn-secondary btn-sm" onclick="saveSearch()">
71
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>
72
+ Save Search
73
+ </button>
74
+ <a href="/api/logs/download/<%= selected.service %>/<%= selected.date %>?format=txt<%= selected.level?'&level='+selected.level:'' %><%= selected.q?'&q='+encodeURIComponent(selected.q):'' %>" class="btn btn-secondary btn-sm">
75
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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>
76
+ Download .txt
77
+ </a>
78
+ <% } %>
79
+ </div>
80
+ </header>
81
+
82
+ <div class="page-content">
83
+ <div class="ss-chips" id="ss-chips-bar" style="margin-bottom:8px;"></div>
84
+
85
+ <div class="card" style="margin-bottom:12px;">
86
+ <form method="GET" action="/logs" id="search-form">
87
+ <div style="display:flex;align-items:center;gap:10px;margin-bottom:10px;">
88
+ <span style="font-size:11px;color:var(--text3);">Mode:</span>
89
+ <div class="mode-tabs">
90
+ <button type="button" class="mode-tab <%= !selected.fromDate ? 'active' : '' %>" onclick="setMode('single')">Single Date</button>
91
+ <button type="button" class="mode-tab <%= selected.fromDate ? 'active' : '' %>" onclick="setMode('range')">Date Range</button>
92
+ </div>
93
+ </div>
94
+
95
+ <div class="form-row" style="gap:10px;flex-wrap:wrap;">
96
+ <div class="form-group" style="min-width:160px;flex:1.5;margin:0">
97
+ <label class="form-label">Service</label>
98
+ <select name="service" class="form-select" onchange="this.form.submit()">
99
+ <option value="">— All Services —</option>
100
+ <% services.forEach(function(s){ %>
101
+ <option value="<%= s.appName %>" <%= selected.service === s.appName ? 'selected' : '' %>><%= s.appName %></option>
102
+ <% }) %>
103
+ </select>
104
+ </div>
105
+
106
+ <!-- Single date mode -->
107
+ <div id="single-date-fields" style="display:<%= selected.fromDate ? 'none' : 'flex' %>;gap:10px;flex-wrap:wrap;">
108
+ <div class="form-group" style="min-width:140px;margin:0">
109
+ <label class="form-label">Date</label>
110
+ <input type="date" name="date" id="date-single" class="form-input" value="<%= singleDate %>" max="<%= today %>"/>
111
+ </div>
112
+ </div>
113
+
114
+ <!-- Date range mode -->
115
+ <div id="range-date-fields" style="display:<%= selected.fromDate ? 'flex' : 'none' %>;gap:10px;flex-wrap:wrap;">
116
+ <div class="form-group" style="min-width:130px;margin:0">
117
+ <label class="form-label">From Date</label>
118
+ <input type="date" name="fromDate" id="date-from" class="form-input" value="<%= selected.fromDate %>" max="<%= today %>"/>
119
+ </div>
120
+ <div class="form-group" style="min-width:130px;margin:0">
121
+ <label class="form-label">To Date</label>
122
+ <input type="date" name="toDate" id="date-to" class="form-input" value="<%= selected.toDate %>" max="<%= today %>"/>
123
+ </div>
124
+ </div>
125
+
126
+ <div class="form-group" style="min-width:120px;margin:0">
127
+ <label class="form-label">Level</label>
128
+ <select name="level" class="form-select">
129
+ <option value="">All Levels</option>
130
+ <option value="error" <%= selected.level === 'error' ? 'selected' : '' %>>Error</option>
131
+ <option value="warn" <%= selected.level === 'warn' ? 'selected' : '' %>>Warn</option>
132
+ <option value="info" <%= selected.level === 'info' ? 'selected' : '' %>>Info</option>
133
+ <option value="debug" <%= selected.level === 'debug' ? 'selected' : '' %>>Debug</option>
134
+ </select>
135
+ </div>
136
+
137
+ <div class="form-group" style="flex:2;min-width:180px;margin:0">
138
+ <label class="form-label" style="display:flex;align-items:center;gap:8px;">
139
+ Search
140
+ <label style="display:flex;align-items:center;gap:4px;font-weight:400;font-size:10px;color:var(--text3);cursor:pointer;">
141
+ <input type="checkbox" id="regex-toggle" onchange="toggleRegex(this)"/> Regex
142
+ </label>
143
+ </label>
144
+ <div class="search-wrap">
145
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
146
+ <input type="text" name="q" id="q-input" class="form-input search-input" placeholder="Search logs…" value="<%= selected.q %>"/>
147
+ </div>
148
+ <input type="hidden" name="regex" id="regex-flag" value="<%= selected.regex || '' %>"/>
149
+ </div>
150
+
151
+ <div class="form-group" style="display:flex;align-items:flex-end;gap:6px;margin:0">
152
+ <button type="submit" class="btn btn-primary">Search</button>
153
+ <a href="/logs" class="btn btn-secondary">Reset</a>
154
+ </div>
155
+ </div>
156
+
157
+ <!-- Time range row (only for single date mode) -->
158
+ <div id="time-range-row" class="time-range-row" style="<%= selected.fromDate ? 'display:none' : 'display:flex' %>">
159
+ <span style="font-size:11px;color:var(--text3);white-space:nowrap;">Time:</span>
160
+ <div class="time-picker-group">
161
+ <span style="font-size:11px;">From</span>
162
+ <select id="fromHour" class="form-select" style="width:70px;">
163
+ <% for(var i=0;i<=23;i++){ var h=i.toString().padStart(2,'0'); %>
164
+ <option value="<%=h%>" <%= fromTime.split(':')[0] === h ? 'selected' : '' %>><%=h%></option>
165
+ <% } %>
166
+ </select>
167
+ <span>:</span>
168
+ <select id="fromMinute" class="form-select" style="width:70px;">
169
+ <% [0,15,30,45].forEach(function(m){ var min=m.toString().padStart(2,'0'); %>
170
+ <option value="<%=min%>" <%= fromTime.split(':')[1] === min ? 'selected' : '' %>><%=min%></option>
171
+ <% }) %>
172
+ </select>
173
+ </div>
174
+ <span style="font-size:11px;color:var(--text3);">–</span>
175
+ <div class="time-picker-group">
176
+ <span style="font-size:11px;">To</span>
177
+ <select id="toHour" class="form-select" style="width:70px;">
178
+ <% for(var i=0;i<=23;i++){ var h=i.toString().padStart(2,'0'); %>
179
+ <option value="<%=h%>" <%= toTime.split(':')[0] === h ? 'selected' : '' %>><%=h%></option>
180
+ <% } %>
181
+ </select>
182
+ <span>:</span>
183
+ <select id="toMinute" class="form-select" style="width:70px;">
184
+ <% [0,15,30,45].forEach(function(m){ var min=m.toString().padStart(2,'0'); %>
185
+ <option value="<%=min%>" <%= toTime.split(':')[1] === min ? 'selected' : '' %>><%=min%></option>
186
+ <% }) %>
187
+ </select>
188
+ </div>
189
+ <input type="hidden" name="fromTime" id="fromTimeHidden" value="<%= fromTime %>"/>
190
+ <input type="hidden" name="toTime" id="toTimeHidden" value="<%= toTime %>"/>
191
+ <input type="hidden" name="tzOffset" id="tz-offset" value="0"/>
192
+ <div class="quick-time-group">
193
+ <span class="quick-time-btn" onclick="setQuickTime('00:00','23:59')">All day</span>
194
+ <span class="quick-time-btn" onclick="setQuickTime('06:00','12:00')">Morning</span>
195
+ <span class="quick-time-btn" onclick="setQuickTime('12:00','18:00')">Afternoon</span>
196
+ <span class="quick-time-btn" onclick="setQuickTime('18:00','23:59')">Evening</span>
197
+ <span class="quick-time-btn" onclick="setQuickTime('23:00','05:59')">Night*</span>
198
+ </div>
199
+ <span style="font-size:10px;color:var(--text3);">*crosses midnight (requires custom)</span>
200
+ </div>
201
+
202
+ <!-- Quick level chips (preserve all current filters) -->
203
+ <div class="chip-bar" style="margin-top:10px;padding-top:8px;border-top:1px solid var(--border);">
204
+ <span style="font-size:11px;color:var(--text3);font-weight:500;">Quick:</span>
205
+ <a href="#" class="chip error <%=selected.level==='error'?'active':''%>" onclick="setQuickFilter('error')"; return false;">
206
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><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> Error
207
+ </a>
208
+ <a href="#" class="chip warn <%=selected.level==='warn'?'active':''%>" onclick="setQuickFilter('warn')"; return false;">
209
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/></svg> Warn
210
+ </a>
211
+ <a href="#" class="chip info <%=selected.level==='info'?'active':''%>" onclick="setQuickFilter('info')"; return false;">
212
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg> Info
213
+ </a>
214
+ <a href="#" class="chip debug <%=selected.level==='debug'?'active':''%>" onclick="setQuickFilter('debug')"; return false;">
215
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/></svg> Debug
216
+ </a>
217
+ <% if (selected.level) { %><a href="#" class="chip" onclick="clearLevelFilter(); return false;" style="color:var(--text3);">✕ Clear</a><% } %>
218
+ </div>
219
+ </form>
220
+ </div>
221
+
222
+ <!-- Results -->
223
+ <div class="card">
224
+ <% if (!selected.service) { %>
225
+ <div class="empty-state">
226
+ <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"/><polyline points="14,2 14,8 20,8"/></svg>
227
+ <p>Select a service to view logs</p>
228
+ </div>
229
+ <% } else if (!results || !results.lines || !results.lines.length) { %>
230
+ <div class="empty-state">
231
+ <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
232
+ <p>No logs found for <strong><%= selected.service %></strong></p>
233
+ </div>
234
+ <% } else {
235
+ var lvlCounts={error:0,warn:0,info:0,debug:0,total:0};
236
+ results.lines.forEach(function(l){ try{var p=JSON.parse(l);var lv=(p.level||'info').toLowerCase();lvlCounts[lv]=(lvlCounts[lv]||0)+1;lvlCounts.total++;}catch(e){lvlCounts.total++;} });
237
+ %>
238
+ <div class="log-stats-bar">
239
+ <div class="ls-item"><strong style="color:var(--text)"><%= results.lines.length.toLocaleString() %></strong><span style="color:var(--text2)">lines</span></div>
240
+ <%if(lvlCounts.error>0){%><div class="ls-item"><span class="dot dot-red"></span><span style="color:var(--red)"><strong><%=lvlCounts.error%></strong> error<%=lvlCounts.error>1?'s':''%></span></div><%}%>
241
+ <%if(lvlCounts.warn>0){%><div class="ls-item"><span class="dot dot-yellow"></span><span style="color:var(--yellow)"><strong><%=lvlCounts.warn%></strong> warn</span></div><%}%>
242
+ <%if(selected.fromDate){%><div class="ls-item" style="color:var(--accent-l);font-size:11px;">📅 Range: <%=selected.fromDate%> → <%=selected.toDate%></div><%}%>
243
+ <div style="margin-left:auto;display:flex;gap:8px;align-items:center;">
244
+ <label style="font-size:12px;color:var(--text3);display:flex;align-items:center;gap:5px;cursor:pointer;"><input type="checkbox" id="wrap-toggle" onchange="toggleWrap(this)"/> Wrap</label>
245
+ <% if(selected.service && selected.date && !selected.fromDate){ %>
246
+ <button class="btn btn-secondary btn-xs" onclick="viewClusters()">Clusters</button>
247
+ <% } %>
248
+ </div>
249
+ </div>
250
+
251
+ <div class="log-list" id="log-list">
252
+ <% results.lines.forEach(function(line, idx) {
253
+ var parsed=null,level='info',ts='',msg='',svc='',reqId='';
254
+ try{ parsed=JSON.parse(line); level=(parsed.level||'info').toLowerCase(); ts=parsed.ts?parsed.ts.replace('T',' ').slice(0,19):''; msg=parsed.message||parsed.msg||line; svc=parsed.appName||''; reqId=parsed.requestId||parsed.traceId||parsed.correlationId||''; }catch(e){ msg=line; }
255
+ %>
256
+ <div class="log-line level-<%= level %>" data-idx="<%= idx %>" onclick="toggleLine(this)">
257
+ <span class="level-icon <%= level %>">
258
+ <%if(level==='error'){%><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2.5"><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>
259
+ <%}else if(level==='warn'){%><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2.5"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/></svg>
260
+ <%}else if(level==='info'){%><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2.5"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
261
+ <%}else{%><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#6b7280" stroke-width="2"><circle cx="12" cy="12" r="3"/></svg><%}%>
262
+ </span>
263
+ <span class="log-ts"><%= ts %></span>
264
+ <span class="log-body" style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis;"><%= (msg||"").replace(/\n/g,"↵ ").replace(/\r/g,"").replace(/\t/g," ") %></span>
265
+ <%if(svc){%><span style="font-size:10px;color:var(--text3);white-space:nowrap;font-family:'JetBrains Mono',monospace;padding:1px 5px;background:var(--surface2);border-radius:3px;"><%=svc%></span><%}%>
266
+ <button class="icon-btn" style="padding:2px 4px;flex-shrink:0;" onclick="event.stopPropagation();showCtx('<%= selected.service||svc %>','<%= selected.date %>',<%=idx%>)" title="Context lines">
267
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="17" y1="10" x2="3" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="21" y1="14" x2="3" y2="14"/><line x1="17" y1="18" x2="3" y2="18"/></svg>
268
+ </button>
269
+ <button class="icon-btn" style="padding:2px 4px;flex-shrink:0;" onclick="event.stopPropagation();bookmarkLine(<%=idx%>,'<%=level%>','<%= encodeURIComponent(msg.slice(0,200)) %>')" title="Bookmark">
270
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>
271
+ </button>
272
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="expand-icon" style="flex-shrink:0;color:var(--text3);transition:transform .15s;"><polyline points="6,9 12,15 18,9"/></svg>
273
+ </div>
274
+ <div class="log-expanded" style="display:none">
275
+ <pre class="log-json"><%= unescapeLog(line) %></pre>
276
+ <% if(reqId){ %>
277
+ <div style="padding:4px 0;"><span class="trace-link" onclick="traceId('<%= reqId %>')">🔗 Trace: <%= reqId.slice(0,36) %></span></div>
278
+ <% } %>
279
+ <% if(parsed && Object.keys(parsed).length>3){ %>
280
+ <div class="field-pills" onclick="event.stopPropagation()">
281
+ <% var SKIP=['ts','level','appName','message','msg','pid','host']; Object.entries(parsed).forEach(function(kv){ if(!SKIP.includes(kv[0])&&kv[1]!==null&&kv[1]!==undefined){ %>
282
+ <span class="field-pill" onclick="filterByField('<%= kv[0] %>','<%= String(kv[1]).replace(/'/g,"\\\'") %>'); "><span class="k"><%=kv[0]%>:</span> <%=typeof kv[1]==='object'?JSON.stringify(kv[1]):String(kv[1]).slice(0,60)%></span>
283
+ <% }}); %>
284
+ </div>
285
+ <% } %>
286
+ </div>
287
+ <% }); %>
288
+ </div>
289
+ <% } %>
290
+ </div>
291
+ </div>
292
+ </div>
293
+ </div>
294
+
295
+ <!-- Context Modal -->
296
+ <div class="modal-lg" id="ctx-modal">
297
+ <div class="modal-inner">
298
+ <div class="modal-hdr">
299
+ <span>📄 Context Lines (click line to expand)</span>
300
+ <button class="icon-btn" onclick="closeModal('ctx-modal')">✕</button>
301
+ </div>
302
+ <div class="modal-bdy" id="ctx-body">Loading...</div>
303
+ </div>
304
+ </div>
305
+
306
+ <!-- Trace ID Modal -->
307
+ <div class="modal-lg" id="trace-modal">
308
+ <div class="modal-inner">
309
+ <div class="modal-hdr">
310
+ <span>🔍 Trace ID Lookup</span>
311
+ <button class="icon-btn" onclick="closeModal('trace-modal')">✕</button>
312
+ </div>
313
+ <div class="modal-bdy" style="padding:12px 18px;">
314
+ <div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;">
315
+ <input type="text" id="trace-input" class="form-input" style="flex:2;min-width:200px;" placeholder="traceId / requestId / correlationId" autocomplete="off">
316
+ <input type="date" id="trace-date" class="form-input" style="width:140px;" placeholder="Date (optional)">
317
+ <button class="btn btn-primary" onclick="runTrace()">Search</button>
318
+ </div>
319
+ <div id="trace-body" style="border-top:1px solid var(--border);padding-top:12px;max-height:50vh;overflow-y:auto;"></div>
320
+ </div>
321
+ </div>
322
+ </div>
323
+
324
+ <!-- Cluster Modal -->
325
+ <div class="modal-lg" id="cluster-modal">
326
+ <div class="modal-inner">
327
+ <div class="modal-hdr">
328
+ <span>🔬 Error Clusters</span>
329
+ <button class="icon-btn" onclick="closeModal('cluster-modal')">✕</button>
330
+ </div>
331
+ <div class="modal-bdy" id="cluster-body"></div>
332
+ </div>
333
+ </div>
334
+
335
+ <!-- Save Search Modal -->
336
+ <div class="modal-lg" id="save-search-modal">
337
+ <div class="modal-inner">
338
+ <div class="modal-hdr">
339
+ <span>💾 Save Current Filters</span>
340
+ <button class="icon-btn" onclick="closeModal('save-search-modal')">✕</button>
341
+ </div>
342
+ <div class="modal-bdy" style="padding:12px 18px;">
343
+ <input type="text" id="ss-name" class="form-input" style="width:100%;margin-bottom:10px;" placeholder="Search name (e.g., Production Errors)">
344
+ <div id="ss-preview" style="font-size:11px;color:var(--text2);padding:8px;background:var(--surface2);border-radius:6px;margin-bottom:12px;"></div>
345
+ <div style="display:flex;gap:8px;justify-content:flex-end;">
346
+ <button class="btn btn-secondary" onclick="closeModal('save-search-modal')">Cancel</button>
347
+ <button class="btn btn-primary" onclick="doSaveSearch()">Save</button>
348
+ </div>
349
+ </div>
350
+ </div>
351
+ </div>
352
+
353
+ <script>
354
+ // Helper: update hidden time fields
355
+ function updateHiddenTimes() {
356
+ const fromHour = document.getElementById('fromHour').value;
357
+ const fromMin = document.getElementById('fromMinute').value;
358
+ const toHour = document.getElementById('toHour').value;
359
+ const toMin = document.getElementById('toMinute').value;
360
+ document.getElementById('fromTimeHidden').value = `${fromHour}:${fromMin}`;
361
+ document.getElementById('toTimeHidden').value = `${toHour}:${toMin}`;
362
+ }
363
+
364
+ // Quick time preset
365
+ function setQuickTime(fromStr, toStr) {
366
+ const [fromH, fromM] = fromStr.split(':');
367
+ const [toH, toM] = toStr.split(':');
368
+ document.getElementById('fromHour').value = fromH;
369
+ document.getElementById('fromMinute').value = fromM;
370
+ document.getElementById('toHour').value = toH;
371
+ document.getElementById('toMinute').value = toM;
372
+ updateHiddenTimes();
373
+ }
374
+
375
+ // Mode toggle (single date vs range)
376
+ function setMode(m) {
377
+ const isSingle = (m === 'single');
378
+ const singleDiv = document.getElementById('single-date-fields');
379
+ const rangeDiv = document.getElementById('range-date-fields');
380
+ const timeRow = document.getElementById('time-range-row');
381
+
382
+ if (isSingle) {
383
+ singleDiv.style.display = 'flex';
384
+ rangeDiv.style.display = 'none';
385
+ timeRow.style.display = 'flex';
386
+ // Clear range fields
387
+ document.getElementById('date-from').value = '';
388
+ document.getElementById('date-to').value = '';
389
+ // Set single date to today if empty
390
+ const dateSingle = document.getElementById('date-single');
391
+ if (!dateSingle.value) {
392
+ const today = new Date().toISOString().slice(0,10);
393
+ dateSingle.value = today;
394
+ }
395
+ // Remove fromDate/toDate from URL by clearing hidden? Actual submission will not send because inputs are disabled? Actually they are text inputs, they will send empty strings. Better to set to empty.
396
+ // Force update hidden times to reflect current UI
397
+ updateHiddenTimes();
398
+ } else {
399
+ singleDiv.style.display = 'none';
400
+ rangeDiv.style.display = 'flex';
401
+ timeRow.style.display = 'none';
402
+ // Clear single date field
403
+ document.getElementById('date-single').value = '';
404
+ // Set default range if empty
405
+ const fromDate = document.getElementById('date-from');
406
+ const toDate = document.getElementById('date-to');
407
+ if (!fromDate.value && !toDate.value) {
408
+ const today = new Date().toISOString().slice(0,10);
409
+ fromDate.value = today;
410
+ toDate.value = today;
411
+ }
412
+ // Time fields not used but keep them default
413
+ document.getElementById('fromHour').value = '00';
414
+ document.getElementById('fromMinute').value = '00';
415
+ document.getElementById('toHour').value = '23';
416
+ document.getElementById('toMinute').value = '59';
417
+ updateHiddenTimes();
418
+ }
419
+ // Update active class on tabs
420
+ document.querySelectorAll('.mode-tab').forEach(tab => {
421
+ const isActive = (tab.textContent.trim() === (isSingle ? 'Single Date' : 'Date Range'));
422
+ tab.classList.toggle('active', isActive);
423
+ });
424
+ }
425
+
426
+ // Quick filter for level (preserves mode)
427
+ function setQuickFilter(level) {
428
+ const service = document.querySelector('select[name="service"]').value;
429
+ const q = document.getElementById('q-input').value;
430
+ const regex = document.getElementById('regex-flag').value;
431
+ const tzOffset = new Date().getTimezoneOffset();
432
+ const isRange = document.getElementById('range-date-fields').style.display === 'flex';
433
+ const fromDate = document.getElementById('date-from')?.value || '';
434
+ const toDate = document.getElementById('date-to')?.value || '';
435
+ const date = document.getElementById('date-single')?.value || '';
436
+ // Toggle: clicking the active level clears it
437
+ const current = new URLSearchParams(location.search).get('level');
438
+ const newLevel = (current === level) ? '' : level;
439
+ let url = '/logs?service=' + encodeURIComponent(service) + '&level=' + newLevel + '&q=' + encodeURIComponent(q);
440
+ if (isRange) {
441
+ url += '&fromDate=' + fromDate + '&toDate=' + toDate;
442
+ } else {
443
+ url += '&date=' + date + '&fromTime=' + (document.querySelector('[name="fromTime"]')?.value||'') + '&toTime=' + (document.querySelector('[name="toTime"]')?.value||'') + '&tzOffset=' + tzOffset;
444
+ }
445
+ if (regex) url += '&regex=1';
446
+ location.href = url;
447
+ }
448
+
449
+ function clearLevelFilter() {
450
+ const form = document.getElementById('search-form');
451
+ const service = document.querySelector('select[name="service"]').value;
452
+ const regex = document.getElementById('regex-flag').value;
453
+ const q = document.getElementById('q-input').value;
454
+ const tzOffset = new Date().getTimezoneOffset();
455
+
456
+ const isRangeMode = document.getElementById('range-date-fields').style.display === 'flex';
457
+ let url = `/logs?service=${encodeURIComponent(service)}&q=${encodeURIComponent(q)}&regex=${regex}&tzOffset=${tzOffset}`;
458
+
459
+ if (isRangeMode) {
460
+ const fromDate = document.getElementById('date-from').value;
461
+ const toDate = document.getElementById('date-to').value;
462
+ if (fromDate) url += `&fromDate=${fromDate}`;
463
+ if (toDate) url += `&toDate=${toDate}`;
464
+ } else {
465
+ const date = document.getElementById('date-single').value;
466
+ if (date) url += `&date=${date}`;
467
+ const fromTime = document.getElementById('fromTimeHidden').value;
468
+ const toTime = document.getElementById('toTimeHidden').value;
469
+ url += `&fromTime=${fromTime}&toTime=${toTime}`;
470
+ }
471
+ window.location.href = url;
472
+ }
473
+
474
+ // Regex toggle
475
+ function toggleRegex(cb) {
476
+ document.getElementById('regex-flag').value = cb.checked ? '1' : '';
477
+ const inp = document.getElementById('q-input');
478
+ inp.placeholder = cb.checked ? 'Regex pattern, e.g. status: [45]\\d\\d' : 'Search logs…';
479
+ inp.style.fontFamily = cb.checked ? "'JetBrains Mono',monospace" : '';
480
+ }
481
+
482
+ // Log expand/collapse
483
+ function toggleLine(el) {
484
+ const next = el.nextElementSibling;
485
+ if (!next) return;
486
+ const open = next.style.display === '';
487
+ next.style.display = open ? 'none' : '';
488
+ const icon = el.querySelector('.expand-icon');
489
+ if (icon) icon.style.transform = open ? '' : 'rotate(180deg)';
490
+ }
491
+
492
+ function toggleWrap(cb) {
493
+ document.querySelectorAll('.log-body').forEach(el => {
494
+ el.style.whiteSpace = cb.checked ? 'pre-wrap' : 'nowrap';
495
+ el.style.overflow = cb.checked ? 'visible' : 'hidden';
496
+ el.style.textOverflow = cb.checked ? 'clip' : 'ellipsis';
497
+ });
498
+ }
499
+
500
+ function filterByField(key, value) {
501
+ const currentQ = document.getElementById('q-input').value;
502
+ const newQuery = currentQ ? `${currentQ} ${key}:${value}` : `${key}:${value}`;
503
+ document.getElementById('q-input').value = newQuery;
504
+ document.getElementById('search-form').submit();
505
+ }
506
+
507
+ // Context modal
508
+ async function showCtx(svc, date, idx) {
509
+ if (!svc || !date) { toast('Service or date missing', 'error'); return; }
510
+ openModal('ctx-modal');
511
+ const body = document.getElementById('ctx-body');
512
+ body.innerHTML = '<div style="text-align:center;padding:30px;color:var(--text3)">Loading…</div>';
513
+ try {
514
+ const r = await fetch('/api/logs/context/'+encodeURIComponent(svc)+'/'+date+'?lineIndex='+idx+'&n=10');
515
+ if (!r.ok) throw new Error('HTTP '+r.status);
516
+ const lines = await r.json();
517
+ if (!Array.isArray(lines)||!lines.length) { body.innerHTML='<div style="text-align:center;padding:30px;color:var(--text3)">No context</div>'; return; }
518
+ body.innerHTML = lines.map(item => {
519
+ let level='info', msg=item.line;
520
+ try { const p=JSON.parse(item.line); level=(p.level||'info').toLowerCase(); msg=p.message||item.line; } catch {}
521
+ const cls='ctx-line level-'+level+(item.isFocus?' focus':'');
522
+ return '<div class="'+cls+'" onclick="this.nextElementSibling.style.display=this.nextElementSibling.style.display===\'none\'?\'block\':\'none\'">'
523
+ +'<span style="color:var(--text3);user-select:none;margin-right:10px;font-size:10px;">'+String(item.index).padStart(4,' ')+'</span>'
524
+ +escHtml(msg.slice(0,200))+'</div>'
525
+ +'<pre style="display:none;margin:0 18px 4px;background:var(--surface2);padding:8px 12px;border-radius:4px;font-size:11px;overflow-x:auto;white-space:pre-wrap;">'+escHtml(item.line)+'</pre>';
526
+ }).join('');
527
+ } catch(e) { body.innerHTML='<div style="text-align:center;padding:30px;color:var(--red)">Error: '+e.message+'</div>'; }
528
+ }
529
+
530
+ // Trace ID modal
531
+ function openTraceModal() {
532
+ document.getElementById('trace-input').value = '';
533
+ document.getElementById('trace-date').value = '';
534
+ document.getElementById('trace-body').innerHTML = '';
535
+ openModal('trace-modal');
536
+ }
537
+
538
+ function traceId(id) {
539
+ document.getElementById('trace-input').value = id;
540
+ openModal('trace-modal');
541
+ runTrace();
542
+ }
543
+
544
+ async function runTrace() {
545
+ const id = document.getElementById('trace-input').value.trim();
546
+ const date = document.getElementById('trace-date').value;
547
+ if (!id) { toast('Enter a trace ID','error'); return; }
548
+ const body = document.getElementById('trace-body');
549
+ body.innerHTML = '<div style="text-align:center;padding:20px;color:var(--text3)">Searching all services…</div>';
550
+ try {
551
+ const url = '/api/logs/trace?traceId='+encodeURIComponent(id)+(date?'&date='+date:'');
552
+ const r = await fetch(url);
553
+ if (!r.ok) throw new Error('HTTP '+r.status);
554
+ const d = await r.json();
555
+ if (!d.length) { body.innerHTML='<div style="text-align:center;padding:20px;color:var(--text3)">No logs found for trace ID: '+escHtml(id)+'</div>'; return; }
556
+ body.innerHTML = '<div style="font-size:12px;color:var(--text2);margin-bottom:10px;"><strong>'+d.length+'</strong> logs across '+[...new Set(d.map(l=>l._appName||l.appName))].length+' services</div>'
557
+ + d.map(l => {
558
+ const ts = l.ts?l.ts.replace('T',' ').slice(0,19):'';
559
+ const svc = l._appName||l.appName||'';
560
+ const msg = l.message||l.msg||JSON.stringify(l);
561
+ const lv = (l.level||'INFO').toLowerCase();
562
+ return '<div class="log-line level-'+lv+'" style="margin-bottom:2px;" onclick="toggleLine(this)">'
563
+ +'<span class="log-ts">'+ts+'</span>'
564
+ +(svc?'<span style="font-size:10px;color:var(--accent-l);font-family:monospace;padding:0 5px;background:var(--accent-dim);border-radius:3px;white-space:nowrap;">'+escHtml(svc)+'</span>':'')
565
+ +'<span class="log-body" style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">'+escHtml(msg.slice(0,150))+'</span>'
566
+ +'<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="expand-icon" style="flex-shrink:0;color:var(--text3);transition:transform .15s;"><polyline points="6,9 12,15 18,9"/></svg></div>'
567
+ +'<div class="log-expanded" style="display:none"><pre class="log-json">'+escHtml(JSON.stringify(l,null,2))+'</pre></div>';
568
+ }).join('');
569
+ } catch(e) { body.innerHTML='<div style="color:var(--red);padding:20px">Error: '+e.message+'</div>'; }
570
+ }
571
+
572
+ // Clusters (only for single date mode)
573
+ async function viewClusters() {
574
+ const svc = '<%= selected.service %>';
575
+ const date = '<%= selected.date %>';
576
+ if (!svc || !date) { toast('Service and date required for clustering', 'error'); return; }
577
+ openModal('cluster-modal');
578
+ const body = document.getElementById('cluster-body');
579
+ body.innerHTML = '<div style="text-align:center;padding:30px;color:var(--text3)">Clustering errors…</div>';
580
+ try {
581
+ const r = await fetch('/api/logs/clusters/'+encodeURIComponent(svc)+'/'+date);
582
+ if (!r.ok) throw new Error('HTTP '+r.status);
583
+ const d = await r.json();
584
+ if (!d.length) { body.innerHTML='<div style="text-align:center;padding:20px;color:var(--text3)">No error clusters found</div>'; return; }
585
+ body.innerHTML = '<table style="width:100%;font-size:12px;border-collapse:collapse;">'
586
+ +'<thead><tr style="border-bottom:1px solid var(--border)"><th style="padding:6px 8px;text-align:left;color:var(--text3);font-size:10px;text-transform:uppercase">Pattern</th><th style="padding:6px 8px;text-align:right;color:var(--text3);font-size:10px;text-transform:uppercase">Count</th></tr></thead><tbody>'
587
+ + d.slice(0,30).map(c =>
588
+ '<tr style="border-bottom:1px solid var(--border)">'
589
+ +'<td style="padding:7px 8px;font-family:monospace;color:var(--text2);font-size:11px;">'+escHtml(c.pattern.slice(0,120))+'</td>'
590
+ +'<td style="padding:7px 8px;text-align:right;color:var(--red);font-weight:600;">'+c.count+'</td>'
591
+ +'</tr>'
592
+ ).join('')
593
+ + '</tbody></table>';
594
+ } catch(e) { body.innerHTML='<div style="color:var(--red);padding:20px">Error: '+e.message+'</div>'; }
595
+ }
596
+
597
+ // Bookmarks
598
+ async function bookmarkLine(idx, level, encodedMsg) {
599
+ const svc = '<%= selected.service %>';
600
+ const dt = '<%= selected.date %>';
601
+ if (!svc||!dt) { toast('Select a service and date first','error'); return; }
602
+ try {
603
+ const r = await fetch('/api/bookmarks', { method:'POST', headers:{'Content-Type':'application/json'},
604
+ body: JSON.stringify({service:svc, date:dt, lineIndex:idx, line:decodeURIComponent(encodedMsg)}) });
605
+ const d = await r.json();
606
+ if (!r.ok) { toast(d.error||'Bookmark failed','error'); return; }
607
+ toast('Bookmarked ⭐','success');
608
+ } catch(e) { toast('Network error','error'); }
609
+ }
610
+
611
+ // Saved searches
612
+ async function loadSavedSearches() {
613
+ try {
614
+ const r = await fetch('/api/saved-searches');
615
+ if (!r.ok) throw new Error();
616
+ const d = await r.json();
617
+ const bar = document.getElementById('ss-chips-bar');
618
+ if (!d.length) { bar.innerHTML=''; return; }
619
+ bar.innerHTML = d.map(s =>
620
+ '<div class="ss-chip" onclick=\'applySavedSearch('+JSON.stringify(encodeURIComponent(JSON.stringify(s)))+')\'>'
621
+ +'<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>'
622
+ +escHtml(s.name)
623
+ +'<span class="del" onclick="event.stopPropagation();deleteSavedSearch(\''+s.id+'\',this)">✕</span>'
624
+ +'</div>'
625
+ ).join('');
626
+ } catch {}
627
+ }
628
+
629
+ function applySavedSearch(encoded) {
630
+ const s = JSON.parse(decodeURIComponent(encoded));
631
+ const p = new URLSearchParams();
632
+ if(s.service) p.set('service', s.service);
633
+ if(s.date) p.set('date', s.date);
634
+ if(s.level) p.set('level', s.level);
635
+ if(s.q) p.set('q', s.q);
636
+ if(s.fromDate)p.set('fromDate',s.fromDate);
637
+ if(s.toDate) p.set('toDate', s.toDate);
638
+ if(s.fromTime)p.set('fromTime',s.fromTime);
639
+ if(s.toTime) p.set('toTime', s.toTime);
640
+ location.href = '/logs?' + p.toString();
641
+ }
642
+
643
+ async function deleteSavedSearch(id, el) {
644
+ try {
645
+ const r = await fetch('/api/saved-searches/'+id, {method:'DELETE'});
646
+ if (r.ok) {
647
+ const chip = el.closest('.ss-chip');
648
+ if(chip) chip.remove();
649
+ toast('Deleted','success');
650
+ } else {
651
+ toast('Delete failed','error');
652
+ }
653
+ } catch(e) { toast('Network error','error'); }
654
+ }
655
+
656
+ function saveSearch() {
657
+ const svc = '<%= selected.service %>';
658
+ const date = '<%= selected.date %>';
659
+ const fromDate = '<%= selected.fromDate %>';
660
+ const toDate = '<%= selected.toDate %>';
661
+ let preview = [];
662
+ if(svc) preview.push('Service: '+svc);
663
+ if(date) preview.push('Date: '+date);
664
+ if(fromDate && toDate) preview.push('Range: '+fromDate+' → '+toDate);
665
+ if('<%= selected.level %>') preview.push('Level: <%= selected.level %>');
666
+ if('<%= selected.q %>') preview.push('Query: <%= selected.q %>');
667
+ document.getElementById('ss-preview').innerText = preview.join(' · ') || '(no filters)';
668
+ document.getElementById('ss-name').value = '';
669
+ openModal('save-search-modal');
670
+ }
671
+
672
+ async function doSaveSearch() {
673
+ const name = document.getElementById('ss-name').value.trim();
674
+ if (!name) { toast('Enter a name','error'); return; }
675
+ const body = {
676
+ name,
677
+ service: '<%= selected.service %>',
678
+ date: '<%= selected.date %>',
679
+ level: '<%= selected.level %>',
680
+ q: <%- JSON.stringify(selected.q || "") %>,
681
+ fromDate: '<%= selected.fromDate %>',
682
+ toDate: '<%= selected.toDate %>',
683
+ fromTime: '<%= selected.fromTime %>',
684
+ toTime: '<%= selected.toTime %>',
685
+ };
686
+ try {
687
+ const r = await fetch('/api/saved-searches', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body) });
688
+ const d = await r.json();
689
+ if (!r.ok) { toast(d.error||'Save failed','error'); return; }
690
+ toast('Search saved!','success');
691
+ closeModal('save-search-modal');
692
+ loadSavedSearches();
693
+ } catch(e) { toast('Network error','error'); }
694
+ }
695
+
696
+ // Modal helpers
697
+ function openModal(id) { document.getElementById(id).classList.add('open'); }
698
+ function closeModal(id) { document.getElementById(id).classList.remove('open'); }
699
+
700
+ // Close modals when clicking overlay
701
+ document.querySelectorAll('.modal-lg').forEach(m => m.addEventListener('click', e => { if(e.target===m) m.classList.remove('open'); }));
702
+ document.addEventListener('keydown', e => {
703
+ if (e.key==='Escape') document.querySelectorAll('.modal-lg.open').forEach(m=>m.classList.remove('open'));
704
+ if ((e.ctrlKey||e.metaKey) && e.key==='f') { e.preventDefault(); document.getElementById('q-input').focus(); }
705
+ });
706
+
707
+ function escHtml(s){ return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
708
+
709
+ function toast(msg, type='info') {
710
+ const div = document.createElement('div');
711
+ div.className = `toast toast-${type}`;
712
+ div.textContent = msg;
713
+ document.body.appendChild(div);
714
+ setTimeout(() => div.remove(), 3000);
715
+ }
716
+
717
+ // Initialisation
718
+ document.addEventListener('DOMContentLoaded', () => {
719
+ // Set up event listeners for time dropdowns
720
+ const fromHour = document.getElementById('fromHour');
721
+ const fromMin = document.getElementById('fromMinute');
722
+ const toHour = document.getElementById('toHour');
723
+ const toMin = document.getElementById('toMinute');
724
+ if (fromHour && fromMin && toHour && toMin) {
725
+ fromHour.addEventListener('change', updateHiddenTimes);
726
+ fromMin.addEventListener('change', updateHiddenTimes);
727
+ toHour.addEventListener('change', updateHiddenTimes);
728
+ toMin.addEventListener('change', updateHiddenTimes);
729
+ updateHiddenTimes();
730
+ }
731
+
732
+ // Regex initialisation
733
+ if ('<%= selected.regex %>' === '1') {
734
+ const regexToggle = document.getElementById('regex-toggle');
735
+ if (regexToggle) {
736
+ regexToggle.checked = true;
737
+ toggleRegex({checked:true});
738
+ }
739
+ }
740
+
741
+ // Timezone offset on form submit
742
+ const searchForm = document.getElementById('search-form');
743
+ if (searchForm) {
744
+ searchForm.addEventListener('submit', () => {
745
+ document.getElementById('tz-offset').value = new Date().getTimezoneOffset();
746
+ });
747
+ }
748
+
749
+ // Load saved searches
750
+ loadSavedSearches();
751
+ });
752
+ </script>
753
+ </body>
754
+ </html>