python-log-viewer 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.
@@ -0,0 +1,775 @@
1
+ """
2
+ Self-contained HTML / CSS / JS template for the log viewer UI.
3
+
4
+ The single placeholder ``{{BASE_URL}}`` is replaced at render time with
5
+ the mount prefix (e.g. ``/logs``, ``/log_viewer``, or just `` ``).
6
+
7
+ The template has **zero** external dependencies – no CDN links.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ _TEMPLATE = r"""<!DOCTYPE html>
13
+ <html lang="en">
14
+ <head>
15
+ <meta charset="UTF-8">
16
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
17
+ <title>Log Viewer</title>
18
+ <style>
19
+ :root {
20
+ --bg: #0d1117;
21
+ --surface: #161b22;
22
+ --border: #30363d;
23
+ --text: #c9d1d9;
24
+ --text-muted: #8b949e;
25
+ --accent: #58a6ff;
26
+ --info: #58a6ff;
27
+ --warning: #d29922;
28
+ --error: #f85149;
29
+ --debug: #8b949e;
30
+ }
31
+ * { margin: 0; padding: 0; box-sizing: border-box; }
32
+ body {
33
+ font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', Consolas, monospace;
34
+ background: var(--bg);
35
+ color: var(--text);
36
+ height: 100vh;
37
+ display: flex;
38
+ flex-direction: column;
39
+ }
40
+
41
+ /* ---- Header / toolbar ---- */
42
+ header {
43
+ background: var(--surface);
44
+ border-bottom: 1px solid var(--border);
45
+ padding: 12px 20px;
46
+ display: flex;
47
+ align-items: center;
48
+ gap: 16px;
49
+ flex-wrap: wrap;
50
+ }
51
+ header h1 {
52
+ font-size: 16px;
53
+ font-weight: 600;
54
+ color: var(--accent);
55
+ white-space: nowrap;
56
+ }
57
+ .controls {
58
+ display: flex;
59
+ align-items: center;
60
+ gap: 10px;
61
+ flex-wrap: wrap;
62
+ flex: 1;
63
+ }
64
+ .controls input[type="text"],
65
+ .controls select {
66
+ background: var(--bg);
67
+ border: 1px solid var(--border);
68
+ border-radius: 6px;
69
+ padding: 6px 12px;
70
+ color: var(--text);
71
+ font-size: 13px;
72
+ font-family: inherit;
73
+ outline: none;
74
+ transition: border-color 0.2s;
75
+ }
76
+ .controls input[type="text"] { width: 200px; }
77
+ .controls input[type="text"]:focus,
78
+ .controls select:focus { border-color: var(--accent); }
79
+ .controls select { cursor: pointer; }
80
+ .controls label {
81
+ display: flex;
82
+ align-items: center;
83
+ gap: 5px;
84
+ font-size: 13px;
85
+ color: var(--text-muted);
86
+ cursor: pointer;
87
+ white-space: nowrap;
88
+ }
89
+ .controls input[type="checkbox"] { accent-color: var(--accent); }
90
+ .btn {
91
+ background: var(--bg);
92
+ border: 1px solid var(--border);
93
+ border-radius: 6px;
94
+ padding: 6px 14px;
95
+ color: var(--text);
96
+ font-size: 13px;
97
+ font-family: inherit;
98
+ cursor: pointer;
99
+ transition: all 0.2s;
100
+ white-space: nowrap;
101
+ }
102
+ .btn:hover { border-color: var(--accent); color: var(--accent); }
103
+ .btn-danger { color: var(--error); border-color: var(--border); }
104
+ .btn-danger:hover { border-color: var(--error); color: var(--error); background: rgba(248,81,73,0.1); }
105
+ .btn-warn { color: var(--warning); border-color: var(--border); }
106
+ .btn-warn:hover { border-color: var(--warning); color: var(--warning); background: rgba(210,153,34,0.1); }
107
+ .btn:disabled { opacity: 0.4; cursor: not-allowed; pointer-events: none; }
108
+
109
+ /* ---- Toast notification ---- */
110
+ .toast {
111
+ position: fixed;
112
+ bottom: 20px;
113
+ right: 20px;
114
+ padding: 10px 18px;
115
+ border-radius: 8px;
116
+ font-size: 13px;
117
+ font-family: inherit;
118
+ color: var(--text);
119
+ background: var(--surface);
120
+ border: 1px solid var(--border);
121
+ box-shadow: 0 4px 12px rgba(0,0,0,0.4);
122
+ z-index: 1000;
123
+ opacity: 0;
124
+ transform: translateY(10px);
125
+ transition: opacity 0.3s, transform 0.3s;
126
+ }
127
+ .toast.show { opacity: 1; transform: translateY(0); }
128
+ .toast.success { border-color: #3fb950; }
129
+ .toast.error { border-color: var(--error); }
130
+
131
+ /* ---- Confirm modal ---- */
132
+ .modal-overlay {
133
+ display: none;
134
+ position: fixed;
135
+ inset: 0;
136
+ background: rgba(0,0,0,0.6);
137
+ z-index: 999;
138
+ align-items: center;
139
+ justify-content: center;
140
+ }
141
+ .modal-overlay.active { display: flex; }
142
+ .modal {
143
+ background: var(--surface);
144
+ border: 1px solid var(--border);
145
+ border-radius: 10px;
146
+ padding: 24px;
147
+ max-width: 400px;
148
+ width: 90%;
149
+ box-shadow: 0 8px 30px rgba(0,0,0,0.5);
150
+ }
151
+ .modal h3 { font-size: 15px; margin-bottom: 10px; }
152
+ .modal p { font-size: 13px; color: var(--text-muted); margin-bottom: 18px; line-height: 1.5; }
153
+ .modal-actions { display: flex; gap: 10px; justify-content: flex-end; }
154
+
155
+ /* ---- Main layout: sidebar + log pane ---- */
156
+ .main-layout {
157
+ flex: 1;
158
+ display: flex;
159
+ overflow: hidden;
160
+ }
161
+
162
+ /* ---- Sidebar ---- */
163
+ .sidebar {
164
+ width: 300px;
165
+ min-width: 240px;
166
+ background: var(--surface);
167
+ border-right: 1px solid var(--border);
168
+ display: flex;
169
+ flex-direction: column;
170
+ overflow: hidden;
171
+ }
172
+ .sidebar-header {
173
+ padding: 12px 14px 8px;
174
+ font-size: 11px;
175
+ font-weight: 600;
176
+ text-transform: uppercase;
177
+ letter-spacing: 0.5px;
178
+ color: var(--text-muted);
179
+ border-bottom: 1px solid var(--border);
180
+ }
181
+ .file-list {
182
+ flex: 1;
183
+ overflow-y: auto;
184
+ padding: 6px 0;
185
+ }
186
+ .folder-group { margin-bottom: 2px; }
187
+ .folder-header {
188
+ padding: 8px 10px;
189
+ font-size: 12px;
190
+ font-weight: 600;
191
+ color: var(--text-muted);
192
+ cursor: pointer;
193
+ display: flex;
194
+ align-items: center;
195
+ gap: 6px;
196
+ user-select: none;
197
+ transition: color 0.15s;
198
+ }
199
+ .folder-header:hover { color: var(--text); }
200
+ .folder-header .folder-icon { font-size: 10px; transition: transform 0.2s; display: inline-block; }
201
+ .folder-header.collapsed .folder-icon { transform: rotate(-90deg); }
202
+ .folder-children { overflow: hidden; }
203
+ .folder-children.collapsed { display: none; }
204
+ .file-item {
205
+ padding: 6px 10px 6px 28px;
206
+ font-size: 12px;
207
+ cursor: pointer;
208
+ display: flex;
209
+ justify-content: space-between;
210
+ align-items: flex-start;
211
+ gap: 8px;
212
+ transition: background 0.15s;
213
+ border-left: 3px solid transparent;
214
+ }
215
+ .file-item:hover { background: rgba(88,166,255,0.06); }
216
+ .file-item.active {
217
+ background: rgba(88,166,255,0.1);
218
+ border-left-color: var(--accent);
219
+ color: var(--accent);
220
+ }
221
+ .file-item .file-name {
222
+ overflow: hidden;
223
+ display: -webkit-box;
224
+ -webkit-line-clamp: 2;
225
+ -webkit-box-orient: vertical;
226
+ word-break: break-all;
227
+ line-height: 1.4;
228
+ min-width: 0;
229
+ }
230
+ .file-item .file-size {
231
+ font-size: 11px;
232
+ color: var(--text-muted);
233
+ flex-shrink: 0;
234
+ white-space: nowrap;
235
+ padding-top: 1px;
236
+ }
237
+
238
+ /* ---- Status bar ---- */
239
+ .status-bar {
240
+ font-size: 12px;
241
+ color: var(--text-muted);
242
+ margin-left: auto;
243
+ white-space: nowrap;
244
+ display: flex;
245
+ align-items: center;
246
+ gap: 6px;
247
+ }
248
+ .status-bar .dot {
249
+ display: inline-block;
250
+ width: 8px;
251
+ height: 8px;
252
+ border-radius: 50%;
253
+ background: #3fb950;
254
+ animation: pulse 2s infinite;
255
+ }
256
+ .status-bar .dot.paused { background: var(--warning); animation: none; }
257
+ @keyframes pulse {
258
+ 0%, 100% { opacity: 1; }
259
+ 50% { opacity: 0.4; }
260
+ }
261
+
262
+ /* ---- Log pane ---- */
263
+ .log-pane {
264
+ flex: 1;
265
+ display: flex;
266
+ flex-direction: column;
267
+ overflow: hidden;
268
+ min-width: 0;
269
+ }
270
+ .log-pane-header {
271
+ display: flex;
272
+ align-items: center;
273
+ gap: 8px;
274
+ padding: 8px 20px;
275
+ font-size: 13px;
276
+ color: var(--text-muted);
277
+ background: var(--surface);
278
+ border-bottom: 1px solid var(--border);
279
+ flex-shrink: 0;
280
+ }
281
+ .log-pane-header .file-icon { font-size: 14px; }
282
+ .log-pane-header .active-file-name {
283
+ color: var(--accent);
284
+ font-weight: 600;
285
+ overflow: hidden;
286
+ text-overflow: ellipsis;
287
+ white-space: nowrap;
288
+ }
289
+ #log-container {
290
+ flex: 1;
291
+ overflow-y: auto;
292
+ padding: 12px 20px;
293
+ scroll-behavior: smooth;
294
+ }
295
+ .log-line {
296
+ padding: 3px 0;
297
+ font-size: 13px;
298
+ line-height: 1.6;
299
+ white-space: pre-wrap;
300
+ word-break: break-all;
301
+ border-bottom: 1px solid transparent;
302
+ }
303
+ .log-line:hover { background: rgba(88,166,255,0.04); }
304
+ .log-line.level-INFO .level-tag { color: var(--info); }
305
+ .log-line.level-WARNING .level-tag { color: var(--warning); }
306
+ .log-line.level-ERROR .level-tag { color: var(--error); }
307
+ .log-line.level-DEBUG .level-tag { color: var(--debug); }
308
+
309
+ /* ---- Colorized log-level backgrounds ---- */
310
+ body.colorize .log-line.level-INFO { background: rgba(56,139,253,0.06); border-left: 3px solid rgba(56,139,253,0.3); padding-left: 8px; }
311
+ body.colorize .log-line.level-WARNING { background: rgba(210,153,34,0.07); border-left: 3px solid rgba(210,153,34,0.35); padding-left: 8px; }
312
+ body.colorize .log-line.level-ERROR { background: rgba(248,81,73,0.08); border-left: 3px solid rgba(248,81,73,0.4); padding-left: 8px; }
313
+ body.colorize .log-line.level-DEBUG { background: rgba(139,148,158,0.05); border-left: 3px solid rgba(139,148,158,0.25); padding-left: 8px; }
314
+ body.colorize .log-line.level-INFO:hover { background: rgba(56,139,253,0.1); }
315
+ body.colorize .log-line.level-WARNING:hover { background: rgba(210,153,34,0.12); }
316
+ body.colorize .log-line.level-ERROR:hover { background: rgba(248,81,73,0.13); }
317
+ body.colorize .log-line.level-DEBUG:hover { background: rgba(139,148,158,0.08); }
318
+ .log-line .timestamp { color: var(--text-muted); }
319
+ .log-line .logger-name { color: #d2a8ff; }
320
+ .log-line .highlight { background: rgba(210,153,34,0.3); border-radius: 2px; padding: 0 2px; }
321
+ .empty-state {
322
+ display: flex;
323
+ align-items: center;
324
+ justify-content: center;
325
+ height: 100%;
326
+ color: var(--text-muted);
327
+ font-size: 14px;
328
+ }
329
+
330
+ /* ---- Sidebar toggle button (small screens) ---- */
331
+ .sidebar-toggle {
332
+ display: none;
333
+ background: none;
334
+ border: 1px solid var(--border);
335
+ border-radius: 6px;
336
+ color: var(--text);
337
+ font-size: 18px;
338
+ padding: 4px 8px;
339
+ cursor: pointer;
340
+ line-height: 1;
341
+ transition: border-color 0.2s, color 0.2s;
342
+ }
343
+ .sidebar-toggle:hover { border-color: var(--accent); color: var(--accent); }
344
+
345
+ /* ---- Sidebar overlay (small screens) ---- */
346
+ .sidebar-overlay {
347
+ display: none;
348
+ position: fixed;
349
+ inset: 0;
350
+ background: rgba(0,0,0,0.5);
351
+ z-index: 49;
352
+ }
353
+ .sidebar-overlay.active { display: block; }
354
+
355
+ /* ---- Responsive ---- */
356
+ @media (max-width: 768px) {
357
+ .sidebar-toggle { display: inline-flex; }
358
+ .sidebar {
359
+ position: fixed;
360
+ top: 0;
361
+ left: 0;
362
+ bottom: 0;
363
+ z-index: 50;
364
+ transform: translateX(-100%);
365
+ transition: transform 0.25s ease;
366
+ width: 280px;
367
+ min-width: 0;
368
+ }
369
+ .sidebar.open { transform: translateX(0); }
370
+ }
371
+ </style>
372
+ </head>
373
+ <body class="{{BODY_CLASS}}">
374
+
375
+ <header>
376
+ <button class="sidebar-toggle" id="sidebar-toggle" onclick="toggleSidebar()" title="Toggle sidebar">&#9776;</button>
377
+ <h1>&#128203; Log Viewer</h1>
378
+ <div class="controls">
379
+ <input type="text" id="search" placeholder="Search logs..." />
380
+ <select id="level-filter">
381
+ <option value="">All Levels</option>
382
+ <option value="DEBUG">DEBUG</option>
383
+ <option value="INFO">INFO</option>
384
+ <option value="WARNING">WARNING</option>
385
+ <option value="ERROR">ERROR</option>
386
+ </select>
387
+ <select id="lines-limit">
388
+ <option value="500" selected>Last 500</option>
389
+ <option value="1000">Last 1000</option>
390
+ <option value="2500">Last 2500</option>
391
+ <option value="5000">Last 5000</option>
392
+ <option value="0">All</option>
393
+ </select>
394
+ <select id="refresh-interval">
395
+ <option value="5000" {{REFRESH_5000_SELECTED}}>Refresh: 5s</option>
396
+ <option value="10000" {{REFRESH_10000_SELECTED}}>Refresh: 10s</option>
397
+ <option value="30000" {{REFRESH_30000_SELECTED}}>Refresh: 30s</option>
398
+ <option value="60000" {{REFRESH_60000_SELECTED}}>Refresh: 1m</option>
399
+ <option value="0" {{REFRESH_0_SELECTED}}>Manual Refresh</option>
400
+ </select>
401
+ <label><input type="checkbox" id="auto-scroll" {{AUTO_SCROLL_CHECKED}} /> Auto-scroll</label>
402
+ <button class="btn" onclick="fetchLogs()">&#8635; Refresh</button>
403
+ <button class="btn btn-warn" id="btn-clear" onclick="confirmAction('clear')" disabled>&#128465; Clear</button>
404
+ <button class="btn btn-danger" id="btn-delete" onclick="confirmAction('delete')" disabled>&#10005; Delete</button>
405
+ <span class="status-bar">
406
+ <span class="dot" id="status-dot"></span>
407
+ <span id="status-text">Live</span> &middot;
408
+ <span id="line-count">0</span> entries
409
+ </span>
410
+ </div>
411
+ </header>
412
+
413
+ <div class="sidebar-overlay" id="sidebar-overlay" onclick="toggleSidebar()"></div>
414
+
415
+ <div class="main-layout">
416
+ <aside class="sidebar" id="sidebar">
417
+ <div class="sidebar-header">Log Files</div>
418
+ <div class="file-list" id="file-list">
419
+ <div class="empty-state" style="padding:20px;font-size:12px;">Loading&hellip;</div>
420
+ </div>
421
+ </aside>
422
+
423
+ <div class="log-pane">
424
+ <div class="log-pane-header" id="log-pane-header" style="display:none;">
425
+ <span class="file-icon">&#128196;</span>
426
+ <span class="active-file-name" id="active-file-label"></span>
427
+ </div>
428
+ <div id="log-container">
429
+ <div class="empty-state" id="empty-state">Select a log file to view</div>
430
+ </div>
431
+ </div>
432
+ </div>
433
+
434
+ <!-- Confirm modal -->
435
+ <div class="modal-overlay" id="modal-overlay">
436
+ <div class="modal">
437
+ <h3 id="modal-title">Confirm</h3>
438
+ <p id="modal-message">Are you sure?</p>
439
+ <div class="modal-actions">
440
+ <button class="btn" onclick="closeModal()">Cancel</button>
441
+ <button class="btn btn-danger" id="modal-confirm" onclick="executeAction()">Confirm</button>
442
+ </div>
443
+ </div>
444
+ </div>
445
+
446
+ <!-- Toast -->
447
+ <div class="toast" id="toast"></div>
448
+
449
+ <script>
450
+ const BASE = '{{BASE_URL}}'.replace(/\/+$/, '');
451
+ const sidebarEl = document.getElementById('sidebar');
452
+ const sidebarOverlay = document.getElementById('sidebar-overlay');
453
+ const container = document.getElementById('log-container');
454
+ const emptyState = document.getElementById('empty-state');
455
+ const fileListEl = document.getElementById('file-list');
456
+ const searchInput = document.getElementById('search');
457
+ const levelFilter = document.getElementById('level-filter');
458
+ const linesLimit = document.getElementById('lines-limit');
459
+ const refreshSelect = document.getElementById('refresh-interval');
460
+ const autoScrollCb = document.getElementById('auto-scroll');
461
+ const lineCountEl = document.getElementById('line-count');
462
+ const statusDot = document.getElementById('status-dot');
463
+ const statusText = document.getElementById('status-text');
464
+
465
+ const btnClear = document.getElementById('btn-clear');
466
+ const btnDelete = document.getElementById('btn-delete');
467
+ const modalOverlay = document.getElementById('modal-overlay');
468
+ const modalTitle = document.getElementById('modal-title');
469
+ const modalMessage = document.getElementById('modal-message');
470
+ const modalConfirmBtn = document.getElementById('modal-confirm');
471
+ const toastEl = document.getElementById('toast');
472
+ const logPaneHeader = document.getElementById('log-pane-header');
473
+ const activeFileLabel = document.getElementById('active-file-label');
474
+
475
+ let refreshTimer = null;
476
+ let activeFile = '';
477
+ let pendingAction = null;
478
+
479
+ function toggleSidebar() {
480
+ sidebarEl.classList.toggle('open');
481
+ sidebarOverlay.classList.toggle('active');
482
+ }
483
+ function closeSidebar() {
484
+ sidebarEl.classList.remove('open');
485
+ sidebarOverlay.classList.remove('active');
486
+ }
487
+
488
+ function formatBytes(bytes) {
489
+ if (bytes < 1024) return bytes + ' B';
490
+ if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
491
+ return (bytes / 1048576).toFixed(1) + ' MB';
492
+ }
493
+
494
+ function detectLevel(text) {
495
+ if (text.includes('ERROR:') || text.includes('ERROR ')) return 'ERROR';
496
+ if (text.includes('WARNING:') || text.includes('WARNING ')) return 'WARNING';
497
+ if (text.includes('DEBUG:') || text.includes('DEBUG ')) return 'DEBUG';
498
+ if (text.includes('INFO:') || text.includes('INFO ')) return 'INFO';
499
+ return '';
500
+ }
501
+
502
+ function formatLine(raw) {
503
+ const search = searchInput.value.trim();
504
+ let text = raw
505
+ .replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
506
+ .replace(/(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d+)/g, '<span class="timestamp">$1</span>')
507
+ .replace(/ - ([\w.]+)/g, ' - <span class="logger-name">$1</span>')
508
+ .replace(/\b(INFO|WARNING|ERROR|DEBUG):/g, '<span class="level-tag">$1:</span>');
509
+ if (search) {
510
+ const escaped = search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
511
+ text = text.replace(new RegExp('(' + escaped + ')', 'gi'), '<span class="highlight">$1</span>');
512
+ }
513
+ return text;
514
+ }
515
+
516
+ function toggleFolder(btn) {
517
+ btn.classList.toggle('collapsed');
518
+ const children = btn.nextElementSibling;
519
+ if (children) children.classList.toggle('collapsed');
520
+ const folder = btn.getAttribute('data-folder');
521
+ if (folder) {
522
+ if (btn.classList.contains('collapsed')) collapsedFolders.add(folder);
523
+ else collapsedFolders.delete(folder);
524
+ }
525
+ }
526
+
527
+ const collapsedFolders = new Set();
528
+
529
+ async function fetchFiles() {
530
+ try {
531
+ const resp = await fetch(BASE + '/api/files');
532
+ const data = await resp.json();
533
+ if (!data.files.length) {
534
+ fileListEl.innerHTML = '<div class="empty-state" style="padding:20px;font-size:12px;">No log files found</div>';
535
+ return;
536
+ }
537
+
538
+ const groups = {};
539
+ data.files.forEach(f => {
540
+ const sep = f.name.lastIndexOf('/');
541
+ const folder = sep !== -1 ? f.name.substring(0, sep) : '';
542
+ const fileName = sep !== -1 ? f.name.substring(sep + 1) : f.name;
543
+ if (!groups[folder]) groups[folder] = [];
544
+ groups[folder].push({ full: f.name, display: fileName, size: f.size });
545
+ });
546
+
547
+ let html = '';
548
+ Object.keys(groups).sort().forEach(folder => {
549
+ const files = groups[folder];
550
+ const isCollapsed = collapsedFolders.has(folder);
551
+ if (folder) {
552
+ html += '<div class="folder-group">'
553
+ + '<div class="folder-header' + (isCollapsed ? ' collapsed' : '') + '" data-folder="' + folder + '" onclick="toggleFolder(this)">'
554
+ + '<span class="folder-icon">&#9660;</span> &#128193; ' + folder + '/'
555
+ + '</div>'
556
+ + '<div class="folder-children' + (isCollapsed ? ' collapsed' : '') + '">';
557
+ }
558
+ files.forEach(f => {
559
+ const cls = f.full === activeFile ? ' active' : '';
560
+ html += '<div class="file-item' + cls + '" data-file="' + f.full + '">'
561
+ + '<span class="file-name">' + f.display + '</span>'
562
+ + '<span class="file-size">' + formatBytes(f.size) + '</span>'
563
+ + '</div>';
564
+ });
565
+ if (folder) {
566
+ html += '</div></div>';
567
+ }
568
+ });
569
+ fileListEl.innerHTML = html;
570
+
571
+ if (!activeFile && data.files.length) {
572
+ selectFile(data.files[0].name);
573
+ }
574
+ } catch (e) {
575
+ console.error('Failed to fetch file list:', e);
576
+ }
577
+ }
578
+
579
+ function selectFile(name) {
580
+ activeFile = name;
581
+ document.querySelectorAll('.file-item').forEach(el => {
582
+ el.classList.toggle('active', el.dataset.file === name);
583
+ });
584
+ activeFileLabel.textContent = name;
585
+ logPaneHeader.style.display = name ? 'flex' : 'none';
586
+ updateActionButtons();
587
+ closeSidebar();
588
+ fetchLogs();
589
+ }
590
+
591
+ fileListEl.addEventListener('click', (e) => {
592
+ const item = e.target.closest('.file-item');
593
+ if (item) selectFile(item.dataset.file);
594
+ });
595
+
596
+ async function fetchLogs() {
597
+ if (!activeFile) return;
598
+ try {
599
+ const lines = parseInt(linesLimit.value);
600
+ const params = new URLSearchParams({ file: activeFile, lines: lines });
601
+ const level = levelFilter.value;
602
+ const search = searchInput.value.trim();
603
+ if (level) params.set('level', level);
604
+ if (search) params.set('search', search);
605
+
606
+ const resp = await fetch(BASE + '/api/content?' + params.toString());
607
+ const data = await resp.json();
608
+ lineCountEl.textContent = data.total;
609
+
610
+ if (!data.lines.length) {
611
+ container.innerHTML = '';
612
+ emptyState.style.display = 'flex';
613
+ emptyState.textContent = 'No log entries found.';
614
+ container.appendChild(emptyState);
615
+ return;
616
+ }
617
+
618
+ emptyState.style.display = 'none';
619
+
620
+ const scrollThreshold = 200;
621
+ const wasNearBottom = (container.scrollHeight - container.scrollTop - container.clientHeight) < scrollThreshold;
622
+
623
+ container.innerHTML = data.lines.map(line => {
624
+ const lvl = detectLevel(line);
625
+ return '<div class="log-line' + (lvl ? ' level-' + lvl : '') + '">' + formatLine(line) + '</div>';
626
+ }).join('');
627
+
628
+ if (autoScrollCb.checked && wasNearBottom) {
629
+ container.scrollTop = container.scrollHeight;
630
+ }
631
+ } catch (e) {
632
+ console.error('Failed to fetch logs:', e);
633
+ }
634
+ }
635
+
636
+ function startRefresh() {
637
+ if (refreshTimer) clearInterval(refreshTimer);
638
+ const interval = parseInt(refreshSelect.value);
639
+ if (interval > 0) {
640
+ refreshTimer = setInterval(() => { fetchLogs(); fetchFiles(); }, interval);
641
+ statusDot.classList.remove('paused');
642
+ statusText.textContent = 'Live';
643
+ } else {
644
+ statusDot.classList.add('paused');
645
+ statusText.textContent = 'Paused';
646
+ }
647
+ }
648
+
649
+ let searchTimeout;
650
+ searchInput.addEventListener('input', () => {
651
+ clearTimeout(searchTimeout);
652
+ searchTimeout = setTimeout(fetchLogs, 400);
653
+ });
654
+ levelFilter.addEventListener('change', fetchLogs);
655
+ linesLimit.addEventListener('change', fetchLogs);
656
+ refreshSelect.addEventListener('change', startRefresh);
657
+
658
+ function updateActionButtons() {
659
+ const hasFile = !!activeFile;
660
+ btnClear.disabled = !hasFile;
661
+ btnDelete.disabled = !hasFile;
662
+ }
663
+
664
+ function showToast(message, type) {
665
+ type = type || 'success';
666
+ toastEl.textContent = message;
667
+ toastEl.className = 'toast ' + type + ' show';
668
+ setTimeout(() => { toastEl.classList.remove('show'); }, 3000);
669
+ }
670
+
671
+ function confirmAction(action) {
672
+ if (!activeFile) return;
673
+ pendingAction = action;
674
+ if (action === 'clear') {
675
+ modalTitle.textContent = 'Clear log file';
676
+ modalMessage.textContent = 'This will erase all content in "' + activeFile + '" but keep the file. Continue?';
677
+ modalConfirmBtn.className = 'btn btn-warn';
678
+ modalConfirmBtn.textContent = 'Clear';
679
+ } else {
680
+ modalTitle.textContent = 'Delete log file';
681
+ modalMessage.textContent = 'This will permanently delete "' + activeFile + '". This cannot be undone. Continue?';
682
+ modalConfirmBtn.className = 'btn btn-danger';
683
+ modalConfirmBtn.textContent = 'Delete';
684
+ }
685
+ modalOverlay.classList.add('active');
686
+ }
687
+
688
+ function closeModal() {
689
+ modalOverlay.classList.remove('active');
690
+ pendingAction = null;
691
+ }
692
+
693
+ async function executeAction() {
694
+ if (!pendingAction || !activeFile) return;
695
+ const action = pendingAction;
696
+ closeModal();
697
+ try {
698
+ let resp;
699
+ if (action === 'clear') {
700
+ resp = await fetch(BASE + '/api/clear?file=' + encodeURIComponent(activeFile), { method: 'POST' });
701
+ } else {
702
+ resp = await fetch(BASE + '/api/file?file=' + encodeURIComponent(activeFile), { method: 'DELETE' });
703
+ }
704
+ const data = await resp.json();
705
+ if (data.success) {
706
+ showToast(data.message, 'success');
707
+ if (action === 'delete') {
708
+ activeFile = '';
709
+ activeFileLabel.textContent = '';
710
+ logPaneHeader.style.display = 'none';
711
+ updateActionButtons();
712
+ }
713
+ await fetchFiles();
714
+ if (activeFile) fetchLogs();
715
+ else {
716
+ container.innerHTML = '';
717
+ emptyState.style.display = 'flex';
718
+ emptyState.textContent = 'Select a log file to view';
719
+ container.appendChild(emptyState);
720
+ lineCountEl.textContent = '0';
721
+ }
722
+ } else {
723
+ showToast(data.error || 'Operation failed', 'error');
724
+ }
725
+ } catch (e) {
726
+ showToast('Request failed: ' + e.message, 'error');
727
+ }
728
+ }
729
+
730
+ modalOverlay.addEventListener('click', (e) => {
731
+ if (e.target === modalOverlay) closeModal();
732
+ });
733
+
734
+ fetchFiles();
735
+ startRefresh();
736
+ </script>
737
+ </body>
738
+ </html>"""
739
+
740
+
741
+ def render_html(
742
+ base_url: str = "",
743
+ *,
744
+ auto_refresh: bool = True,
745
+ refresh_timer: int = 5000,
746
+ auto_scroll: bool = True,
747
+ colorize: bool = True,
748
+ ) -> str:
749
+ """Return the complete HTML page with placeholders filled in.
750
+
751
+ Parameters
752
+ ----------
753
+ base_url:
754
+ URL prefix the JS ``fetch`` calls use (e.g. ``/logs``).
755
+ auto_refresh:
756
+ Whether to enable auto-refresh by default.
757
+ refresh_timer:
758
+ Default refresh interval in ms (only used when *auto_refresh* is True).
759
+ auto_scroll:
760
+ Whether to auto-scroll to the bottom on refresh.
761
+ colorize:
762
+ Whether to show coloured log-level backgrounds.
763
+ """
764
+ selected = str(refresh_timer) if auto_refresh else "0"
765
+
766
+ html = _TEMPLATE
767
+ for val in ("0", "1000", "3000", "5000", "10000", "30000", "60000"):
768
+ placeholder = "{{REFRESH_%s_SELECTED}}" % val
769
+ html = html.replace(placeholder, "selected" if selected == val else "")
770
+
771
+ html = html.replace("{{AUTO_SCROLL_CHECKED}}", "checked" if auto_scroll else "")
772
+ html = html.replace("{{BODY_CLASS}}", "colorize" if colorize else "")
773
+ html = html.replace("{{BASE_URL}}", base_url.rstrip("/"))
774
+
775
+ return html