feat(audit): comprehensive audit analytics dashboard improvements

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 <noreply@anthropic.com>
This commit is contained in:
TheFlow 2025-10-25 11:46:15 +13:00
parent f6963de4c6
commit eec4686d22
2 changed files with 270 additions and 23 deletions

View file

@ -145,9 +145,41 @@
</div>
</div>
<!-- Service Health (24h) -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-8">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Service Health (Last 24 Hours)</h3>
<div id="service-health-24h" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<!-- Will be populated by JS -->
</div>
</div>
<!-- Violations & Blocks (7 days) -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-8">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Violations & Blocks (Last 7 Days)</h3>
<div id="violations-7days" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<!-- Will be populated by JS -->
</div>
</div>
<!-- Timeline Chart -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-8">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Decisions Over Time</h3>
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900">Decisions Over Time</h3>
<div class="flex gap-2">
<button data-timeline-mode="6hourly" data-action="switchTimelineMode" data-arg0="6hourly"
class="px-3 py-1 text-sm rounded bg-gray-200 text-gray-700 hover:bg-gray-300 transition">
6-Hourly (24h)
</button>
<button data-timeline-mode="daily" data-action="switchTimelineMode" data-arg0="daily"
class="px-3 py-1 text-sm rounded bg-purple-600 text-white hover:bg-purple-700 transition">
Daily (7d)
</button>
<button data-timeline-mode="weekly" data-action="switchTimelineMode" data-arg0="weekly"
class="px-3 py-1 text-sm rounded bg-gray-200 text-gray-700 hover:bg-gray-300 transition">
Weekly (4w)
</button>
</div>
</div>
<div id="timeline-chart" class="chart-container">
<!-- Chart will be rendered here -->
</div>
@ -156,7 +188,7 @@
<!-- Recent Decisions -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
<div class="p-6 border-b border-gray-200">
<h3 class="text-lg font-semibold text-gray-900">Recent Decisions</h3>
<h3 class="text-lg font-semibold text-gray-900">Recent Decisions (Last 10)</h3>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">

View file

@ -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 = '<p class="text-gray-500 text-center py-12 col-span-full">No data in last 24 hours</p>';
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 `
<div class="${bgColor} rounded-lg p-4 border ${borderColor}">
<div class="flex items-start justify-between mb-2">
<span class="text-sm font-semibold text-gray-900">${service === 'unknown' ? 'Unknown' : service}</span>
<span class="${iconColor} text-xl font-bold">${icon}</span>
</div>
<div class="space-y-1 text-sm">
<p class="text-green-700"><strong> Allowed:</strong> ${stats.allowed}</p>
${stats.blocked > 0 ? `<p class="text-red-700"><strong>✗ Blocked:</strong> ${stats.blocked}</p>` : ''}
${stats.violations > 0 ? `<p class="text-orange-700"><strong>⚠ Violations:</strong> ${stats.violations}</p>` : ''}
</div>
</div>
`;
}).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 = `
<div class="bg-green-50 rounded-lg p-6 border border-green-200 col-span-full text-center">
<p class="text-lg font-semibold text-green-900"> No violations or blocks in the last 7 days</p>
<p class="text-sm text-green-700 mt-2">All governance decisions passed successfully</p>
</div>
`;
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 `
<div class="bg-red-50 rounded-lg p-4 border border-red-200">
<p class="text-sm font-semibold text-gray-900 mb-2">${dayLabel}</p>
<div class="space-y-1 text-sm">
<p class="text-gray-700"><strong>Total:</strong> ${stats.total} decisions</p>
${stats.blocked > 0 ? `<p class="text-red-700"><strong>✗ Blocked:</strong> ${stats.blocked}</p>` : ''}
${stats.violations > 0 ? `<p class="text-orange-700"><strong>⚠ Violations:</strong> ${stats.violations}</p>` : ''}
${servicesInvolved ? `<p class="text-xs text-gray-600 mt-2"><strong>Services:</strong> ${servicesInvolved}</p>` : ''}
</div>
</div>
`;
}).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 `
<div class="flex flex-col items-center flex-1">
<div class="w-full flex items-end justify-center h-48">
<div class="w-8 bg-purple-600 rounded-t transition-all duration-300 hover:bg-purple-700"
<div class="w-full max-w-16 bg-purple-600 rounded-t transition-all duration-300 hover:bg-purple-700"
data-height="${barHeight}"
title="${hour}: ${count} decisions"></div>
title="${bucket.label}: ${bucket.count} decisions"></div>
</div>
<span class="text-xs text-gray-600 mt-2">${hour}</span>
<span class="text-xs text-gray-600 mt-2 text-center">${bucket.label}</span>
</div>
`;
}).join('');
chartEl.innerHTML = `<div class="flex items-end gap-2 h-full">${html}</div>`; setProgressBarWidths(chartEl);
chartEl.innerHTML = `<div class="flex items-end gap-2 h-full">${html}</div>`;
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);
}
});