tractatus/public/js/admin/audit-analytics.js
TheFlow 35be6a797d fix(audit): remove duplicated block count from Activity Type Analysis badge
Changed badge from showing '5 blocks' (duplicates text) to showing risk level:
- Clean (0 blocks)
- Low Risk (<5% block rate)
- Medium Risk (5-10% block rate)
- High Risk (≥10% block rate)

Provides more useful information without redundancy
2025-10-28 12:25:14 +13:00

1537 lines
56 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Audit Analytics Dashboard
* Displays governance decision analytics from MemoryProxy audit trail
*/
let auditData = [];
// Rule descriptions for tooltips and legend
const RULE_DESCRIPTIONS = {
'inst_072': {
title: 'Defense-in-Depth Credential Protection',
description: 'Multi-layer security protection for credentials and sensitive data. Prevents credential leaks through 5 layers: prevention, mitigation, detection, backstop, and recovery.'
},
'inst_084': {
title: 'GitHub Repository URL Protection',
description: 'Hard block on GitHub URL modifications. Prevents accidental exposure of private repository structure or incorrect links.'
},
'inst_038': {
title: 'Pre-Action Validation Required',
description: 'Mandatory pre-action checks before file modifications. Validates context pressure, instruction history, token checkpoints, and CSP compliance.'
},
'inst_008': {
title: 'Content Security Policy (CSP) Compliance',
description: 'Enforces strict CSP rules to prevent XSS attacks. No inline scripts or styles allowed in HTML/JS files.'
},
'inst_016': {
title: 'Prohibited Terms: "Guardrails"',
description: 'Bans "guardrails" terminology as conceptually inadequate for AI safety. Use "governance framework" instead.'
},
'inst_017': {
title: 'Prohibited Terms: "Alignment"',
description: 'Bans single "alignment" in favor of explicit pluralistic deliberation. Prevents false consensus assumptions.'
},
'inst_018': {
title: 'Prohibited Terms: "Safety Measures"',
description: 'Bans vague "safety measures" language. Requires specific, verifiable safety mechanisms.'
}
};
// 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');
// Build query parameters
const environment = document.getElementById('environment-filter')?.value || 'all';
let url = '/api/admin/audit-logs?days=30';
if (environment !== 'all') {
url += `&environment=${environment}`;
}
console.log('[Audit Analytics] Fetching from:', url);
const response = await fetch(url, {
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
async function renderDashboard() {
updateSummaryCards();
await renderBusinessIntelligence();
renderFrameworkSaves();
renderBlockReasons();
renderSeverityBreakdown();
renderActionChart();
renderServiceChart();
renderServiceHealth24h();
renderViolations7days();
renderTimelineChart();
renderAuditTable();
}
// Update summary cards
function updateSummaryCards() {
const totalDecisions = auditData.length;
const allowedCount = auditData.filter(d => d.allowed).length;
const blockedCount = auditData.filter(d => !d.allowed).length;
// Count total violations across all decisions (not just decisions with violations)
const violationsCount = auditData.reduce((sum, d) => sum + (d.violations ? d.violations.length : 0), 0);
const servicesSet = new Set(auditData.map(d => d.service).filter(s => s && s !== 'unknown'));
document.getElementById('total-decisions').textContent = totalDecisions;
document.getElementById('allowed-count').textContent = allowedCount;
document.getElementById('allowed-rate').textContent = totalDecisions > 0
? `${((allowedCount / totalDecisions) * 100).toFixed(1)}%`
: '0%';
document.getElementById('blocked-count').textContent = blockedCount;
document.getElementById('block-rate').textContent = totalDecisions > 0
? `${((blockedCount / totalDecisions) * 100).toFixed(1)}%`
: '0%';
document.getElementById('violations-count').textContent = violationsCount;
document.getElementById('services-count').textContent = servicesSet.size || 0;
// Environment distribution breakdown
updateEnvironmentDistribution();
// Data quality insights
updateDataInsights(blockedCount, violationsCount);
}
// Update environment distribution display
function updateEnvironmentDistribution() {
const environmentStats = {
development: 0,
production: 0,
unspecified: 0
};
auditData.forEach(d => {
if (d.environment === 'development') {
environmentStats.development++;
} else if (d.environment === 'production') {
environmentStats.production++;
} else {
environmentStats.unspecified++;
}
});
const total = auditData.length;
const statsEl = document.getElementById('environment-stats');
statsEl.innerHTML = `
<div class="text-center p-2 bg-blue-50 border border-blue-200 rounded">
<div class="font-semibold text-blue-700">Development</div>
<div class="text-2xl font-bold text-blue-900">${environmentStats.development}</div>
<div class="text-xs text-blue-600">${total > 0 ? ((environmentStats.development / total) * 100).toFixed(1) : 0}%</div>
</div>
<div class="text-center p-2 bg-green-50 border border-green-200 rounded">
<div class="font-semibold text-green-700">Production</div>
<div class="text-2xl font-bold text-green-900">${environmentStats.production}</div>
<div class="text-xs text-green-600">${total > 0 ? ((environmentStats.production / total) * 100).toFixed(1) : 0}%</div>
</div>
<div class="text-center p-2 bg-amber-50 border border-amber-200 rounded">
<div class="font-semibold text-amber-700">Unspecified</div>
<div class="text-2xl font-bold text-amber-900">${environmentStats.unspecified}</div>
<div class="text-xs text-amber-600">${total > 0 ? ((environmentStats.unspecified / total) * 100).toFixed(1) : 0}%</div>
</div>
`;
// Show warning if there are unspecified records
if (environmentStats.unspecified > 0) {
const warningHTML = `
<div class="col-span-3 mt-2 p-2 bg-amber-50 border border-amber-300 rounded text-xs text-amber-900">
<strong>⚠️ Note:</strong> ${environmentStats.unspecified} record(s) have no environment field.
These are included in "All Environments" but excluded when filtering by Development or Production.
</div>
`;
statsEl.innerHTML += warningHTML;
}
}
// Update data quality insights
function updateDataInsights(blockedCount, violationsCount) {
const insightsEl = document.getElementById('data-insights');
// Check if violations > blocked (indicates some decisions had multiple violations)
if (violationsCount > blockedCount && blockedCount > 0) {
const multipleViolationDecisions = auditData.filter(d =>
!d.allowed && d.violations && d.violations.length > 1
).length;
const avgViolationsPerBlock = (violationsCount / blockedCount).toFixed(1);
insightsEl.innerHTML = `
<div class="bg-blue-50 rounded-lg border-2 border-blue-200 p-4">
<div class="flex items-start">
<div class="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center mr-3 flex-shrink-0">
<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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<div class="flex-1">
<h4 class="text-sm font-semibold text-blue-900 mb-2">
📊 Data Quality Insight: Multiple Violations Per Decision
</h4>
<p class="text-sm text-blue-800 mb-2">
<strong>${violationsCount} violations</strong> occurred across <strong>${blockedCount} blocked decisions</strong>
(${avgViolationsPerBlock} violations per block on average).
</p>
<p class="text-sm text-blue-700">
${multipleViolationDecisions > 0
? `<strong>${multipleViolationDecisions} decision(s)</strong> triggered multiple rule violations simultaneously (e.g., a file with both inline styles AND inline event handlers).`
: 'This indicates violations are being tracked granularly with detailed rule breakdowns.'
}
</p>
<div class="mt-2 text-xs text-blue-600 bg-blue-100 rounded px-2 py-1 inline-block">
✓ This is expected behavior - each specific violation is logged separately for audit trail precision
</div>
</div>
</div>
</div>
`;
} else if (violationsCount === blockedCount && blockedCount > 0) {
insightsEl.innerHTML = `
<div class="bg-green-50 rounded-lg border-2 border-green-200 p-4">
<div class="flex items-start">
<div class="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center mr-3 flex-shrink-0">
<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 class="flex-1">
<h4 class="text-sm font-semibold text-green-900 mb-2">
✓ Data Quality: 1:1 Block-to-Violation Ratio
</h4>
<p class="text-sm text-green-800">
Each blocked decision corresponds to exactly one rule violation. Clean, single-violation blocks indicate precise governance enforcement.
</p>
</div>
</div>
</div>
`;
} else {
// No insights to show
insightsEl.innerHTML = '';
}
}
// Render Business Intelligence
async function renderBusinessIntelligence() {
// Activity Type Breakdown
const byActivityType = {};
auditData.forEach(d => {
const type = d.activityType || 'Unknown';
if (!byActivityType[type]) {
byActivityType[type] = { total: 0, allowed: 0, blocked: 0 };
}
byActivityType[type].total++;
if (d.allowed) {
byActivityType[type].allowed++;
} else {
byActivityType[type].blocked++;
}
});
const activityEl = document.getElementById('activity-type-breakdown');
activityEl.innerHTML = '';
const sortedActivity = Object.entries(byActivityType).sort((a, b) => b[1].blocked - a[1].blocked);
if (sortedActivity.length === 0) {
activityEl.innerHTML = '<p class="text-sm text-gray-500 italic">No activity data available</p>';
} else {
sortedActivity.forEach(([type, data]) => {
const blockRate = (data.blocked / data.total * 100).toFixed(1);
const isHighRisk = data.blocked > 0;
const activityDiv = document.createElement('div');
activityDiv.className = `flex items-center justify-between p-3 rounded-lg border ${isHighRisk ? 'border-orange-200 bg-orange-50' : 'border-gray-200 bg-gray-50'}`;
const leftDiv = document.createElement('div');
leftDiv.innerHTML = `
<div class="font-medium text-gray-900 text-sm">${type}</div>
<div class="text-xs text-gray-600 mt-1">
${data.total} total · ${data.blocked} blocked · ${blockRate}% block rate
</div>
`;
const badge = document.createElement('span');
// Determine risk level based on block rate
const riskLevel = data.blocked === 0 ? 'Clean' :
parseFloat(blockRate) >= 10 ? 'High Risk' :
parseFloat(blockRate) >= 5 ? 'Medium Risk' :
'Low Risk';
const badgeColor = data.blocked === 0 ? 'bg-green-100 text-green-800' :
parseFloat(blockRate) >= 10 ? 'bg-red-100 text-red-800' :
parseFloat(blockRate) >= 5 ? 'bg-orange-100 text-orange-800' :
'bg-yellow-100 text-yellow-800';
badge.className = `px-3 py-1 rounded-full text-xs font-medium ${badgeColor}`;
badge.textContent = riskLevel;
activityDiv.appendChild(leftDiv);
activityDiv.appendChild(badge);
activityEl.appendChild(activityDiv);
});
}
// Cost Avoidance - filter by selected period
const period = document.getElementById('cost-period-selector')?.value || '30';
let filteredData = auditData;
if (period !== 'all') {
const days = parseInt(period);
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - days);
filteredData = auditData.filter(d => new Date(d.timestamp) >= cutoffDate);
}
const blockedDecisions = filteredData.filter(d => !d.allowed);
let totalCost = 0;
const costByLevel = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 };
// Load cost factors from API (fallback to defaults)
const apiCostFactors = await loadCostConfig();
const costFactors = apiCostFactors ? {
CRITICAL: apiCostFactors.CRITICAL.amount,
HIGH: apiCostFactors.HIGH.amount,
MEDIUM: apiCostFactors.MEDIUM.amount,
LOW: apiCostFactors.LOW.amount
} : { CRITICAL: 50000, HIGH: 10000, MEDIUM: 2000, LOW: 500 };
blockedDecisions.forEach(d => {
if (d.violations && d.violations.length > 0) {
d.violations.forEach(v => {
const severity = v.severity || 'LOW';
const cost = costFactors[severity] || 0;
totalCost += cost;
costByLevel[severity] += cost;
});
}
});
document.getElementById('cost-avoidance-total').textContent = `$${totalCost.toLocaleString()}`;
const breakdownEl = document.getElementById('cost-avoidance-breakdown');
breakdownEl.innerHTML = Object.entries(costByLevel)
.filter(([_, cost]) => cost > 0)
.map(([level, cost]) => `<div class="text-gray-600">${level}: $${cost.toLocaleString()}</div>`)
.join('');
// Framework Maturity Score
const recentBlockRate = blockedDecisions.length / Math.max(auditData.length, 1);
const maturityScore = Math.max(0, Math.min(100, Math.round(100 - (recentBlockRate * 500))));
document.getElementById('maturity-score').textContent = maturityScore;
document.getElementById('maturity-trend').innerHTML =
maturityScore > 80 ? '<span class="text-green-600">↑ Excellent</span>' :
maturityScore > 60 ? '<span class="text-blue-600">→ Good</span>' :
'<span class="text-orange-600">↗ Learning</span>';
document.getElementById('maturity-message').textContent =
maturityScore > 80 ? 'Framework teaching good practices' :
maturityScore > 60 ? 'Team adapting well to governance' :
'Framework actively preventing violations';
const progressBar = document.getElementById('maturity-progress');
progressBar.style.width = maturityScore + '%';
// PHASE 3.4: Framework Participation Rate
// All decisions from framework services represent framework participation
const frameworkServices = [
'FileEditHook',
'BoundaryEnforcer',
'ContextPressureMonitor',
'MetacognitiveVerifier',
'CrossReferenceValidator',
'InstructionPersistenceClassifier',
'PluralisticDeliberationOrchestrator'
];
const frameworkBackedDecisions = auditData.filter(d =>
frameworkServices.includes(d.service)
);
const participationRate = auditData.length > 0
? ((frameworkBackedDecisions.length / auditData.length) * 100).toFixed(1)
: '0.0';
document.getElementById('participation-rate').textContent = `${participationRate}%`;
// Message based on participation rate
const rate = parseFloat(participationRate);
let participationMessage = '';
if (rate >= 80) {
participationMessage = 'Excellent - Framework actively guiding most decisions';
} else if (rate >= 60) {
participationMessage = 'Good - Framework participating in majority of decisions';
} else if (rate >= 40) {
participationMessage = 'Moderate - Framework guidance available for some decisions';
} else if (rate >= 20) {
participationMessage = 'Low - Framework participation needs improvement';
} else {
participationMessage = 'Critical - Framework rarely providing guidance';
}
document.getElementById('participation-message').textContent = participationMessage;
// Breakdown by service
const participationByService = {};
frameworkBackedDecisions.forEach(d => {
const service = d.service || 'Unknown';
if (!participationByService[service]) {
participationByService[service] = 0;
}
participationByService[service]++;
});
const participationBreakdownEl = document.getElementById('participation-breakdown');
const sortedServices = Object.entries(participationByService)
.sort((a, b) => b[1] - a[1])
.slice(0, 5); // Top 5 services
if (sortedServices.length > 0) {
participationBreakdownEl.innerHTML = sortedServices
.map(([service, count]) => {
const percentage = ((count / frameworkBackedDecisions.length) * 100).toFixed(0);
return `<div class="text-gray-600">${service}: ${count} (${percentage}%)</div>`;
})
.join('');
} else {
participationBreakdownEl.innerHTML = '<div class="text-gray-500 italic">No framework guidance data yet</div>';
}
// Team Comparison (AI vs Human)
// Use same framework services list defined above
const aiDecisions = auditData.filter(d =>
frameworkServices.includes(d.service)
);
const humanDecisions = auditData.filter(d =>
!frameworkServices.includes(d.service) && d.service && d.service !== 'unknown'
);
const comparisonEl = document.getElementById('team-comparison');
comparisonEl.innerHTML = '';
[
{ label: 'AI Assistant', data: aiDecisions, color: 'blue' },
{ label: 'Human Direct', data: humanDecisions, color: 'purple' }
].forEach(team => {
const blocked = team.data.filter(d => !d.allowed).length;
const blockRate = team.data.length > 0 ? (blocked / team.data.length * 100).toFixed(1) : '0.0';
const teamDiv = document.createElement('div');
teamDiv.className = `border-2 border-${team.color}-200 rounded-lg p-4`;
teamDiv.innerHTML = `
<div class="font-semibold text-gray-900 mb-2">${team.label}</div>
<div class="text-2xl font-bold text-${team.color}-600 mb-1">${blockRate}%</div>
<div class="text-xs text-gray-600">Block Rate (${blocked}/${team.data.length})</div>
<div class="mt-2 text-xs ${blocked === 0 ? 'text-green-600' : 'text-gray-600'}">
${blocked === 0 ? '✓ Clean performance' : `${blocked} violations prevented`}
</div>
`;
comparisonEl.appendChild(teamDiv);
});
// ROI Projections
const roiEl = document.getElementById('roi-projections');
roiEl.innerHTML = '';
const blockedCount = auditData.filter(d => !d.allowed).length;
const highSeverityCount = auditData.filter(d =>
!d.allowed && d.violations && d.violations.some(v => v.severity === 'HIGH' || v.severity === 'CRITICAL')
).length;
const projections = [
{ users: '1,000', decisions: Math.round(auditData.length * 10), blocks: Math.round(blockedCount * 10), critical: Math.round(highSeverityCount * 10) },
{ users: '10,000', decisions: Math.round(auditData.length * 100), blocks: Math.round(blockedCount * 100), critical: Math.round(highSeverityCount * 100) },
{ users: '70,000', decisions: Math.round(auditData.length * 700), blocks: Math.round(blockedCount * 700), critical: Math.round(highSeverityCount * 700) }
];
projections.forEach(proj => {
const projDiv = document.createElement('div');
projDiv.className = 'border-2 border-purple-200 rounded-lg p-4 bg-white';
projDiv.innerHTML = `
<div class="text-center mb-3">
<div class="text-2xl font-bold text-purple-600">${proj.users}</div>
<div class="text-xs text-gray-600">Users</div>
</div>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-600">Decisions/month:</span>
<span class="font-semibold">${proj.decisions.toLocaleString()}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">Blocks/month:</span>
<span class="font-semibold text-orange-600">${proj.blocks.toLocaleString()}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">Critical saves:</span>
<span class="font-semibold text-red-600">${proj.critical.toLocaleString()}</span>
</div>
</div>
`;
roiEl.appendChild(projDiv);
});
}
// Render Framework Saves (High severity blocks)
function renderFrameworkSaves() {
const blockedDecisions = auditData.filter(d => !d.allowed);
const highSeverityBlocks = blockedDecisions.filter(d =>
d.violations && d.violations.some(v => v.severity === 'HIGH' || v.severity === 'CRITICAL')
);
document.getElementById('framework-saves-count').textContent = highSeverityBlocks.length;
const savesListEl = document.getElementById('framework-saves-list');
if (highSeverityBlocks.length === 0) {
savesListEl.innerHTML = '<p class="text-sm text-gray-500 italic">No high-severity blocks in this period</p>';
return;
}
savesListEl.innerHTML = '';
highSeverityBlocks.slice(0, 10).forEach(block => {
const violation = block.violations.find(v => v.severity === 'HIGH' || v.severity === 'CRITICAL');
const timestamp = new Date(block.timestamp).toLocaleString();
const file = block.metadata?.filePath || block.metadata?.file || 'N/A';
const isCritical = violation.severity === 'CRITICAL';
const saveDiv = document.createElement('div');
saveDiv.className = `flex items-start p-3 bg-white rounded-lg border ${isCritical ? 'border-red-200' : 'border-orange-200'} hover:shadow-md transition`;
const badge = document.createElement('span');
badge.className = `inline-flex items-center px-2 py-1 text-xs font-medium rounded-full ${isCritical ? 'bg-red-100 text-red-800' : 'bg-orange-100 text-orange-800'}`;
badge.textContent = violation.severity;
const badgeContainer = document.createElement('div');
badgeContainer.className = 'flex-shrink-0 mr-3';
badgeContainer.appendChild(badge);
const content = document.createElement('div');
content.className = 'flex-1 min-w-0';
content.innerHTML = `
<p class="text-sm font-medium text-gray-900 truncate">${violation.ruleId || 'Unknown Rule'}</p>
<p class="text-xs text-gray-600 mt-1">${violation.details || violation.ruleText || 'No details'}</p>
<p class="text-xs text-gray-500 mt-1">File: ${file}</p>
<p class="text-xs text-gray-400 mt-1">${timestamp}</p>
`;
saveDiv.appendChild(badgeContainer);
saveDiv.appendChild(content);
savesListEl.appendChild(saveDiv);
});
}
// Render Block Reasons
function renderBlockReasons() {
const blockedDecisions = auditData.filter(d => !d.allowed);
const reasonCounts = {};
blockedDecisions.forEach(d => {
let reason = 'Unknown';
if (d.violations && d.violations.length > 0) {
reason = d.violations[0].ruleId || d.violations[0].ruleText || 'Unknown';
} else if (d.metadata?.reason) {
reason = d.metadata.reason;
}
reasonCounts[reason] = (reasonCounts[reason] || 0) + 1;
});
const sortedReasons = Object.entries(reasonCounts)
.sort((a, b) => b[1] - a[1])
.slice(0, 10);
const reasonsListEl = document.getElementById('block-reasons-list');
if (sortedReasons.length === 0) {
reasonsListEl.innerHTML = '<p class="text-sm text-gray-500 italic">No blocks in this period</p>';
return;
}
const maxCount = sortedReasons[0][1];
reasonsListEl.innerHTML = '';
sortedReasons.forEach(([reason, count]) => {
const percentage = (count / blockedDecisions.length * 100).toFixed(1);
const barWidth = (count / maxCount * 100).toFixed(1);
const container = document.createElement('div');
container.className = 'flex items-center space-x-3';
const innerDiv = document.createElement('div');
innerDiv.className = 'flex-1';
// Get rule description if available
const ruleDesc = RULE_DESCRIPTIONS[reason];
const tooltip = ruleDesc
? `${ruleDesc.title}\n\n${ruleDesc.description}`
: reason;
const header = document.createElement('div');
header.className = 'flex items-center justify-between mb-1';
const reasonSpan = document.createElement('span');
reasonSpan.className = 'text-sm font-medium text-gray-700 truncate cursor-help';
reasonSpan.title = tooltip;
reasonSpan.textContent = reason;
// Add info icon for rules with descriptions
if (ruleDesc) {
const infoIcon = document.createElement('span');
infoIcon.className = 'ml-2 text-blue-500 text-xs';
infoIcon.textContent = '';
infoIcon.title = tooltip;
reasonSpan.appendChild(infoIcon);
}
const countSpan = document.createElement('span');
countSpan.className = 'text-sm text-gray-600';
countSpan.textContent = `${count} (${percentage}%)`;
header.appendChild(reasonSpan);
header.appendChild(countSpan);
const progressBg = document.createElement('div');
progressBg.className = 'w-full bg-gray-200 rounded-full h-2';
const progressBar = document.createElement('div');
progressBar.className = 'bg-red-600 h-2 rounded-full';
progressBar.style.width = `${barWidth}%`;
progressBg.appendChild(progressBar);
innerDiv.appendChild(header);
innerDiv.appendChild(progressBg);
container.appendChild(innerDiv);
reasonsListEl.appendChild(container);
});
}
// Render Severity Breakdown
function renderSeverityBreakdown() {
const blockedDecisions = auditData.filter(d => !d.allowed);
const severityCounts = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 };
blockedDecisions.forEach(d => {
if (d.violations && d.violations.length > 0) {
d.violations.forEach(v => {
const severity = v.severity || 'MEDIUM';
severityCounts[severity] = (severityCounts[severity] || 0) + 1;
});
}
});
const severityEl = document.getElementById('severity-breakdown');
const totalViolations = Object.values(severityCounts).reduce((a, b) => a + b, 0);
if (totalViolations === 0) {
severityEl.innerHTML = '<p class="text-sm text-gray-500 italic">No severity data available</p>';
return;
}
const severityConfig = {
CRITICAL: { color: 'red', icon: '🔴', bg: 'bg-red-50', border: 'border-red-200', text: 'text-red-700' },
HIGH: { color: 'orange', icon: '🟠', bg: 'bg-orange-50', border: 'border-orange-200', text: 'text-orange-700' },
MEDIUM: { color: 'yellow', icon: '🟡', bg: 'bg-yellow-50', border: 'border-yellow-200', text: 'text-yellow-700' },
LOW: { color: 'gray', icon: '⚪', bg: 'bg-gray-50', border: 'border-gray-200', text: 'text-gray-700' }
};
severityEl.innerHTML = '';
Object.entries(severityCounts).forEach(([severity, count]) => {
const percentage = (count / totalViolations * 100).toFixed(1);
const config = severityConfig[severity];
const div = document.createElement('div');
div.className = `flex items-center justify-between p-3 ${config.bg} rounded-lg border ${config.border}`;
div.innerHTML = `
<div class="flex items-center">
<span class="text-2xl mr-3">${config.icon}</span>
<span class="text-sm font-medium text-gray-900">${severity}</span>
</div>
<div class="text-right">
<div class="text-lg font-bold ${config.text}">${count}</div>
<div class="text-xs text-gray-600">${percentage}%</div>
</div>
`;
severityEl.appendChild(div);
});
}
// 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 containerHeight = 192; // h-48 = 192px
const html = buckets.map(bucket => {
const percentage = (bucket.count / maxCount) * 100;
const minPercentage = Math.max(percentage, 5);
const pixelHeight = Math.round((minPercentage / 100) * containerHeight);
return `
<div class="flex flex-col items-center flex-1">
<div class="w-full flex flex-col items-center justify-end h-48">
<span class="text-xs font-semibold text-gray-700 mb-1">${bucket.count}</span>
<div class="w-3/4 min-w-10 bg-purple-600 rounded-t transition-all duration-300 hover:bg-purple-700 flex items-center justify-center"
data-pixel-height="${pixelHeight}"
title="${bucket.label}: ${bucket.count} decisions">&nbsp;</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>`;
}
// Cost Configuration
async function loadCostConfig() {
try {
const token = getAuthToken();
const response = await fetch('/api/admin/cost-config', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) {
throw new Error('Failed to load cost config');
}
const data = await response.json();
return data.costFactors;
} catch (error) {
console.error('Error loading cost config:', error);
return null;
}
}
async function saveCostConfig(costFactors) {
try {
const token = getAuthToken();
const response = await fetch('/api/admin/cost-config', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ costFactors })
});
if (!response.ok) {
throw new Error('Failed to save cost config');
}
return await response.json();
} catch (error) {
console.error('Error saving cost config:', error);
throw error;
}
}
async function showCostConfigModal() {
const costFactors = await loadCostConfig();
if (!costFactors) {
alert('Failed to load cost configuration');
return;
}
// Slider ranges by severity
const sliderConfig = {
CRITICAL: { min: 1000, max: 250000, step: 1000 },
HIGH: { min: 500, max: 50000, step: 500 },
MEDIUM: { min: 100, max: 10000, step: 100 },
LOW: { min: 50, max: 5000, step: 50 }
};
const modal = document.createElement('div');
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4';
modal.innerHTML = `
<div class="bg-white rounded-lg shadow-xl max-w-2xl w-full flex flex-col modal-container">
<div class="p-6 border-b border-gray-200 flex-shrink-0">
<h3 class="text-xl font-bold text-gray-900">Configure Cost Factors</h3>
<p class="text-sm text-gray-600 mt-1">Set organizational cost values for different violation severities</p>
</div>
<div class="p-6 space-y-4 overflow-y-auto flex-1 modal-content">
${['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'].map(severity => {
const config = sliderConfig[severity];
const amount = Math.min(Math.max(costFactors[severity].amount, config.min), config.max);
return `
<div class="border border-gray-200 rounded-lg p-4">
<label class="block font-semibold text-gray-900 mb-3">${severity}</label>
<div class="mb-4">
<div class="flex items-center justify-between mb-2">
<label class="block text-sm text-gray-600">Amount</label>
<span class="text-sm font-medium text-gray-900">$<span id="cost-${severity}-display">${amount.toLocaleString()}</span></span>
</div>
<input type="range" id="cost-${severity}-slider"
min="${config.min}" max="${config.max}" step="${config.step}"
value="${amount}"
class="slider">
<div class="flex justify-between text-xs text-gray-500 mt-1">
<span>$${(config.min / 1000).toFixed(0)}k</span>
<span>$${(config.max / 1000).toFixed(0)}k</span>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mb-3">
<div>
<label class="block text-sm text-gray-600 mb-1">Exact Amount ($)</label>
<input type="number" id="cost-${severity}-amount"
value="${costFactors[severity].amount}"
min="0" step="${config.step}"
class="w-full px-3 py-2 border border-gray-300 rounded focus:ring-2 focus:ring-purple-500 focus:border-transparent">
</div>
<div>
<label class="block text-sm text-gray-600 mb-1">Currency</label>
<input type="text" id="cost-${severity}-currency"
value="${costFactors[severity].currency}"
class="w-full px-3 py-2 border border-gray-300 rounded focus:ring-2 focus:ring-purple-500 focus:border-transparent">
</div>
</div>
<div>
<label class="block text-sm text-gray-600 mb-1">Rationale</label>
<textarea id="cost-${severity}-rationale" rows="2"
class="w-full px-3 py-2 border border-gray-300 rounded focus:ring-2 focus:ring-purple-500 focus:border-transparent">${costFactors[severity].rationale}</textarea>
</div>
</div>
`}).join('')}
<div class="bg-amber-50 border border-amber-200 rounded-lg p-3 text-sm text-amber-800">
<strong>Note:</strong> These values are illustrative placeholders for research purposes.
Organizations should determine appropriate values based on their incident cost data.
</div>
</div>
<div class="p-6 border-t border-gray-200 flex justify-end space-x-3 flex-shrink-0">
<button id="cancel-config-btn" class="px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50">
Cancel
</button>
<button id="save-config-btn" class="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700">
Save Configuration
</button>
</div>
</div>
<style>
/* Modal container styling */
.modal-container {
max-height: 90vh;
}
/* Modal content scrollbar styling */
.modal-content {
scrollbar-width: thin;
scrollbar-color: #9333ea #e9d5ff;
}
.modal-content::-webkit-scrollbar {
width: 8px;
}
.modal-content::-webkit-scrollbar-track {
background: #e9d5ff;
border-radius: 4px;
}
.modal-content::-webkit-scrollbar-thumb {
background: #9333ea;
border-radius: 4px;
}
.modal-content::-webkit-scrollbar-thumb:hover {
background: #7e22ce;
}
/* Custom slider styling */
.slider {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 8px;
background: #e9d5ff;
border-radius: 5px;
outline: none;
}
/* WebKit browsers (Chrome, Safari, Edge) */
.slider::-webkit-slider-track {
width: 100%;
height: 8px;
background: #e9d5ff;
border-radius: 5px;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: #9333ea;
cursor: pointer;
transition: all 0.15s ease;
}
.slider::-webkit-slider-thumb:hover {
background: #7e22ce;
transform: scale(1.1);
}
/* Firefox */
.slider::-moz-range-track {
width: 100%;
height: 8px;
background: #e9d5ff;
border-radius: 5px;
}
.slider::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: #9333ea;
cursor: pointer;
border: none;
transition: all 0.15s ease;
}
.slider::-moz-range-thumb:hover {
background: #7e22ce;
transform: scale(1.1);
}
</style>
`;
document.body.appendChild(modal);
// Sync sliders with number inputs
['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'].forEach(severity => {
const slider = modal.querySelector(`#cost-${severity}-slider`);
const input = modal.querySelector(`#cost-${severity}-amount`);
const display = modal.querySelector(`#cost-${severity}-display`);
// Slider changes update input and display
slider.addEventListener('input', (e) => {
const value = parseFloat(e.target.value);
input.value = value;
display.textContent = value.toLocaleString();
});
// Input changes update slider and display
input.addEventListener('input', (e) => {
const value = parseFloat(e.target.value) || 0;
const config = sliderConfig[severity];
const clampedValue = Math.min(Math.max(value, config.min), config.max);
slider.value = clampedValue;
display.textContent = value.toLocaleString();
});
});
// Close on cancel
modal.querySelector('#cancel-config-btn').addEventListener('click', () => {
document.body.removeChild(modal);
});
// Save configuration
modal.querySelector('#save-config-btn').addEventListener('click', async () => {
const updatedCostFactors = {};
['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'].forEach(severity => {
updatedCostFactors[severity] = {
amount: parseFloat(document.getElementById(`cost-${severity}-amount`).value) || 0,
currency: document.getElementById(`cost-${severity}-currency`).value || 'USD',
rationale: document.getElementById(`cost-${severity}-rationale`).value || ''
};
});
try {
await saveCostConfig(updatedCostFactors);
alert('Cost configuration saved successfully!');
document.body.removeChild(modal);
loadAuditData(); // Reload to show updated calculations
} catch (error) {
alert('Failed to save cost configuration. Please try again.');
}
});
// Close on background click
modal.addEventListener('click', (e) => {
if (e.target === modal) {
document.body.removeChild(modal);
}
});
}
// 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!');
}
// Setup cost configuration button
const configCostsBtn = document.getElementById('configure-costs-btn');
if (configCostsBtn) {
console.log('[Audit Analytics] Cost config button found, attaching event listener');
configCostsBtn.addEventListener('click', showCostConfigModal);
}
// Setup cost period selector
const periodSelector = document.getElementById('cost-period-selector');
if (periodSelector) {
console.log('[Audit Analytics] Cost period selector found, attaching event listener');
periodSelector.addEventListener('change', () => {
console.log('[Audit Analytics] Period changed, recalculating cost avoidance...');
renderBusinessIntelligence();
});
}
// Setup environment filter
const environmentFilter = document.getElementById('environment-filter');
if (environmentFilter) {
console.log('[Audit Analytics] Environment filter found, attaching event listener');
environmentFilter.addEventListener('change', () => {
const env = environmentFilter.value;
console.log(`[Audit Analytics] Environment changed to: ${env}, reloading data...`);
loadAuditData();
});
}
// 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], [data-pixel-height]');
elements.forEach(el => {
if (el.dataset.width) {
el.style.width = el.dataset.width + '%';
}
if (el.dataset.height) {
el.style.height = el.dataset.height + '%';
}
if (el.dataset.pixelHeight) {
const height = Math.max(parseInt(el.dataset.pixelHeight), 10); // Minimum 10px
el.style.height = height + 'px';
}
});
}
// 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);
}
});
// Show legend modal
function showLegendModal() {
const modal = document.getElementById('legend-modal');
const content = document.getElementById('legend-content');
// Populate legend content
content.innerHTML = '';
Object.entries(RULE_DESCRIPTIONS).forEach(([ruleId, ruleInfo]) => {
const ruleDiv = document.createElement('div');
ruleDiv.className = 'border-l-4 border-blue-500 pl-4 py-2';
const titleDiv = document.createElement('div');
titleDiv.className = 'font-semibold text-gray-900';
titleDiv.textContent = `${ruleId}: ${ruleInfo.title}`;
const descDiv = document.createElement('div');
descDiv.className = 'text-sm text-gray-600 mt-1';
descDiv.textContent = ruleInfo.description;
ruleDiv.appendChild(titleDiv);
ruleDiv.appendChild(descDiv);
content.appendChild(ruleDiv);
});
modal.classList.remove('hidden');
}
// Hide legend modal
function hideLegendModal() {
const modal = document.getElementById('legend-modal');
modal.classList.add('hidden');
}
// Add event listeners for modal
document.addEventListener('DOMContentLoaded', () => {
const showBtn = document.getElementById('show-legend-btn');
const closeBtn = document.getElementById('close-legend-btn');
const modal = document.getElementById('legend-modal');
if (showBtn) {
showBtn.addEventListener('click', showLegendModal);
}
if (closeBtn) {
closeBtn.addEventListener('click', hideLegendModal);
}
// Close modal when clicking outside
if (modal) {
modal.addEventListener('click', (e) => {
if (e.target === modal) {
hideLegendModal();
}
});
}
});