// log-panel.js — Log ring buffer DOM rendering, filter, auto-scroll // 對齊 Design Spec v2.1 control-panel.md §4.4-4.5 const MAX_LINES = 2000; const buffer = []; // [{ts, level, stream, line}] let autoScroll = true; let filterText = ''; let filterLevel = ''; // ---------- init ---------- export function initLogPanel(existing) { buffer.length = 0; if (Array.isArray(existing)) { for (const line of existing) buffer.push(line); } renderAll(); updateLineCount(); const output = document.getElementById('log-output'); if (output) { output.addEventListener('scroll', () => { const nearBottom = output.scrollTop + output.clientHeight >= output.scrollHeight - 30; if (!nearBottom) { autoScroll = false; const jumpBtn = document.getElementById('btn-jump-latest'); if (jumpBtn) jumpBtn.removeAttribute('hidden'); } else { autoScroll = true; const jumpBtn = document.getElementById('btn-jump-latest'); if (jumpBtn) jumpBtn.setAttribute('hidden', ''); } }); } const jumpBtn = document.getElementById('btn-jump-latest'); if (jumpBtn) { jumpBtn.addEventListener('click', () => { autoScroll = true; const el = document.getElementById('log-output'); if (el) el.scrollTop = el.scrollHeight; jumpBtn.setAttribute('hidden', ''); }); } const followCb = document.getElementById('cb-follow-tail'); if (followCb) { followCb.addEventListener('change', (e) => { autoScroll = e.target.checked; if (autoScroll) scrollToBottom(); }); } const tsCb = document.getElementById('cb-show-ts'); if (tsCb) { tsCb.addEventListener('change', () => renderAll()); } } // ---------- append from event ---------- export function appendLogs(entries) { for (const e of entries) { buffer.push(e); } // ring buffer 裁切 if (buffer.length > MAX_LINES) { buffer.splice(0, buffer.length - MAX_LINES); } renderAppend(entries); updateLineCount(); if (autoScroll) scrollToBottom(); } export function clearLog() { buffer.length = 0; const output = document.getElementById('log-output'); if (output) output.innerHTML = ''; updateLineCount(); } // ---------- filter ---------- export function applyLogFilter(opts) { if (opts.text !== undefined) filterText = opts.text.toLowerCase(); if (opts.level !== undefined) filterLevel = opts.level.toLowerCase(); renderAll(); } function matches(entry) { if (filterLevel && (entry.level || '').toLowerCase() !== filterLevel) return false; if (filterText && !((entry.line || '').toLowerCase().includes(filterText))) return false; return true; } // ---------- render ---------- function renderAll() { const output = document.getElementById('log-output'); if (!output) return; output.innerHTML = ''; const showTs = document.getElementById('cb-show-ts')?.checked !== false; const frag = document.createDocumentFragment(); for (const e of buffer) { if (!matches(e)) continue; frag.appendChild(buildLine(e, showTs)); } output.appendChild(frag); if (autoScroll) scrollToBottom(); } function renderAppend(entries) { const output = document.getElementById('log-output'); if (!output) return; const showTs = document.getElementById('cb-show-ts')?.checked !== false; const frag = document.createDocumentFragment(); for (const e of entries) { if (!matches(e)) continue; frag.appendChild(buildLine(e, showTs)); } output.appendChild(frag); // DOM 行數裁切(跟 ring buffer 同步) while (output.childElementCount > MAX_LINES) { output.removeChild(output.firstChild); } } function buildLine(entry, showTs) { const row = document.createElement('div'); const level = (entry.level || '').toLowerCase(); row.className = 'log-line level-' + (level || 'plain'); let html = ''; if (showTs && entry.ts) { const d = new Date(entry.ts); const hh = String(d.getHours()).padStart(2, '0'); const mm = String(d.getMinutes()).padStart(2, '0'); const ss = String(d.getSeconds()).padStart(2, '0'); html += `${hh}:${mm}:${ss} `; } if (entry.level) { html += `${entry.level.toUpperCase().padEnd(5)} `; } html += ``; row.innerHTML = html; row.querySelector('.log-msg').textContent = entry.line || ''; return row; } function scrollToBottom() { const el = document.getElementById('log-output'); if (el) el.scrollTop = el.scrollHeight; } function updateLineCount() { const el = document.getElementById('footer-lines'); if (!el) return; el.textContent = `Lines: ${buffer.length} / ${MAX_LINES}`; } // ---------- 閃爍最後一條 ERROR ---------- export function flashLastError() { const output = document.getElementById('log-output'); if (!output) return; // 找最後一條 ERROR row const errors = output.querySelectorAll('.log-line.level-error'); if (errors.length === 0) { // fallback: 捲到最底 scrollToBottom(); return; } const target = errors[errors.length - 1]; target.scrollIntoView({ block: 'center', behavior: 'smooth' }); // flash 2 次 target.classList.remove('flash'); void target.offsetWidth; target.classList.add('flash'); setTimeout(() => target.classList.remove('flash'), 1500); }