tractatus/public/js/admin/audit-analytics.js
TheFlow eec4686d22 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>
2025-10-25 11:46:15 +13:00

546 lines
18 KiB
JavaScript

/**
* Audit Analytics Dashboard
* Displays governance decision analytics from MemoryProxy audit trail
*/
let auditData = [];
// Get auth token from localStorage
function getAuthToken() {
return localStorage.getItem('admin_token');
}
// Check authentication
function checkAuth() {
const token = getAuthToken();
if (!token) {
window.location.href = '/admin/login.html';
return false;
}
return true;
}
// Load audit data from API
async function loadAuditData() {
console.log('[Audit Analytics] Loading audit data...');
try {
const token = getAuthToken();
console.log('[Audit Analytics] Token:', token ? 'Present' : 'Missing');
const response = await fetch('/api/admin/audit-logs?days=30', {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
console.log('[Audit Analytics] Response status:', response.status);
if (response.status === 401) {
console.log('[Audit Analytics] Unauthorized - redirecting to login');
localStorage.removeItem('admin_token');
localStorage.removeItem('admin_user');
window.location.href = '/admin/login.html';
return;
}
const data = await response.json();
console.log('[Audit Analytics] Data received:', data);
if (data.success) {
auditData = data.decisions || [];
console.log('[Audit Analytics] Audit data loaded:', auditData.length, 'decisions');
renderDashboard();
} else {
console.error('[Audit Analytics] API returned error:', data.error);
showError('Failed to load audit data: ' + (data.error || 'Unknown error'));
}
} catch (error) {
console.error('[Audit Analytics] Error loading audit data:', error);
showError('Error loading audit data. Please check console for details.');
}
}
// Render dashboard
function renderDashboard() {
updateSummaryCards();
renderActionChart();
renderServiceChart();
renderServiceHealth24h();
renderViolations7days();
renderTimelineChart();
}
// Update summary cards
function updateSummaryCards() {
const totalDecisions = auditData.length;
const allowedCount = auditData.filter(d => d.allowed).length;
const violationsCount = auditData.filter(d => d.violations && d.violations.length > 0).length;
const servicesSet = new Set(auditData.map(d => d.service).filter(s => s && s !== 'unknown'));
document.getElementById('total-decisions').textContent = totalDecisions;
document.getElementById('allowed-rate').textContent = totalDecisions > 0
? `${((allowedCount / totalDecisions) * 100).toFixed(1)}%`
: '0%';
document.getElementById('violations-count').textContent = violationsCount;
document.getElementById('services-count').textContent = servicesSet.size || 0;
}
// Render action type chart
function renderActionChart() {
const actionCounts = {};
auditData.forEach(decision => {
const action = decision.action || 'unknown';
actionCounts[action] = (actionCounts[action] || 0) + 1;
});
const chartEl = document.getElementById('action-chart');
if (Object.keys(actionCounts).length === 0) {
chartEl.innerHTML = '<p class="text-gray-500 text-center py-12">No data available</p>';
return;
}
const sorted = Object.entries(actionCounts)
.sort((a, b) => b[1] - a[1])
.slice(0, 10);
const maxCount = Math.max(...sorted.map(([, count]) => count));
const html = sorted.map(([action, count]) => {
const percentage = (count / maxCount) * 100;
const label = action.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
return `
<div class="mb-4">
<div class="flex items-center justify-between mb-1">
<span class="text-sm font-medium text-gray-700">${label}</span>
<span class="text-sm text-gray-600">${count}</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div class="bg-blue-600 h-2 rounded-full transition-all duration-300" data-width="${percentage}"></div>
</div>
</div>
`;
}).join('');
chartEl.innerHTML = html; setProgressBarWidths(chartEl);
}
// Render service chart
function renderServiceChart() {
const serviceCounts = {};
auditData.forEach(decision => {
const service = decision.service || 'unknown';
serviceCounts[service] = (serviceCounts[service] || 0) + 1;
});
const chartEl = document.getElementById('service-chart');
if (Object.keys(serviceCounts).length === 0) {
chartEl.innerHTML = '<p class="text-gray-500 text-center py-12">No data available</p>';
return;
}
const sorted = Object.entries(serviceCounts)
.sort((a, b) => b[1] - a[1]);
const maxCount = Math.max(...sorted.map(([, count]) => count));
// Color palette for services
const colors = [
'bg-blue-600',
'bg-green-600',
'bg-purple-600',
'bg-orange-600',
'bg-pink-600',
'bg-indigo-600',
'bg-red-600',
'bg-yellow-600'
];
const html = sorted.map(([service, count], index) => {
const percentage = (count / maxCount) * 100;
// Ensure minimum 8% width so all bars are visible
const displayPercentage = Math.max(percentage, 8);
const color = colors[index % colors.length];
const label = service === 'unknown' ? 'Unknown' : service;
return `
<div class="mb-4">
<div class="flex items-center justify-between mb-1">
<span class="text-sm font-medium text-gray-700">${label}</span>
<span class="text-sm text-gray-600">${count}</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div class="${color} h-2 rounded-full transition-all duration-300" data-width="${displayPercentage}"></div>
</div>
</div>
`;
}).join('');
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');
if (auditData.length === 0) {
chartEl.innerHTML = '<p class="text-gray-500 text-center py-12">No data available</p>';
return;
}
const now = new Date();
let buckets = [];
let filteredData = [];
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 maxCount = Math.max(...buckets.map(b => b.count), 1);
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-full max-w-16 bg-purple-600 rounded-t transition-all duration-300 hover:bg-purple-700"
data-height="${barHeight}"
title="${bucket.label}: ${bucket.count} decisions"></div>
</div>
<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);
}
// Render audit table
function renderAuditTable() {
const tbody = document.getElementById('audit-log-tbody');
if (auditData.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="px-6 py-4 text-center text-gray-500">No audit data available</td></tr>';
return;
}
const recent = auditData.slice(0, 10);
const html = recent.map(decision => {
const timestamp = new Date(decision.timestamp).toLocaleString();
const action = decision.action || 'Unknown';
const sessionId = decision.sessionId || 'N/A';
const allowed = decision.allowed;
const violations = decision.violations || [];
const statusClass = allowed ? 'text-green-600 bg-green-100' : 'text-red-600 bg-red-100';
const statusText = allowed ? 'Allowed' : 'Blocked';
const violationsText = violations.length > 0
? violations.join(', ')
: 'None';
return `
<tr class="log-entry cursor-pointer" data-action="showDecisionDetails" data-arg0="${decision.timestamp}">
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">${timestamp}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">${action}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">${sessionId.substring(0, 20)}...</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 py-1 text-xs font-semibold rounded-full ${statusClass}">${statusText}</span>
</td>
<td class="px-6 py-4 text-sm text-gray-600">${violationsText.substring(0, 40)}${violationsText.length > 40 ? '...' : ''}</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm">
<button class="text-blue-600 hover:text-blue-800 font-medium">View</button>
</td>
</tr>
`;
}).join('');
tbody.innerHTML = html;
}
// Show decision details
function showDecisionDetails(timestamp) {
const decision = auditData.find(d => d.timestamp === timestamp);
if (!decision) return;
alert(`Decision Details:\n\n${JSON.stringify(decision, null, 2)}`);
}
// Show error
function showError(message) {
const tbody = document.getElementById('audit-log-tbody');
tbody.innerHTML = `<tr><td colspan="6" class="px-6 py-4 text-center text-red-600">${message}</td></tr>`;
}
// Initialize
function init() {
if (!checkAuth()) return;
// Setup refresh button
const refreshBtn = document.getElementById('refresh-btn');
if (refreshBtn) {
console.log('[Audit Analytics] Refresh button found, attaching event listener');
refreshBtn.addEventListener('click', () => {
console.log('[Audit Analytics] Refresh button clicked, loading data...');
loadAuditData();
});
} else {
console.error('[Audit Analytics] Refresh button not found!');
}
// Load initial data
loadAuditData();
}
// Run initialization
init();
// Set widths/heights from data attributes (CSP compliance)
function setProgressBarWidths(container) {
const elements = container.querySelectorAll('[data-width], [data-height]');
elements.forEach(el => {
if (el.dataset.width) el.style.width = el.dataset.width + '%';
if (el.dataset.height) el.style.height = el.dataset.height + '%';
});
}
// Event delegation for data-action buttons (CSP compliance)
document.addEventListener('click', (e) => {
const button = e.target.closest('[data-action]');
if (!button) return;
const action = button.dataset.action;
const arg0 = button.dataset.arg0;
if (action === 'showDecisionDetails') {
showDecisionDetails(arg0);
} else if (action === 'switchTimelineMode') {
switchTimelineMode(arg0);
}
});