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:
parent
dca0e46bca
commit
485ce6df0e
2 changed files with 270 additions and 23 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue