diff --git a/public/admin/audit-analytics.html b/public/admin/audit-analytics.html new file mode 100644 index 00000000..1e0de208 --- /dev/null +++ b/public/admin/audit-analytics.html @@ -0,0 +1,176 @@ + + + + + + Audit Analytics | Tractatus Admin + + + + + + + + + + +
+
+
+
+

Audit Analytics

+

Governance decision monitoring and insights

+
+
+ +
+
+
+
+ + +
+ + +
+ +
+
+
+

Total Decisions

+

-

+
+
+ + + +
+
+
+ + +
+
+
+

Allowed Rate

+

-

+
+
+ + + +
+
+
+ + +
+
+
+

Violations

+

-

+
+
+ + + +
+
+
+ + +
+
+
+

Services Active

+

-

+
+
+ + + +
+
+
+
+ + +
+ +
+

Decisions by Action Type

+
+ +
+
+ + +
+

Decisions Over Time

+
+ +
+
+
+ + +
+
+

Recent Decisions

+
+
+ + + + + + + + + + + + + + + + +
TimestampActionSessionStatusViolationsDetails
Loading...
+
+
+ +
+ + + + + diff --git a/public/js/admin/audit-analytics.js b/public/js/admin/audit-analytics.js new file mode 100644 index 00000000..82d6b494 --- /dev/null +++ b/public/js/admin/audit-analytics.js @@ -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 = '

No data available

'; + 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 ` +
+
+ ${label} + ${count} +
+
+
+
+
+ `; + }).join(''); + + chartEl.innerHTML = html; +} + +// Render timeline chart +function renderTimelineChart() { + const chartEl = document.getElementById('timeline-chart'); + + if (auditData.length === 0) { + chartEl.innerHTML = '

No data available

'; + 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 ` +
+
+
+
+ ${hour} +
+ `; + }).join(''); + + chartEl.innerHTML = `
${html}
`; +} + +// Render audit table +function renderAuditTable() { + const tbody = document.getElementById('audit-log-tbody'); + + if (auditData.length === 0) { + tbody.innerHTML = 'No audit data available'; + 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 ` + + ${timestamp} + ${action} + ${sessionId.substring(0, 20)}... + + ${statusText} + + ${violationsText.substring(0, 40)}${violationsText.length > 40 ? '...' : ''} + + + + + `; + }).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 = `${message}`; +} + +// Refresh button +document.getElementById('refresh-btn')?.addEventListener('click', loadAuditData); + +// Initialize +loadAuditData(); diff --git a/src/controllers/audit.controller.js b/src/controllers/audit.controller.js new file mode 100644 index 00000000..236b3642 --- /dev/null +++ b/src/controllers/audit.controller.js @@ -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 +}; diff --git a/src/routes/audit.routes.js b/src/routes/audit.routes.js new file mode 100644 index 00000000..450c8348 --- /dev/null +++ b/src/routes/audit.routes.js @@ -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; diff --git a/src/routes/index.js b/src/routes/index.js index 1632bf37..23c4c6d4 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -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);