From 485ce6df0ea250133c68c3234c9c12c7332449a6 Mon Sep 17 00:00:00 2001 From: TheFlow Date: Sat, 25 Oct 2025 11:46:15 +1300 Subject: [PATCH] feat(audit): comprehensive audit analytics dashboard improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented improvements from AUDIT_ANALYTICS_IMPROVEMENTS.md: 1. Added Service Health (24h) section: - Shows which services are healthy (allowed, no violations) - Green/red status indicators per service - Displays allowed, blocked, and violation counts 2. Added Violations & Blocks (7 days) section: - Long-term view of violations and blocks - Shows only days with issues - Displays "No violations" message when clean - Lists services involved in violations 3. Fixed Timeline Chart with proper time bucketing: - Replaced broken hour-of-day aggregation - Added 3 modes: 6-hourly (24h), Daily (7d), Weekly (4w) - Proper date-based bucketing instead of hour grouping - Interactive mode switching with CSP-compliant event delegation 4. Simplified Recent Decisions table: - Reduced from 50 to 10 most recent decisions - Updated heading to clarify scope All changes are CSP-compliant (no inline styles/handlers, Tailwind only). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- public/admin/audit-analytics.html | 36 +++- public/js/admin/audit-analytics.js | 257 ++++++++++++++++++++++++++--- 2 files changed, 270 insertions(+), 23 deletions(-) diff --git a/public/admin/audit-analytics.html b/public/admin/audit-analytics.html index c62fa140..2affe497 100644 --- a/public/admin/audit-analytics.html +++ b/public/admin/audit-analytics.html @@ -145,9 +145,41 @@ + +
+

Service Health (Last 24 Hours)

+
+ +
+
+ + +
+

Violations & Blocks (Last 7 Days)

+
+ +
+
+
-

Decisions Over Time

+
+

Decisions Over Time

+
+ + + +
+
@@ -156,7 +188,7 @@
-

Recent Decisions

+

Recent Decisions (Last 10)

diff --git a/public/js/admin/audit-analytics.js b/public/js/admin/audit-analytics.js index ebc2ade6..332342c7 100644 --- a/public/js/admin/audit-analytics.js +++ b/public/js/admin/audit-analytics.js @@ -66,8 +66,9 @@ function renderDashboard() { updateSummaryCards(); renderActionChart(); renderServiceChart(); + renderServiceHealth24h(); + renderViolations7days(); renderTimelineChart(); - renderAuditTable(); } // Update summary cards @@ -183,6 +184,162 @@ function renderServiceChart() { chartEl.innerHTML = html; setProgressBarWidths(chartEl); } +// Render service health (24h) +function renderServiceHealth24h() { + const chartEl = document.getElementById('service-health-24h'); + + // Filter last 24 hours + const now = new Date(); + const twentyFourHoursAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); + const last24h = auditData.filter(d => new Date(d.timestamp) >= twentyFourHoursAgo); + + if (last24h.length === 0) { + chartEl.innerHTML = '

No data in last 24 hours

'; + return; + } + + // Group by service + const serviceStats = {}; + last24h.forEach(decision => { + const service = decision.service || 'unknown'; + if (!serviceStats[service]) { + serviceStats[service] = { + allowed: 0, + blocked: 0, + violations: 0 + }; + } + + if (decision.allowed) { + serviceStats[service].allowed++; + } else { + serviceStats[service].blocked++; + } + + if (decision.violations && decision.violations.length > 0) { + serviceStats[service].violations += decision.violations.length; + } + }); + + const html = Object.entries(serviceStats).map(([service, stats]) => { + const isHealthy = stats.blocked === 0 && stats.violations === 0; + const bgColor = isHealthy ? 'bg-green-50' : 'bg-red-50'; + const borderColor = isHealthy ? 'border-green-200' : 'border-red-200'; + const icon = isHealthy ? '✓' : '⚠'; + const iconColor = isHealthy ? 'text-green-600' : 'text-red-600'; + + return ` +
+
+ ${service === 'unknown' ? 'Unknown' : service} + ${icon} +
+
+

✓ Allowed: ${stats.allowed}

+ ${stats.blocked > 0 ? `

✗ Blocked: ${stats.blocked}

` : ''} + ${stats.violations > 0 ? `

âš  Violations: ${stats.violations}

` : ''} +
+
+ `; + }).join(''); + + chartEl.innerHTML = html; +} + +// Render violations (7 days) +function renderViolations7days() { + const chartEl = document.getElementById('violations-7days'); + + // Filter last 7 days + const now = new Date(); + const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + const last7days = auditData.filter(d => new Date(d.timestamp) >= sevenDaysAgo); + + // Group by day + const dayStats = {}; + last7days.forEach(decision => { + const date = new Date(decision.timestamp); + const dayKey = date.toISOString().split('T')[0]; // YYYY-MM-DD + + if (!dayStats[dayKey]) { + dayStats[dayKey] = { + date: date, + total: 0, + blocked: 0, + violations: 0, + services: new Set() + }; + } + + dayStats[dayKey].total++; + if (!decision.allowed) { + dayStats[dayKey].blocked++; + } + if (decision.violations && decision.violations.length > 0) { + dayStats[dayKey].violations += decision.violations.length; + } + if (decision.service && decision.service !== 'unknown') { + dayStats[dayKey].services.add(decision.service); + } + }); + + // Check if there are any violations or blocks + const hasIssues = Object.values(dayStats).some(stats => stats.blocked > 0 || stats.violations > 0); + + if (!hasIssues) { + chartEl.innerHTML = ` +
+

✓ No violations or blocks in the last 7 days

+

All governance decisions passed successfully

+
+ `; + return; + } + + // Show only days with issues + const daysWithIssues = Object.entries(dayStats) + .filter(([, stats]) => stats.blocked > 0 || stats.violations > 0) + .sort((a, b) => b[1].date - a[1].date); // Most recent first + + const html = daysWithIssues.map(([dayKey, stats]) => { + const dayLabel = stats.date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }); + const servicesInvolved = Array.from(stats.services).join(', '); + + return ` +
+

${dayLabel}

+
+

Total: ${stats.total} decisions

+ ${stats.blocked > 0 ? `

✗ Blocked: ${stats.blocked}

` : ''} + ${stats.violations > 0 ? `

âš  Violations: ${stats.violations}

` : ''} + ${servicesInvolved ? `

Services: ${servicesInvolved}

` : ''} +
+
+ `; + }).join(''); + + chartEl.innerHTML = html; +} + +// Timeline mode state +let timelineMode = 'daily'; + +// Switch timeline mode +function switchTimelineMode(mode) { + timelineMode = mode; + + // Update button styles + document.querySelectorAll('[data-timeline-mode]').forEach(btn => { + if (btn.dataset.timelineMode === mode) { + btn.className = 'px-3 py-1 text-sm rounded bg-purple-600 text-white hover:bg-purple-700 transition'; + } else { + btn.className = 'px-3 py-1 text-sm rounded bg-gray-200 text-gray-700 hover:bg-gray-300 transition'; + } + }); + + renderTimelineChart(); +} + // Render timeline chart function renderTimelineChart() { const chartEl = document.getElementById('timeline-chart'); @@ -192,40 +349,96 @@ function renderTimelineChart() { return; } - // Group by hour - const hourCounts = {}; + const now = new Date(); + let buckets = []; + let filteredData = []; - auditData.forEach(decision => { - const date = new Date(decision.timestamp); - const hour = `${date.getHours()}:00`; - hourCounts[hour] = (hourCounts[hour] || 0) + 1; + if (timelineMode === '6hourly') { + // Last 24 hours in 6-hour buckets + const twentyFourHoursAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); + filteredData = auditData.filter(d => new Date(d.timestamp) >= twentyFourHoursAgo); + + // Create 4 buckets: 0-6h, 6-12h, 12-18h, 18-24h ago + for (let i = 3; i >= 0; i--) { + const bucketEnd = new Date(now.getTime() - i * 6 * 60 * 60 * 1000); + const bucketStart = new Date(bucketEnd.getTime() - 6 * 60 * 60 * 1000); + buckets.push({ + label: i === 0 ? 'Last 6h' : `${i * 6}-${(i + 1) * 6}h ago`, + start: bucketStart, + end: bucketEnd, + count: 0 + }); + } + } else if (timelineMode === 'daily') { + // Last 7 days + const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + filteredData = auditData.filter(d => new Date(d.timestamp) >= sevenDaysAgo); + + // Create 7 daily buckets + for (let i = 6; i >= 0; i--) { + const bucketEnd = new Date(now); + bucketEnd.setHours(23, 59, 59, 999); + bucketEnd.setDate(bucketEnd.getDate() - i); + + const bucketStart = new Date(bucketEnd); + bucketStart.setHours(0, 0, 0, 0); + + buckets.push({ + label: bucketStart.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), + start: bucketStart, + end: bucketEnd, + count: 0 + }); + } + } else if (timelineMode === 'weekly') { + // Last 4 weeks + const fourWeeksAgo = new Date(now.getTime() - 28 * 24 * 60 * 60 * 1000); + filteredData = auditData.filter(d => new Date(d.timestamp) >= fourWeeksAgo); + + // Create 4 weekly buckets + for (let i = 3; i >= 0; i--) { + const bucketEnd = new Date(now.getTime() - i * 7 * 24 * 60 * 60 * 1000); + const bucketStart = new Date(bucketEnd.getTime() - 7 * 24 * 60 * 60 * 1000); + buckets.push({ + label: `Week ${4 - i}`, + start: bucketStart, + end: bucketEnd, + count: 0 + }); + } + } + + // Count decisions in each bucket + filteredData.forEach(decision => { + const timestamp = new Date(decision.timestamp); + for (const bucket of buckets) { + if (timestamp >= bucket.start && timestamp <= bucket.end) { + bucket.count++; + break; + } + } }); - const sorted = Object.entries(hourCounts).sort((a, b) => { - const hourA = parseInt(a[0]); - const hourB = parseInt(b[0]); - return hourA - hourB; - }); + const maxCount = Math.max(...buckets.map(b => b.count), 1); - const maxCount = Math.max(...sorted.map(([, count]) => count)); - - const html = sorted.map(([hour, count]) => { - const percentage = (count / maxCount) * 100; + const html = buckets.map(bucket => { + const percentage = (bucket.count / maxCount) * 100; const barHeight = Math.max(percentage, 5); return `
-
+ title="${bucket.label}: ${bucket.count} decisions">
- ${hour} + ${bucket.label} `; }).join(''); - chartEl.innerHTML = `
${html}
`; setProgressBarWidths(chartEl); + chartEl.innerHTML = `
${html}
`; + setProgressBarWidths(chartEl); } // Render audit table @@ -237,7 +450,7 @@ function renderAuditTable() { return; } - const recent = auditData.slice(0, 50); + const recent = auditData.slice(0, 10); const html = recent.map(decision => { const timestamp = new Date(decision.timestamp).toLocaleString(); @@ -327,5 +540,7 @@ document.addEventListener('click', (e) => { if (action === 'showDecisionDetails') { showDecisionDetails(arg0); + } else if (action === 'switchTimelineMode') { + switchTimelineMode(arg0); } });