// ========== LOG INSIGHTS PANEL ========== (function() { injectModal('log-insights-modal', `

๐Ÿ” Log Insights

Top Visitors

๐ŸŒ Perimeter (public traffic at the reverse proxy)

`); const modal = document.getElementById('log-insights-modal'); const openBtn = document.getElementById('log-insights-btn'); const closeBtn = document.getElementById('li-close'); const refreshBtn = document.getElementById('li-refresh'); const disposeBtn = document.getElementById('li-dispose-btn'); const periodSel = document.getElementById('li-period'); const insightsDiv = document.getElementById('li-insights'); const summaryDiv = document.getElementById('li-summary'); const ipsDiv = document.getElementById('li-ips-table'); const perimeterDiv = document.getElementById('li-perimeter'); const storageDiv = document.getElementById('li-storage'); if (openBtn) { openBtn.addEventListener('click', () => { modal.style.display = 'flex'; loadInsights(); }); } closeBtn.addEventListener('click', () => modal.style.display = 'none'); refreshBtn.addEventListener('click', loadInsights); periodSel.addEventListener('change', loadInsights); disposeBtn.addEventListener('click', showDisposePreview); // DC-120: perimeter fetch runs in parallel with the main insights // request so a slow perimeter response never blanks the panel the // user opened the modal for. A monotonically increasing request ID // guards against stale responses: if the user changes period/refreshes, // the new request's ID will be greater, and the old callback will // no-op instead of overwriting fresh data. The ID is incremented // at the START of loadInsights so ALL in-flight callbacks check the // same monotonically increasing value. var perimeterReqId = 0; async function loadInsights() { // Increment first โ€” ANY perimeter callback with the old ID must // self-discard, even the ones already in flight from a prior click. var thisReq = ++perimeterReqId; const hours = periodSel.value; insightsDiv.innerHTML = '
Analyzing logs...
'; summaryDiv.innerHTML = ''; ipsDiv.innerHTML = ''; if (perimeterDiv) perimeterDiv.innerHTML = '
Loading perimeter...
'; storageDiv.innerHTML = ''; // DC-120: fire perimeter IN PARALLEL โ€” don't await main insights. // If main fails, perimeter still runs and renders its own terminal state. loadPerimeter(hours, thisReq); try { const res = await fetch('/api/v1/log-insights?hours=' + hours); const data = await res.json(); if (!data.success) { insightsDiv.innerHTML = '
Error: ' + data.error + '
'; return; } // Render insights as plain English cards let insightsHtml = ''; (data.insights || []).forEach(function(ins) { const sevColor = ins.severity === 'warning' ? 'var(--warn-fg, #f0c674)' : ins.severity === 'critical' ? 'var(--bad-fg, #ff6b6b)' : ins.severity === 'ok' ? 'var(--good-fg, #98c379)' : 'var(--muted)'; insightsHtml += '
' + '' + ins.title + '
' + '' + ins.plain + '
'; }); insightsDiv.innerHTML = insightsHtml; // Summary stats var s = data.summary; summaryDiv.innerHTML = statCard('Requests', s.totalRequests) + statCard('Unique IPs', s.uniqueIPs) + statCard('Security Events', s.securityEvents) + statCard('Failed Actions', s.failedActions); // Top IPs table var ips = data.topIPs || []; if (ips.length === 0) { ipsDiv.innerHTML = '
No activity in this period.
'; } else { var html = ''; html += ''; ips.forEach(function(ip) { var failStyle = ip.failures > 0 ? 'color: var(--bad-fg, #ff6b6b); font-weight: 600;' : ''; var actions = (ip.topActions || []).map(function(a) { return a[0]; }).join(', '); var lastSeen = ip.lastSeen ? new Date(ip.lastSeen).toLocaleString() : '?'; html += '' + '' + '' + '' + '' + '' + ''; }); html += '
IP AddressRequestsFailuresTop ActionsLast Seen
' + ip.ip + '' + ip.count + '' + ip.failures + '' + actions + '' + lastSeen + '
'; ipsDiv.innerHTML = html; } // Storage info var st = data.storage || {}; var stHtml = 'Log Storage
'; if (st.auditLog) stHtml += 'Audit log: ' + st.auditLog.sizeMB + ' MB (' + st.auditLog.entries + ' entries)
'; if (st.securityEvents) stHtml += 'Security events: ' + st.securityEvents.sizeMB + ' MB (' + st.securityEvents.entries + ' entries)'; storageDiv.innerHTML = stHtml; } catch (e) { insightsDiv.innerHTML = '
Failed to load: ' + e.message + '
'; } } // DC-120: render the caddy-source perimeter (public traffic at the // reverse proxy). Separate fetch so a failure here leaves the rest of // the modal intact. A request ID guards against stale responses. async function loadPerimeter(hours, reqId) { if (!perimeterDiv) return; try { const res = await fetch('/api/v1/security/events/perimeter?hours=' + hours + '&limit=15'); // Stale-response guard: if a newer request has superseded this one, // discard this response silently (the new callback will render fresh data). if (reqId !== perimeterReqId) return; const data = await res.json(); // Stale-parse guard: a newer request can begin while JSON parsing // is pending; check again before touching the DOM. if (reqId !== perimeterReqId) return; if (!data.success) { perimeterDiv.innerHTML = '
Perimeter unavailable: ' + escapeHtml(data.error || 'unknown error') + '
'; return; } const sum = data.summary || {}; let html = '
' + sum.events + ' requests from ' + sum.uniqueIPs + ' IPs' + (sum.denied ? ' ยท ' + sum.denied + ' denied' : '') + (sum.error ? ' ยท ' + sum.error + ' errors' : '') + '
'; const ips = data.topIPs || []; if (ips.length === 0) { html += '
No perimeter traffic in this period.
'; } else { html += '' + '' + '' + '' + '' + ''; ips.forEach(function(p) { var deniedStyle = p.denied > 0 ? 'color: var(--warn-fg, #f0c674); font-weight: 600;' : ''; var errStyle = p.error > 0 ? 'color: var(--bad-fg, #ff6b6b); font-weight: 600;' : ''; html += '' + '' + '' + '' + '' + '' + ''; }); html += '
Source IPRequestsDeniedErrorsHosts Hit
' + escapeHtml(p.ip) + '' + p.count + '' + p.denied + '' + p.error + '' + (p.hosts && p.hosts.length ? escapeHtml(p.hosts.join(', ')) : 'โ€”') + '
'; } const hosts = data.byHost || []; if (hosts.length > 0) { html += '
By host: ' + hosts.map(function(h) { return escapeHtml(h.host) + ' (' + h.count + (h.denied ? ', ' + h.denied + ' denied' : '') + (h.error ? ', ' + h.error + ' err' : '') + ')'; }).join(' ยท ') + '
'; } perimeterDiv.innerHTML = html; } catch (e) { // Stale-rejection guard: if a newer request has superseded this // one, discard this error instead of overwriting fresh data. if (reqId !== perimeterReqId) return; perimeterDiv.innerHTML = '
Perimeter failed to load: ' + escapeHtml(e.message) + '
'; } } function statCard(label, value) { return '
' + '
' + value + '
' + '
' + label + '
'; } async function showDisposePreview() { var keepDays = prompt('Delete logs older than how many days?', '30'); if (!keepDays) return; keepDays = parseInt(keepDays); if (isNaN(keepDays) || keepDays < 1) { alert('Invalid number'); return; } try { var res = await fetch('/api/v1/log-insights/dispose', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ keepDays: keepDays }) }); var data = await res.json(); if (!data.success) { alert('Error: ' + data.error); return; } var msg = data.message + '\n\n' + 'Audit entries to delete: ' + data.wouldDelete.auditEntries + '\n' + 'Security events to delete: ' + data.wouldDelete.securityEvents + '\n\n' + 'Click OK to confirm deletion.'; if (confirm(msg)) { await executeDispose(keepDays); } } catch (e) { alert('Failed: ' + e.message); } } async function executeDispose(keepDays) { try { var res = await fetch('/api/v1/log-insights/dispose', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ keepDays: keepDays, confirm: true }) }); var data = await res.json(); if (!data.success) { alert('Error: ' + data.error); return; } alert('Cleaned up!\n\nDeleted: ' + data.deleted.auditEntries + ' audit entries, ' + data.deleted.securityEvents + ' security events.\nRemaining: ' + data.remaining.auditEntries + ' audit, ' + data.remaining.securityEvents + ' security.'); loadInsights(); } catch (e) { alert('Failed: ' + e.message); } } function injectModal(id, html) { if (document.getElementById(id)) return; var div = document.createElement('div'); div.innerHTML = html; document.body.appendChild(div.firstElementChild); } // DC-120: local escapeHtml โ€” this file loads standalone (line-order in // index.html) BEFORE dist/core.js, and the bundled globals.js copy never // leaks to window (esbuild IIFE-wraps it), so a bare global reference // would throw at render time. Same escaping contract as globals.js. function escapeHtml(text) { return String(text ?? '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); } })();