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.
- log4lab/__init__.py +1 -0
- log4lab/cli.py +123 -0
- log4lab/export.py +744 -0
- log4lab/server.py +155 -0
- log4lab/tail.py +283 -0
- log4lab/templates/index.html +717 -0
- log4lab/templates/runs.html +182 -0
- log4lab-0.0.2.dist-info/METADATA +222 -0
- log4lab-0.0.2.dist-info/RECORD +15 -0
- log4lab-0.0.2.dist-info/WHEEL +5 -0
- log4lab-0.0.2.dist-info/entry_points.txt +2 -0
- log4lab-0.0.2.dist-info/licenses/LICENSE +21 -0
- log4lab-0.0.2.dist-info/top_level.txt +2 -0
- tests/__init__.py +1 -0
- tests/test_server.py +214 -0
log4lab/export.py
ADDED
|
@@ -0,0 +1,744 @@
|
|
|
1
|
+
"""Export logs to a self-contained HTML file."""
|
|
2
|
+
import json
|
|
3
|
+
import base64
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
import mimetypes
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def read_and_encode_image(file_path: Path) -> Optional[str]:
|
|
10
|
+
"""Read an image file and encode it as a base64 data URL.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
file_path: Path to the image file
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
Base64 data URL string, or None if file cannot be read
|
|
17
|
+
"""
|
|
18
|
+
if not file_path.exists() or not file_path.is_file():
|
|
19
|
+
return None
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
# Determine MIME type
|
|
23
|
+
mime_type, _ = mimetypes.guess_type(str(file_path))
|
|
24
|
+
if mime_type is None:
|
|
25
|
+
mime_type = "application/octet-stream"
|
|
26
|
+
|
|
27
|
+
# Read and encode file
|
|
28
|
+
with file_path.open("rb") as f:
|
|
29
|
+
file_data = f.read()
|
|
30
|
+
encoded = base64.b64encode(file_data).decode('utf-8')
|
|
31
|
+
return f"data:{mime_type};base64,{encoded}"
|
|
32
|
+
except Exception:
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def is_text_file(file_path: Path) -> bool:
|
|
37
|
+
"""Check if a file is a text file that can be syntax highlighted or rendered as markdown.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
file_path: Path to the file
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
True if the file should be treated as text content
|
|
44
|
+
"""
|
|
45
|
+
if not file_path.exists() or not file_path.is_file():
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
ext = file_path.suffix.lower().lstrip('.')
|
|
49
|
+
text_extensions = {
|
|
50
|
+
'js', 'jsx', 'ts', 'tsx', 'py', 'rb', 'java', 'cpp', 'c', 'cs', 'go', 'rs',
|
|
51
|
+
'php', 'sh', 'bash', 'zsh', 'sql', 'json', 'yaml', 'yml', 'xml', 'html',
|
|
52
|
+
'css', 'scss', 'sass', 'less', 'md', 'txt', 'log'
|
|
53
|
+
}
|
|
54
|
+
return ext in text_extensions
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def read_text_content(file_path: Path) -> Optional[str]:
|
|
58
|
+
"""Read text content from a file.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
file_path: Path to the text file
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Text content or None if file cannot be read
|
|
65
|
+
"""
|
|
66
|
+
if not file_path.exists() or not file_path.is_file():
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
with file_path.open('r', encoding='utf-8', errors='ignore') as f:
|
|
71
|
+
return f.read()
|
|
72
|
+
except Exception:
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def load_logs(log_path: Path) -> list:
|
|
77
|
+
"""Load all logs from the JSONL file.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
log_path: Path to the JSONL log file
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
List of log entry dictionaries
|
|
84
|
+
"""
|
|
85
|
+
logs = []
|
|
86
|
+
if not log_path.exists():
|
|
87
|
+
return logs
|
|
88
|
+
|
|
89
|
+
with log_path.open() as f:
|
|
90
|
+
for line in f:
|
|
91
|
+
line = line.strip()
|
|
92
|
+
if not line:
|
|
93
|
+
continue
|
|
94
|
+
try:
|
|
95
|
+
obj = json.loads(line)
|
|
96
|
+
logs.append(obj)
|
|
97
|
+
except json.JSONDecodeError:
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
return logs
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def embed_cache_files(logs: list, log_dir: Path) -> list:
|
|
104
|
+
"""Embed cache files (images, code, markdown, etc.) as base64 data URLs and text content.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
logs: List of log entries
|
|
108
|
+
log_dir: Directory containing the log file (base for cache paths)
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
List of log entries with embedded cache files
|
|
112
|
+
"""
|
|
113
|
+
embedded_logs = []
|
|
114
|
+
|
|
115
|
+
for entry in logs:
|
|
116
|
+
# Make a copy to avoid modifying original
|
|
117
|
+
entry_copy = entry.copy()
|
|
118
|
+
|
|
119
|
+
if 'cache_path' in entry_copy and entry_copy['cache_path']:
|
|
120
|
+
cache_path = log_dir / entry_copy['cache_path']
|
|
121
|
+
|
|
122
|
+
# Try to read as binary file first (for images, PDFs, etc.)
|
|
123
|
+
data_url = read_and_encode_image(cache_path)
|
|
124
|
+
if data_url:
|
|
125
|
+
entry_copy['_embedded_cache'] = data_url
|
|
126
|
+
|
|
127
|
+
# Also try to read as text if it's a text file
|
|
128
|
+
if is_text_file(cache_path):
|
|
129
|
+
text_content = read_text_content(cache_path)
|
|
130
|
+
if text_content:
|
|
131
|
+
entry_copy['_embedded_cache_text'] = text_content
|
|
132
|
+
|
|
133
|
+
embedded_logs.append(entry_copy)
|
|
134
|
+
|
|
135
|
+
return embedded_logs
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def generate_standalone_html(logs: list, output_path: Path, title: str = "Log4Lab Export"):
|
|
139
|
+
"""Generate a self-contained HTML file with all logs and embedded images.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
logs: List of log entries (with embedded cache files)
|
|
143
|
+
output_path: Path where the HTML file should be written
|
|
144
|
+
title: Title for the HTML page
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
# Inline Tailwind CSS - using the Play CDN version embedded
|
|
148
|
+
tailwind_config = """
|
|
149
|
+
tailwind.config = {
|
|
150
|
+
darkMode: 'class'
|
|
151
|
+
};
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
html_content = f"""<!DOCTYPE html>
|
|
155
|
+
<html lang="en" class="dark:bg-gray-900 dark:text-gray-100">
|
|
156
|
+
<head>
|
|
157
|
+
<meta charset="UTF-8">
|
|
158
|
+
<title>{title}</title>
|
|
159
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
160
|
+
<!-- Syntax highlighting -->
|
|
161
|
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css">
|
|
162
|
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-dark.min.css">
|
|
163
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-core.min.js"></script>
|
|
164
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/autoloader/prism-autoloader.min.js"></script>
|
|
165
|
+
<!-- Markdown rendering -->
|
|
166
|
+
<script src="https://cdn.jsdelivr.net/npm/marked@9.1.6/marked.min.js"></script>
|
|
167
|
+
<script>
|
|
168
|
+
{tailwind_config}
|
|
169
|
+
if (localStorage.theme === 'dark' ||
|
|
170
|
+
(!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {{
|
|
171
|
+
document.documentElement.classList.add('dark');
|
|
172
|
+
}} else {{
|
|
173
|
+
document.documentElement.classList.remove('dark');
|
|
174
|
+
}}
|
|
175
|
+
</script>
|
|
176
|
+
</head>
|
|
177
|
+
<body class="bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100 transition-colors">
|
|
178
|
+
<div class="max-w-6xl mx-auto p-6">
|
|
179
|
+
<div class="flex justify-between items-center mb-4">
|
|
180
|
+
<h1 class="text-3xl font-bold">{title} <span class="text-sm font-light">(Exported)</span></h1>
|
|
181
|
+
<div class="flex space-x-2">
|
|
182
|
+
<button id="themeToggle"
|
|
183
|
+
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">
|
|
184
|
+
Toggle Theme
|
|
185
|
+
</button>
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
|
|
189
|
+
<div class="flex space-x-4 mb-4 items-center flex-wrap">
|
|
190
|
+
<div>
|
|
191
|
+
<label class="block text-sm font-medium">Level</label>
|
|
192
|
+
<select id="levelFilter" class="border rounded px-2 py-1 dark:bg-gray-800 dark:border-gray-700">
|
|
193
|
+
<option value="">All</option>
|
|
194
|
+
</select>
|
|
195
|
+
</div>
|
|
196
|
+
|
|
197
|
+
<div>
|
|
198
|
+
<label class="block text-sm font-medium">Section</label>
|
|
199
|
+
<input id="sectionFilter" type="text"
|
|
200
|
+
class="border rounded px-2 py-1 dark:bg-gray-800 dark:border-gray-700"
|
|
201
|
+
placeholder="e.g. train" />
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
<div>
|
|
205
|
+
<label class="block text-sm font-medium">Group</label>
|
|
206
|
+
<input id="groupFilter" type="text"
|
|
207
|
+
class="border rounded px-2 py-1 dark:bg-gray-800 dark:border-gray-700"
|
|
208
|
+
placeholder="e.g. experiment1" />
|
|
209
|
+
</div>
|
|
210
|
+
|
|
211
|
+
<div>
|
|
212
|
+
<label class="block text-sm font-medium">Run Name</label>
|
|
213
|
+
<input id="runNameFilter" type="text"
|
|
214
|
+
class="border rounded px-2 py-1 dark:bg-gray-800 dark:border-gray-700"
|
|
215
|
+
placeholder="e.g. my_experiment" />
|
|
216
|
+
</div>
|
|
217
|
+
|
|
218
|
+
<div>
|
|
219
|
+
<label class="block text-sm font-medium">Run ID</label>
|
|
220
|
+
<input id="runIdFilter" type="text"
|
|
221
|
+
class="border rounded px-2 py-1 dark:bg-gray-800 dark:border-gray-700"
|
|
222
|
+
placeholder="e.g. abc123" />
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
<div>
|
|
226
|
+
<label class="block text-sm font-medium">Time Range</label>
|
|
227
|
+
<select id="timeFilter" class="border rounded px-2 py-1 dark:bg-gray-800 dark:border-gray-700">
|
|
228
|
+
<option value="">All Time</option>
|
|
229
|
+
<option value="60">Last 1 minute</option>
|
|
230
|
+
<option value="300">Last 5 minutes</option>
|
|
231
|
+
<option value="600">Last 10 minutes</option>
|
|
232
|
+
<option value="1800">Last 30 minutes</option>
|
|
233
|
+
<option value="3600">Last 1 hour</option>
|
|
234
|
+
<option value="21600">Last 6 hours</option>
|
|
235
|
+
<option value="86400">Last 24 hours</option>
|
|
236
|
+
</select>
|
|
237
|
+
</div>
|
|
238
|
+
|
|
239
|
+
<button id="clearFilters"
|
|
240
|
+
class="bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700 px-3 py-1 rounded mt-5">
|
|
241
|
+
Clear
|
|
242
|
+
</button>
|
|
243
|
+
|
|
244
|
+
<button id="sortToggle"
|
|
245
|
+
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">
|
|
246
|
+
⬆️ Oldest First
|
|
247
|
+
</button>
|
|
248
|
+
</div>
|
|
249
|
+
|
|
250
|
+
<div id="log-container" class="space-y-2"></div>
|
|
251
|
+
|
|
252
|
+
<div id="pagination" class="mt-6 flex items-center justify-between border-t border-gray-300 dark:border-gray-700 pt-4">
|
|
253
|
+
<div class="text-sm text-gray-600 dark:text-gray-400">
|
|
254
|
+
Showing <span id="showing-start">0</span>-<span id="showing-end">0</span> of <span id="total-count">0</span> logs
|
|
255
|
+
</div>
|
|
256
|
+
<div class="flex items-center space-x-2">
|
|
257
|
+
<label class="text-sm text-gray-600 dark:text-gray-400">Per page:</label>
|
|
258
|
+
<select id="perPageSelect" class="border rounded px-2 py-1 text-sm dark:bg-gray-800 dark:border-gray-700">
|
|
259
|
+
<option value="20">20</option>
|
|
260
|
+
<option value="50" selected>50</option>
|
|
261
|
+
<option value="100">100</option>
|
|
262
|
+
<option value="200">200</option>
|
|
263
|
+
</select>
|
|
264
|
+
</div>
|
|
265
|
+
<div class="flex space-x-2">
|
|
266
|
+
<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>
|
|
267
|
+
Previous
|
|
268
|
+
</button>
|
|
269
|
+
<span class="px-3 py-1 text-sm text-gray-600 dark:text-gray-400">
|
|
270
|
+
Page <span id="currentPage">1</span> of <span id="totalPages">1</span>
|
|
271
|
+
</span>
|
|
272
|
+
<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>
|
|
273
|
+
Next
|
|
274
|
+
</button>
|
|
275
|
+
</div>
|
|
276
|
+
</div>
|
|
277
|
+
</div>
|
|
278
|
+
|
|
279
|
+
<script>
|
|
280
|
+
// Embedded log data
|
|
281
|
+
const allLogs = {json.dumps(logs, ensure_ascii=False)};
|
|
282
|
+
|
|
283
|
+
const container = document.getElementById("log-container");
|
|
284
|
+
const levelFilter = document.getElementById("levelFilter");
|
|
285
|
+
const sectionFilter = document.getElementById("sectionFilter");
|
|
286
|
+
const groupFilter = document.getElementById("groupFilter");
|
|
287
|
+
const runNameFilter = document.getElementById("runNameFilter");
|
|
288
|
+
const runIdFilter = document.getElementById("runIdFilter");
|
|
289
|
+
const timeFilter = document.getElementById("timeFilter");
|
|
290
|
+
const clearBtn = document.getElementById("clearFilters");
|
|
291
|
+
const themeToggle = document.getElementById("themeToggle");
|
|
292
|
+
const sortToggle = document.getElementById("sortToggle");
|
|
293
|
+
|
|
294
|
+
// Pagination elements
|
|
295
|
+
const perPageSelect = document.getElementById("perPageSelect");
|
|
296
|
+
const prevPageBtn = document.getElementById("prevPage");
|
|
297
|
+
const nextPageBtn = document.getElementById("nextPage");
|
|
298
|
+
const currentPageSpan = document.getElementById("currentPage");
|
|
299
|
+
const totalPagesSpan = document.getElementById("totalPages");
|
|
300
|
+
const showingStartSpan = document.getElementById("showing-start");
|
|
301
|
+
const showingEndSpan = document.getElementById("showing-end");
|
|
302
|
+
const totalCountSpan = document.getElementById("total-count");
|
|
303
|
+
|
|
304
|
+
let knownLevels = new Set();
|
|
305
|
+
let currentPage = 1;
|
|
306
|
+
let perPage = 50;
|
|
307
|
+
let sortOldestFirst = false;
|
|
308
|
+
|
|
309
|
+
// Initialize known levels from all logs
|
|
310
|
+
allLogs.forEach(entry => {{
|
|
311
|
+
if (entry.level) knownLevels.add(entry.level);
|
|
312
|
+
}});
|
|
313
|
+
|
|
314
|
+
themeToggle.onclick = () => {{
|
|
315
|
+
const html = document.documentElement;
|
|
316
|
+
if (html.classList.contains('dark')) {{
|
|
317
|
+
html.classList.remove('dark');
|
|
318
|
+
localStorage.theme = 'light';
|
|
319
|
+
}} else {{
|
|
320
|
+
html.classList.add('dark');
|
|
321
|
+
localStorage.theme = 'dark';
|
|
322
|
+
}}
|
|
323
|
+
}};
|
|
324
|
+
|
|
325
|
+
function matchesFilters(entry) {{
|
|
326
|
+
const level = levelFilter.value.toLowerCase();
|
|
327
|
+
const section = sectionFilter.value.toLowerCase();
|
|
328
|
+
const group = groupFilter.value.toLowerCase();
|
|
329
|
+
const runName = runNameFilter.value.toLowerCase();
|
|
330
|
+
const runId = runIdFilter.value.toLowerCase();
|
|
331
|
+
const timeRange = parseInt(timeFilter.value);
|
|
332
|
+
|
|
333
|
+
if (level && (!entry.level || entry.level.toLowerCase() !== level)) return false;
|
|
334
|
+
if (section && (!entry.section || !entry.section.toLowerCase().includes(section))) return false;
|
|
335
|
+
if (group && (!entry.group || !entry.group.toLowerCase().includes(group))) return false;
|
|
336
|
+
if (runName && (!entry.run_name || !entry.run_name.toLowerCase().includes(runName))) return false;
|
|
337
|
+
if (runId && (!entry.run_id || !entry.run_id.toLowerCase().includes(runId))) return false;
|
|
338
|
+
|
|
339
|
+
// Time range filter
|
|
340
|
+
if (timeRange && entry.time) {{
|
|
341
|
+
const logTime = new Date(entry.time);
|
|
342
|
+
const now = new Date();
|
|
343
|
+
const diffSeconds = (now - logTime) / 1000;
|
|
344
|
+
if (diffSeconds > timeRange) return false;
|
|
345
|
+
}}
|
|
346
|
+
|
|
347
|
+
return true;
|
|
348
|
+
}}
|
|
349
|
+
|
|
350
|
+
function updateLevelFilter() {{
|
|
351
|
+
const currentSelection = levelFilter.value;
|
|
352
|
+
const sortedLevels = Array.from(knownLevels).sort();
|
|
353
|
+
|
|
354
|
+
levelFilter.innerHTML = '<option value="">All</option>';
|
|
355
|
+
sortedLevels.forEach(level => {{
|
|
356
|
+
const option = document.createElement('option');
|
|
357
|
+
option.value = level.toLowerCase();
|
|
358
|
+
option.textContent = level;
|
|
359
|
+
levelFilter.appendChild(option);
|
|
360
|
+
}});
|
|
361
|
+
|
|
362
|
+
if (currentSelection) {{
|
|
363
|
+
levelFilter.value = currentSelection;
|
|
364
|
+
}}
|
|
365
|
+
}}
|
|
366
|
+
|
|
367
|
+
function updatePaginationInfo(filteredLogs) {{
|
|
368
|
+
const totalLogs = filteredLogs.length;
|
|
369
|
+
const totalPages = Math.max(1, Math.ceil(totalLogs / perPage));
|
|
370
|
+
|
|
371
|
+
if (currentPage > totalPages) {{
|
|
372
|
+
currentPage = totalPages;
|
|
373
|
+
}}
|
|
374
|
+
if (currentPage < 1) {{
|
|
375
|
+
currentPage = 1;
|
|
376
|
+
}}
|
|
377
|
+
|
|
378
|
+
const startIdx = (currentPage - 1) * perPage;
|
|
379
|
+
const endIdx = Math.min(startIdx + perPage, totalLogs);
|
|
380
|
+
|
|
381
|
+
currentPageSpan.textContent = currentPage;
|
|
382
|
+
totalPagesSpan.textContent = totalPages;
|
|
383
|
+
showingStartSpan.textContent = totalLogs > 0 ? startIdx + 1 : 0;
|
|
384
|
+
showingEndSpan.textContent = endIdx;
|
|
385
|
+
totalCountSpan.textContent = totalLogs;
|
|
386
|
+
|
|
387
|
+
prevPageBtn.disabled = currentPage <= 1;
|
|
388
|
+
nextPageBtn.disabled = currentPage >= totalPages;
|
|
389
|
+
}}
|
|
390
|
+
|
|
391
|
+
function renderLogs() {{
|
|
392
|
+
container.innerHTML = "";
|
|
393
|
+
|
|
394
|
+
const filteredLogs = allLogs.filter(matchesFilters);
|
|
395
|
+
const sortedLogs = sortOldestFirst ? filteredLogs.slice() : filteredLogs.slice().reverse();
|
|
396
|
+
|
|
397
|
+
const startIdx = (currentPage - 1) * perPage;
|
|
398
|
+
const endIdx = startIdx + perPage;
|
|
399
|
+
const pageLog = sortedLogs.slice(startIdx, endIdx);
|
|
400
|
+
|
|
401
|
+
pageLog.forEach(renderLog);
|
|
402
|
+
updatePaginationInfo(filteredLogs);
|
|
403
|
+
}}
|
|
404
|
+
|
|
405
|
+
function getTimeAgo(timestamp) {{
|
|
406
|
+
if (!timestamp) return '';
|
|
407
|
+
|
|
408
|
+
let ts = timestamp;
|
|
409
|
+
if (!ts.endsWith('Z') && !ts.includes('+') && !ts.includes('-', 10)) {{
|
|
410
|
+
ts = ts + 'Z';
|
|
411
|
+
}}
|
|
412
|
+
|
|
413
|
+
const logTime = new Date(ts);
|
|
414
|
+
const now = new Date();
|
|
415
|
+
const diffMs = now - logTime;
|
|
416
|
+
const diffSec = Math.floor(diffMs / 1000);
|
|
417
|
+
const diffMin = Math.floor(diffSec / 60);
|
|
418
|
+
const diffHour = Math.floor(diffMin / 60);
|
|
419
|
+
const diffDay = Math.floor(diffHour / 24);
|
|
420
|
+
|
|
421
|
+
if (diffSec < 0) {{
|
|
422
|
+
const absSec = Math.abs(diffSec);
|
|
423
|
+
if (absSec < 60) return `in ${{absSec}}s`;
|
|
424
|
+
const absMin = Math.floor(absSec / 60);
|
|
425
|
+
if (absMin < 60) return `in ${{absMin}}m`;
|
|
426
|
+
const absHour = Math.floor(absMin / 60);
|
|
427
|
+
return `in ${{absHour}}h`;
|
|
428
|
+
}}
|
|
429
|
+
|
|
430
|
+
if (diffSec < 10) return 'just now';
|
|
431
|
+
if (diffSec < 60) return `${{diffSec}}s ago`;
|
|
432
|
+
if (diffMin < 60) return `${{diffMin}}m ago`;
|
|
433
|
+
if (diffHour < 24) return `${{diffHour}}h ago`;
|
|
434
|
+
return `${{diffDay}}d ago`;
|
|
435
|
+
}}
|
|
436
|
+
|
|
437
|
+
function getFileLanguage(ext) {{
|
|
438
|
+
const languageMap = {{
|
|
439
|
+
'js': 'javascript',
|
|
440
|
+
'jsx': 'jsx',
|
|
441
|
+
'ts': 'typescript',
|
|
442
|
+
'tsx': 'tsx',
|
|
443
|
+
'py': 'python',
|
|
444
|
+
'rb': 'ruby',
|
|
445
|
+
'java': 'java',
|
|
446
|
+
'cpp': 'cpp',
|
|
447
|
+
'c': 'c',
|
|
448
|
+
'cs': 'csharp',
|
|
449
|
+
'go': 'go',
|
|
450
|
+
'rs': 'rust',
|
|
451
|
+
'php': 'php',
|
|
452
|
+
'sh': 'bash',
|
|
453
|
+
'bash': 'bash',
|
|
454
|
+
'zsh': 'bash',
|
|
455
|
+
'sql': 'sql',
|
|
456
|
+
'json': 'json',
|
|
457
|
+
'yaml': 'yaml',
|
|
458
|
+
'yml': 'yaml',
|
|
459
|
+
'xml': 'xml',
|
|
460
|
+
'html': 'html',
|
|
461
|
+
'css': 'css',
|
|
462
|
+
'scss': 'scss',
|
|
463
|
+
'sass': 'sass',
|
|
464
|
+
'less': 'less',
|
|
465
|
+
'md': 'markdown',
|
|
466
|
+
'txt': 'text',
|
|
467
|
+
'log': 'text'
|
|
468
|
+
}};
|
|
469
|
+
return languageMap[ext] || 'text';
|
|
470
|
+
}}
|
|
471
|
+
|
|
472
|
+
function renderCachePath(entry) {{
|
|
473
|
+
// Use embedded cache data if available
|
|
474
|
+
if (entry._embedded_cache) {{
|
|
475
|
+
const ext = (entry.cache_path || '').split('.').pop().toLowerCase();
|
|
476
|
+
const uniqueId = `file-${{Date.now()}}-${{Math.random().toString(36).substr(2, 9)}}`;
|
|
477
|
+
|
|
478
|
+
// Images
|
|
479
|
+
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp'].includes(ext)) {{
|
|
480
|
+
return `<div class="mt-2">
|
|
481
|
+
<div class="text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Cached Artifact:</div>
|
|
482
|
+
<img src="${{entry._embedded_cache}}" alt="Cached artifact" class="max-w-full h-auto rounded border border-gray-300 dark:border-gray-600"
|
|
483
|
+
style="max-height: 400px; object-fit: contain;" />
|
|
484
|
+
</div>`;
|
|
485
|
+
}}
|
|
486
|
+
|
|
487
|
+
// PDFs
|
|
488
|
+
if (ext === 'pdf') {{
|
|
489
|
+
return `<div class="mt-2">
|
|
490
|
+
<div class="text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Cached Artifact (PDF):</div>
|
|
491
|
+
<embed src="${{entry._embedded_cache}}" type="application/pdf" class="w-full rounded border border-gray-300 dark:border-gray-600" style="height: 400px;" />
|
|
492
|
+
</div>`;
|
|
493
|
+
}}
|
|
494
|
+
|
|
495
|
+
// Markdown files
|
|
496
|
+
if (ext === 'md' && entry._embedded_cache_text) {{
|
|
497
|
+
return `<div class="mt-2">
|
|
498
|
+
<div class="text-xs font-medium text-gray-600 dark:text-gray-400 mb-1 flex items-center justify-between">
|
|
499
|
+
<span>Cached Artifact (Markdown):</span>
|
|
500
|
+
<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">
|
|
501
|
+
<span class="arrow transform transition-transform" id="arrow-${{uniqueId}}">▼</span>
|
|
502
|
+
<span class="btn-text" id="btn-${{uniqueId}}">Hide</span>
|
|
503
|
+
</button>
|
|
504
|
+
</div>
|
|
505
|
+
<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">
|
|
506
|
+
${{marked.parse(entry._embedded_cache_text)}}
|
|
507
|
+
</div>
|
|
508
|
+
</div>`;
|
|
509
|
+
}}
|
|
510
|
+
|
|
511
|
+
// Code files
|
|
512
|
+
const language = getFileLanguage(ext);
|
|
513
|
+
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) && entry._embedded_cache_text) {{
|
|
514
|
+
return `<div class="mt-2">
|
|
515
|
+
<div class="text-xs font-medium text-gray-600 dark:text-gray-400 mb-1 flex items-center justify-between">
|
|
516
|
+
<span>Cached Artifact (${{language.toUpperCase()}}):</span>
|
|
517
|
+
<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">
|
|
518
|
+
<span class="arrow transform transition-transform" id="arrow-${{uniqueId}}">▶</span>
|
|
519
|
+
<span class="btn-text" id="btn-${{uniqueId}}">Show</span>
|
|
520
|
+
</button>
|
|
521
|
+
</div>
|
|
522
|
+
<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">
|
|
523
|
+
<pre class="text-sm"><code class="language-${{language}}">${{entry._embedded_cache_text.replace(/[<>&"']/g, function(m) {{ return {{ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }}[m]; }})}}</code></pre>
|
|
524
|
+
</div>
|
|
525
|
+
</div>`;
|
|
526
|
+
}}
|
|
527
|
+
|
|
528
|
+
// Other files
|
|
529
|
+
return `<div class="mt-2">
|
|
530
|
+
<div class="text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Cached Artifact:</div>
|
|
531
|
+
<a href="${{entry._embedded_cache}}" download="${{entry.cache_path}}" class="text-sm text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 underline">
|
|
532
|
+
📎 ${{entry.cache_path}}
|
|
533
|
+
</a>
|
|
534
|
+
</div>`;
|
|
535
|
+
}}
|
|
536
|
+
|
|
537
|
+
// Fallback if no embedded cache
|
|
538
|
+
if (entry.cache_path) {{
|
|
539
|
+
return `<div class="mt-2">
|
|
540
|
+
<div class="text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Cached Artifact:</div>
|
|
541
|
+
<span class="text-sm text-gray-500">📎 ${{entry.cache_path}} (not embedded)</span>
|
|
542
|
+
</div>`;
|
|
543
|
+
}}
|
|
544
|
+
|
|
545
|
+
return '';
|
|
546
|
+
}}
|
|
547
|
+
|
|
548
|
+
function renderLog(entry) {{
|
|
549
|
+
const div = document.createElement("div");
|
|
550
|
+
const levelLower = (entry.level || '').toLowerCase();
|
|
551
|
+
const border =
|
|
552
|
+
levelLower === "error" ? "border-red-500" :
|
|
553
|
+
levelLower === "warn" || levelLower === "warning" ? "border-yellow-400" :
|
|
554
|
+
levelLower === "debug" ? "border-gray-400" :
|
|
555
|
+
"border-blue-400";
|
|
556
|
+
div.className = `p-3 bg-white dark:bg-gray-800 border-l-4 ${{border}} rounded shadow-sm transition`;
|
|
557
|
+
|
|
558
|
+
const timeAgo = getTimeAgo(entry.time);
|
|
559
|
+
const timeDisplay = entry.time ? `<span class="time-ago font-medium text-gray-700 dark:text-gray-200" data-timestamp="${{entry.time}}">${{timeAgo}}</span>` : '';
|
|
560
|
+
|
|
561
|
+
const cachePathHtml = renderCachePath(entry);
|
|
562
|
+
const mainContent = entry.message || entry.msg || entry.event || '';
|
|
563
|
+
|
|
564
|
+
const runInfo = [];
|
|
565
|
+
if (entry.run_name) runInfo.push(`run: <span class="font-semibold">${{entry.run_name}}</span>`);
|
|
566
|
+
if (entry.run_id) runInfo.push(`id: <span class="font-semibold">${{entry.run_id}}</span>`);
|
|
567
|
+
const runInfoHtml = runInfo.length > 0 ? `<span class="text-xs text-blue-600 dark:text-blue-400 ml-2">🔗 ${{runInfo.join(', ')}}</span>` : '';
|
|
568
|
+
|
|
569
|
+
div.innerHTML = `
|
|
570
|
+
<div class="flex justify-between text-sm text-gray-600 dark:text-gray-300">
|
|
571
|
+
<div class="flex items-center space-x-2">
|
|
572
|
+
${{timeDisplay}}
|
|
573
|
+
<span class="text-xs">${{entry.time || ''}}</span>
|
|
574
|
+
</div>
|
|
575
|
+
<div class="flex items-center space-x-2">
|
|
576
|
+
<span class="font-semibold">${{entry.level || ''}}</span>
|
|
577
|
+
<span>${{entry.section || ''}}</span>
|
|
578
|
+
${{runInfoHtml}}
|
|
579
|
+
</div>
|
|
580
|
+
</div>
|
|
581
|
+
<div class="mt-2 text-gray-900 dark:text-gray-100 font-mono text-sm whitespace-pre-wrap">${{mainContent}}</div>
|
|
582
|
+
${{cachePathHtml}}
|
|
583
|
+
<div class="mt-2">
|
|
584
|
+
<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';"
|
|
585
|
+
class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 flex items-center gap-1">
|
|
586
|
+
<span class="arrow transform transition-transform" style="transform: rotate(0deg)">▶</span>
|
|
587
|
+
<span class="btn-text">Show JSON</span>
|
|
588
|
+
</button>
|
|
589
|
+
<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>
|
|
590
|
+
</div>
|
|
591
|
+
`;
|
|
592
|
+
|
|
593
|
+
container.appendChild(div);
|
|
594
|
+
}}
|
|
595
|
+
|
|
596
|
+
// Initialize
|
|
597
|
+
updateLevelFilter();
|
|
598
|
+
renderLogs();
|
|
599
|
+
|
|
600
|
+
// Update time displays periodically
|
|
601
|
+
function updateTimeDisplays() {{
|
|
602
|
+
document.querySelectorAll('.time-ago').forEach(element => {{
|
|
603
|
+
const timestamp = element.getAttribute('data-timestamp');
|
|
604
|
+
if (timestamp) {{
|
|
605
|
+
element.textContent = getTimeAgo(timestamp);
|
|
606
|
+
}}
|
|
607
|
+
}});
|
|
608
|
+
}}
|
|
609
|
+
setInterval(updateTimeDisplays, 5000);
|
|
610
|
+
|
|
611
|
+
// Filter change handlers
|
|
612
|
+
levelFilter.onchange = () => {{
|
|
613
|
+
currentPage = 1;
|
|
614
|
+
renderLogs();
|
|
615
|
+
}};
|
|
616
|
+
sectionFilter.oninput = () => {{
|
|
617
|
+
currentPage = 1;
|
|
618
|
+
renderLogs();
|
|
619
|
+
}};
|
|
620
|
+
groupFilter.oninput = () => {{
|
|
621
|
+
currentPage = 1;
|
|
622
|
+
renderLogs();
|
|
623
|
+
}};
|
|
624
|
+
runNameFilter.oninput = () => {{
|
|
625
|
+
currentPage = 1;
|
|
626
|
+
renderLogs();
|
|
627
|
+
}};
|
|
628
|
+
runIdFilter.oninput = () => {{
|
|
629
|
+
currentPage = 1;
|
|
630
|
+
renderLogs();
|
|
631
|
+
}};
|
|
632
|
+
timeFilter.onchange = () => {{
|
|
633
|
+
currentPage = 1;
|
|
634
|
+
renderLogs();
|
|
635
|
+
}};
|
|
636
|
+
clearBtn.onclick = () => {{
|
|
637
|
+
levelFilter.value = "";
|
|
638
|
+
sectionFilter.value = "";
|
|
639
|
+
groupFilter.value = "";
|
|
640
|
+
runNameFilter.value = "";
|
|
641
|
+
runIdFilter.value = "";
|
|
642
|
+
timeFilter.value = "";
|
|
643
|
+
currentPage = 1;
|
|
644
|
+
renderLogs();
|
|
645
|
+
}};
|
|
646
|
+
|
|
647
|
+
// Pagination controls
|
|
648
|
+
prevPageBtn.onclick = () => {{
|
|
649
|
+
if (currentPage > 1) {{
|
|
650
|
+
currentPage--;
|
|
651
|
+
renderLogs();
|
|
652
|
+
window.scrollTo({{ top: 0, behavior: 'smooth' }});
|
|
653
|
+
}}
|
|
654
|
+
}};
|
|
655
|
+
|
|
656
|
+
nextPageBtn.onclick = () => {{
|
|
657
|
+
const totalPages = Math.ceil(allLogs.filter(matchesFilters).length / perPage);
|
|
658
|
+
if (currentPage < totalPages) {{
|
|
659
|
+
currentPage++;
|
|
660
|
+
renderLogs();
|
|
661
|
+
window.scrollTo({{ top: 0, behavior: 'smooth' }});
|
|
662
|
+
}}
|
|
663
|
+
}};
|
|
664
|
+
|
|
665
|
+
perPageSelect.onchange = () => {{
|
|
666
|
+
perPage = parseInt(perPageSelect.value);
|
|
667
|
+
currentPage = 1;
|
|
668
|
+
renderLogs();
|
|
669
|
+
}};
|
|
670
|
+
|
|
671
|
+
// Sort toggle
|
|
672
|
+
sortToggle.onclick = () => {{
|
|
673
|
+
sortOldestFirst = !sortOldestFirst;
|
|
674
|
+
sortToggle.textContent = sortOldestFirst ? '⬇️ Newest First' : '⬆️ Oldest First';
|
|
675
|
+
currentPage = 1;
|
|
676
|
+
renderLogs();
|
|
677
|
+
}};
|
|
678
|
+
|
|
679
|
+
// File content toggle function
|
|
680
|
+
window.toggleFileContent = function(uniqueId) {{
|
|
681
|
+
const content = document.getElementById(`content-${{uniqueId}}`);
|
|
682
|
+
const arrow = document.getElementById(`arrow-${{uniqueId}}`);
|
|
683
|
+
const btn = document.getElementById(`btn-${{uniqueId}}`);
|
|
684
|
+
|
|
685
|
+
if (content.classList.contains('hidden')) {{
|
|
686
|
+
// Show content
|
|
687
|
+
content.classList.remove('hidden');
|
|
688
|
+
arrow.style.transform = 'rotate(90deg)';
|
|
689
|
+
btn.textContent = 'Hide';
|
|
690
|
+
|
|
691
|
+
// Apply syntax highlighting for code blocks
|
|
692
|
+
const codeElement = content.querySelector('code[class*="language-"]');
|
|
693
|
+
if (codeElement) {{
|
|
694
|
+
Prism.highlightElement(codeElement);
|
|
695
|
+
}}
|
|
696
|
+
}} else {{
|
|
697
|
+
// Hide content
|
|
698
|
+
content.classList.add('hidden');
|
|
699
|
+
arrow.style.transform = 'rotate(0deg)';
|
|
700
|
+
btn.textContent = 'Show';
|
|
701
|
+
}}
|
|
702
|
+
}};
|
|
703
|
+
|
|
704
|
+
// Apply syntax highlighting to initially visible markdown code blocks
|
|
705
|
+
setTimeout(function() {{
|
|
706
|
+
document.querySelectorAll('.markdown-content pre code').forEach(function(block) {{
|
|
707
|
+
Prism.highlightElement(block);
|
|
708
|
+
}});
|
|
709
|
+
}}, 100);
|
|
710
|
+
</script>
|
|
711
|
+
</body>
|
|
712
|
+
</html>
|
|
713
|
+
"""
|
|
714
|
+
|
|
715
|
+
# Write the HTML file
|
|
716
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
717
|
+
with output_path.open('w', encoding='utf-8') as f:
|
|
718
|
+
f.write(html_content)
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def export_logs_to_html(
|
|
722
|
+
log_path: Path,
|
|
723
|
+
output_path: Path,
|
|
724
|
+
title: str = "Log4Lab Export",
|
|
725
|
+
embed_images: bool = True
|
|
726
|
+
) -> None:
|
|
727
|
+
"""Export logs to a self-contained HTML file.
|
|
728
|
+
|
|
729
|
+
Args:
|
|
730
|
+
log_path: Path to the JSONL log file
|
|
731
|
+
output_path: Path where the HTML file should be written
|
|
732
|
+
title: Title for the HTML page
|
|
733
|
+
embed_images: Whether to embed images as base64 data URLs
|
|
734
|
+
"""
|
|
735
|
+
# Load logs
|
|
736
|
+
logs = load_logs(log_path)
|
|
737
|
+
|
|
738
|
+
# Embed cache files if requested
|
|
739
|
+
if embed_images:
|
|
740
|
+
log_dir = log_path.parent
|
|
741
|
+
logs = embed_cache_files(logs, log_dir)
|
|
742
|
+
|
|
743
|
+
# Generate HTML
|
|
744
|
+
generate_standalone_html(logs, output_path, title)
|