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/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 {{ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&#39;' }}[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)