(self, parsed)
| 1451 | |
| 1452 | rid_html = _html_escape(rid) |
| 1453 | |
| 1454 | # Log viewer: tail-first, auto-refresh, lazy-load older chunks on scroll. |
| 1455 | # We keep HTML as a static template so it can be copied into a standalone file. |
| 1456 | html = """<!doctype html> |
| 1457 | <html><head><meta charset='utf-8'/> |
| 1458 | <meta name='viewport' content='width=device-width, initial-scale=1'/> |
| 1459 | <title>Log @@RID@@</title> |
| 1460 | <style> |
| 1461 | body{font-family:ui-sans-serif,system-ui,Segoe UI,Roboto,Helvetica,Arial;max-width:1400px;margin:16px auto;padding:0 12px;} |
| 1462 | code,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;} |
| 1463 | #log{white-space:pre;background:#0b1020;color:#e5e7eb;padding:12px;border-radius:8px;height:78vh;overflow:auto;font-size:12px;line-height:1.35;tab-size:4;} |
| 1464 | .small{color:#6b7280;font-size:12px;} |
| 1465 | .btn{padding:4px 8px;border:1px solid #e5e7eb;border-radius:6px;background:#fff;cursor:pointer;} |
| 1466 | .row{display:flex;gap:10px;align-items:center;flex-wrap:wrap;} |
| 1467 | </style> |
| 1468 | </head><body> |
| 1469 | <a href='/run?id=@@RID@@'>back</a> |
| 1470 | <h2>run.log tail</h2> |
| 1471 | <div class='row'> |
| 1472 | <div class='small'>run_id: <code>@@RID@@</code></div> |
| 1473 | <button class='btn' id='btnFollow'>follow: on</button> |
| 1474 | <button class='btn' id='btnReload'>reload tail</button> |
| 1475 | <div class='small' id='status'></div> |
| 1476 | </div> |
| 1477 | <div id='log'></div> |
| 1478 | <script> |
| 1479 | (function(){ |
| 1480 | const runId = "@@RID@@"; |
| 1481 | const logEl = document.getElementById('log'); |
| 1482 | const statusEl = document.getElementById('status'); |
| 1483 | const btnFollow = document.getElementById('btnFollow'); |
| 1484 | const btnReload = document.getElementById('btnReload'); |
| 1485 | |
| 1486 | let follow = true; |
| 1487 | let loading = false; |
| 1488 | let loadedStart = null; |
| 1489 | let loadedEnd = null; |
| 1490 | |
| 1491 | const MAX_BYTES = 65536; |
| 1492 | const POLL_MS = 800; |
| 1493 | |
| 1494 | function setStatus(s){ statusEl.textContent = s; } |
| 1495 | function nearBottom(){ return (logEl.scrollHeight - (logEl.scrollTop + logEl.clientHeight)) < 40; } |
| 1496 | function scrollToBottom(){ logEl.scrollTop = logEl.scrollHeight; } |
| 1497 | |
| 1498 | async function fetchChunk(params){ |
| 1499 | const url = new URL('/api/log_chunk', window.location.origin); |
| 1500 | url.searchParams.set('id', runId); |
| 1501 | url.searchParams.set('kind', 'run'); |
| 1502 | url.searchParams.set('max_bytes', String(MAX_BYTES)); |
| 1503 | for (const [k,v] of Object.entries(params || {})) { |
| 1504 | url.searchParams.set(k, String(v)); |
| 1505 | } |
| 1506 | const resp = await fetch(url.toString()); |
| 1507 | if (!resp.ok) { |
| 1508 | const t = await resp.text(); |
| 1509 | throw new Error('http ' + resp.status + ' ' + t); |
| 1510 | } |
no test coverage detected