log4lab 0.1.0__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.
- log4lab/__init__.py +1 -0
- log4lab/cli.py +64 -0
- log4lab/server.py +155 -0
- log4lab/tail.py +283 -0
- log4lab/templates/index.html +579 -0
- log4lab/templates/runs.html +182 -0
- log4lab-0.1.0.dist-info/METADATA +338 -0
- log4lab-0.1.0.dist-info/RECORD +14 -0
- log4lab-0.1.0.dist-info/WHEEL +5 -0
- log4lab-0.1.0.dist-info/entry_points.txt +2 -0
- log4lab-0.1.0.dist-info/licenses/LICENSE +21 -0
- log4lab-0.1.0.dist-info/top_level.txt +2 -0
- tests/__init__.py +1 -0
- tests/test_server.py +214 -0
|
@@ -0,0 +1,579 @@
|
|
|
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
|
+
<script>
|
|
8
|
+
if (localStorage.theme === 'dark' ||
|
|
9
|
+
(!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
|
10
|
+
document.documentElement.classList.add('dark');
|
|
11
|
+
} else {
|
|
12
|
+
document.documentElement.classList.remove('dark');
|
|
13
|
+
}
|
|
14
|
+
</script>
|
|
15
|
+
</head>
|
|
16
|
+
<body class="bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100 transition-colors">
|
|
17
|
+
<div class="max-w-6xl mx-auto p-6">
|
|
18
|
+
<div class="flex justify-between items-center mb-4">
|
|
19
|
+
<h1 class="text-3xl font-bold">Log4Lab <span class="text-sm font-light">(Live)</span></h1>
|
|
20
|
+
<div class="flex space-x-2">
|
|
21
|
+
<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">
|
|
22
|
+
📋 Runs Index
|
|
23
|
+
</a>
|
|
24
|
+
<button id="themeToggle"
|
|
25
|
+
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">
|
|
26
|
+
Toggle Theme
|
|
27
|
+
</button>
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<div class="flex space-x-4 mb-4 items-center flex-wrap">
|
|
32
|
+
<div>
|
|
33
|
+
<label class="block text-sm font-medium">Level</label>
|
|
34
|
+
<select id="levelFilter" class="border rounded px-2 py-1 dark:bg-gray-800 dark:border-gray-700">
|
|
35
|
+
<option value="">All</option>
|
|
36
|
+
</select>
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
<div>
|
|
40
|
+
<label class="block text-sm font-medium">Section</label>
|
|
41
|
+
<input id="sectionFilter" type="text"
|
|
42
|
+
class="border rounded px-2 py-1 dark:bg-gray-800 dark:border-gray-700"
|
|
43
|
+
placeholder="e.g. train" />
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
<div>
|
|
47
|
+
<label class="block text-sm font-medium">Group</label>
|
|
48
|
+
<input id="groupFilter" type="text"
|
|
49
|
+
class="border rounded px-2 py-1 dark:bg-gray-800 dark:border-gray-700"
|
|
50
|
+
placeholder="e.g. experiment1" />
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<div>
|
|
54
|
+
<label class="block text-sm font-medium">Run Name</label>
|
|
55
|
+
<input id="runNameFilter" type="text"
|
|
56
|
+
class="border rounded px-2 py-1 dark:bg-gray-800 dark:border-gray-700"
|
|
57
|
+
placeholder="e.g. my_experiment" />
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<div>
|
|
61
|
+
<label class="block text-sm font-medium">Run ID</label>
|
|
62
|
+
<input id="runIdFilter" type="text"
|
|
63
|
+
class="border rounded px-2 py-1 dark:bg-gray-800 dark:border-gray-700"
|
|
64
|
+
placeholder="e.g. abc123" />
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<div>
|
|
68
|
+
<label class="block text-sm font-medium">Time Range</label>
|
|
69
|
+
<select id="timeFilter" class="border rounded px-2 py-1 dark:bg-gray-800 dark:border-gray-700">
|
|
70
|
+
<option value="">All Time</option>
|
|
71
|
+
<option value="60">Last 1 minute</option>
|
|
72
|
+
<option value="300">Last 5 minutes</option>
|
|
73
|
+
<option value="600">Last 10 minutes</option>
|
|
74
|
+
<option value="1800">Last 30 minutes</option>
|
|
75
|
+
<option value="3600">Last 1 hour</option>
|
|
76
|
+
<option value="21600">Last 6 hours</option>
|
|
77
|
+
<option value="86400">Last 24 hours</option>
|
|
78
|
+
</select>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<button id="clearFilters"
|
|
82
|
+
class="bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700 px-3 py-1 rounded mt-5">
|
|
83
|
+
Clear
|
|
84
|
+
</button>
|
|
85
|
+
|
|
86
|
+
<button id="sortToggle"
|
|
87
|
+
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">
|
|
88
|
+
⬆️ Oldest First
|
|
89
|
+
</button>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<div id="log-container" class="space-y-2"></div>
|
|
93
|
+
|
|
94
|
+
<div id="pagination" class="mt-6 flex items-center justify-between border-t border-gray-300 dark:border-gray-700 pt-4">
|
|
95
|
+
<div class="text-sm text-gray-600 dark:text-gray-400">
|
|
96
|
+
Showing <span id="showing-start">0</span>-<span id="showing-end">0</span> of <span id="total-count">0</span> logs
|
|
97
|
+
</div>
|
|
98
|
+
<div class="flex items-center space-x-2">
|
|
99
|
+
<label class="text-sm text-gray-600 dark:text-gray-400">Per page:</label>
|
|
100
|
+
<select id="perPageSelect" class="border rounded px-2 py-1 text-sm dark:bg-gray-800 dark:border-gray-700">
|
|
101
|
+
<option value="20">20</option>
|
|
102
|
+
<option value="50" selected>50</option>
|
|
103
|
+
<option value="100">100</option>
|
|
104
|
+
<option value="200">200</option>
|
|
105
|
+
</select>
|
|
106
|
+
</div>
|
|
107
|
+
<div class="flex space-x-2">
|
|
108
|
+
<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>
|
|
109
|
+
Previous
|
|
110
|
+
</button>
|
|
111
|
+
<span class="px-3 py-1 text-sm text-gray-600 dark:text-gray-400">
|
|
112
|
+
Page <span id="currentPage">1</span> of <span id="totalPages">1</span>
|
|
113
|
+
</span>
|
|
114
|
+
<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>
|
|
115
|
+
Next
|
|
116
|
+
</button>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
<script>
|
|
122
|
+
const container = document.getElementById("log-container");
|
|
123
|
+
const source = new EventSource("/stream");
|
|
124
|
+
const levelFilter = document.getElementById("levelFilter");
|
|
125
|
+
const sectionFilter = document.getElementById("sectionFilter");
|
|
126
|
+
const groupFilter = document.getElementById("groupFilter");
|
|
127
|
+
const runNameFilter = document.getElementById("runNameFilter");
|
|
128
|
+
const runIdFilter = document.getElementById("runIdFilter");
|
|
129
|
+
const timeFilter = document.getElementById("timeFilter");
|
|
130
|
+
const clearBtn = document.getElementById("clearFilters");
|
|
131
|
+
const themeToggle = document.getElementById("themeToggle");
|
|
132
|
+
const sortToggle = document.getElementById("sortToggle");
|
|
133
|
+
|
|
134
|
+
// Pagination elements
|
|
135
|
+
const perPageSelect = document.getElementById("perPageSelect");
|
|
136
|
+
const prevPageBtn = document.getElementById("prevPage");
|
|
137
|
+
const nextPageBtn = document.getElementById("nextPage");
|
|
138
|
+
const currentPageSpan = document.getElementById("currentPage");
|
|
139
|
+
const totalPagesSpan = document.getElementById("totalPages");
|
|
140
|
+
const showingStartSpan = document.getElementById("showing-start");
|
|
141
|
+
const showingEndSpan = document.getElementById("showing-end");
|
|
142
|
+
const totalCountSpan = document.getElementById("total-count");
|
|
143
|
+
|
|
144
|
+
let allLogs = [];
|
|
145
|
+
let knownLevels = new Set();
|
|
146
|
+
let currentPage = 1;
|
|
147
|
+
let perPage = 50;
|
|
148
|
+
let sortOldestFirst = false;
|
|
149
|
+
|
|
150
|
+
// Parse URL parameters
|
|
151
|
+
function getUrlParams() {
|
|
152
|
+
const params = new URLSearchParams(window.location.search);
|
|
153
|
+
return {
|
|
154
|
+
level: params.get('level') || '',
|
|
155
|
+
section: params.get('section') || '',
|
|
156
|
+
group: params.get('group') || '',
|
|
157
|
+
run_name: params.get('run_name') || '',
|
|
158
|
+
run_id: params.get('run_id') || '',
|
|
159
|
+
time: params.get('time') || ''
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Apply URL parameters to filters
|
|
164
|
+
function applyUrlParams() {
|
|
165
|
+
const params = getUrlParams();
|
|
166
|
+
if (params.level) levelFilter.value = params.level;
|
|
167
|
+
if (params.section) sectionFilter.value = params.section;
|
|
168
|
+
if (params.group) groupFilter.value = params.group;
|
|
169
|
+
if (params.run_name) runNameFilter.value = params.run_name;
|
|
170
|
+
if (params.run_id) runIdFilter.value = params.run_id;
|
|
171
|
+
if (params.time) timeFilter.value = params.time;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Apply filters from URL on page load
|
|
175
|
+
applyUrlParams();
|
|
176
|
+
|
|
177
|
+
themeToggle.onclick = () => {
|
|
178
|
+
const html = document.documentElement;
|
|
179
|
+
if (html.classList.contains('dark')) {
|
|
180
|
+
html.classList.remove('dark');
|
|
181
|
+
localStorage.theme = 'light';
|
|
182
|
+
} else {
|
|
183
|
+
html.classList.add('dark');
|
|
184
|
+
localStorage.theme = 'dark';
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
function matchesFilters(entry) {
|
|
189
|
+
const level = levelFilter.value.toLowerCase();
|
|
190
|
+
const section = sectionFilter.value.toLowerCase();
|
|
191
|
+
const group = groupFilter.value.toLowerCase();
|
|
192
|
+
const runName = runNameFilter.value.toLowerCase();
|
|
193
|
+
const runId = runIdFilter.value.toLowerCase();
|
|
194
|
+
const timeRange = parseInt(timeFilter.value);
|
|
195
|
+
|
|
196
|
+
if (level && (!entry.level || entry.level.toLowerCase() !== level)) return false;
|
|
197
|
+
if (section && (!entry.section || !entry.section.toLowerCase().includes(section))) return false;
|
|
198
|
+
if (group && (!entry.group || !entry.group.toLowerCase().includes(group))) return false;
|
|
199
|
+
if (runName && (!entry.run_name || !entry.run_name.toLowerCase().includes(runName))) return false;
|
|
200
|
+
if (runId && (!entry.run_id || !entry.run_id.toLowerCase().includes(runId))) return false;
|
|
201
|
+
|
|
202
|
+
// Time range filter
|
|
203
|
+
if (timeRange && entry.time) {
|
|
204
|
+
const logTime = new Date(entry.time);
|
|
205
|
+
const now = new Date();
|
|
206
|
+
const diffSeconds = (now - logTime) / 1000;
|
|
207
|
+
if (diffSeconds > timeRange) return false;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function updateLevelFilter() {
|
|
214
|
+
const currentSelection = levelFilter.value;
|
|
215
|
+
const sortedLevels = Array.from(knownLevels).sort();
|
|
216
|
+
|
|
217
|
+
// Keep "All" option and rebuild the rest
|
|
218
|
+
levelFilter.innerHTML = '<option value="">All</option>';
|
|
219
|
+
sortedLevels.forEach(level => {
|
|
220
|
+
const option = document.createElement('option');
|
|
221
|
+
option.value = level.toLowerCase();
|
|
222
|
+
option.textContent = level;
|
|
223
|
+
levelFilter.appendChild(option);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// Restore previous selection if it still exists
|
|
227
|
+
if (currentSelection) {
|
|
228
|
+
levelFilter.value = currentSelection;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function updatePaginationInfo(filteredLogs) {
|
|
233
|
+
const totalLogs = filteredLogs.length;
|
|
234
|
+
const totalPages = Math.max(1, Math.ceil(totalLogs / perPage));
|
|
235
|
+
|
|
236
|
+
// Ensure current page is within bounds
|
|
237
|
+
if (currentPage > totalPages) {
|
|
238
|
+
currentPage = totalPages;
|
|
239
|
+
}
|
|
240
|
+
if (currentPage < 1) {
|
|
241
|
+
currentPage = 1;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const startIdx = (currentPage - 1) * perPage;
|
|
245
|
+
const endIdx = Math.min(startIdx + perPage, totalLogs);
|
|
246
|
+
|
|
247
|
+
// Update pagination display
|
|
248
|
+
currentPageSpan.textContent = currentPage;
|
|
249
|
+
totalPagesSpan.textContent = totalPages;
|
|
250
|
+
showingStartSpan.textContent = totalLogs > 0 ? startIdx + 1 : 0;
|
|
251
|
+
showingEndSpan.textContent = endIdx;
|
|
252
|
+
totalCountSpan.textContent = totalLogs;
|
|
253
|
+
|
|
254
|
+
// Update button states
|
|
255
|
+
prevPageBtn.disabled = currentPage <= 1;
|
|
256
|
+
nextPageBtn.disabled = currentPage >= totalPages;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function renderLogs() {
|
|
260
|
+
container.innerHTML = "";
|
|
261
|
+
|
|
262
|
+
const filteredLogs = allLogs.filter(matchesFilters);
|
|
263
|
+
// Sort based on toggle: newest first (default) or oldest first
|
|
264
|
+
const sortedLogs = sortOldestFirst ? filteredLogs.slice() : filteredLogs.slice().reverse();
|
|
265
|
+
|
|
266
|
+
// Calculate pagination
|
|
267
|
+
const startIdx = (currentPage - 1) * perPage;
|
|
268
|
+
const endIdx = startIdx + perPage;
|
|
269
|
+
const pageLog = sortedLogs.slice(startIdx, endIdx);
|
|
270
|
+
|
|
271
|
+
// Render logs for current page
|
|
272
|
+
pageLog.forEach(renderLog);
|
|
273
|
+
|
|
274
|
+
// Update pagination info
|
|
275
|
+
updatePaginationInfo(filteredLogs);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function getTimeAgo(timestamp) {
|
|
279
|
+
if (!timestamp) return '';
|
|
280
|
+
|
|
281
|
+
// Parse timestamp - if it doesn't end with Z and looks like UTC, add Z
|
|
282
|
+
let ts = timestamp;
|
|
283
|
+
if (!ts.endsWith('Z') && !ts.includes('+') && !ts.includes('-', 10)) {
|
|
284
|
+
// Looks like an ISO timestamp without timezone - treat as UTC
|
|
285
|
+
ts = ts + 'Z';
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const logTime = new Date(ts);
|
|
289
|
+
const now = new Date();
|
|
290
|
+
const diffMs = now - logTime;
|
|
291
|
+
const diffSec = Math.floor(diffMs / 1000);
|
|
292
|
+
const diffMin = Math.floor(diffSec / 60);
|
|
293
|
+
const diffHour = Math.floor(diffMin / 60);
|
|
294
|
+
const diffDay = Math.floor(diffHour / 24);
|
|
295
|
+
|
|
296
|
+
// Handle future timestamps
|
|
297
|
+
if (diffSec < 0) {
|
|
298
|
+
const absSec = Math.abs(diffSec);
|
|
299
|
+
if (absSec < 60) return `in ${absSec}s`;
|
|
300
|
+
const absMin = Math.floor(absSec / 60);
|
|
301
|
+
if (absMin < 60) return `in ${absMin}m`;
|
|
302
|
+
const absHour = Math.floor(absMin / 60);
|
|
303
|
+
return `in ${absHour}h`;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (diffSec < 10) return 'just now';
|
|
307
|
+
if (diffSec < 60) return `${diffSec}s ago`;
|
|
308
|
+
if (diffMin < 60) return `${diffMin}m ago`;
|
|
309
|
+
if (diffHour < 24) return `${diffHour}h ago`;
|
|
310
|
+
return `${diffDay}d ago`;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function renderRunInfo(entry) {
|
|
314
|
+
if (!entry.run_name && !entry.run_id) return '';
|
|
315
|
+
|
|
316
|
+
const parts = [];
|
|
317
|
+
if (entry.run_name) parts.push(`run: <span class="font-semibold">${entry.run_name}</span>`);
|
|
318
|
+
if (entry.run_id) parts.push(`id: <span class="font-semibold">${entry.run_id}</span>`);
|
|
319
|
+
|
|
320
|
+
let url = '/runs';
|
|
321
|
+
if (entry.run_id) {
|
|
322
|
+
url = `/?run_id=${encodeURIComponent(entry.run_id)}`;
|
|
323
|
+
} else if (entry.run_name) {
|
|
324
|
+
url = `/?run_name=${encodeURIComponent(entry.run_name)}`;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return `<a href="${url}" class="text-xs text-blue-600 dark:text-blue-400 hover:underline ml-2">🔗 ${parts.join(', ')}</a>`;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function renderCachePath(cachePath) {
|
|
331
|
+
if (!cachePath) return '';
|
|
332
|
+
|
|
333
|
+
const ext = cachePath.split('.').pop().toLowerCase();
|
|
334
|
+
const cacheUrl = `/cache/${encodeURIComponent(cachePath)}`;
|
|
335
|
+
|
|
336
|
+
// Check if it's an image
|
|
337
|
+
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp'].includes(ext)) {
|
|
338
|
+
return `<div class="mt-2">
|
|
339
|
+
<div class="text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Cached Artifact:</div>
|
|
340
|
+
<img src="${cacheUrl}" alt="Cached artifact" class="max-w-full h-auto rounded border border-gray-300 dark:border-gray-600 cursor-pointer"
|
|
341
|
+
onclick="window.open('${cacheUrl}', '_blank')"
|
|
342
|
+
style="max-height: 400px; object-fit: contain;" />
|
|
343
|
+
</div>`;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Check if it's a PDF
|
|
347
|
+
if (ext === 'pdf') {
|
|
348
|
+
return `<div class="mt-2">
|
|
349
|
+
<div class="text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Cached Artifact (PDF):</div>
|
|
350
|
+
<embed src="${cacheUrl}" type="application/pdf" class="w-full rounded border border-gray-300 dark:border-gray-600" style="height: 400px;" />
|
|
351
|
+
<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>
|
|
352
|
+
</div>`;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// For other file types, just show a link
|
|
356
|
+
return `<div class="mt-2">
|
|
357
|
+
<div class="text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Cached Artifact:</div>
|
|
358
|
+
<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">
|
|
359
|
+
📎 ${cachePath}
|
|
360
|
+
</a>
|
|
361
|
+
</div>`;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function renderLog(entry) {
|
|
365
|
+
const div = document.createElement("div");
|
|
366
|
+
const levelLower = (entry.level || '').toLowerCase();
|
|
367
|
+
const border =
|
|
368
|
+
levelLower === "error" ? "border-red-500" :
|
|
369
|
+
levelLower === "warn" || levelLower === "warning" ? "border-yellow-400" :
|
|
370
|
+
levelLower === "debug" ? "border-gray-400" :
|
|
371
|
+
"border-blue-400";
|
|
372
|
+
div.className = `p-3 bg-white dark:bg-gray-800 border-l-4 ${border} rounded shadow-sm transition`;
|
|
373
|
+
|
|
374
|
+
const timeAgo = getTimeAgo(entry.time);
|
|
375
|
+
const timeDisplay = entry.time ? `<span class="time-ago font-medium text-gray-700 dark:text-gray-200" data-timestamp="${entry.time}">${timeAgo}</span>` : '';
|
|
376
|
+
|
|
377
|
+
const cachePathHtml = renderCachePath(entry.cache_path);
|
|
378
|
+
const runInfoHtml = renderRunInfo(entry);
|
|
379
|
+
|
|
380
|
+
// Use 'message' field for HTML content, fallback to 'msg'
|
|
381
|
+
const mainContent = entry.message || entry.msg || '';
|
|
382
|
+
|
|
383
|
+
// Generate unique ID for collapse functionality
|
|
384
|
+
const uniqueId = `json-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
385
|
+
|
|
386
|
+
div.innerHTML = `
|
|
387
|
+
<div class="flex justify-between text-sm text-gray-600 dark:text-gray-300">
|
|
388
|
+
<div class="flex items-center space-x-2">
|
|
389
|
+
${timeDisplay}
|
|
390
|
+
<span class="text-xs">${entry.time || ''}</span>
|
|
391
|
+
</div>
|
|
392
|
+
<div class="flex items-center space-x-2">
|
|
393
|
+
<span class="font-semibold">${entry.level || ''}</span>
|
|
394
|
+
<span>${entry.section || ''}</span>
|
|
395
|
+
${runInfoHtml}
|
|
396
|
+
</div>
|
|
397
|
+
</div>
|
|
398
|
+
<div class="mt-2 text-gray-900 dark:text-gray-100 font-mono text-sm whitespace-pre-wrap">${mainContent}</div>
|
|
399
|
+
${cachePathHtml}
|
|
400
|
+
<div class="mt-2">
|
|
401
|
+
<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';"
|
|
402
|
+
class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 flex items-center gap-1">
|
|
403
|
+
<span class="arrow transform transition-transform" style="transform: rotate(0deg)">▶</span>
|
|
404
|
+
<span class="btn-text">Show JSON</span>
|
|
405
|
+
</button>
|
|
406
|
+
<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>
|
|
407
|
+
</div>
|
|
408
|
+
`;
|
|
409
|
+
|
|
410
|
+
container.appendChild(div);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function prependLog(entry) {
|
|
414
|
+
const div = document.createElement("div");
|
|
415
|
+
const levelLower = (entry.level || '').toLowerCase();
|
|
416
|
+
const border =
|
|
417
|
+
levelLower === "error" ? "border-red-500" :
|
|
418
|
+
levelLower === "warn" || levelLower === "warning" ? "border-yellow-400" :
|
|
419
|
+
levelLower === "debug" ? "border-gray-400" :
|
|
420
|
+
"border-blue-400";
|
|
421
|
+
div.className = `p-3 bg-white dark:bg-gray-800 border-l-4 ${border} rounded shadow-sm transition`;
|
|
422
|
+
|
|
423
|
+
const timeAgo = getTimeAgo(entry.time);
|
|
424
|
+
const timeDisplay = entry.time ? `<span class="time-ago font-medium text-gray-700 dark:text-gray-200" data-timestamp="${entry.time}">${timeAgo}</span>` : '';
|
|
425
|
+
|
|
426
|
+
const cachePathHtml = renderCachePath(entry.cache_path);
|
|
427
|
+
const runInfoHtml = renderRunInfo(entry);
|
|
428
|
+
const mainContent = entry.message || entry.msg || '';
|
|
429
|
+
|
|
430
|
+
div.innerHTML = `
|
|
431
|
+
<div class="flex justify-between text-sm text-gray-600 dark:text-gray-300">
|
|
432
|
+
<div class="flex items-center space-x-2">
|
|
433
|
+
${timeDisplay}
|
|
434
|
+
<span class="text-xs">${entry.time || ''}</span>
|
|
435
|
+
</div>
|
|
436
|
+
<div class="flex items-center space-x-2">
|
|
437
|
+
<span class="font-semibold">${entry.level || ''}</span>
|
|
438
|
+
<span>${entry.section || ''}</span>
|
|
439
|
+
${runInfoHtml}
|
|
440
|
+
</div>
|
|
441
|
+
</div>
|
|
442
|
+
<div class="mt-2 text-gray-900 dark:text-gray-100 font-mono text-sm whitespace-pre-wrap">${mainContent}</div>
|
|
443
|
+
${cachePathHtml}
|
|
444
|
+
<div class="mt-2">
|
|
445
|
+
<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';"
|
|
446
|
+
class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 flex items-center gap-1">
|
|
447
|
+
<span class="arrow transform transition-transform" style="transform: rotate(0deg)">▶</span>
|
|
448
|
+
<span class="btn-text">Show JSON</span>
|
|
449
|
+
</button>
|
|
450
|
+
<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>
|
|
451
|
+
</div>
|
|
452
|
+
`;
|
|
453
|
+
|
|
454
|
+
container.insertBefore(div, container.firstChild);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
source.onmessage = (event) => {
|
|
458
|
+
const entry = JSON.parse(event.data);
|
|
459
|
+
allLogs.push(entry);
|
|
460
|
+
|
|
461
|
+
// Track new log levels and update filter dropdown
|
|
462
|
+
if (entry.level && !knownLevels.has(entry.level)) {
|
|
463
|
+
knownLevels.add(entry.level);
|
|
464
|
+
updateLevelFilter();
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// With pagination, we need to re-render when new entries arrive
|
|
468
|
+
// if we're on page 1 (to show newest entries) or if filters are active
|
|
469
|
+
if (matchesFilters(entry)) {
|
|
470
|
+
const hasActiveFilters = levelFilter.value || sectionFilter.value || groupFilter.value || runNameFilter.value || runIdFilter.value || timeFilter.value;
|
|
471
|
+
|
|
472
|
+
if (currentPage === 1 && !hasActiveFilters) {
|
|
473
|
+
// On page 1 with no filters, we can optimize by prepending
|
|
474
|
+
const filteredCount = allLogs.filter(matchesFilters).length;
|
|
475
|
+
if (filteredCount <= perPage) {
|
|
476
|
+
prependLog(entry);
|
|
477
|
+
updatePaginationInfo(allLogs.filter(matchesFilters));
|
|
478
|
+
} else {
|
|
479
|
+
renderLogs();
|
|
480
|
+
}
|
|
481
|
+
} else {
|
|
482
|
+
// Otherwise re-render to maintain pagination integrity
|
|
483
|
+
renderLogs();
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
// Function to update all time-ago displays
|
|
489
|
+
function updateTimeDisplays() {
|
|
490
|
+
document.querySelectorAll('.time-ago').forEach(element => {
|
|
491
|
+
const timestamp = element.getAttribute('data-timestamp');
|
|
492
|
+
if (timestamp) {
|
|
493
|
+
element.textContent = getTimeAgo(timestamp);
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Update time displays every 5 seconds for better responsiveness
|
|
499
|
+
setInterval(updateTimeDisplays, 5000);
|
|
500
|
+
|
|
501
|
+
// Initial render when page loads
|
|
502
|
+
renderLogs();
|
|
503
|
+
|
|
504
|
+
// Re-render logs every minute if time filter is active (to hide old entries)
|
|
505
|
+
setInterval(() => {
|
|
506
|
+
if (timeFilter.value) {
|
|
507
|
+
renderLogs();
|
|
508
|
+
}
|
|
509
|
+
}, 60000);
|
|
510
|
+
|
|
511
|
+
levelFilter.onchange = () => {
|
|
512
|
+
currentPage = 1; // Reset to page 1 when filters change
|
|
513
|
+
renderLogs();
|
|
514
|
+
};
|
|
515
|
+
sectionFilter.oninput = () => {
|
|
516
|
+
currentPage = 1;
|
|
517
|
+
renderLogs();
|
|
518
|
+
};
|
|
519
|
+
groupFilter.oninput = () => {
|
|
520
|
+
currentPage = 1;
|
|
521
|
+
renderLogs();
|
|
522
|
+
};
|
|
523
|
+
runNameFilter.oninput = () => {
|
|
524
|
+
currentPage = 1;
|
|
525
|
+
renderLogs();
|
|
526
|
+
};
|
|
527
|
+
runIdFilter.oninput = () => {
|
|
528
|
+
currentPage = 1;
|
|
529
|
+
renderLogs();
|
|
530
|
+
};
|
|
531
|
+
timeFilter.onchange = () => {
|
|
532
|
+
currentPage = 1;
|
|
533
|
+
renderLogs();
|
|
534
|
+
};
|
|
535
|
+
clearBtn.onclick = () => {
|
|
536
|
+
levelFilter.value = "";
|
|
537
|
+
sectionFilter.value = "";
|
|
538
|
+
groupFilter.value = "";
|
|
539
|
+
runNameFilter.value = "";
|
|
540
|
+
runIdFilter.value = "";
|
|
541
|
+
timeFilter.value = "";
|
|
542
|
+
currentPage = 1;
|
|
543
|
+
renderLogs();
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
// Pagination controls
|
|
547
|
+
prevPageBtn.onclick = () => {
|
|
548
|
+
if (currentPage > 1) {
|
|
549
|
+
currentPage--;
|
|
550
|
+
renderLogs();
|
|
551
|
+
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
552
|
+
}
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
nextPageBtn.onclick = () => {
|
|
556
|
+
const totalPages = Math.ceil(allLogs.filter(matchesFilters).length / perPage);
|
|
557
|
+
if (currentPage < totalPages) {
|
|
558
|
+
currentPage++;
|
|
559
|
+
renderLogs();
|
|
560
|
+
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
561
|
+
}
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
perPageSelect.onchange = () => {
|
|
565
|
+
perPage = parseInt(perPageSelect.value);
|
|
566
|
+
currentPage = 1; // Reset to page 1 when changing per page
|
|
567
|
+
renderLogs();
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
// Sort toggle
|
|
571
|
+
sortToggle.onclick = () => {
|
|
572
|
+
sortOldestFirst = !sortOldestFirst;
|
|
573
|
+
sortToggle.textContent = sortOldestFirst ? '⬇️ Newest First' : '⬆️ Oldest First';
|
|
574
|
+
currentPage = 1; // Reset to page 1 when changing sort
|
|
575
|
+
renderLogs();
|
|
576
|
+
};
|
|
577
|
+
</script>
|
|
578
|
+
</body>
|
|
579
|
+
</html>
|