From 7ebb1b1a01c9b42ad4aa13ecc752ac6a8234061b Mon Sep 17 00:00:00 2001 From: Krystie Date: Wed, 12 Aug 2026 20:44:59 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20Log=20Insights=20panel=20=E2=80=94=20pl?= =?UTF-8?q?ain=20English=20activity=20summary=20+=20safe=20log=20disposal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New route /api/v1/log-insights: analyzes audit logs + security events - Shows top IPs with request counts, failures, and top actions - Plain English insights (heavy users, auth failures, security alerts) - Summary stats: total requests, unique IPs, failed actions - Storage info showing log file sizes and entry counts - New route POST /api/v1/log-insights/dispose: preview-then-confirm cleanup - First call shows what would be deleted (preview mode) - Second call with confirm:true actually deletes - Configurable retention period (default 30 days) - Frontend panel with modal UI showing insights as cards - Period selector (1h, 6h, 24h, 7d) - Top visitors table with IP, requests, failures, actions, last seen - Storage info footer - Clean Old Logs button with preview confirmation dialog - Wired into app.js and dashboard navbar (๐Ÿ” Insights button) - Addresses QA issue: users need to see who is accessing before cleanup --- dashcaddy-api/routes/log-insights.js | 153 ++++++++++++++++++++++ dashcaddy-api/src/app.js | 15 +++ status/index.html | 2 + status/js/log-insights.js | 184 +++++++++++++++++++++++++++ 4 files changed, 354 insertions(+) create mode 100644 dashcaddy-api/routes/log-insights.js create mode 100644 status/js/log-insights.js diff --git a/dashcaddy-api/routes/log-insights.js b/dashcaddy-api/routes/log-insights.js new file mode 100644 index 0000000..8476173 --- /dev/null +++ b/dashcaddy-api/routes/log-insights.js @@ -0,0 +1,153 @@ +const express = require('express'); +const fs = require('fs').promises; + +module.exports = function({ asyncHandler, ok, auditLogger, securityEventStore }) { + const router = express.Router(); + + // GET /api/v1/log-insights โ€” Plain English summary of who's doing what + router.get('/log-insights', asyncHandler(async (req, res) => { + const hours = parseInt(req.query.hours) || 24; + const since = new Date(Date.now() - hours * 60 * 60 * 1000).toISOString(); + + // --- Collect data --- + const auditEntries = await auditLogger.query({ limit: 10000 }); + const recentAudit = auditEntries.filter(e => e.timestamp >= since); + + let securityEvents = []; + try { securityEvents = securityEventStore.query({ since, limit: 10000 }); } catch {} + + // --- Analyze IPs --- + const ipMap = {}; + recentAudit.forEach(e => { + const ip = e.ip || 'unknown'; + if (!ipMap[ip]) ipMap[ip] = { count: 0, actions: {}, resources: new Set(), first: e.timestamp, last: e.timestamp, failures: 0 }; + const s = ipMap[ip]; + s.count++; + const cat = (e.action || 'unknown').split('.')[0]; + s.actions[cat] = (s.actions[cat] || 0) + 1; + if (e.resource) s.resources.add(e.resource); + if (e.timestamp < s.first) s.first = e.timestamp; + if (e.timestamp > s.last) s.last = e.timestamp; + if (e.outcome === 'failure' || e.outcome === 'denied') s.failures++; + }); + + // --- Build plain-English insights --- + const insights = []; + const ipArray = Object.entries(ipMap).sort((a, b) => b[1].count - a[1].count); + + // Heavy users + ipArray.slice(0, 3).forEach(([ip, s]) => { + const topAction = Object.entries(s.actions).sort((a, b) => b[1] - a[1])[0]; + insights.push({ + severity: s.count > 500 ? 'warning' : 'info', + title: ip + ' โ€” ' + s.count + ' requests in ' + hours + 'h', + plain: ip + ' made ' + s.count + ' requests (mostly ' + (topAction ? topAction[0] : 'unknown') + ')' + + (s.failures > 0 ? ', ' + s.failures + ' failed' : '') + '.' + }); + }); + + // Auth failures + const totalFailures = recentAudit.filter(e => e.outcome === 'failure' || e.outcome === 'denied').length; + if (totalFailures > 5) { + insights.push({ + severity: totalFailures > 50 ? 'warning' : 'info', + title: totalFailures + ' failed actions', + plain: totalFailures + ' requests were denied or failed in the last ' + hours + ' hours.' + + (totalFailures > 50 ? ' This could indicate someone trying to brute-force access.' : '') + }); + } + + // Security events + const secBySev = {}; + securityEvents.forEach(e => { secBySev[e.severity] = (secBySev[e.severity] || 0) + 1; }); + if (secBySev.critical || secBySev.error) { + insights.push({ + severity: 'warning', + title: ((secBySev.critical || 0) + (secBySev.error || 0)) + ' security alerts', + plain: (secBySev.critical || 0) + ' critical and ' + (secBySev.error || 0) + ' error-level security events were logged.' + }); + } + + // Quiet / nothing + if (insights.length === 0) { + insights.push({ severity: 'ok', title: 'All quiet', plain: 'No notable activity in the last ' + hours + ' hours.' }); + } + + // --- Storage info --- + const auditPath = process.env.AUDIT_LOG_FILE || '/opt/dashcaddy/dashcaddy-api/data/audit-log.json'; + const secPath = process.env.SECURITY_EVENT_LOG_FILE || '/opt/dashcaddy/dashcaddy-api/data/security-events.jsonl'; + let storage = {}; + try { + const a = await fs.stat(auditPath); + storage.auditLog = { sizeMB: +(a.size / 1048576).toFixed(2), entries: auditEntries.length }; + } catch {} + try { + const s = await fs.stat(secPath); + storage.securityEvents = { sizeMB: +(s.size / 1048576).toFixed(2), entries: securityEvents.length }; + } catch {} + + ok(res, { + period: { hours, since, until: new Date().toISOString() }, + summary: { + totalRequests: recentAudit.length, + uniqueIPs: ipArray.length, + securityEvents: securityEvents.length, + failedActions: totalFailures + }, + topIPs: ipArray.slice(0, 10).map(([ip, s]) => ({ + ip: ip, + count: s.count, + failures: s.failures, + topActions: Object.entries(s.actions).sort((a, b) => b[1] - a[1]).slice(0, 3), + activeFrom: s.first, + lastSeen: s.last + })), + insights: insights, + storage: storage + }); + })); + + // POST /api/v1/log-insights/dispose โ€” Preview then confirm cleanup + router.post('/log-insights/dispose', asyncHandler(async (req, res) => { + const keepDays = parseInt(req.body.keepDays) || 30; + const confirm = req.body.confirm === true; + const cutoff = new Date(Date.now() - keepDays * 86400000).toISOString(); + + const auditPath = process.env.AUDIT_LOG_FILE || '/opt/dashcaddy/dashcaddy-api/data/audit-log.json'; + const secPath = process.env.SECURITY_EVENT_LOG_FILE || '/opt/dashcaddy/dashcaddy-api/data/security-events.jsonl'; + + const auditRaw = await fs.readFile(auditPath, 'utf8').catch(function () { return '[]'; }); + const auditData = JSON.parse(auditRaw); + const oldAudit = auditData.filter(function (e) { return e.timestamp < cutoff; }); + + const secRaw = await fs.readFile(secPath, 'utf8').catch(function () { return ''; }); + const secLines = secRaw.split('\n').filter(Boolean); + const oldSec = secLines.filter(function (l) { try { return JSON.parse(l).timestamp < cutoff; } catch (e) { return false; } }); + + if (!confirm) { + ok(res, { + preview: true, + message: 'This will delete ' + oldAudit.length + ' audit entries and ' + oldSec.length + ' security events older than ' + keepDays + ' days. Send {confirm: true} to proceed.', + wouldDelete: { auditEntries: oldAudit.length, securityEvents: oldSec.length }, + cutoffDate: cutoff + }); + return; + } + + // Execute cleanup + const keptAudit = auditData.filter(function (e) { return e.timestamp >= cutoff; }); + await fs.writeFile(auditPath, JSON.stringify(keptAudit, null, 2)); + + const keptSec = secLines.filter(function (l) { try { return JSON.parse(l).timestamp >= cutoff; } catch (e) { return false; } }); + await fs.writeFile(secPath, keptSec.join('\n') + '\n'); + + ok(res, { + disposed: true, + deleted: { auditEntries: oldAudit.length, securityEvents: oldSec.length }, + remaining: { auditEntries: keptAudit.length, securityEvents: keptSec.length }, + cutoffDate: cutoff + }); + })); + + return router; +}; diff --git a/dashcaddy-api/src/app.js b/dashcaddy-api/src/app.js index 834ded4..ca751ef 100644 --- a/dashcaddy-api/src/app.js +++ b/dashcaddy-api/src/app.js @@ -93,6 +93,7 @@ const eventsRoutes = require('../routes/events'); const workflowsRoutes = require('../routes/workflows'); const dependenciesRoutes = require('../routes/dependencies'); const securityRoutes = require('../routes/security'); +const logInsightsRoutes = require('../routes/log-insights'); const billingRoutes = require('../routes/billing'); const DependencyManager = require('./managers/dependency-manager'); const autoRestartRoutes = require('../routes/auto-restart'); @@ -753,6 +754,20 @@ async function createApp() { apiRouter.use('/security', securityRoutes({ log: ctx.log, })); + + // Log Insights โ€” plain English activity summary + safe log disposal + apiRouter.use(logInsightsRoutes({ + asyncHandler: ctx.asyncHandler, + ok: ctx.ok, + auditLogger: ctx.auditLogger, + securityEventStore: (function() { + try { + var getStore = require('./security/event-store').getStore; + return getStore(); + } catch (e) { return null; } + })() + })); + apiRouter.use('/dependencies', dependenciesRoutes({ dependencyManager: ctx.dependencyManager, servicesStateManager: ctx.servicesStateManager, diff --git a/status/index.html b/status/index.html index 69cdc65..78aec2a 100644 --- a/status/index.html +++ b/status/index.html @@ -206,6 +206,7 @@ + @@ -950,6 +951,7 @@ + diff --git a/status/js/log-insights.js b/status/js/log-insights.js new file mode 100644 index 0000000..1c31562 --- /dev/null +++ b/status/js/log-insights.js @@ -0,0 +1,184 @@ +// ========== LOG INSIGHTS PANEL ========== +(function() { + injectModal('log-insights-modal', `
+
+

๐Ÿ” Log Insights

+ + +
+ + + + + +
+ + +
+ + +
+ + +
+

Top Visitors

+
+
+ + +
+ +
+ +
+
+
`); + + 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 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); + + async function loadInsights() { + const hours = periodSel.value; + insightsDiv.innerHTML = '
Analyzing logs...
'; + summaryDiv.innerHTML = ''; + ipsDiv.innerHTML = ''; + storageDiv.innerHTML = ''; + + 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 + '
'; + } + } + + 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); + } +})();