log4lab 0.0.2__py3-none-any.whl

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.
@@ -0,0 +1,717 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en" class="dark:bg-gray-900 dark:text-gray-100">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>Log4Lab — Live Structured Logs</title>
6
+ <script src="https://cdn.tailwindcss.com"></script>
7
+ <!-- Syntax highlighting -->
8
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css">
9
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-dark.min.css">
10
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-core.min.js"></script>
11
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/autoloader/prism-autoloader.min.js"></script>
12
+ <!-- Markdown rendering -->
13
+ <script src="https://cdn.jsdelivr.net/npm/marked@9.1.6/marked.min.js"></script>
14
+ <script>
15
+ if (localStorage.theme === 'dark' ||
16
+ (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
17
+ document.documentElement.classList.add('dark');
18
+ } else {
19
+ document.documentElement.classList.remove('dark');
20
+ }
21
+ </script>
22
+ </head>
23
+ <body class="bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100 transition-colors">
24
+ <div class="max-w-6xl mx-auto p-6">
25
+ <div class="flex justify-between items-center mb-4">
26
+ <h1 class="text-3xl font-bold">Log4Lab <span class="text-sm font-light">(Live)</span></h1>
27
+ <div class="flex space-x-2">
28
+ <a href="/runs" class="px-3 py-1 rounded border text-sm bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700">
29
+ 📋 Runs Index
30
+ </a>
31
+ <button id="themeToggle"
32
+ class="px-3 py-1 rounded border text-sm bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700">
33
+ Toggle Theme
34
+ </button>
35
+ </div>
36
+ </div>
37
+
38
+ <div class="flex space-x-4 mb-4 items-center flex-wrap">
39
+ <div>
40
+ <label class="block text-sm font-medium">Level</label>
41
+ <select id="levelFilter" class="border rounded px-2 py-1 dark:bg-gray-800 dark:border-gray-700">
42
+ <option value="">All</option>
43
+ </select>
44
+ </div>
45
+
46
+ <div>
47
+ <label class="block text-sm font-medium">Section</label>
48
+ <input id="sectionFilter" type="text"
49
+ class="border rounded px-2 py-1 dark:bg-gray-800 dark:border-gray-700"
50
+ placeholder="e.g. train" />
51
+ </div>
52
+
53
+ <div>
54
+ <label class="block text-sm font-medium">Group</label>
55
+ <input id="groupFilter" type="text"
56
+ class="border rounded px-2 py-1 dark:bg-gray-800 dark:border-gray-700"
57
+ placeholder="e.g. experiment1" />
58
+ </div>
59
+
60
+ <div>
61
+ <label class="block text-sm font-medium">Run Name</label>
62
+ <input id="runNameFilter" type="text"
63
+ class="border rounded px-2 py-1 dark:bg-gray-800 dark:border-gray-700"
64
+ placeholder="e.g. my_experiment" />
65
+ </div>
66
+
67
+ <div>
68
+ <label class="block text-sm font-medium">Run ID</label>
69
+ <input id="runIdFilter" type="text"
70
+ class="border rounded px-2 py-1 dark:bg-gray-800 dark:border-gray-700"
71
+ placeholder="e.g. abc123" />
72
+ </div>
73
+
74
+ <div>
75
+ <label class="block text-sm font-medium">Time Range</label>
76
+ <select id="timeFilter" class="border rounded px-2 py-1 dark:bg-gray-800 dark:border-gray-700">
77
+ <option value="">All Time</option>
78
+ <option value="60">Last 1 minute</option>
79
+ <option value="300">Last 5 minutes</option>
80
+ <option value="600">Last 10 minutes</option>
81
+ <option value="1800">Last 30 minutes</option>
82
+ <option value="3600">Last 1 hour</option>
83
+ <option value="21600">Last 6 hours</option>
84
+ <option value="86400">Last 24 hours</option>
85
+ </select>
86
+ </div>
87
+
88
+ <button id="clearFilters"
89
+ class="bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700 px-3 py-1 rounded mt-5">
90
+ Clear
91
+ </button>
92
+
93
+ <button id="sortToggle"
94
+ class="bg-blue-100 dark:bg-blue-900 hover:bg-blue-200 dark:hover:bg-blue-800 px-3 py-1 rounded mt-5 text-sm">
95
+ ⬆️ Oldest First
96
+ </button>
97
+ </div>
98
+
99
+ <div id="log-container" class="space-y-2"></div>
100
+
101
+ <div id="pagination" class="mt-6 flex items-center justify-between border-t border-gray-300 dark:border-gray-700 pt-4">
102
+ <div class="text-sm text-gray-600 dark:text-gray-400">
103
+ Showing <span id="showing-start">0</span>-<span id="showing-end">0</span> of <span id="total-count">0</span> logs
104
+ </div>
105
+ <div class="flex items-center space-x-2">
106
+ <label class="text-sm text-gray-600 dark:text-gray-400">Per page:</label>
107
+ <select id="perPageSelect" class="border rounded px-2 py-1 text-sm dark:bg-gray-800 dark:border-gray-700">
108
+ <option value="20">20</option>
109
+ <option value="50" selected>50</option>
110
+ <option value="100">100</option>
111
+ <option value="200">200</option>
112
+ </select>
113
+ </div>
114
+ <div class="flex space-x-2">
115
+ <button id="prevPage" class="px-3 py-1 border rounded text-sm bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed" disabled>
116
+ Previous
117
+ </button>
118
+ <span class="px-3 py-1 text-sm text-gray-600 dark:text-gray-400">
119
+ Page <span id="currentPage">1</span> of <span id="totalPages">1</span>
120
+ </span>
121
+ <button id="nextPage" class="px-3 py-1 border rounded text-sm bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed" disabled>
122
+ Next
123
+ </button>
124
+ </div>
125
+ </div>
126
+ </div>
127
+
128
+ <script>
129
+ const container = document.getElementById("log-container");
130
+ const source = new EventSource("/stream");
131
+ const levelFilter = document.getElementById("levelFilter");
132
+ const sectionFilter = document.getElementById("sectionFilter");
133
+ const groupFilter = document.getElementById("groupFilter");
134
+ const runNameFilter = document.getElementById("runNameFilter");
135
+ const runIdFilter = document.getElementById("runIdFilter");
136
+ const timeFilter = document.getElementById("timeFilter");
137
+ const clearBtn = document.getElementById("clearFilters");
138
+ const themeToggle = document.getElementById("themeToggle");
139
+ const sortToggle = document.getElementById("sortToggle");
140
+
141
+ // Pagination elements
142
+ const perPageSelect = document.getElementById("perPageSelect");
143
+ const prevPageBtn = document.getElementById("prevPage");
144
+ const nextPageBtn = document.getElementById("nextPage");
145
+ const currentPageSpan = document.getElementById("currentPage");
146
+ const totalPagesSpan = document.getElementById("totalPages");
147
+ const showingStartSpan = document.getElementById("showing-start");
148
+ const showingEndSpan = document.getElementById("showing-end");
149
+ const totalCountSpan = document.getElementById("total-count");
150
+
151
+ let allLogs = [];
152
+ let knownLevels = new Set();
153
+ let currentPage = 1;
154
+ let perPage = 50;
155
+ let sortOldestFirst = false;
156
+
157
+ // Parse URL parameters
158
+ function getUrlParams() {
159
+ const params = new URLSearchParams(window.location.search);
160
+ return {
161
+ level: params.get('level') || '',
162
+ section: params.get('section') || '',
163
+ group: params.get('group') || '',
164
+ run_name: params.get('run_name') || '',
165
+ run_id: params.get('run_id') || '',
166
+ time: params.get('time') || ''
167
+ };
168
+ }
169
+
170
+ // Apply URL parameters to filters
171
+ function applyUrlParams() {
172
+ const params = getUrlParams();
173
+ if (params.level) levelFilter.value = params.level;
174
+ if (params.section) sectionFilter.value = params.section;
175
+ if (params.group) groupFilter.value = params.group;
176
+ if (params.run_name) runNameFilter.value = params.run_name;
177
+ if (params.run_id) runIdFilter.value = params.run_id;
178
+ if (params.time) timeFilter.value = params.time;
179
+ }
180
+
181
+ // Apply filters from URL on page load
182
+ applyUrlParams();
183
+
184
+ themeToggle.onclick = () => {
185
+ const html = document.documentElement;
186
+ if (html.classList.contains('dark')) {
187
+ html.classList.remove('dark');
188
+ localStorage.theme = 'light';
189
+ } else {
190
+ html.classList.add('dark');
191
+ localStorage.theme = 'dark';
192
+ }
193
+ };
194
+
195
+ function matchesFilters(entry) {
196
+ const level = levelFilter.value.toLowerCase();
197
+ const section = sectionFilter.value.toLowerCase();
198
+ const group = groupFilter.value.toLowerCase();
199
+ const runName = runNameFilter.value.toLowerCase();
200
+ const runId = runIdFilter.value.toLowerCase();
201
+ const timeRange = parseInt(timeFilter.value);
202
+
203
+ if (level && (!entry.level || entry.level.toLowerCase() !== level)) return false;
204
+ if (section && (!entry.section || !entry.section.toLowerCase().includes(section))) return false;
205
+ if (group && (!entry.group || !entry.group.toLowerCase().includes(group))) return false;
206
+ if (runName && (!entry.run_name || !entry.run_name.toLowerCase().includes(runName))) return false;
207
+ if (runId && (!entry.run_id || !entry.run_id.toLowerCase().includes(runId))) return false;
208
+
209
+ // Time range filter
210
+ if (timeRange && entry.time) {
211
+ const logTime = new Date(entry.time);
212
+ const now = new Date();
213
+ const diffSeconds = (now - logTime) / 1000;
214
+ if (diffSeconds > timeRange) return false;
215
+ }
216
+
217
+ return true;
218
+ }
219
+
220
+ function updateLevelFilter() {
221
+ const currentSelection = levelFilter.value;
222
+ const sortedLevels = Array.from(knownLevels).sort();
223
+
224
+ // Keep "All" option and rebuild the rest
225
+ levelFilter.innerHTML = '<option value="">All</option>';
226
+ sortedLevels.forEach(level => {
227
+ const option = document.createElement('option');
228
+ option.value = level.toLowerCase();
229
+ option.textContent = level;
230
+ levelFilter.appendChild(option);
231
+ });
232
+
233
+ // Restore previous selection if it still exists
234
+ if (currentSelection) {
235
+ levelFilter.value = currentSelection;
236
+ }
237
+ }
238
+
239
+ function updatePaginationInfo(filteredLogs) {
240
+ const totalLogs = filteredLogs.length;
241
+ const totalPages = Math.max(1, Math.ceil(totalLogs / perPage));
242
+
243
+ // Ensure current page is within bounds
244
+ if (currentPage > totalPages) {
245
+ currentPage = totalPages;
246
+ }
247
+ if (currentPage < 1) {
248
+ currentPage = 1;
249
+ }
250
+
251
+ const startIdx = (currentPage - 1) * perPage;
252
+ const endIdx = Math.min(startIdx + perPage, totalLogs);
253
+
254
+ // Update pagination display
255
+ currentPageSpan.textContent = currentPage;
256
+ totalPagesSpan.textContent = totalPages;
257
+ showingStartSpan.textContent = totalLogs > 0 ? startIdx + 1 : 0;
258
+ showingEndSpan.textContent = endIdx;
259
+ totalCountSpan.textContent = totalLogs;
260
+
261
+ // Update button states
262
+ prevPageBtn.disabled = currentPage <= 1;
263
+ nextPageBtn.disabled = currentPage >= totalPages;
264
+ }
265
+
266
+ function renderLogs() {
267
+ container.innerHTML = "";
268
+
269
+ const filteredLogs = allLogs.filter(matchesFilters);
270
+ // Sort based on toggle: newest first (default) or oldest first
271
+ const sortedLogs = sortOldestFirst ? filteredLogs.slice() : filteredLogs.slice().reverse();
272
+
273
+ // Calculate pagination
274
+ const startIdx = (currentPage - 1) * perPage;
275
+ const endIdx = startIdx + perPage;
276
+ const pageLog = sortedLogs.slice(startIdx, endIdx);
277
+
278
+ // Render logs for current page
279
+ pageLog.forEach(renderLog);
280
+
281
+ // Update pagination info
282
+ updatePaginationInfo(filteredLogs);
283
+
284
+ // Load markdown files after rendering
285
+ setTimeout(loadMarkdownFiles, 100);
286
+ }
287
+
288
+ function getTimeAgo(timestamp) {
289
+ if (!timestamp) return '';
290
+
291
+ // Parse timestamp - if it doesn't end with Z and looks like UTC, add Z
292
+ let ts = timestamp;
293
+ if (!ts.endsWith('Z') && !ts.includes('+') && !ts.includes('-', 10)) {
294
+ // Looks like an ISO timestamp without timezone - treat as UTC
295
+ ts = ts + 'Z';
296
+ }
297
+
298
+ const logTime = new Date(ts);
299
+ const now = new Date();
300
+ const diffMs = now - logTime;
301
+ const diffSec = Math.floor(diffMs / 1000);
302
+ const diffMin = Math.floor(diffSec / 60);
303
+ const diffHour = Math.floor(diffMin / 60);
304
+ const diffDay = Math.floor(diffHour / 24);
305
+
306
+ // Handle future timestamps
307
+ if (diffSec < 0) {
308
+ const absSec = Math.abs(diffSec);
309
+ if (absSec < 60) return `in ${absSec}s`;
310
+ const absMin = Math.floor(absSec / 60);
311
+ if (absMin < 60) return `in ${absMin}m`;
312
+ const absHour = Math.floor(absMin / 60);
313
+ return `in ${absHour}h`;
314
+ }
315
+
316
+ if (diffSec < 10) return 'just now';
317
+ if (diffSec < 60) return `${diffSec}s ago`;
318
+ if (diffMin < 60) return `${diffMin}m ago`;
319
+ if (diffHour < 24) return `${diffHour}h ago`;
320
+ return `${diffDay}d ago`;
321
+ }
322
+
323
+ function renderRunInfo(entry) {
324
+ if (!entry.run_name && !entry.run_id) return '';
325
+
326
+ const parts = [];
327
+ if (entry.run_name) parts.push(`run: <span class="font-semibold">${entry.run_name}</span>`);
328
+ if (entry.run_id) parts.push(`id: <span class="font-semibold">${entry.run_id}</span>`);
329
+
330
+ let url = '/runs';
331
+ if (entry.run_id) {
332
+ url = `/?run_id=${encodeURIComponent(entry.run_id)}`;
333
+ } else if (entry.run_name) {
334
+ url = `/?run_name=${encodeURIComponent(entry.run_name)}`;
335
+ }
336
+
337
+ return `<a href="${url}" class="text-xs text-blue-600 dark:text-blue-400 hover:underline ml-2">🔗 ${parts.join(', ')}</a>`;
338
+ }
339
+
340
+ function getFileLanguage(ext) {
341
+ const languageMap = {
342
+ 'js': 'javascript',
343
+ 'jsx': 'jsx',
344
+ 'ts': 'typescript',
345
+ 'tsx': 'tsx',
346
+ 'py': 'python',
347
+ 'rb': 'ruby',
348
+ 'java': 'java',
349
+ 'cpp': 'cpp',
350
+ 'c': 'c',
351
+ 'cs': 'csharp',
352
+ 'go': 'go',
353
+ 'rs': 'rust',
354
+ 'php': 'php',
355
+ 'sh': 'bash',
356
+ 'bash': 'bash',
357
+ 'zsh': 'bash',
358
+ 'sql': 'sql',
359
+ 'json': 'json',
360
+ 'yaml': 'yaml',
361
+ 'yml': 'yaml',
362
+ 'xml': 'xml',
363
+ 'html': 'html',
364
+ 'css': 'css',
365
+ 'scss': 'scss',
366
+ 'sass': 'sass',
367
+ 'less': 'less',
368
+ 'md': 'markdown',
369
+ 'txt': 'text',
370
+ 'log': 'text'
371
+ };
372
+ return languageMap[ext] || 'text';
373
+ }
374
+
375
+ function renderCachePath(cachePath) {
376
+ if (!cachePath) return '';
377
+
378
+ const ext = cachePath.split('.').pop().toLowerCase();
379
+ const cacheUrl = `/cache/${encodeURIComponent(cachePath)}`;
380
+ const uniqueId = `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
381
+
382
+ // Check if it's an image
383
+ if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp'].includes(ext)) {
384
+ return `<div class="mt-2">
385
+ <div class="text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Cached Artifact:</div>
386
+ <img src="${cacheUrl}" alt="Cached artifact" class="max-w-full h-auto rounded border border-gray-300 dark:border-gray-600 cursor-pointer"
387
+ onclick="window.open('${cacheUrl}', '_blank')"
388
+ style="max-height: 400px; object-fit: contain;" />
389
+ </div>`;
390
+ }
391
+
392
+ // Check if it's a PDF
393
+ if (ext === 'pdf') {
394
+ return `<div class="mt-2">
395
+ <div class="text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Cached Artifact (PDF):</div>
396
+ <embed src="${cacheUrl}" type="application/pdf" class="w-full rounded border border-gray-300 dark:border-gray-600" style="height: 400px;" />
397
+ <a href="${cacheUrl}" target="_blank" class="text-xs text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">Open PDF in new tab</a>
398
+ </div>`;
399
+ }
400
+
401
+ // Check if it's a markdown file
402
+ if (ext === 'md') {
403
+ return `<div class="mt-2">
404
+ <div class="text-xs font-medium text-gray-600 dark:text-gray-400 mb-1 flex items-center justify-between">
405
+ <span>Cached Artifact (Markdown):</span>
406
+ <button onclick="toggleFileContent('${uniqueId}')" class="text-xs bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 px-2 py-1 rounded flex items-center gap-1">
407
+ <span class="arrow transform transition-transform" id="arrow-${uniqueId}">▼</span>
408
+ <span class="btn-text" id="btn-${uniqueId}">Hide</span>
409
+ </button>
410
+ </div>
411
+ <div id="content-${uniqueId}" class="markdown-content bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded p-3 max-h-96 overflow-y-auto">
412
+ Loading markdown...
413
+ </div>
414
+ <a href="${cacheUrl}" target="_blank" class="text-xs text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 mt-1 inline-block">Open file in new tab</a>
415
+ </div>`;
416
+ }
417
+
418
+ // Check if it's a code file
419
+ const language = getFileLanguage(ext);
420
+ if (['javascript', 'jsx', 'typescript', 'tsx', 'python', 'ruby', 'java', 'cpp', 'c', 'csharp', 'go', 'rust', 'php', 'bash', 'sql', 'json', 'yaml', 'xml', 'html', 'css', 'scss', 'sass', 'less'].includes(language)) {
421
+ return `<div class="mt-2">
422
+ <div class="text-xs font-medium text-gray-600 dark:text-gray-400 mb-1 flex items-center justify-between">
423
+ <span>Cached Artifact (${language.toUpperCase()}):</span>
424
+ <button onclick="toggleFileContent('${uniqueId}')" class="text-xs bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 px-2 py-1 rounded flex items-center gap-1">
425
+ <span class="arrow transform transition-transform" id="arrow-${uniqueId}">▶</span>
426
+ <span class="btn-text" id="btn-${uniqueId}">Show</span>
427
+ </button>
428
+ </div>
429
+ <div id="content-${uniqueId}" class="hidden code-content bg-gray-50 dark:bg-gray-900 border border-gray-300 dark:border-gray-600 rounded max-h-96 overflow-y-auto">
430
+ <pre class="text-sm"><code class="language-${language}">Loading code...</code></pre>
431
+ </div>
432
+ <a href="${cacheUrl}" target="_blank" class="text-xs text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 mt-1 inline-block">Open file in new tab</a>
433
+ </div>`;
434
+ }
435
+
436
+ // For other file types, just show a link
437
+ return `<div class="mt-2">
438
+ <div class="text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Cached Artifact:</div>
439
+ <a href="${cacheUrl}" target="_blank" class="text-sm text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 underline">
440
+ 📎 ${cachePath}
441
+ </a>
442
+ </div>`;
443
+ }
444
+
445
+ function renderLog(entry) {
446
+ const div = document.createElement("div");
447
+ const levelLower = (entry.level || '').toLowerCase();
448
+ const border =
449
+ levelLower === "error" ? "border-red-500" :
450
+ levelLower === "warn" || levelLower === "warning" ? "border-yellow-400" :
451
+ levelLower === "debug" ? "border-gray-400" :
452
+ "border-blue-400";
453
+ div.className = `p-3 bg-white dark:bg-gray-800 border-l-4 ${border} rounded shadow-sm transition`;
454
+
455
+ const timeAgo = getTimeAgo(entry.time);
456
+ const timeDisplay = entry.time ? `<span class="time-ago font-medium text-gray-700 dark:text-gray-200" data-timestamp="${entry.time}">${timeAgo}</span>` : '';
457
+
458
+ const cachePathHtml = renderCachePath(entry.cache_path);
459
+ const runInfoHtml = renderRunInfo(entry);
460
+
461
+ // Use 'message' field for HTML content, fallback to 'msg' or 'event'
462
+ const mainContent = entry.message || entry.msg || entry.event || '';
463
+
464
+ // Generate unique ID for collapse functionality
465
+ const uniqueId = `json-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
466
+
467
+ div.innerHTML = `
468
+ <div class="flex justify-between text-sm text-gray-600 dark:text-gray-300">
469
+ <div class="flex items-center space-x-2">
470
+ ${timeDisplay}
471
+ <span class="text-xs">${entry.time || ''}</span>
472
+ </div>
473
+ <div class="flex items-center space-x-2">
474
+ <span class="font-semibold">${entry.level || ''}</span>
475
+ <span>${entry.section || ''}</span>
476
+ ${runInfoHtml}
477
+ </div>
478
+ </div>
479
+ <div class="mt-2 text-gray-900 dark:text-gray-100 font-mono text-sm whitespace-pre-wrap">${mainContent}</div>
480
+ ${cachePathHtml}
481
+ <div class="mt-2">
482
+ <button onclick="this.nextElementSibling.classList.toggle('hidden'); this.querySelector('.arrow').style.transform = this.nextElementSibling.classList.contains('hidden') ? 'rotate(0deg)' : 'rotate(90deg)'; this.querySelector('.btn-text').textContent = this.nextElementSibling.classList.contains('hidden') ? 'Show JSON' : 'Hide JSON';"
483
+ class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 flex items-center gap-1">
484
+ <span class="arrow transform transition-transform" style="transform: rotate(0deg)">▶</span>
485
+ <span class="btn-text">Show JSON</span>
486
+ </button>
487
+ <pre class="hidden mt-1 text-xs bg-gray-100 dark:bg-gray-900 p-2 rounded overflow-x-auto">${JSON.stringify(entry, null, 2)}</pre>
488
+ </div>
489
+ `;
490
+
491
+ container.appendChild(div);
492
+ }
493
+
494
+ function prependLog(entry) {
495
+ const div = document.createElement("div");
496
+ const levelLower = (entry.level || '').toLowerCase();
497
+ const border =
498
+ levelLower === "error" ? "border-red-500" :
499
+ levelLower === "warn" || levelLower === "warning" ? "border-yellow-400" :
500
+ levelLower === "debug" ? "border-gray-400" :
501
+ "border-blue-400";
502
+ div.className = `p-3 bg-white dark:bg-gray-800 border-l-4 ${border} rounded shadow-sm transition`;
503
+
504
+ const timeAgo = getTimeAgo(entry.time);
505
+ const timeDisplay = entry.time ? `<span class="time-ago font-medium text-gray-700 dark:text-gray-200" data-timestamp="${entry.time}">${timeAgo}</span>` : '';
506
+
507
+ const cachePathHtml = renderCachePath(entry.cache_path);
508
+ const runInfoHtml = renderRunInfo(entry);
509
+ const mainContent = entry.message || entry.msg || entry.event || '';
510
+
511
+ div.innerHTML = `
512
+ <div class="flex justify-between text-sm text-gray-600 dark:text-gray-300">
513
+ <div class="flex items-center space-x-2">
514
+ ${timeDisplay}
515
+ <span class="text-xs">${entry.time || ''}</span>
516
+ </div>
517
+ <div class="flex items-center space-x-2">
518
+ <span class="font-semibold">${entry.level || ''}</span>
519
+ <span>${entry.section || ''}</span>
520
+ ${runInfoHtml}
521
+ </div>
522
+ </div>
523
+ <div class="mt-2 text-gray-900 dark:text-gray-100 font-mono text-sm whitespace-pre-wrap">${mainContent}</div>
524
+ ${cachePathHtml}
525
+ <div class="mt-2">
526
+ <button onclick="this.nextElementSibling.classList.toggle('hidden'); this.querySelector('.arrow').style.transform = this.nextElementSibling.classList.contains('hidden') ? 'rotate(0deg)' : 'rotate(90deg)'; this.querySelector('.btn-text').textContent = this.nextElementSibling.classList.contains('hidden') ? 'Show JSON' : 'Hide JSON';"
527
+ class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 flex items-center gap-1">
528
+ <span class="arrow transform transition-transform" style="transform: rotate(0deg)">▶</span>
529
+ <span class="btn-text">Show JSON</span>
530
+ </button>
531
+ <pre class="hidden mt-1 text-xs bg-gray-100 dark:bg-gray-900 p-2 rounded overflow-x-auto">${JSON.stringify(entry, null, 2)}</pre>
532
+ </div>
533
+ `;
534
+
535
+ container.insertBefore(div, container.firstChild);
536
+ }
537
+
538
+ source.onmessage = (event) => {
539
+ const entry = JSON.parse(event.data);
540
+ allLogs.push(entry);
541
+
542
+ // Track new log levels and update filter dropdown
543
+ if (entry.level && !knownLevels.has(entry.level)) {
544
+ knownLevels.add(entry.level);
545
+ updateLevelFilter();
546
+ }
547
+
548
+ // With pagination, we need to re-render when new entries arrive
549
+ // if we're on page 1 (to show newest entries) or if filters are active
550
+ if (matchesFilters(entry)) {
551
+ const hasActiveFilters = levelFilter.value || sectionFilter.value || groupFilter.value || runNameFilter.value || runIdFilter.value || timeFilter.value;
552
+
553
+ if (currentPage === 1 && !hasActiveFilters) {
554
+ // On page 1 with no filters, we can optimize by prepending
555
+ const filteredCount = allLogs.filter(matchesFilters).length;
556
+ if (filteredCount <= perPage) {
557
+ prependLog(entry);
558
+ updatePaginationInfo(allLogs.filter(matchesFilters));
559
+ } else {
560
+ renderLogs();
561
+ }
562
+ } else {
563
+ // Otherwise re-render to maintain pagination integrity
564
+ renderLogs();
565
+ }
566
+ }
567
+ };
568
+
569
+ // Function to update all time-ago displays
570
+ function updateTimeDisplays() {
571
+ document.querySelectorAll('.time-ago').forEach(element => {
572
+ const timestamp = element.getAttribute('data-timestamp');
573
+ if (timestamp) {
574
+ element.textContent = getTimeAgo(timestamp);
575
+ }
576
+ });
577
+ }
578
+
579
+ // Update time displays every 5 seconds for better responsiveness
580
+ setInterval(updateTimeDisplays, 5000);
581
+
582
+ // File content toggle and loading functions
583
+ window.toggleFileContent = function(uniqueId) {
584
+ const content = document.getElementById(`content-${uniqueId}`);
585
+ const arrow = document.getElementById(`arrow-${uniqueId}`);
586
+ const btn = document.getElementById(`btn-${uniqueId}`);
587
+
588
+ if (content.classList.contains('hidden')) {
589
+ // Show content
590
+ content.classList.remove('hidden');
591
+ arrow.style.transform = 'rotate(90deg)';
592
+ btn.textContent = 'Hide';
593
+
594
+ // Load content if not already loaded
595
+ if (content.querySelector('.markdown-content, .code-content') &&
596
+ (content.textContent.includes('Loading') || content.querySelector('code')?.textContent.includes('Loading'))) {
597
+ loadFileContent(uniqueId);
598
+ }
599
+ } else {
600
+ // Hide content
601
+ content.classList.add('hidden');
602
+ arrow.style.transform = 'rotate(0deg)';
603
+ btn.textContent = 'Show';
604
+ }
605
+ };
606
+
607
+ async function loadFileContent(uniqueId) {
608
+ const content = document.getElementById(`content-${uniqueId}`);
609
+ const cacheUrl = content.closest('.mt-2').querySelector('a[href*="/cache/"]').href;
610
+
611
+ try {
612
+ const response = await fetch(cacheUrl);
613
+ const text = await response.text();
614
+
615
+ if (content.classList.contains('markdown-content')) {
616
+ // Render markdown
617
+ content.innerHTML = marked.parse(text);
618
+ } else if (content.classList.contains('code-content')) {
619
+ // Render code with syntax highlighting
620
+ const codeElement = content.querySelector('code');
621
+ codeElement.textContent = text;
622
+ Prism.highlightElement(codeElement);
623
+ }
624
+ } catch (error) {
625
+ content.innerHTML = `<div class="text-red-500 text-sm">Error loading file: ${error.message}</div>`;
626
+ }
627
+ }
628
+
629
+ // Load markdown files immediately (since they're shown by default)
630
+ function loadMarkdownFiles() {
631
+ document.querySelectorAll('.markdown-content').forEach((element) => {
632
+ if (element.textContent.includes('Loading')) {
633
+ const uniqueId = element.id.replace('content-', '');
634
+ loadFileContent(uniqueId);
635
+ }
636
+ });
637
+ }
638
+
639
+ // Initial render when page loads
640
+ renderLogs();
641
+
642
+ // Re-render logs every minute if time filter is active (to hide old entries)
643
+ setInterval(() => {
644
+ if (timeFilter.value) {
645
+ renderLogs();
646
+ }
647
+ }, 60000);
648
+
649
+ levelFilter.onchange = () => {
650
+ currentPage = 1; // Reset to page 1 when filters change
651
+ renderLogs();
652
+ };
653
+ sectionFilter.oninput = () => {
654
+ currentPage = 1;
655
+ renderLogs();
656
+ };
657
+ groupFilter.oninput = () => {
658
+ currentPage = 1;
659
+ renderLogs();
660
+ };
661
+ runNameFilter.oninput = () => {
662
+ currentPage = 1;
663
+ renderLogs();
664
+ };
665
+ runIdFilter.oninput = () => {
666
+ currentPage = 1;
667
+ renderLogs();
668
+ };
669
+ timeFilter.onchange = () => {
670
+ currentPage = 1;
671
+ renderLogs();
672
+ };
673
+ clearBtn.onclick = () => {
674
+ levelFilter.value = "";
675
+ sectionFilter.value = "";
676
+ groupFilter.value = "";
677
+ runNameFilter.value = "";
678
+ runIdFilter.value = "";
679
+ timeFilter.value = "";
680
+ currentPage = 1;
681
+ renderLogs();
682
+ };
683
+
684
+ // Pagination controls
685
+ prevPageBtn.onclick = () => {
686
+ if (currentPage > 1) {
687
+ currentPage--;
688
+ renderLogs();
689
+ window.scrollTo({ top: 0, behavior: 'smooth' });
690
+ }
691
+ };
692
+
693
+ nextPageBtn.onclick = () => {
694
+ const totalPages = Math.ceil(allLogs.filter(matchesFilters).length / perPage);
695
+ if (currentPage < totalPages) {
696
+ currentPage++;
697
+ renderLogs();
698
+ window.scrollTo({ top: 0, behavior: 'smooth' });
699
+ }
700
+ };
701
+
702
+ perPageSelect.onchange = () => {
703
+ perPage = parseInt(perPageSelect.value);
704
+ currentPage = 1; // Reset to page 1 when changing per page
705
+ renderLogs();
706
+ };
707
+
708
+ // Sort toggle
709
+ sortToggle.onclick = () => {
710
+ sortOldestFirst = !sortOldestFirst;
711
+ sortToggle.textContent = sortOldestFirst ? '⬇️ Newest First' : '⬆️ Oldest First';
712
+ currentPage = 1; // Reset to page 1 when changing sort
713
+ renderLogs();
714
+ };
715
+ </script>
716
+ </body>
717
+ </html>