// ========== ERROR LOG VIEWER (DC-052) ========== // DC-052: Adds Level / Context / Search / Time-range filters, server-side // pagination with Load More, click-to-expand stack frames, and a distinct // contexts dropdown backed by /api/v1/error-logs/contexts. Mirrors the // audit-log UX (DC-050) so operators can drill into a subsystem as easily // as they can audit who-did-what. (function() { // Inject modal HTML. Same weather-modal shell as audit-log so styles // are shared; wider min-width because error stacks need room to breathe. injectModal('error-log-modal', `

📋 Error Logs

Loading error logs...
`); const modal = document.getElementById('error-log-modal'); const viewBtn = document.getElementById('view-error-logs'); const refreshBtn = document.getElementById('error-log-refresh'); const clearBtn = document.getElementById('error-log-clear'); const closeBtn = document.getElementById('error-log-close'); const levelSel = document.getElementById('error-log-level'); const contextSel = document.getElementById('error-log-context'); const searchInput = document.getElementById('error-log-search'); const sinceInput = document.getElementById('error-log-since'); const untilInput = document.getElementById('error-log-until'); const container = document.getElementById('error-log-container'); const loadMoreBtn = document.getElementById('error-log-load-more'); const totalSpan = document.getElementById('error-log-total'); const PAGE_SIZE = 50; let currentOffset = 0; let inflight = null; let filterNonce = 0; // Cached distinct contexts so the dropdown is populated once per open and // re-populated after a clear (which removes all contexts) or a refresh // that surfaces a new subsystem for the first time. let knownContexts = []; // datetime-local fields are naive local time — convert to UTC ISO so the // server compares correctly. Same shape as audit-log.js so the operator // sees consistent behaviour between the two modals. function toIso(localDtValue) { if (!localDtValue) return null; const d = new Date(localDtValue); if (isNaN(d.getTime())) return null; return d.toISOString(); } // Pull the distinct contexts list once per open. Failures are silent // (the dropdown will just show "All" only) so a transient backend hiccup // doesn't block the operator from seeing the actual error rows. async function refreshContexts() { try { const res = await fetch('/api/v1/error-logs/contexts'); if (!res.ok) return; const data = await res.json(); if (!data.success || !Array.isArray(data.contexts)) return; knownContexts = data.contexts; const currentValue = contextSel.value; contextSel.innerHTML = ''; for (const c of data.contexts) { const opt = document.createElement('option'); opt.value = c.name; opt.textContent = `${c.name} (${c.count})`; contextSel.appendChild(opt); } // Restore previous selection if still present. if (currentValue && data.contexts.some((c) => c.name === currentValue)) { contextSel.value = currentValue; } } catch { /* ignore */ } } function buildQuery() { const params = new URLSearchParams(); params.set('limit', String(PAGE_SIZE)); params.set('offset', String(currentOffset)); if (levelSel.value) params.set('level', levelSel.value); if (contextSel.value) params.set('context', contextSel.value); const since = toIso(sinceInput.value); const until = toIso(untilInput.value); if (since) params.set('since', since); if (until) params.set('until', until); const search = (searchInput.value || '').trim(); if (search) params.set('search', search); return params; } async function loadLogs(append) { try { if (!append) { if (inflight) inflight.abort(); inflight = new AbortController(); currentOffset = 0; filterNonce++; container.innerHTML = '
Loading...
'; } else { if (inflight) inflight.abort(); inflight = new AbortController(); } const myNonce = filterNonce; const params = buildQuery(); const res = await fetch('/api/v1/error-logs?' + params.toString(), { signal: inflight.signal, }); // Mirror audit-log: surface 4xx/5xx explicitly instead of falling // through to a misleading "no entries yet" empty state. if (!res.ok) { container.innerHTML = `
Failed: HTTP ${res.status}
`; loadMoreBtn.style.display = 'none'; totalSpan.textContent = ''; return; } const data = await res.json(); if (!data.success) { container.innerHTML = `
Failed: ${escapeHtml(data.error || 'unknown')}
`; loadMoreBtn.style.display = 'none'; totalSpan.textContent = ''; return; } // Stale-response guard: a non-append load happened after this fetch, // discard so we don't splice into the wrong DOM. if (!append && myNonce !== filterNonce) return; const logs = Array.isArray(data.logs) ? data.logs : []; if (logs.length === 0 && !append) { const reason = (data.filters && (data.filters.level || data.filters.context || data.filters.search || data.filters.since || data.filters.until)) ? 'No error log entries match your filters.' : '✅ No errors logged! Everything is working smoothly.'; container.innerHTML = `
📋${escapeHtml(reason)}
`; loadMoreBtn.style.display = 'none'; totalSpan.textContent = data.total ? `${data.total} total` : ''; return; } let html = ''; if (!append) { html = ''; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; } for (const log of logs) { const level = (log.level || '?').toUpperCase(); const levelColor = level === 'ERR' ? 'var(--bad-fg)' : (level === 'WARN' ? 'var(--warn-fg, #f0c674)' : 'var(--muted)'); const ts = log.timestamp ? new Date(log.timestamp).toLocaleString() : '—'; const ctx = log.context || '—'; const msg = (log.error || '').split('\n')[0]; const ip = (log.request && log.request.ip) || ''; html += ``; html += ``; html += ``; html += ``; html += ``; html += ``; html += ''; if (log.detail) { html += ``; } } if (!append) { html += '
WhenLevelContextMessageIP
${escapeHtml(ts)}${escapeHtml(level)}${escapeHtml(ctx)}${escapeHtml(msg)}${escapeHtml(ip)}
'; container.innerHTML = html; } else { const table = container.querySelector('table'); if (table) table.insertAdjacentHTML('beforeend', html); } currentOffset += logs.length; loadMoreBtn.style.display = data.hasMore ? '' : 'none'; totalSpan.textContent = `${data.total} total${data.hasMore ? ' (showing ' + currentOffset + ')' : ''}`; // Toggle detail rows on click — same pattern as audit-log.js container.querySelectorAll('.error-log-row').forEach((row) => { if (row.dataset.wired) return; row.dataset.wired = 'true'; row.addEventListener('click', () => { const detail = row.nextElementSibling; if (detail && detail.classList.contains('error-log-detail')) { detail.style.display = detail.style.display === 'none' ? '' : 'none'; } }); }); } catch (e) { if (e && e.name === 'AbortError') return; container.innerHTML = `
Failed: ${escapeHtml(e.message)}
`; totalSpan.textContent = ''; } } async function clearLogs() { if (!confirm('Clear the entire error log? This cannot be undone.')) return; try { const res = await secureFetch('/api/v1/error-logs', { method: 'DELETE', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ confirm: 'CLEAR' }), }); const data = await res.json(); if (data.success) { // After a clear, the contexts list will be empty — re-fetch so the // dropdown reflects reality. Load the now-empty page in parallel. await refreshContexts(); loadLogs(false); showNotification('✅ Error logs cleared', 'success', 3000); } else { showNotification('❌ ' + (data.error || 'Clear failed'), 'error', 4000); } } catch (e) { showNotification('❌ ' + e.message, 'error', 4000); } } // Debounce text-input changes so we don't refetch on every keystroke. let searchDebounce; function wireFilters() { levelSel?.addEventListener('change', () => loadLogs(false)); contextSel?.addEventListener('change', () => loadLogs(false)); searchInput?.addEventListener('input', () => { clearTimeout(searchDebounce); searchDebounce = setTimeout(() => loadLogs(false), 250); }); let dateDebounce; [sinceInput, untilInput].forEach((el) => { el?.addEventListener('change', () => { clearTimeout(dateDebounce); dateDebounce = setTimeout(() => loadLogs(false), 250); }); }); refreshBtn?.addEventListener('click', () => loadLogs(false)); loadMoreBtn?.addEventListener('click', () => loadLogs(true)); clearBtn?.addEventListener('click', clearLogs); wireModal(modal, closeBtn); } viewBtn?.addEventListener('click', async () => { modal?.classList.add('show'); await refreshContexts(); loadLogs(false); }); wireFilters(); })();