285 lines
15 KiB
JavaScript
285 lines
15 KiB
JavaScript
// ========== LOG INSIGHTS PANEL ==========
|
|
(function() {
|
|
injectModal('log-insights-modal', `<div id="log-insights-modal" class="weather-modal">
|
|
<div class="weather-modal-content" style="min-width: 800px; max-width: 1000px;">
|
|
<h3>🔍 Log Insights</h3>
|
|
<p class="modal-subtitle">Who's accessing your server and what they're doing — in plain English.</p>
|
|
|
|
<div style="display: flex; gap: 12px; margin-bottom: 16px; align-items: center;">
|
|
<label class="text-muted-sm">Period:</label>
|
|
<select id="li-period" style="padding: 6px 10px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.85rem;">
|
|
<option value="1">Last 1 hour</option>
|
|
<option value="6">Last 6 hours</option>
|
|
<option value="24" selected>Last 24 hours</option>
|
|
<option value="168">Last 7 days</option>
|
|
</select>
|
|
<button id="li-refresh" class="btn-sm">🔄 Refresh</button>
|
|
<span style="flex: 1;"></span>
|
|
<button id="li-dispose-btn" style="padding: 6px 12px; font-size: 0.8rem; color: var(--warn-fg, #f0c674); border-color: var(--warn-fg, #f0c674);">🧹 Clean Old Logs</button>
|
|
</div>
|
|
|
|
<!-- Plain English Insights -->
|
|
<div id="li-insights" style="margin-bottom: 16px;"></div>
|
|
|
|
<!-- Summary Stats -->
|
|
<div id="li-summary" style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 16px;"></div>
|
|
|
|
<!-- Top IPs Table -->
|
|
<div id="li-ips-section">
|
|
<h4 style="margin: 12px 0 8px; font-size: 0.95rem;">Top Visitors</h4>
|
|
<div id="li-ips-table" class="scroll-container" style="max-height: 300px;"></div>
|
|
</div>
|
|
|
|
<!-- DC-120: Perimeter (caddy-source) — public traffic reaching the reverse proxy -->
|
|
<div id="li-perimeter-section">
|
|
<h4 style="margin: 12px 0 8px; font-size: 0.95rem;">🌐 Perimeter <span style="font-size: 0.75rem; color: var(--muted); font-weight: 400;">(public traffic at the reverse proxy)</span></h4>
|
|
<div id="li-perimeter" class="scroll-container" style="max-height: 320px;"></div>
|
|
</div>
|
|
|
|
<!-- Storage Info -->
|
|
<div id="li-storage" style="margin-top: 16px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);"></div>
|
|
|
|
<div class="weather-modal-buttons">
|
|
<button id="li-close">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>`);
|
|
|
|
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 = '<div class="panel-empty"><span class="brand-spinner"></span> Analyzing logs...</div>';
|
|
summaryDiv.innerHTML = '';
|
|
ipsDiv.innerHTML = '';
|
|
if (perimeterDiv) perimeterDiv.innerHTML = '<div class="panel-empty"><span class="brand-spinner"></span> Loading perimeter...</div>';
|
|
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 = '<div class="panel-empty">Error: ' + data.error + '</div>'; 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 += '<div style="padding: 10px 14px; margin-bottom: 8px; background: var(--bg); border-radius: 6px; border-left: 3px solid ' + sevColor + ';">' +
|
|
'<strong style="font-size: 0.9rem;">' + ins.title + '</strong><br>' +
|
|
'<span style="font-size: 0.85rem; color: var(--muted);">' + ins.plain + '</span></div>';
|
|
});
|
|
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 = '<div class="panel-empty">No activity in this period.</div>';
|
|
} else {
|
|
var html = '<table style="width: 100%; font-size: 0.85rem; border-collapse: collapse;">';
|
|
html += '<tr style="border-bottom: 1px solid var(--border);"><th style="text-align:left; padding: 6px;">IP Address</th><th style="text-align:right; padding: 6px;">Requests</th><th style="text-align:right; padding: 6px;">Failures</th><th style="text-align:left; padding: 6px;">Top Actions</th><th style="text-align:left; padding: 6px;">Last Seen</th></tr>';
|
|
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 += '<tr style="border-bottom: 1px solid var(--border);">' +
|
|
'<td style="padding: 6px; font-family: monospace;">' + ip.ip + '</td>' +
|
|
'<td style="padding: 6px; text-align: right;">' + ip.count + '</td>' +
|
|
'<td style="padding: 6px; text-align: right; ' + failStyle + '">' + ip.failures + '</td>' +
|
|
'<td style="padding: 6px;">' + actions + '</td>' +
|
|
'<td style="padding: 6px; color: var(--muted);">' + lastSeen + '</td>' +
|
|
'</tr>';
|
|
});
|
|
html += '</table>';
|
|
ipsDiv.innerHTML = html;
|
|
}
|
|
|
|
// Storage info
|
|
var st = data.storage || {};
|
|
var stHtml = '<strong style="font-size: 0.85rem;">Log Storage</strong><br>';
|
|
if (st.auditLog) stHtml += '<span style="font-size: 0.8rem; color: var(--muted);">Audit log: ' + st.auditLog.sizeMB + ' MB (' + st.auditLog.entries + ' entries)</span><br>';
|
|
if (st.securityEvents) stHtml += '<span style="font-size: 0.8rem; color: var(--muted);">Security events: ' + st.securityEvents.sizeMB + ' MB (' + st.securityEvents.entries + ' entries)</span>';
|
|
storageDiv.innerHTML = stHtml;
|
|
|
|
} catch (e) {
|
|
insightsDiv.innerHTML = '<div class="panel-empty">Failed to load: ' + e.message + '</div>';
|
|
}
|
|
}
|
|
|
|
// 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 = '<div class="panel-empty">Perimeter unavailable: ' + escapeHtml(data.error || 'unknown error') + '</div>';
|
|
return;
|
|
}
|
|
|
|
const sum = data.summary || {};
|
|
let html = '<div style="font-size: 0.8rem; color: var(--muted); margin-bottom: 8px;">' +
|
|
sum.events + ' requests from ' + sum.uniqueIPs + ' IPs' +
|
|
(sum.denied ? ' · <span style="color: var(--warn-fg, #f0c674);">' + sum.denied + ' denied</span>' : '') +
|
|
(sum.error ? ' · <span style="color: var(--bad-fg, #ff6b6b);">' + sum.error + ' errors</span>' : '') +
|
|
'</div>';
|
|
|
|
const ips = data.topIPs || [];
|
|
if (ips.length === 0) {
|
|
html += '<div class="panel-empty">No perimeter traffic in this period.</div>';
|
|
} else {
|
|
html += '<table style="width: 100%; font-size: 0.85rem; border-collapse: collapse;">' +
|
|
'<tr style="border-bottom: 1px solid var(--border);"><th style="text-align:left; padding: 6px;">Source IP</th>' +
|
|
'<th style="text-align:right; padding: 6px;">Requests</th>' +
|
|
'<th style="text-align:right; padding: 6px;">Denied</th>' +
|
|
'<th style="text-align:right; padding: 6px;">Errors</th>' +
|
|
'<th style="text-align:left; padding: 6px;">Hosts Hit</th></tr>';
|
|
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 += '<tr style="border-bottom: 1px solid var(--border);">' +
|
|
'<td style="padding: 6px; font-family: monospace;">' + escapeHtml(p.ip) + '</td>' +
|
|
'<td style="padding: 6px; text-align: right;">' + p.count + '</td>' +
|
|
'<td style="padding: 6px; text-align: right; ' + deniedStyle + '">' + p.denied + '</td>' +
|
|
'<td style="padding: 6px; text-align: right; ' + errStyle + '">' + p.error + '</td>' +
|
|
'<td style="padding: 6px; color: var(--muted);">' + (p.hosts && p.hosts.length ? escapeHtml(p.hosts.join(', ')) : '—') + '</td>' +
|
|
'</tr>';
|
|
});
|
|
html += '</table>';
|
|
}
|
|
|
|
const hosts = data.byHost || [];
|
|
if (hosts.length > 0) {
|
|
html += '<div style="font-size: 0.75rem; color: var(--muted); margin-top: 10px;">By host: ' +
|
|
hosts.map(function(h) {
|
|
return escapeHtml(h.host) + ' (' + h.count + (h.denied ? ', ' + h.denied + ' denied' : '') + (h.error ? ', ' + h.error + ' err' : '') + ')';
|
|
}).join(' · ') + '</div>';
|
|
}
|
|
|
|
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 = '<div class="panel-empty">Perimeter failed to load: ' + escapeHtml(e.message) + '</div>';
|
|
}
|
|
}
|
|
|
|
function statCard(label, value) {
|
|
return '<div style="text-align: center; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">' +
|
|
'<div style="font-size: 1.5rem; font-weight: 700;">' + value + '</div>' +
|
|
'<div style="font-size: 0.75rem; color: var(--muted);">' + label + '</div></div>';
|
|
}
|
|
|
|
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, '"').replace(/'/g, ''');
|
|
}
|
|
})();
|