tractatus/public/js/admin/audit-analytics.js
TheFlow 6a9c02cfd8 fix(audit): add missing renderAuditTable() call to renderDashboard()
The Recent Decisions table was not loading because renderAuditTable()
was not being called in the renderDashboard() function.

Added renderAuditTable() call to ensure the table renders with the
10 most recent decisions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-25 11:50:54 +13:00

547 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();
renderAuditTable();
}
// 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);
}
});