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>
547 lines
18 KiB
JavaScript
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);
|
|
}
|
|
});
|