|
184 | 184 | cursor: pointer; |
185 | 185 | transition: all 0.2s; |
186 | 186 | font-weight: 500; |
| 187 | + display: inline-block; |
| 188 | + text-decoration: none; |
187 | 189 | }} |
188 | 190 |
|
189 | 191 | .btn:hover {{ |
|
496 | 498 | <div class="control-group"> |
497 | 499 | <button id="clearBtn" class="btn" onclick="clearLogs()">Clear</button> |
498 | 500 | <button id="autoScrollBtn" class="btn active" onclick="toggleAutoScroll()">Auto-scroll</button> |
499 | | - <button id="downloadBtn" class="btn download" onclick="downloadLogs()">Download Logs</button> |
| 501 | + <a id="downloadBtn" class="btn download" href="#" onclick="return false;" title="Available once the run starts" style="opacity:0.5;cursor:default;">Download Logs</a> |
500 | 502 | <input type="text" id="searchBox" class="search-box" placeholder="Search logs..." oninput="filterLogs()"> |
501 | 503 | <span class="log-count">Logs: <span id="logCount">0</span></span> |
502 | 504 | </div> |
|
522 | 524 | </div> |
523 | 525 |
|
524 | 526 | <script> |
| 527 | + // Injected server-side: the run's id (or null if the run hasn't been |
| 528 | + // created yet) and the backend's configured hostname (or null if it |
| 529 | + // was just "localhost"/"127.0.0.1" - meaningless once embedded in a |
| 530 | + // page that may be opened from a different device than the one |
| 531 | + // running the CLI). See updateDownloadLink() below. |
| 532 | + const RUN_ID = {run_id}; |
| 533 | + const BACKEND_HOST = {backend_host}; |
| 534 | + |
525 | 535 | let eventSource = null; |
526 | 536 | let autoScroll = true; |
527 | 537 | let logCount = 0; |
|
538 | 548 | // The "Download Logs" button reads the full file from disk directly, |
539 | 549 | // so it isn't affected by this cap. |
540 | 550 | const MAX_RETAINED_LOGS = 5000; |
| 551 | + // Cap how many entries can sit unrendered in the queue. Without this, |
| 552 | + // a fast burst (hundreds of thousands of lines) makes the queue grow |
| 553 | + // into a multi-minute backlog: the view still eventually renders |
| 554 | + // everything, but always shows stale content instead of what's |
| 555 | + // happening right now - as bad as no live view at all for reading |
| 556 | + // context to answer a prompt while a test is running. Dropping the |
| 557 | + // oldest not-yet-rendered entries keeps the view within a few |
| 558 | + // seconds of real-time, at the cost of some historical entries never |
| 559 | + // appearing live during a burst (the full history is unaffected - |
| 560 | + // it's still in the saved log file and via "Download Logs"). |
| 561 | + const MAX_PENDING_QUEUE = 2000; |
541 | 562 |
|
542 | 563 | function connectToLogStream() {{ |
543 | 564 | const statusIndicator = document.getElementById('statusIndicator'); |
544 | 565 | const statusText = document.getElementById('statusText'); |
545 | | - |
| 566 | + |
546 | 567 | eventSource = new EventSource('/api/logs/stream'); |
547 | | - |
| 568 | + |
548 | 569 | eventSource.addEventListener('connected', function(e) {{ |
549 | 570 | console.log('Connected to log stream'); |
550 | 571 | statusIndicator.className = 'status-indicator connected'; |
551 | 572 | statusText.textContent = 'Connected'; |
552 | 573 | }}); |
553 | | - |
| 574 | + |
554 | 575 | eventSource.addEventListener('log', function(e) {{ |
555 | 576 | const logEntry = JSON.parse(e.data); |
556 | 577 | logBatchQueue.push(logEntry); |
| 578 | + if (logBatchQueue.length > MAX_PENDING_QUEUE) {{ |
| 579 | + logBatchQueue.splice(0, logBatchQueue.length - MAX_PENDING_QUEUE); |
| 580 | + }} |
557 | 581 | scheduleBatchProcessing(); |
558 | 582 | }}); |
559 | | - |
| 583 | + |
560 | 584 | eventSource.addEventListener('keepalive', function(e) {{ |
561 | 585 | // Silent keepalive |
562 | 586 | }}); |
563 | | - |
| 587 | + |
564 | 588 | eventSource.addEventListener('end', function(e) {{ |
565 | 589 | console.log('Log stream ended'); |
566 | 590 | statusIndicator.className = 'status-indicator disconnected'; |
|
746 | 770 | }}); |
747 | 771 | }} |
748 | 772 |
|
749 | | - function downloadLogs() {{ |
750 | | - // First try server download (if server is still running) |
751 | | - fetch('/download_logs') |
752 | | - .then(response => {{ |
753 | | - if (response.ok) {{ |
754 | | - return response.blob(); |
755 | | - }} |
756 | | - throw new Error('Server not available'); |
757 | | - }}) |
758 | | - .then(blob => {{ |
759 | | - // Server download successful |
760 | | - const url = window.URL.createObjectURL(blob); |
761 | | - const a = document.createElement('a'); |
762 | | - a.href = url; |
763 | | - a.download = 'test_run_logs.log'; |
764 | | - document.body.appendChild(a); |
765 | | - a.click(); |
766 | | - window.URL.revokeObjectURL(url); |
767 | | - document.body.removeChild(a); |
768 | | - }}) |
769 | | - .catch(error => {{ |
770 | | - // Server not available, use client-side download |
771 | | - console.log('Server unavailable, using client-side download'); |
772 | | - downloadLogsClientSide(); |
773 | | - }}); |
774 | | - }} |
775 | | -
|
776 | | - function downloadLogsClientSide() {{ |
777 | | - // Build log content from stored logs |
778 | | - let logContent = ''; |
779 | | - allLogs.forEach(entry => {{ |
780 | | - const timestamp = entry.timestamp || new Date().toISOString(); |
781 | | - const level = entry.level || 'INFO'; |
782 | | - const message = entry.message || ''; |
783 | | - // Replace literal \n strings with actual newlines, then add entry |
784 | | - const cleanMessage = message.replace(/\\n/g, '\n'); |
785 | | - logContent += `${{timestamp}} [${{level}}] ${{cleanMessage}}\n`; |
786 | | - }}); |
787 | | -
|
788 | | - if (logContent === '') {{ |
789 | | - alert('No logs to download'); |
790 | | - return; |
791 | | - }} |
792 | | -
|
793 | | - // Create blob and download |
794 | | - const blob = new Blob([logContent], {{ type: 'text/plain;charset=utf-8' }}); |
795 | | - const url = window.URL.createObjectURL(blob); |
796 | | - const a = document.createElement('a'); |
797 | | - a.href = url; |
798 | | - |
799 | | - // Generate filename with current timestamp |
800 | | - const now = new Date(); |
801 | | - const dateStr = now.toISOString().slice(0,19).replace(/[T:]/g, '-'); |
802 | | - a.download = `test_run_logs_${{dateStr}}.log`; |
803 | | - |
804 | | - document.body.appendChild(a); |
805 | | - a.click(); |
806 | | - window.URL.revokeObjectURL(url); |
807 | | - document.body.removeChild(a); |
808 | | - |
809 | | - console.log(`Downloaded ${{allLogs.length}} log entries client-side`); |
810 | | - }} |
811 | | - |
812 | 773 | function escapeHtml(text) {{ |
813 | 774 | const div = document.createElement('div'); |
814 | 775 | div.textContent = text; |
|
832 | 793 | }} |
833 | 794 | }}); |
834 | 795 |
|
| 796 | + function updateDownloadLink() {{ |
| 797 | + if (RUN_ID === null) {{ |
| 798 | + return; |
| 799 | + }} |
| 800 | + // Prefer the backend's own configured hostname when it's a real |
| 801 | + // address; otherwise fall back to whatever host this page was |
| 802 | + // actually loaded from (correct whenever the CLI and backend |
| 803 | + // run on the same machine, which is the common case - see the |
| 804 | + // comment on BACKEND_HOST above). |
| 805 | + const host = BACKEND_HOST || window.location.hostname; |
| 806 | + const btn = document.getElementById('downloadBtn'); |
| 807 | + btn.href = `${{window.location.protocol}}//${{host}}/api/v1/test_run_executions/${{RUN_ID}}/log`; |
| 808 | + btn.target = '_blank'; |
| 809 | + btn.removeAttribute('onclick'); |
| 810 | + btn.title = ''; |
| 811 | + btn.style.opacity = ''; |
| 812 | + btn.style.cursor = ''; |
| 813 | + }} |
| 814 | + |
835 | 815 | window.addEventListener('load', function() {{ |
| 816 | + updateDownloadLink(); |
836 | 817 | connectToLogStream(); |
837 | 818 | }}); |
838 | 819 |
|
|
0 commit comments