Fixed broken "Decisions Over Time" chart that wasn't displaying bars. Root cause: Empty divs with percentage heights collapsed in flex containers. Fixes applied: 1. **Pixel heights instead of percentages** - Calculate absolute pixel heights from h-48 container (192px) - Percentage heights don't work in flex containers with items-end 2. **Non-breaking space inside bars** - Added to prevent empty div collapse - Even with height set, empty divs can collapse in some layouts 3. **Decision count labels** - Display count above each bar for exact numbers - Shows both visual proportion (bar height) and exact value (label) 4. **Minimum 10px height** - Ensures small values are always visible - Prevents bars from disappearing for low counts 5. **Wider bars** - Changed from max-w-16 (64px) to w-3/4 (75% width) - More visible and easier to interact with Timeline modes working: - ✅ 6-Hourly (24h) - 4 bars showing last 24 hours in 6-hour buckets - ✅ Daily (7d) - 7 bars showing last 7 days - ✅ Weekly (4w) - 4 bars showing last 4 weeks All modes show current snapshot updated on refresh. Files changed: - public/js/admin/audit-analytics.js: Timeline rendering logic - public/admin/audit-analytics.html: Updated cache version - public/*.html: Cache version bump for consistency 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
558 lines
18 KiB
JavaScript
558 lines
18 KiB
JavaScript
/**
|
|
* Audit Analytics Dashboard
|
|
* Displays governance decision analytics from MemoryProxy audit trail
|
|
*/
|
|
|
|
let auditData = [];
|
|
|
|
// Get auth token from localStorage
|
|
function getAuthToken() {
|
|
return localStorage.getItem('admin_token');
|
|
}
|
|
|
|
// Check authentication
|
|
function checkAuth() {
|
|
const token = getAuthToken();
|
|
if (!token) {
|
|
window.location.href = '/admin/login.html';
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Load audit data from API
|
|
async function loadAuditData() {
|
|
console.log('[Audit Analytics] Loading audit data...');
|
|
try {
|
|
const token = getAuthToken();
|
|
console.log('[Audit Analytics] Token:', token ? 'Present' : 'Missing');
|
|
|
|
const response = await fetch('/api/admin/audit-logs?days=30', {
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`,
|
|
'Content-Type': 'application/json'
|
|
}
|
|
});
|
|
|
|
console.log('[Audit Analytics] Response status:', response.status);
|
|
|
|
if (response.status === 401) {
|
|
console.log('[Audit Analytics] Unauthorized - redirecting to login');
|
|
localStorage.removeItem('admin_token');
|
|
localStorage.removeItem('admin_user');
|
|
window.location.href = '/admin/login.html';
|
|
return;
|
|
}
|
|
|
|
const data = await response.json();
|
|
console.log('[Audit Analytics] Data received:', data);
|
|
|
|
if (data.success) {
|
|
auditData = data.decisions || [];
|
|
console.log('[Audit Analytics] Audit data loaded:', auditData.length, 'decisions');
|
|
renderDashboard();
|
|
} else {
|
|
console.error('[Audit Analytics] API returned error:', data.error);
|
|
showError('Failed to load audit data: ' + (data.error || 'Unknown error'));
|
|
}
|
|
} catch (error) {
|
|
console.error('[Audit Analytics] Error loading audit data:', error);
|
|
showError('Error loading audit data. Please check console for details.');
|
|
}
|
|
}
|
|
|
|
// Render dashboard
|
|
function renderDashboard() {
|
|
updateSummaryCards();
|
|
renderActionChart();
|
|
renderServiceChart();
|
|
renderServiceHealth24h();
|
|
renderViolations7days();
|
|
renderTimelineChart();
|
|
renderAuditTable();
|
|
}
|
|
|
|
// Update summary cards
|
|
function updateSummaryCards() {
|
|
const totalDecisions = auditData.length;
|
|
const allowedCount = auditData.filter(d => d.allowed).length;
|
|
const violationsCount = auditData.filter(d => d.violations && d.violations.length > 0).length;
|
|
const servicesSet = new Set(auditData.map(d => d.service).filter(s => s && s !== 'unknown'));
|
|
|
|
document.getElementById('total-decisions').textContent = totalDecisions;
|
|
document.getElementById('allowed-rate').textContent = totalDecisions > 0
|
|
? `${((allowedCount / totalDecisions) * 100).toFixed(1)}%`
|
|
: '0%';
|
|
document.getElementById('violations-count').textContent = violationsCount;
|
|
document.getElementById('services-count').textContent = servicesSet.size || 0;
|
|
}
|
|
|
|
// Render action type chart
|
|
function renderActionChart() {
|
|
const actionCounts = {};
|
|
|
|
auditData.forEach(decision => {
|
|
const action = decision.action || 'unknown';
|
|
actionCounts[action] = (actionCounts[action] || 0) + 1;
|
|
});
|
|
|
|
const chartEl = document.getElementById('action-chart');
|
|
|
|
if (Object.keys(actionCounts).length === 0) {
|
|
chartEl.innerHTML = '<p class="text-gray-500 text-center py-12">No data available</p>';
|
|
return;
|
|
}
|
|
|
|
const sorted = Object.entries(actionCounts)
|
|
.sort((a, b) => b[1] - a[1])
|
|
.slice(0, 10);
|
|
|
|
const maxCount = Math.max(...sorted.map(([, count]) => count));
|
|
|
|
const html = sorted.map(([action, count]) => {
|
|
const percentage = (count / maxCount) * 100;
|
|
const label = action.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
|
|
|
return `
|
|
<div class="mb-4">
|
|
<div class="flex items-center justify-between mb-1">
|
|
<span class="text-sm font-medium text-gray-700">${label}</span>
|
|
<span class="text-sm text-gray-600">${count}</span>
|
|
</div>
|
|
<div class="w-full bg-gray-200 rounded-full h-2">
|
|
<div class="bg-blue-600 h-2 rounded-full transition-all duration-300" data-width="${percentage}"></div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
chartEl.innerHTML = html; setProgressBarWidths(chartEl);
|
|
}
|
|
|
|
// Render service chart
|
|
function renderServiceChart() {
|
|
const serviceCounts = {};
|
|
|
|
auditData.forEach(decision => {
|
|
const service = decision.service || 'unknown';
|
|
serviceCounts[service] = (serviceCounts[service] || 0) + 1;
|
|
});
|
|
|
|
const chartEl = document.getElementById('service-chart');
|
|
|
|
if (Object.keys(serviceCounts).length === 0) {
|
|
chartEl.innerHTML = '<p class="text-gray-500 text-center py-12">No data available</p>';
|
|
return;
|
|
}
|
|
|
|
const sorted = Object.entries(serviceCounts)
|
|
.sort((a, b) => b[1] - a[1]);
|
|
|
|
const maxCount = Math.max(...sorted.map(([, count]) => count));
|
|
|
|
// Color palette for services
|
|
const colors = [
|
|
'bg-blue-600',
|
|
'bg-green-600',
|
|
'bg-purple-600',
|
|
'bg-orange-600',
|
|
'bg-pink-600',
|
|
'bg-indigo-600',
|
|
'bg-red-600',
|
|
'bg-yellow-600'
|
|
];
|
|
|
|
const html = sorted.map(([service, count], index) => {
|
|
const percentage = (count / maxCount) * 100;
|
|
// Ensure minimum 8% width so all bars are visible
|
|
const displayPercentage = Math.max(percentage, 8);
|
|
const color = colors[index % colors.length];
|
|
const label = service === 'unknown' ? 'Unknown' : service;
|
|
|
|
return `
|
|
<div class="mb-4">
|
|
<div class="flex items-center justify-between mb-1">
|
|
<span class="text-sm font-medium text-gray-700">${label}</span>
|
|
<span class="text-sm text-gray-600">${count}</span>
|
|
</div>
|
|
<div class="w-full bg-gray-200 rounded-full h-2">
|
|
<div class="${color} h-2 rounded-full transition-all duration-300" data-width="${displayPercentage}"></div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
chartEl.innerHTML = html; setProgressBarWidths(chartEl);
|
|
}
|
|
|
|
// Render service health (24h)
|
|
function renderServiceHealth24h() {
|
|
const chartEl = document.getElementById('service-health-24h');
|
|
|
|
// Filter last 24 hours
|
|
const now = new Date();
|
|
const twentyFourHoursAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
|
const last24h = auditData.filter(d => new Date(d.timestamp) >= twentyFourHoursAgo);
|
|
|
|
if (last24h.length === 0) {
|
|
chartEl.innerHTML = '<p class="text-gray-500 text-center py-12 col-span-full">No data in last 24 hours</p>';
|
|
return;
|
|
}
|
|
|
|
// Group by service
|
|
const serviceStats = {};
|
|
last24h.forEach(decision => {
|
|
const service = decision.service || 'unknown';
|
|
if (!serviceStats[service]) {
|
|
serviceStats[service] = {
|
|
allowed: 0,
|
|
blocked: 0,
|
|
violations: 0
|
|
};
|
|
}
|
|
|
|
if (decision.allowed) {
|
|
serviceStats[service].allowed++;
|
|
} else {
|
|
serviceStats[service].blocked++;
|
|
}
|
|
|
|
if (decision.violations && decision.violations.length > 0) {
|
|
serviceStats[service].violations += decision.violations.length;
|
|
}
|
|
});
|
|
|
|
const html = Object.entries(serviceStats).map(([service, stats]) => {
|
|
const isHealthy = stats.blocked === 0 && stats.violations === 0;
|
|
const bgColor = isHealthy ? 'bg-green-50' : 'bg-red-50';
|
|
const borderColor = isHealthy ? 'border-green-200' : 'border-red-200';
|
|
const icon = isHealthy ? '✓' : '⚠';
|
|
const iconColor = isHealthy ? 'text-green-600' : 'text-red-600';
|
|
|
|
return `
|
|
<div class="${bgColor} rounded-lg p-4 border ${borderColor}">
|
|
<div class="flex items-start justify-between mb-2">
|
|
<span class="text-sm font-semibold text-gray-900">${service === 'unknown' ? 'Unknown' : service}</span>
|
|
<span class="${iconColor} text-xl font-bold">${icon}</span>
|
|
</div>
|
|
<div class="space-y-1 text-sm">
|
|
<p class="text-green-700"><strong>✓ Allowed:</strong> ${stats.allowed}</p>
|
|
${stats.blocked > 0 ? `<p class="text-red-700"><strong>✗ Blocked:</strong> ${stats.blocked}</p>` : ''}
|
|
${stats.violations > 0 ? `<p class="text-orange-700"><strong>⚠ Violations:</strong> ${stats.violations}</p>` : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
chartEl.innerHTML = html;
|
|
}
|
|
|
|
// Render violations (7 days)
|
|
function renderViolations7days() {
|
|
const chartEl = document.getElementById('violations-7days');
|
|
|
|
// Filter last 7 days
|
|
const now = new Date();
|
|
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
const last7days = auditData.filter(d => new Date(d.timestamp) >= sevenDaysAgo);
|
|
|
|
// Group by day
|
|
const dayStats = {};
|
|
last7days.forEach(decision => {
|
|
const date = new Date(decision.timestamp);
|
|
const dayKey = date.toISOString().split('T')[0]; // YYYY-MM-DD
|
|
|
|
if (!dayStats[dayKey]) {
|
|
dayStats[dayKey] = {
|
|
date: date,
|
|
total: 0,
|
|
blocked: 0,
|
|
violations: 0,
|
|
services: new Set()
|
|
};
|
|
}
|
|
|
|
dayStats[dayKey].total++;
|
|
if (!decision.allowed) {
|
|
dayStats[dayKey].blocked++;
|
|
}
|
|
if (decision.violations && decision.violations.length > 0) {
|
|
dayStats[dayKey].violations += decision.violations.length;
|
|
}
|
|
if (decision.service && decision.service !== 'unknown') {
|
|
dayStats[dayKey].services.add(decision.service);
|
|
}
|
|
});
|
|
|
|
// Check if there are any violations or blocks
|
|
const hasIssues = Object.values(dayStats).some(stats => stats.blocked > 0 || stats.violations > 0);
|
|
|
|
if (!hasIssues) {
|
|
chartEl.innerHTML = `
|
|
<div class="bg-green-50 rounded-lg p-6 border border-green-200 col-span-full text-center">
|
|
<p class="text-lg font-semibold text-green-900">✓ No violations or blocks in the last 7 days</p>
|
|
<p class="text-sm text-green-700 mt-2">All governance decisions passed successfully</p>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
// Show only days with issues
|
|
const daysWithIssues = Object.entries(dayStats)
|
|
.filter(([, stats]) => stats.blocked > 0 || stats.violations > 0)
|
|
.sort((a, b) => b[1].date - a[1].date); // Most recent first
|
|
|
|
const html = daysWithIssues.map(([dayKey, stats]) => {
|
|
const dayLabel = stats.date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
|
|
const servicesInvolved = Array.from(stats.services).join(', ');
|
|
|
|
return `
|
|
<div class="bg-red-50 rounded-lg p-4 border border-red-200">
|
|
<p class="text-sm font-semibold text-gray-900 mb-2">${dayLabel}</p>
|
|
<div class="space-y-1 text-sm">
|
|
<p class="text-gray-700"><strong>Total:</strong> ${stats.total} decisions</p>
|
|
${stats.blocked > 0 ? `<p class="text-red-700"><strong>✗ Blocked:</strong> ${stats.blocked}</p>` : ''}
|
|
${stats.violations > 0 ? `<p class="text-orange-700"><strong>⚠ Violations:</strong> ${stats.violations}</p>` : ''}
|
|
${servicesInvolved ? `<p class="text-xs text-gray-600 mt-2"><strong>Services:</strong> ${servicesInvolved}</p>` : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
chartEl.innerHTML = html;
|
|
}
|
|
|
|
// Timeline mode state
|
|
let timelineMode = 'daily';
|
|
|
|
// Switch timeline mode
|
|
function switchTimelineMode(mode) {
|
|
timelineMode = mode;
|
|
|
|
// Update button styles
|
|
document.querySelectorAll('[data-timeline-mode]').forEach(btn => {
|
|
if (btn.dataset.timelineMode === mode) {
|
|
btn.className = 'px-3 py-1 text-sm rounded bg-purple-600 text-white hover:bg-purple-700 transition';
|
|
} else {
|
|
btn.className = 'px-3 py-1 text-sm rounded bg-gray-200 text-gray-700 hover:bg-gray-300 transition';
|
|
}
|
|
});
|
|
|
|
renderTimelineChart();
|
|
}
|
|
|
|
// Render timeline chart
|
|
function renderTimelineChart() {
|
|
const chartEl = document.getElementById('timeline-chart');
|
|
|
|
if (auditData.length === 0) {
|
|
chartEl.innerHTML = '<p class="text-gray-500 text-center py-12">No data available</p>';
|
|
return;
|
|
}
|
|
|
|
const now = new Date();
|
|
let buckets = [];
|
|
let filteredData = [];
|
|
|
|
if (timelineMode === '6hourly') {
|
|
// Last 24 hours in 6-hour buckets
|
|
const twentyFourHoursAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
|
filteredData = auditData.filter(d => new Date(d.timestamp) >= twentyFourHoursAgo);
|
|
|
|
// Create 4 buckets: 0-6h, 6-12h, 12-18h, 18-24h ago
|
|
for (let i = 3; i >= 0; i--) {
|
|
const bucketEnd = new Date(now.getTime() - i * 6 * 60 * 60 * 1000);
|
|
const bucketStart = new Date(bucketEnd.getTime() - 6 * 60 * 60 * 1000);
|
|
buckets.push({
|
|
label: i === 0 ? 'Last 6h' : `${i * 6}-${(i + 1) * 6}h ago`,
|
|
start: bucketStart,
|
|
end: bucketEnd,
|
|
count: 0
|
|
});
|
|
}
|
|
} else if (timelineMode === 'daily') {
|
|
// Last 7 days
|
|
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
filteredData = auditData.filter(d => new Date(d.timestamp) >= sevenDaysAgo);
|
|
|
|
// Create 7 daily buckets
|
|
for (let i = 6; i >= 0; i--) {
|
|
const bucketEnd = new Date(now);
|
|
bucketEnd.setHours(23, 59, 59, 999);
|
|
bucketEnd.setDate(bucketEnd.getDate() - i);
|
|
|
|
const bucketStart = new Date(bucketEnd);
|
|
bucketStart.setHours(0, 0, 0, 0);
|
|
|
|
buckets.push({
|
|
label: bucketStart.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
|
|
start: bucketStart,
|
|
end: bucketEnd,
|
|
count: 0
|
|
});
|
|
}
|
|
} else if (timelineMode === 'weekly') {
|
|
// Last 4 weeks
|
|
const fourWeeksAgo = new Date(now.getTime() - 28 * 24 * 60 * 60 * 1000);
|
|
filteredData = auditData.filter(d => new Date(d.timestamp) >= fourWeeksAgo);
|
|
|
|
// Create 4 weekly buckets
|
|
for (let i = 3; i >= 0; i--) {
|
|
const bucketEnd = new Date(now.getTime() - i * 7 * 24 * 60 * 60 * 1000);
|
|
const bucketStart = new Date(bucketEnd.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
buckets.push({
|
|
label: `Week ${4 - i}`,
|
|
start: bucketStart,
|
|
end: bucketEnd,
|
|
count: 0
|
|
});
|
|
}
|
|
}
|
|
|
|
// Count decisions in each bucket
|
|
filteredData.forEach(decision => {
|
|
const timestamp = new Date(decision.timestamp);
|
|
for (const bucket of buckets) {
|
|
if (timestamp >= bucket.start && timestamp <= bucket.end) {
|
|
bucket.count++;
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
|
|
const maxCount = Math.max(...buckets.map(b => b.count), 1);
|
|
const 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>`;
|
|
}
|
|
|
|
// Initialize
|
|
function init() {
|
|
if (!checkAuth()) return;
|
|
|
|
// Setup refresh button
|
|
const refreshBtn = document.getElementById('refresh-btn');
|
|
if (refreshBtn) {
|
|
console.log('[Audit Analytics] Refresh button found, attaching event listener');
|
|
refreshBtn.addEventListener('click', () => {
|
|
console.log('[Audit Analytics] Refresh button clicked, loading data...');
|
|
loadAuditData();
|
|
});
|
|
} else {
|
|
console.error('[Audit Analytics] Refresh button not found!');
|
|
}
|
|
|
|
// Load initial data
|
|
loadAuditData();
|
|
}
|
|
|
|
// Run initialization
|
|
init();
|
|
// Set widths/heights from data attributes (CSP compliance)
|
|
function setProgressBarWidths(container) {
|
|
const elements = container.querySelectorAll('[data-width], [data-height], [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);
|
|
}
|
|
});
|