Problem: Users noticed environment counts don't add up to total - Total (All Environments): 868 - Development: 400 - Production: 300 - 400 + 300 = 700 ≠ 868 Root cause: Some audit logs have no environment field (null/undefined) - These records ARE counted in "All Environments" - These records are NOT counted when filtering by "Development" or "Production" Solution: - Added "Environment Distribution" section showing breakdown - Displays: Development, Production, and Unspecified counts - Shows warning when unspecified records exist - Makes it clear why filtered totals may not match grand total Technical details: - Frontend filtering in audit-analytics.js - Backend query uses: query.environment = environment (exact match only) - Missing environment fields excluded from filtered results 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1444 lines
51 KiB
JavaScript
1444 lines
51 KiB
JavaScript
/**
|
||
* 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;
|
||
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-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();
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
}
|
||
|
||
// 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');
|
||
badge.className = `px-3 py-1 rounded-full text-xs font-medium ${isHighRisk ? 'bg-orange-200 text-orange-800' : 'bg-green-100 text-green-800'}`;
|
||
badge.textContent = isHighRisk ? `${data.blocked} blocks` : 'Clean';
|
||
|
||
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
|
||
const frameworkBackedDecisions = auditData.filter(d =>
|
||
d.metadata && d.metadata.framework_backed_decision === true
|
||
);
|
||
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)
|
||
const aiDecisions = auditData.filter(d =>
|
||
d.service === 'FileEditHook' || d.service === 'BoundaryEnforcer' ||
|
||
d.service === 'ContextPressureMonitor' || d.service === 'MetacognitiveVerifier'
|
||
);
|
||
const humanDecisions = auditData.filter(d => !aiDecisions.includes(d));
|
||
|
||
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"> </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();
|
||
}
|
||
});
|
||
}
|
||
});
|