feat: Session 3 - Audit analytics dashboard

Created comprehensive audit analytics dashboard for monitoring governance
decisions from MemoryProxy audit trail.

Features:
- Real-time dashboard with summary metrics
- Decisions by action type (bar chart)
- Timeline visualization (hourly distribution)
- Recent decisions table with filtering
- Apache 2.0 licensed

Components:
- Frontend: /admin/audit-analytics.html
- JavaScript: /js/admin/audit-analytics.js
- Backend API: /api/admin/audit-logs
- Backend API: /api/admin/audit-analytics

Metrics Displayed:
- Total decisions count
- Allowed rate percentage
- Violations count
- Active services count

Visualizations:
- Action type distribution
- Timeline (decisions over time)
- Recent decisions log (last 50)

Session 3 Achievement: Advanced monitoring and insights for governance framework

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
TheFlow 2025-10-10 13:05:14 +13:00
parent bc8dd68822
commit ec12ba4d71
5 changed files with 597 additions and 0 deletions

View file

@ -0,0 +1,176 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Audit Analytics | Tractatus Admin</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="/css/tailwind.css">
<style>
html { scroll-behavior: smooth; }
.metric-card {
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.metric-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.chart-container {
position: relative;
height: 300px;
}
.log-entry {
transition: background-color 0.2s ease;
}
.log-entry:hover {
background-color: #f3f4f6;
}
</style>
</head>
<body class="bg-gray-50">
<!-- Navigation -->
<script src="/js/components/navbar.js"></script>
<!-- Page Header -->
<div class="bg-white border-b border-gray-200">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-gray-900">Audit Analytics</h1>
<p class="text-gray-600 mt-2">Governance decision monitoring and insights</p>
</div>
<div class="flex items-center gap-4">
<button id="refresh-btn" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition">
<svg class="w-5 h-5 inline-block mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
Refresh
</button>
</div>
</div>
</div>
</div>
<!-- Main Content -->
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Summary Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<!-- Total Decisions -->
<div class="metric-card bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 font-medium">Total Decisions</p>
<p id="total-decisions" class="text-3xl font-bold text-gray-900 mt-2">-</p>
</div>
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
</div>
</div>
<!-- Allowed Rate -->
<div class="metric-card bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 font-medium">Allowed Rate</p>
<p id="allowed-rate" class="text-3xl font-bold text-green-600 mt-2">-</p>
</div>
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
</div>
</div>
<!-- Violations -->
<div class="metric-card bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 font-medium">Violations</p>
<p id="violations-count" class="text-3xl font-bold text-red-600 mt-2">-</p>
</div>
<div class="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
</div>
</div>
</div>
<!-- Services Active -->
<div class="metric-card bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 font-medium">Services Active</p>
<p id="services-count" class="text-3xl font-bold text-purple-600 mt-2">-</p>
</div>
<div class="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>
</svg>
</div>
</div>
</div>
</div>
<!-- Charts Row -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<!-- Decisions by Action Type -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Decisions by Action Type</h3>
<div id="action-chart" class="chart-container">
<!-- Chart will be rendered here -->
</div>
</div>
<!-- Decisions Over Time -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Decisions Over Time</h3>
<div id="timeline-chart" class="chart-container">
<!-- Chart will be rendered here -->
</div>
</div>
</div>
<!-- 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>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Timestamp</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Action</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Session</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Violations</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Details</th>
</tr>
</thead>
<tbody id="audit-log-tbody" class="bg-white divide-y divide-gray-200">
<tr>
<td colspan="6" class="px-6 py-4 text-center text-gray-500">Loading...</td>
</tr>
</tbody>
</table>
</div>
</div>
</main>
<script src="/js/admin/audit-analytics.js"></script>
</body>
</html>

View file

@ -0,0 +1,198 @@
/**
* Audit Analytics Dashboard
* Displays governance decision analytics from MemoryProxy audit trail
*/
let auditData = [];
// Load audit data from API
async function loadAuditData() {
try {
const response = await fetch('/api/admin/audit-logs');
const data = await response.json();
if (data.success) {
auditData = data.decisions || [];
renderDashboard();
} else {
showError('Failed to load audit data: ' + (data.error || 'Unknown error'));
}
} catch (error) {
console.error('Error loading audit data:', error);
showError('Error loading audit data. Please check console for details.');
}
}
// Render dashboard
function renderDashboard() {
updateSummaryCards();
renderActionChart();
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.action));
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;
}
// 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" style="width: ${percentage}%"></div>
</div>
</div>
`;
}).join('');
chartEl.innerHTML = html;
}
// 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;
}
// Group by hour
const hourCounts = {};
auditData.forEach(decision => {
const date = new Date(decision.timestamp);
const hour = `${date.getHours()}:00`;
hourCounts[hour] = (hourCounts[hour] || 0) + 1;
});
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(...sorted.map(([, count]) => count));
const html = sorted.map(([hour, count]) => {
const percentage = (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"
style="height: ${barHeight}%"
title="${hour}: ${count} decisions"></div>
</div>
<span class="text-xs text-gray-600 mt-2">${hour}</span>
</div>
`;
}).join('');
chartEl.innerHTML = `<div class="flex items-end gap-2 h-full">${html}</div>`;
}
// 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, 50);
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" onclick="showDecisionDetails('${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>`;
}
// Refresh button
document.getElementById('refresh-btn')?.addEventListener('click', loadAuditData);
// Initialize
loadAuditData();

View file

@ -0,0 +1,189 @@
/*
* Copyright 2025 John G Stroh
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Audit Controller
* Serves audit logs from MemoryProxy for analytics dashboard
*/
const fs = require('fs').promises;
const path = require('path');
const logger = require('../utils/logger.util');
/**
* Get audit logs for analytics
* GET /api/admin/audit-logs
*/
async function getAuditLogs(req, res) {
try {
const { days = 7, limit = 1000 } = req.query;
// Calculate date range
const today = new Date();
const startDate = new Date(today);
startDate.setDate(today.getDate() - parseInt(days));
// Read audit files
const auditDir = path.join(__dirname, '../../.memory/audit');
const decisions = [];
// Get all audit files in date range
const files = await fs.readdir(auditDir);
const auditFiles = files.filter(f => f.startsWith('decisions-') && f.endsWith('.jsonl'));
for (const file of auditFiles) {
const filePath = path.join(auditDir, file);
const content = await fs.readFile(filePath, 'utf8');
// Parse JSONL (one JSON object per line)
const lines = content.trim().split('\n');
for (const line of lines) {
if (!line.trim()) continue;
try {
const decision = JSON.parse(line);
const decisionDate = new Date(decision.timestamp);
if (decisionDate >= startDate) {
decisions.push(decision);
}
} catch (parseError) {
logger.error('Error parsing audit line:', parseError);
}
}
}
// Sort by timestamp (most recent first)
decisions.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
// Apply limit
const limited = decisions.slice(0, parseInt(limit));
res.json({
success: true,
decisions: limited,
total: decisions.length,
limited: limited.length,
dateRange: {
start: startDate.toISOString(),
end: today.toISOString()
}
});
} catch (error) {
logger.error('Error fetching audit logs:', error);
res.status(500).json({
success: false,
error: error.message
});
}
}
/**
* Get audit analytics summary
* GET /api/admin/audit-analytics
*/
async function getAuditAnalytics(req, res) {
try {
const { days = 7 } = req.query;
// Get audit logs
const auditLogsResponse = await getAuditLogs(req, { json: (data) => data });
const decisions = auditLogsResponse.decisions;
// Calculate analytics
const analytics = {
total: decisions.length,
allowed: decisions.filter(d => d.allowed).length,
blocked: decisions.filter(d => !d.allowed).length,
violations: decisions.filter(d => d.violations && d.violations.length > 0).length,
byAction: {},
bySession: {},
byDate: {},
timeline: [],
topViolations: [],
dateRange: auditLogsResponse.dateRange
};
// Group by action
decisions.forEach(d => {
const action = d.action || 'unknown';
analytics.byAction[action] = (analytics.byAction[action] || 0) + 1;
});
// Group by session
decisions.forEach(d => {
const session = d.sessionId || 'unknown';
analytics.bySession[session] = (analytics.bySession[session] || 0) + 1;
});
// Group by date
decisions.forEach(d => {
const date = new Date(d.timestamp).toISOString().split('T')[0];
analytics.byDate[date] = (analytics.byDate[date] || 0) + 1;
});
// Timeline (last 24 hours by hour)
const hourCounts = {};
decisions.forEach(d => {
const hour = new Date(d.timestamp).getHours();
hourCounts[hour] = (hourCounts[hour] || 0) + 1;
});
for (let i = 0; i < 24; i++) {
analytics.timeline.push({
hour: i,
count: hourCounts[i] || 0
});
}
// Top violations
const violationCounts = {};
decisions.forEach(d => {
if (d.violations && d.violations.length > 0) {
d.violations.forEach(v => {
violationCounts[v] = (violationCounts[v] || 0) + 1;
});
}
});
analytics.topViolations = Object.entries(violationCounts)
.map(([violation, count]) => ({ violation, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 10);
res.json({
success: true,
analytics
});
} catch (error) {
logger.error('Error calculating audit analytics:', error);
res.status(500).json({
success: false,
error: error.message
});
}
}
module.exports = {
getAuditLogs,
getAuditAnalytics
};

View file

@ -0,0 +1,32 @@
/*
* Copyright 2025 John G Stroh
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Audit Routes
* API endpoints for audit log analytics
*/
const express = require('express');
const router = express.Router();
const auditController = require('../controllers/audit.controller');
// Get audit logs
router.get('/audit-logs', auditController.getAuditLogs);
// Get audit analytics
router.get('/audit-analytics', auditController.getAuditAnalytics);
module.exports = router;

View file

@ -13,6 +13,7 @@ const blogRoutes = require('./blog.routes');
const mediaRoutes = require('./media.routes');
const casesRoutes = require('./cases.routes');
const adminRoutes = require('./admin.routes');
const auditRoutes = require('./audit.routes');
const governanceRoutes = require('./governance.routes');
const kohaRoutes = require('./koha.routes');
@ -23,6 +24,7 @@ router.use('/blog', blogRoutes);
router.use('/media', mediaRoutes);
router.use('/cases', casesRoutes);
router.use('/admin', adminRoutes);
router.use('/admin', auditRoutes);
router.use('/governance', governanceRoutes);
router.use('/koha', kohaRoutes);