Files
container-census/web/app.js
Self Hosters 2cf3b7c0d6 Fix module path and add build time display to UI
Backend changes:
- Updated go.mod module path from github.com/container-census to
  github.com/selfhosters-cc to match correct GitHub organization
- Updated all import paths across codebase to use new module name
- This fixes ldflags injection of BuildTime during compilation
- BuildTime now correctly shows in /api/health response

Frontend changes:
- Added build time badge next to version in header
- Shows date and time in compact format (e.g., "🔨 12/11/2025 8:06 PM")
- Hover shows full timestamp
- Only displays if build_time is not "unknown"

The build script already sets BuildTime via ldflags, but it was being
ignored because the module path in go.mod didn't match the ldflags path.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-11 20:12:10 -05:00

7762 lines
295 KiB
JavaScript
Raw Permalink 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.
// State
let containers = [];
let hosts = [];
let activities = [];
let images = {};
let graphData = null;
let cy = null; // Cytoscape instance
let autoRefreshInterval = null;
let currentTab = 'dashboard';
let lifecycles = [];
let lastRefreshTime = null;
let lastRefreshInterval = null;
let statsModalRefreshInterval = null;
let isSidebarOpen = false;
let vulnerabilityCache = {}; // Cache vulnerability data by imageID
let vulnerabilityScansMap = {}; // Pre-loaded map of all scans to avoid 404s
let vulnerabilitySummary = null; // Cache overall summary
let cardDesignTheme = 'material'; // Default card design theme (compact, material, dashboard)
// Session-based authentication (cookies handle auth automatically)
// Redirect to login page on 401 Unauthorized
async function fetchWithAuth(url, options = {}) {
const response = await fetch(url, options);
// Redirect to login if unauthorized
if (response.status === 401) {
window.location.href = '/login.html';
throw new Error('Unauthorized - redirecting to login');
}
return response;
}
// Logout function
async function logout() {
try {
await fetch('/api/logout', { method: 'POST' });
} catch (error) {
console.error('Logout error:', error);
} finally {
window.location.href = '/login.html';
}
}
// Initialize
document.addEventListener('DOMContentLoaded', () => {
setupEventListeners();
initializeRouting();
loadVersion();
loadTelemetrySchedule();
loadData();
startAutoRefresh();
updateLastRefreshIndicator();
// Initialize notifications if function exists
if (typeof initNotifications === 'function') {
initNotifications();
}
// Setup help menu
setupHelpMenu();
// Check if onboarding tour should be shown
checkAndShowOnboarding();
});
// URL Hash Routing
function initializeRouting() {
// Load tab from URL hash on page load
const hash = window.location.hash.slice(1); // Remove #
if (hash && hash.startsWith('/')) {
const tab = hash.slice(1); // Remove leading /
const validTabs = ['dashboard', 'containers', 'monitoring', 'images', 'security', 'graph', 'hosts', 'history', 'activity', 'reports', 'notifications', 'settings'];
if (validTabs.includes(tab)) {
currentTab = tab;
switchTab(tab, false); // Don't push to history on initial load
} else {
// Invalid hash, default to dashboard
currentTab = 'dashboard';
switchTab('dashboard', true);
}
} else {
// No hash or invalid format, default to dashboard
currentTab = 'dashboard';
switchTab('dashboard', true);
}
// Listen for hash changes (back/forward buttons)
window.addEventListener('hashchange', () => {
const hash = window.location.hash.slice(1);
if (hash && hash.startsWith('/')) {
const tab = hash.slice(1);
const validTabs = ['dashboard', 'containers', 'monitoring', 'images', 'security', 'graph', 'hosts', 'history', 'activity', 'reports', 'notifications', 'settings'];
if (validTabs.includes(tab)) {
currentTab = tab;
switchTab(tab, false); // Don't push to history on hash change
}
}
});
}
// Update URL hash without reloading
function updateURL(tab) {
window.location.hash = '#/' + tab;
}
// Last refresh indicator
function updateLastRefreshIndicator() {
const indicator = document.getElementById('lastUpdated');
if (!indicator) return;
if (lastRefreshInterval) {
clearInterval(lastRefreshInterval);
}
function update() {
if (lastRefreshTime) {
const now = Date.now();
const diff = Math.floor((now - lastRefreshTime) / 1000);
if (diff < 60) {
indicator.textContent = `Updated ${diff}s ago`;
} else {
const mins = Math.floor(diff / 60);
indicator.textContent = `Updated ${mins}m ago`;
}
} else {
indicator.textContent = 'Loading...';
}
}
update();
lastRefreshInterval = setInterval(update, 1000);
}
// Set last refresh time
function markRefresh() {
lastRefreshTime = Date.now();
const indicator = document.getElementById('lastUpdated');
if (indicator) {
indicator.classList.add('refreshing');
setTimeout(() => indicator.classList.remove('refreshing'), 1000);
}
}
// Toast notification system
function showToast(title, message, type = 'info') {
const toast = document.createElement('div');
toast.className = `toast ${type}`;
const icons = {
success: '✅',
error: '❌',
info: '',
warning: '⚠️'
};
toast.innerHTML = `
<span class="toast-icon">${icons[type] || icons.info}</span>
<div class="toast-content">
<div class="toast-title">${title}</div>
<div class="toast-message">${message}</div>
</div>
<button class="toast-close" onclick="this.parentElement.remove()">×</button>
`;
document.body.appendChild(toast);
// Trigger animation
setTimeout(() => toast.classList.add('show'), 10);
// Auto-remove after 5 seconds
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 300);
}, 5000);
}
// Update navigation badges
function updateNavigationBadges() {
// Containers badge
const containersBadge = document.getElementById('containersBadge');
if (containersBadge && containers.length > 0) {
containersBadge.textContent = containers.length;
}
// Running containers in monitoring badge
const monitoringBadge = document.getElementById('monitoringBadge');
const runningCount = containers.filter(c => c.state === 'running').length;
if (monitoringBadge && runningCount > 0) {
monitoringBadge.textContent = runningCount;
}
// Images badge
const imagesBadge = document.getElementById('imagesBadge');
if (imagesBadge && images) {
const totalImages = Object.values(images).reduce((sum, imgs) => sum + imgs.length, 0);
if (totalImages > 0) {
imagesBadge.textContent = totalImages;
}
}
// Hosts badge
const hostsBadge = document.getElementById('hostsBadge');
if (hostsBadge && hosts.length > 0) {
hostsBadge.textContent = hosts.length;
}
// Activity badge
const activityBadge = document.getElementById('activityBadge');
if (activityBadge && activities.length > 0) {
activityBadge.textContent = activities.length;
}
}
// Sidebar toggle for mobile
function toggleSidebar() {
const sidebar = document.querySelector('.sidebar');
const body = document.body;
isSidebarOpen = !isSidebarOpen;
if (isSidebarOpen) {
sidebar.classList.add('open');
body.classList.add('sidebar-open');
} else {
sidebar.classList.remove('open');
body.classList.remove('sidebar-open');
}
}
// Filter state persistence
function saveFilterState() {
const state = {
search: document.getElementById('searchInput')?.value || '',
hostFilter: document.getElementById('hostFilter')?.value || '',
stateFilter: document.getElementById('stateFilter')?.value || ''
};
sessionStorage.setItem(`filters_${currentTab}`, JSON.stringify(state));
}
function restoreFilterState() {
const stateStr = sessionStorage.getItem(`filters_${currentTab}`);
const hostFilter = document.getElementById('hostFilter');
const stateFilter = document.getElementById('stateFilter');
if (!stateStr) {
// Clear filters when switching tabs if no saved state
// Note: searchInput is already cleared in switchTab()
if (hostFilter) hostFilter.value = '';
if (stateFilter) stateFilter.value = '';
return;
}
try {
const state = JSON.parse(stateStr);
// Note: searchInput is always cleared in switchTab(), so we don't restore it
if (hostFilter) hostFilter.value = state.hostFilter || '';
if (stateFilter) stateFilter.value = state.stateFilter || '';
// Don't apply filters here - let the tab's load function handle it
// This prevents double-rendering when switching tabs
} catch (e) {
console.error('Error restoring filter state:', e);
}
}
// Apply filters based on current tab
function applyCurrentFilters() {
if (currentTab === 'containers') {
filterContainers();
} else if (currentTab === 'images') {
filterImages();
} else if (currentTab === 'monitoring') {
filterMonitoring();
} else if (currentTab === 'history') {
filterHistory();
}
}
// Keyboard shortcuts
function setupKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
// Ignore if user is typing in an input
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) {
// Allow '/' to focus search even when in input (if it's empty)
if (e.key === '/' && e.target.value === '') {
e.preventDefault();
document.getElementById('searchInput')?.focus();
}
return;
}
// Tab switching with number keys
if ((e.key >= '1' && e.key <= '9') || e.key === '0') {
e.preventDefault();
const tabs = ['dashboard', 'containers', 'monitoring', 'images', 'security', 'graph', 'hosts', 'history', 'activity', 'reports'];
const tabIndex = e.key === '0' ? 9 : parseInt(e.key) - 1;
if (tabs[tabIndex]) {
switchTab(tabs[tabIndex]);
}
}
// 'N' for notifications
if (e.key === 'n' || e.key === 'N') {
e.preventDefault();
switchTab('notifications');
}
// '/' to focus search
if (e.key === '/') {
e.preventDefault();
const searchInput = document.getElementById('searchInput');
if (searchInput) {
searchInput.focus();
searchInput.select();
}
}
// 'Escape' to close sidebar on mobile
if (e.key === 'Escape' && isSidebarOpen) {
toggleSidebar();
}
});
}
// Event Listeners
function setupEventListeners() {
document.getElementById('scanBtn').addEventListener('click', triggerScan);
document.getElementById('submitTelemetryBtn').addEventListener('click', submitTelemetry);
document.getElementById('autoRefresh').addEventListener('change', handleAutoRefreshToggle);
// Dashboard scan button
const dashboardScanBtn = document.getElementById('dashboardScanBtn');
if (dashboardScanBtn) {
dashboardScanBtn.addEventListener('click', triggerScan);
}
const searchInput = document.getElementById('searchInput');
const hostFilter = document.getElementById('hostFilter');
const stateFilter = document.getElementById('stateFilter');
if (searchInput) {
searchInput.addEventListener('input', () => {
applyCurrentFilters();
saveFilterState();
});
}
if (hostFilter) {
hostFilter.addEventListener('change', () => {
applyCurrentFilters();
saveFilterState();
});
}
if (stateFilter) {
stateFilter.addEventListener('change', () => {
applyCurrentFilters();
saveFilterState();
});
}
// Sidebar navigation
document.querySelectorAll('.nav-item').forEach(btn => {
btn.addEventListener('click', (e) => {
const tab = e.currentTarget.dataset.tab;
if (tab) switchTab(tab);
});
});
// Mobile sidebar toggle
const sidebarToggle = document.getElementById('sidebarToggle');
if (sidebarToggle) {
sidebarToggle.addEventListener('click', toggleSidebar);
}
// Mobile menu button (created via CSS ::before)
document.body.addEventListener('click', (e) => {
if (window.innerWidth <= 768) {
const rect = { left: 15, top: 15, right: 60, bottom: 60 };
if (e.clientX >= rect.left && e.clientX <= rect.right &&
e.clientY >= rect.top && e.clientY <= rect.bottom) {
toggleSidebar();
}
}
});
// Setup keyboard shortcuts
setupKeyboardShortcuts();
// Modal close on background click
document.getElementById('logModal').addEventListener('click', (e) => {
if (e.target.classList.contains('modal')) closeLogModal();
});
document.getElementById('confirmModal').addEventListener('click', (e) => {
if (e.target.classList.contains('modal')) closeConfirmModal();
});
document.getElementById('confirmCancelBtn').addEventListener('click', closeConfirmModal);
// Add Agent modal handlers
const addAgentBtn = document.getElementById('addAgentBtn');
const closeAddAgent = document.getElementById('closeAddAgent');
const cancelAgentBtn = document.getElementById('cancelAgentBtn');
const testAgentBtn = document.getElementById('testAgentBtn');
const addAgentForm = document.getElementById('addAgentForm');
const addAgentModal = document.getElementById('addAgentModal');
if (addAgentBtn) addAgentBtn.addEventListener('click', openAddAgentModal);
if (closeAddAgent) closeAddAgent.addEventListener('click', closeAddAgentModal);
if (cancelAgentBtn) cancelAgentBtn.addEventListener('click', closeAddAgentModal);
if (testAgentBtn) testAgentBtn.addEventListener('click', testAgentConnection);
if (addAgentForm) addAgentForm.addEventListener('submit', handleAddAgent);
if (addAgentModal) {
addAgentModal.addEventListener('click', (e) => {
if (e.target.classList.contains('modal')) closeAddAgentModal();
});
}
// Graph filter handlers
document.getElementById('showNetworks')?.addEventListener('change', applyGraphFilters);
document.getElementById('showVolumes')?.addEventListener('change', applyGraphFilters);
document.getElementById('showDepends')?.addEventListener('change', applyGraphFilters);
document.getElementById('showLinks')?.addEventListener('change', applyGraphFilters);
// Graph display option handlers
document.getElementById('colorByProject')?.addEventListener('change', applyGraphFilters);
document.getElementById('hideEdgeLabels')?.addEventListener('change', toggleEdgeLabels);
// Activity log filter
document.getElementById('activityTypeFilter')?.addEventListener('change', loadActivityLog);
// History filter
document.getElementById('historyHostFilter')?.addEventListener('change', loadContainerHistory);
// Graph selector handlers
document.getElementById('composeProjectSelect')?.addEventListener('change', handleComposeProjectChange);
document.getElementById('networkSelect')?.addEventListener('change', handleNetworkChange);
document.getElementById('layoutSelect')?.addEventListener('change', handleLayoutChange);
// Graph search handler
document.getElementById('graphSearch')?.addEventListener('input', handleGraphSearch);
// Graph zoom control handlers
document.getElementById('zoomInBtn')?.addEventListener('click', zoomIn);
document.getElementById('zoomOutBtn')?.addEventListener('click', zoomOut);
document.getElementById('zoomResetBtn')?.addEventListener('click', zoomReset);
document.getElementById('fitGraphBtn')?.addEventListener('click', fitGraph);
// Security tab handlers
document.getElementById('scanAllImagesBtn')?.addEventListener('click', scanAllImages);
document.getElementById('updateTrivyDBBtn')?.addEventListener('click', updateTrivyDB);
document.getElementById('exportVulnerabilitiesBtn')?.addEventListener('click', exportVulnerabilities);
document.getElementById('vulnerabilitySettingsBtn')?.addEventListener('click', openVulnerabilitySettingsModal);
document.getElementById('securitySearchInput')?.addEventListener('input', filterSecurityScans);
document.getElementById('securitySeverityFilter')?.addEventListener('change', filterSecurityScans);
document.getElementById('securityStatusFilter')?.addEventListener('change', filterSecurityScans);
// Vulnerability settings modal
const vulnSettingsForm = document.getElementById('vulnerabilitySettingsForm');
if (vulnSettingsForm) {
vulnSettingsForm.addEventListener('submit', saveVulnerabilitySettings);
}
const vulnSettingsModal = document.getElementById('vulnerabilitySettingsModal');
if (vulnSettingsModal) {
vulnSettingsModal.addEventListener('click', (e) => {
if (e.target.classList.contains('modal')) closeVulnerabilitySettingsModal();
});
}
}
// Tab Management
function switchTab(tab, updateHistory = true) {
currentTab = tab;
// Update URL hash
if (updateHistory) {
updateURL(tab);
}
// Update navigation items (new sidebar)
document.querySelectorAll('.nav-item').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tab === tab);
});
// Update tab content
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.remove('active');
});
document.getElementById(`${tab}Tab`).classList.add('active');
// Close mobile sidebar after selection
if (window.innerWidth <= 768 && isSidebarOpen) {
toggleSidebar();
}
// Show/hide and configure filters based on tab
updateFiltersForTab(tab);
// Clear search term when switching tabs
const searchInput = document.getElementById('searchInput');
if (searchInput) {
searchInput.value = '';
}
// Restore filter state for this tab (but search is already cleared above)
restoreFilterState();
// Stop security tab polling when leaving
if (tab !== 'security') {
stopQueueStatusPolling();
}
// Auto-refresh data when switching to a tab
if (tab === 'dashboard') {
loadDashboard();
} else if (tab === 'containers') {
loadContainers();
} else if (tab === 'monitoring') {
loadMonitoringData();
} else if (tab === 'images') {
loadImages();
} else if (tab === 'security') {
loadSecurityTab();
} else if (tab === 'hosts') {
loadHosts().then(() => renderHosts(hosts));
} else if (tab === 'graph') {
loadGraph();
} else if (tab === 'history') {
loadContainerHistory();
} else if (tab === 'activity') {
loadActivityLog();
} else if (tab === 'reports') {
initializeReportsTab();
} else if (tab === 'settings') {
loadCollectors();
loadScannerSettings();
loadTelemetrySettings();
loadImageUpdateSettings();
}
// Add pulse animation to nav item briefly
const navItem = document.querySelector(`.nav-item[data-tab="${tab}"]`);
if (navItem) {
navItem.classList.add('pulse');
setTimeout(() => navItem.classList.remove('pulse'), 2000);
}
}
// Update filters visibility and configuration based on current tab
function updateFiltersForTab(tab) {
const filtersBar = document.getElementById('filtersBar');
const searchInput = document.getElementById('searchInput');
const stateFilter = document.getElementById('stateFilter');
// Tabs that support filtering
const filterableTabs = ['containers', 'monitoring', 'images', 'history'];
if (filterableTabs.includes(tab)) {
filtersBar.style.display = 'flex';
// Update placeholder based on tab
if (tab === 'containers') {
if (searchInput) searchInput.placeholder = 'Search containers...';
if (stateFilter) stateFilter.style.display = 'block';
} else if (tab === 'monitoring') {
if (searchInput) searchInput.placeholder = 'Search running containers...';
if (stateFilter) stateFilter.style.display = 'none';
} else if (tab === 'images') {
if (searchInput) searchInput.placeholder = 'Search images...';
if (stateFilter) stateFilter.style.display = 'none';
} else if (tab === 'history') {
if (searchInput) searchInput.placeholder = 'Search container history...';
if (stateFilter) stateFilter.style.display = 'none';
}
} else {
// Hide filters for non-filterable tabs
filtersBar.style.display = 'none';
}
}
// Monitoring Tab
async function loadMonitoringData() {
try {
// Load both containers and hosts
await Promise.all([loadContainers(), loadHosts()]);
// Update stats and badges (since loadContainers doesn't do it on non-containers tab)
updateStats();
updateNavigationBadges();
markRefresh();
// Apply filters if any are active (this will call filterMonitoring and render)
applyCurrentFilters();
} catch (error) {
console.error('Error loading monitoring data:', error);
document.getElementById('monitoringGrid').innerHTML = '<div class="error">Failed to load monitoring data</div>';
}
}
function renderMonitoringGrid(containersToRender) {
const grid = document.getElementById('monitoringGrid');
if (containersToRender.length === 0) {
grid.innerHTML = '<div class="loading">No running containers found</div>';
return;
}
grid.innerHTML = containersToRender.map((container, index) => {
// Stats are available if memory_limit is set (since we removed omitempty, it's always present if stats were collected)
const hasStats = container.memory_limit > 0;
const cpuDisplay = hasStats ? container.cpu_percent.toFixed(1) + '%' : '-';
const memoryMB = hasStats ? (container.memory_usage / 1024 / 1024).toFixed(0) : '-';
const limitMB = hasStats ? (container.memory_limit / 1024 / 1024).toFixed(0) : '?';
const memoryPercent = hasStats ? container.memory_percent.toFixed(1) + '%' : '-';
const cardId = `monitoring-card-${index}`;
const chartId = `monitoring-chart-${index}`;
// Debug logging
if (container.host_id !== 1) { // Log non-local containers
console.log(`Container ${container.name} (host: ${container.host_name}, hostId: ${container.host_id}):`, {
cpu_percent: container.cpu_percent,
memory_usage: container.memory_usage,
memory_limit: container.memory_limit,
hasStats: hasStats,
willRenderChart: hasStats
});
}
return `
<div class="monitoring-card" id="${cardId}">
<div class="monitoring-card-header">
<div>
<div class="monitoring-card-title">${escapeHtml(container.name)}</div>
<div class="monitoring-card-host">📍 ${escapeHtml(container.host_name)}</div>
<div class="monitoring-card-host">🖼️ ${escapeHtml(container.image)}</div>
</div>
</div>
<div class="monitoring-card-stats">
<div class="monitoring-stat">
<div class="monitoring-stat-label">CPU Usage</div>
<div class="monitoring-stat-value">${cpuDisplay}</div>
</div>
<div class="monitoring-stat">
<div class="monitoring-stat-label">Memory</div>
<div class="monitoring-stat-value">${memoryMB} MB</div>
<div class="monitoring-stat-label" style="margin-top: 5px;">of ${limitMB} MB (${memoryPercent})</div>
</div>
</div>
${hasStats ? `
<div class="monitoring-chart">
<canvas id="${chartId}"></canvas>
<div id="${chartId}-placeholder" style="display: none; text-align: center; color: #999; padding: 20px; font-size: 12px;">
Collecting data... Check back in a few minutes
</div>
</div>
` : ''}
${hasStats ? `
<button class="btn btn-primary stats-btn-${index}" data-index="${index}">
📊 View Detailed Stats
</button>
` : `
<button class="btn btn-secondary" disabled title="Stats collection not enabled or no data yet">
📊 No Stats Available
</button>
`}
</div>
`;
}).join('');
// Add event listeners to stats buttons and render mini charts
containersToRender.forEach((container, index) => {
const hasStats = container.memory_limit > 0;
if (hasStats) {
const btn = document.querySelector(`.stats-btn-${index}`);
if (btn) {
btn.addEventListener('click', () => {
console.log('Opening stats modal for:', container.name, 'hostId:', container.host_id, 'containerId:', container.id);
openStatsModal(container.host_id, container.id, container.name);
});
}
// Render mini sparkline chart
renderMiniChart(`monitoring-chart-${index}`, container.host_id, container.id);
}
});
}
// Render mini sparkline chart for monitoring cards
async function renderMiniChart(canvasId, hostId, containerId) {
try {
// Fetch last hour of stats for sparkline
const url = `/api/containers/${hostId}/${containerId}/stats?range=1h`;
console.log(`Fetching stats from: ${url}`);
const response = await fetch(url);
if (!response.ok) {
console.error(`Failed to fetch stats for ${canvasId}: ${response.status} ${response.statusText}`);
return;
}
const stats = await response.json();
console.log(`Stats for ${canvasId}:`, stats ? stats.length : 'null', 'data points');
const canvas = document.getElementById(canvasId);
const placeholder = document.getElementById(`${canvasId}-placeholder`);
if (!canvas) return;
if (!stats || stats.length === 0) {
console.warn(`No stats data available for ${canvasId} - showing placeholder`);
// Hide canvas and show placeholder message
canvas.style.display = 'none';
if (placeholder) {
placeholder.style.display = 'block';
}
return;
}
// Hide placeholder if data exists
if (placeholder) {
placeholder.style.display = 'none';
}
canvas.style.display = 'block';
// Destroy existing chart if it exists to avoid "Canvas is already in use" error
const existingChart = Chart.getChart(canvasId);
if (existingChart) {
existingChart.destroy();
}
const ctx = canvas.getContext('2d');
// Take last 20 points for sparkline
const recentStats = stats.slice(-20);
const cpuData = recentStats.map(s => s.cpu_percent || 0);
const memoryData = recentStats.map(s => (s.memory_usage || 0) / 1024 / 1024);
new Chart(ctx, {
type: 'line',
data: {
labels: recentStats.map(() => ''),
datasets: [
{
label: 'CPU %',
data: cpuData,
borderColor: 'rgb(75, 192, 192)',
backgroundColor: 'rgba(75, 192, 192, 0.1)',
borderWidth: 2,
pointRadius: 0,
tension: 0.4,
yAxisID: 'y'
},
{
label: 'Memory MB',
data: memoryData,
borderColor: 'rgb(255, 99, 132)',
backgroundColor: 'rgba(255, 99, 132, 0.1)',
borderWidth: 2,
pointRadius: 0,
tension: 0.4,
yAxisID: 'y1'
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false
},
plugins: {
legend: {
display: true,
position: 'top',
labels: {
boxWidth: 12,
padding: 8,
font: {
size: 11
}
}
},
tooltip: {
enabled: true,
mode: 'index',
intersect: false,
callbacks: {
label: function(context) {
let label = context.dataset.label || '';
if (label) {
label += ': ';
}
if (context.parsed.y !== null) {
label += context.parsed.y.toFixed(2);
if (context.dataset.yAxisID === 'y') {
label += '%';
} else {
label += ' MB';
}
}
return label;
}
}
}
},
scales: {
x: {
display: false
},
y: {
display: true,
beginAtZero: true,
position: 'left',
title: {
display: true,
text: 'CPU %',
font: {
size: 10
}
},
ticks: {
font: {
size: 9
}
}
},
y1: {
display: true,
beginAtZero: true,
position: 'right',
title: {
display: true,
text: 'Memory MB',
font: {
size: 10
}
},
ticks: {
font: {
size: 9
}
},
grid: {
drawOnChartArea: false
}
}
}
}
});
} catch (error) {
console.error('Error rendering mini chart:', error);
}
}
// Load version from API
async function loadVersion() {
try {
const response = await fetch('/api/health');
const data = await response.json();
const badge = document.getElementById('versionBadge');
if (data.version) {
// Format build time for display
let buildTimeText = '';
if (data.build_time && data.build_time !== 'unknown') {
const buildDate = new Date(data.build_time);
buildTimeText = `\nBuilt: ${buildDate.toLocaleString()}`;
}
if (data.update_available && data.latest_version) {
// Show update indicator
badge.innerHTML = `v${data.version} → v${data.latest_version} <span style="font-size: 1.2em;">⬆️</span>`;
badge.style.cursor = 'pointer';
badge.title = `Click to view update${buildTimeText}`;
badge.onclick = () => {
if (data.release_url) {
window.open(data.release_url, '_blank');
}
};
// Log update notification
console.log(`🎉 Container Census update available: v${data.version} → v${data.latest_version}`);
console.log(` Download: ${data.release_url || 'https://github.com/selfhosters-cc/container-census/releases'}`);
} else {
// No update available
badge.textContent = 'v' + data.version;
badge.style.cursor = 'default';
badge.title = `Current version${buildTimeText}`;
badge.onclick = null;
}
}
// Show/hide logout button based on auth status
const logoutBtn = document.getElementById('logoutBtn');
if (logoutBtn) {
logoutBtn.style.display = data.auth_enabled ? '' : 'none';
}
} catch (error) {
console.error('Error loading version:', error);
}
}
// Load telemetry schedule from API
async function loadTelemetrySchedule() {
try {
const response = await fetch('/api/telemetry/schedule');
const data = await response.json();
const scheduleDiv = document.getElementById('telemetrySchedule');
if (data.enabled_endpoints === 0) {
scheduleDiv.innerHTML = '<small style="color: #999;">No automatic telemetry (no endpoints configured)</small>';
return;
}
if (data.next_submission) {
const nextDate = new Date(data.next_submission);
const now = new Date();
const diffMs = nextDate - now;
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffMins = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
let timeStr = '';
if (diffMs < 0) {
timeStr = 'overdue';
} else if (diffHours < 1) {
timeStr = `in ${diffMins} minutes`;
} else if (diffHours < 24) {
timeStr = `in ${diffHours} hour${diffHours > 1 ? 's' : ''}`;
} else {
const diffDays = Math.floor(diffHours / 24);
timeStr = `in ${diffDays} day${diffDays > 1 ? 's' : ''}`;
}
const endpointText = data.enabled_endpoints === 1 ? 'endpoint' : 'endpoints';
scheduleDiv.innerHTML = `<small style="color: #999;">Next telemetry: ${timeStr} to ${data.enabled_endpoints} ${endpointText}</small>`;
} else if (data.message) {
scheduleDiv.innerHTML = `<small style="color: #999;">${data.message}</small>`;
}
} catch (error) {
console.error('Error loading telemetry schedule:', error);
}
}
// Load UI settings from system settings
// Auto-refresh
function startAutoRefresh() {
const checkbox = document.getElementById('autoRefresh');
if (checkbox.checked) {
autoRefreshInterval = setInterval(() => {
// Always refresh telemetry schedule
loadTelemetrySchedule();
if (currentTab === 'containers') {
loadContainers();
} else if (currentTab === 'images') {
loadImages();
} else if (currentTab === 'activity') {
loadActivityLog();
} else if (currentTab === 'settings') {
loadCollectors(); // Auto-refresh telemetry status
}
}, 30000); // 30 seconds
}
}
function stopAutoRefresh() {
if (autoRefreshInterval) {
clearInterval(autoRefreshInterval);
autoRefreshInterval = null;
}
}
function handleAutoRefreshToggle(e) {
if (e.target.checked) {
startAutoRefresh();
} else {
stopAutoRefresh();
}
}
// Data Loading
async function loadData() {
try {
await loadUISettings();
await Promise.all([
loadHosts(),
loadContainers(),
loadActivityLog()
]);
updateStats();
updateHostFilter();
updateNavigationBadges();
markRefresh();
if (currentTab === 'images') {
await loadImages();
} else if (currentTab === 'hosts') {
renderHosts(hosts);
}
} catch (error) {
console.error('Error loading data:', error);
}
}
async function loadHosts() {
try {
const response = await fetch('/api/hosts');
const data = await response.json();
hosts = Array.isArray(data) ? data : [];
} catch (error) {
console.error('Error loading hosts:', error);
hosts = [];
}
}
async function loadContainers() {
try {
const response = await fetch('/api/containers');
const data = await response.json();
const allContainers = Array.isArray(data) ? data : [];
// Debug: Log first container's image tags
if (allContainers.length > 0) {
const first = allContainers[0];
console.log('DEBUG - First container:', {
name: first.name,
image: first.image,
image_id: first.image_id,
image_tags: first.image_tags
});
}
// Filter to only show containers from enabled hosts
const enabledHostIds = new Set((hosts || []).filter(h => h.enabled).map(h => h.id));
containers = allContainers.filter(c => enabledHostIds.has(c.host_id));
// Only render/filter if we're on the containers tab
// Other tabs (like monitoring) will handle their own rendering
if (currentTab === 'containers') {
filterContainers();
updateStats();
updateNavigationBadges();
markRefresh();
}
} catch (error) {
console.error('Error loading containers:', error);
containers = [];
if (currentTab === 'containers') {
document.getElementById('containersBody').innerHTML =
'<tr><td colspan="8" class="error">Failed to load containers</td></tr>';
}
}
}
async function loadImages() {
try {
const response = await fetch('/api/images');
images = await response.json() || {};
// Apply filters if any are active
applyCurrentFilters();
updateNavigationBadges();
markRefresh();
} catch (error) {
console.error('Error loading images:', error);
images = {};
document.getElementById('imagesBody').innerHTML =
'<tr><td colspan="7" class="error">Failed to load images</td></tr>';
}
}
async function loadActivityLog() {
try {
const activityType = document.getElementById('activityTypeFilter')?.value || 'all';
const response = await fetch(`/api/activity-log?limit=50&type=${activityType}`);
const data = await response.json();
activities = Array.isArray(data) ? data : [];
renderActivityLog(activities);
updateStats();
updateNavigationBadges();
markRefresh();
} catch (error) {
console.error('Error loading activity log:', error);
activities = [];
document.getElementById('activityLogBody').innerHTML =
'<tr><td colspan="6" class="error">Failed to load activity log</td></tr>';
}
}
async function triggerScan() {
const btn = document.getElementById('scanBtn');
const btnIcon = document.getElementById('scanBtnIcon');
btn.disabled = true;
btn.classList.add('scanning');
if (btnIcon) btnIcon.classList.add('spinning');
showToast('Scan Started', 'Scanning all configured hosts...', 'info');
const resetButton = () => {
btn.disabled = false;
btn.classList.remove('scanning');
if (btnIcon) btnIcon.classList.remove('spinning');
};
try {
const startTime = Date.now();
const response = await fetch('/api/scan', { method: 'POST' });
if (response.ok) {
// Wait 3 seconds then refresh data once and reset button
setTimeout(async () => {
await loadData();
resetButton();
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
showToast('Scan Complete', `Scan finished in ${duration}s`, 'success');
}, 3000);
} else {
resetButton();
throw new Error('Scan request failed');
}
} catch (error) {
console.error('Error triggering scan:', error);
resetButton();
showToast('Scan Failed', 'Failed to trigger scan: ' + error.message, 'error');
}
}
async function submitTelemetry() {
const btn = document.getElementById('submitTelemetryBtn');
btn.disabled = true;
btn.classList.add('submitting');
// Add visual indicator to collector items
const collectorItems = document.querySelectorAll('.collector-item');
collectorItems.forEach(item => {
item.classList.add('submitting');
});
try {
const response = await fetch('/api/telemetry/submit', { method: 'POST' });
if (response.ok) {
const data = await response.json();
showNotification(data.message || 'Telemetry submitted successfully', 'success');
// Wait a moment for submission to complete, then refresh status
setTimeout(async () => {
if (currentTab === 'settings') {
await loadCollectors();
}
}, 1500);
} else {
const error = await response.json();
showNotification('Failed to submit telemetry: ' + (error.error || 'Unknown error'), 'error');
// Remove submitting state on error
collectorItems.forEach(item => {
item.classList.remove('submitting');
});
}
} catch (error) {
console.error('Error submitting telemetry:', error);
showNotification('Failed to submit telemetry: ' + error.message, 'error');
// Remove submitting state on error
collectorItems.forEach(item => {
item.classList.remove('submitting');
});
} finally {
btn.disabled = false;
btn.classList.remove('submitting');
}
}
// Container Management Actions
async function startContainer(hostId, containerId, containerName) {
try {
const response = await fetch(`/api/containers/${hostId}/${containerId}/start`, {
method: 'POST'
});
if (response.ok) {
showNotification(`Container "${containerName}" started successfully`, 'success');
// Trigger a scan to get updated state
setTimeout(async () => {
await fetch('/api/scan', { method: 'POST' });
await loadData();
}, 2000);
} else {
const error = await response.json();
showNotification(`Failed to start container: ${error.error}`, 'error');
}
} catch (error) {
console.error('Error starting container:', error);
showNotification('Failed to start container', 'error');
}
}
async function stopContainer(hostId, containerId, containerName) {
showConfirmDialog(
'Stop Container',
`Are you sure you want to stop "${containerName}"?`,
async () => {
try {
const response = await fetch(`/api/containers/${hostId}/${containerId}/stop`, {
method: 'POST'
});
if (response.ok) {
showNotification(`Container "${containerName}" stopped successfully`, 'success');
// Trigger a scan to get updated state
setTimeout(async () => {
await fetch('/api/scan', { method: 'POST' });
await loadData();
}, 2000);
} else {
const error = await response.json();
showNotification(`Failed to stop container: ${error.error}`, 'error');
}
} catch (error) {
console.error('Error stopping container:', error);
showNotification('Failed to stop container', 'error');
}
}
);
}
async function restartContainer(hostId, containerId, containerName) {
showConfirmDialog(
'Restart Container',
`Are you sure you want to restart "${containerName}"?`,
async () => {
try {
const response = await fetch(`/api/containers/${hostId}/${containerId}/restart`, {
method: 'POST'
});
if (response.ok) {
showNotification(`Container "${containerName}" restarted successfully`, 'success');
// Trigger a scan to get updated state
setTimeout(async () => {
await fetch('/api/scan', { method: 'POST' });
await loadData();
}, 2000);
} else {
const error = await response.json();
showNotification(`Failed to restart container: ${error.error}`, 'error');
}
} catch (error) {
console.error('Error restarting container:', error);
showNotification('Failed to restart container', 'error');
}
}
);
}
async function removeContainer(hostId, containerId, containerName) {
showConfirmDialog(
'Remove Container',
`Are you sure you want to remove "${containerName}"? This action cannot be undone.`,
async () => {
try {
const response = await fetch(`/api/containers/${hostId}/${containerId}?force=true`, {
method: 'DELETE'
});
if (response.ok) {
showNotification(`Container "${containerName}" removed successfully`, 'success');
// Immediately remove from local state
containers = containers.filter(c => !(c.host_id === hostId && c.id === containerId));
// Update UI immediately
if (currentTab === 'containers') {
filterContainers(); // This will re-render with current filters
}
// Update stats
updateStats();
// Trigger a scan in the background to sync the database
fetch('/api/scan', { method: 'POST' }).catch(err =>
console.log('Background scan triggered:', err)
);
} else {
const error = await response.json();
showNotification(`Failed to remove container: ${error.error}`, 'error');
}
} catch (error) {
console.error('Error removing container:', error);
showNotification('Failed to remove container', 'error');
}
},
'danger'
);
}
// Track current log view state for refresh
let currentLogView = {
hostId: null,
containerName: null
};
async function viewLogs(hostId, containerName, displayName) {
// Store current view for refresh
currentLogView.hostId = hostId;
currentLogView.containerName = containerName;
document.getElementById('logContainerName').textContent = displayName || containerName;
document.getElementById('logContent').textContent = 'Loading logs...';
document.getElementById('logModal').classList.add('show');
try {
// Use container name instead of ID for reliability after updates
const response = await fetch(`/api/containers/${hostId}/${encodeURIComponent(containerName)}/logs?tail=500`);
if (response.ok) {
const data = await response.json();
document.getElementById('logContent').textContent = data.logs || 'No logs available';
} else {
const error = await response.json();
document.getElementById('logContent').textContent = `Error: ${error.error}`;
}
} catch (error) {
console.error('Error loading logs:', error);
document.getElementById('logContent').textContent = 'Failed to load logs: ' + error.message;
}
}
// Refresh logs for currently viewed container
async function refreshLogs() {
if (!currentLogView.hostId || !currentLogView.containerName) {
showNotification('No logs currently loaded', 'warning');
return;
}
const displayName = document.getElementById('logContainerName').textContent;
document.getElementById('logContent').textContent = 'Refreshing logs...';
try {
const response = await fetch(`/api/containers/${currentLogView.hostId}/${encodeURIComponent(currentLogView.containerName)}/logs?tail=500`);
if (response.ok) {
const data = await response.json();
document.getElementById('logContent').textContent = data.logs || 'No logs available';
showNotification('Logs refreshed', 'success');
} else {
const error = await response.json();
document.getElementById('logContent').textContent = `Error: ${error.error}`;
showNotification('Failed to refresh logs', 'error');
}
} catch (error) {
console.error('Error refreshing logs:', error);
document.getElementById('logContent').textContent = 'Failed to refresh logs: ' + error.message;
showNotification('Failed to refresh logs', 'error');
}
}
// Image Management Actions
async function removeImage(hostId, imageId, imageName) {
showConfirmDialog(
'Remove Image',
`Are you sure you want to remove image "${imageName}"?`,
async () => {
try {
const response = await fetch(`/api/images/${hostId}/${encodeURIComponent(imageId)}?force=true`, {
method: 'DELETE'
});
if (response.ok) {
showNotification(`Image "${imageName}" removed successfully`, 'success');
// Immediately remove from local state
for (const [hostName, hostData] of Object.entries(images)) {
if (hostData.host_id === hostId) {
images[hostName].images = (hostData.images || []).filter(img => img.Id !== imageId);
break;
}
}
// Update UI immediately
if (currentTab === 'images') {
renderImages(images);
}
// Trigger a scan in the background to sync the database
fetch('/api/scan', { method: 'POST' }).catch(err =>
console.log('Background scan triggered:', err)
);
} else {
const error = await response.json();
showNotification(`Failed to remove image: ${error.error}`, 'error');
}
} catch (error) {
console.error('Error removing image:', error);
showNotification('Failed to remove image', 'error');
}
},
'danger'
);
}
async function pruneImages(hostId, hostName) {
showConfirmDialog(
'Prune Images',
`Are you sure you want to prune all unused images on "${hostName}"? This will remove all dangling images.`,
async () => {
try {
const response = await fetch(`/api/images/host/${hostId}/prune`, {
method: 'POST'
});
if (response.ok) {
const data = await response.json();
const sizeMB = (data.space_reclaimed / (1024 * 1024)).toFixed(2);
showNotification(`Images pruned successfully. Space reclaimed: ${sizeMB} MB`, 'success');
await loadImages();
} else {
const error = await response.json();
showNotification(`Failed to prune images: ${error.error}`, 'error');
}
} catch (error) {
console.error('Error pruning images:', error);
showNotification('Failed to prune images', 'error');
}
}
);
}
// Theme-specific card renderers
function renderCompactCard(cont) {
// Debug: Log image tags for first container only
if (window.debugImageTags !== true) {
console.log('Container:', cont.name, 'Image:', cont.image, 'Tags:', cont.image_tags);
window.debugImageTags = true;
}
const isRunning = cont.state === 'running';
const isStopped = cont.state === 'exited';
const isPaused = cont.state === 'paused';
const hasStats = cont.cpu_percent > 0 || cont.memory_usage > 0;
const cpuDisplay = cont.cpu_percent > 0 ? cont.cpu_percent.toFixed(1) + '%' : '-';
const memoryMB = cont.memory_usage > 0 ? (cont.memory_usage / 1024 / 1024).toFixed(0) : '-';
const limitMB = cont.memory_limit > 0 ? (cont.memory_limit / 1024 / 1024).toFixed(0) : '?';
const memoryDisplay = cont.memory_usage > 0 ? `${memoryMB}MB / ${limitMB}MB` : '-';
const memoryPercent = cont.memory_percent > 0 ? cont.memory_percent.toFixed(1) : '0';
const cpuPercent = cont.cpu_percent > 0 ? cont.cpu_percent.toFixed(1) : '0';
const stateIcon = isRunning ? '✅' : isStopped ? '⏹️' : isPaused ? '⏸️' : '❓';
const createdTime = formatDate(cont.created);
const statusText = cont.status || '-';
const uptime = isRunning && cont.started_at ? formatUptime(cont.started_at) : '';
return `
<div class="container-card-modern theme-compact ${cont.state}">
<div class="metro-status-bar"></div>
<div class="metro-content">
<div class="metro-header">
<div class="metro-title-section">
<div class="metro-icon">${stateIcon}</div>
<div class="metro-title-info">
<h3 class="metro-name">${escapeHtml(cont.name)}</h3>
<div class="metro-chips">
<span class="chip chip-host">📍 ${escapeHtml(cont.host_name)}</span>
<span class="chip chip-state ${cont.state}">${cont.state}</span>
<span class="chip chip-image" title="${escapeHtml(cont.image)}">🏷️ ${escapeHtml(extractImageTag(cont.image, cont.image_tags))}</span>
${uptime ? `<span class="chip chip-uptime" title="Uptime">⏱️ ${uptime}</span>` : `<span class="chip chip-time">📅 ${createdTime}</span>`}
</div>
</div>
</div>
<div class="metro-actions">
${hasStats && isRunning ? `
<button class="btn-icon" onclick="openStatsModal(${cont.host_id}, '${escapeAttr(cont.id)}', '${escapeAttr(cont.name)}')" title="View Stats">📊</button>
` : ''}
${hasStats && isRunning ? `
<button class="btn-icon" onclick="viewContainerTimeline(${cont.host_id}, '${escapeAttr(cont.id)}', '${escapeAttr(cont.name)}')" title="Timeline">📈</button>
` : ''}
</div>
</div>
<div class="metro-details">
<div class="detail-inline">
<span class="detail-label">🖼️ Image:</span>
<code class="detail-value">${escapeHtml(cont.image)}</code>
${cont.update_available ? '<span class="badge-update">⬆️ Update Available</span>' : ''}
</div>
${(cont.image.endsWith(':latest') || !cont.image.includes(':')) && isRunning ? `
<button class="btn btn-xs btn-primary" onclick="checkContainerUpdate(${cont.host_id}, '${escapeAttr(cont.name)}', '${escapeAttr(cont.name)}')" title="Check for updates">
🔍 Check
</button>
` : ''}
${cont.update_available ? `
<button class="btn btn-xs btn-success" onclick="updateContainer(${cont.host_id}, '${escapeAttr(cont.name)}', '${escapeAttr(cont.name)}', '${escapeAttr(cont.image)}')" title="Update image">
⬆️ Update
</button>
` : ''}
${cont.ports && cont.ports.length > 0 && cont.ports.some(p => p.public_port > 0) ? `
<div class="detail-inline">
<span class="detail-label">🔌 Ports:</span>
<span class="detail-value">${formatPorts(cont.ports)}</span>
</div>
` : ''}
${statusText !== '-' ? `
<div class="detail-inline">
<span class="detail-label">📝 Status:</span>
<span class="detail-value">${escapeHtml(statusText)}</span>
</div>
` : ''}
</div>
${hasStats ? `
<div class="metro-metrics">
<div class="metric-inline">
<span class="metric-icon">💻</span>
<span class="metric-label">CPU</span>
<span class="metric-value">${cpuDisplay}</span>
<div class="metric-bar">
<div class="metric-bar-fill" style="width: ${cpuPercent}%"></div>
</div>
</div>
<div class="metric-inline">
<span class="metric-icon">💾</span>
<span class="metric-label">Memory</span>
<span class="metric-value">${memoryDisplay}</span>
<div class="metric-bar">
<div class="metric-bar-fill" style="width: ${memoryPercent}%"></div>
</div>
</div>
</div>
` : ''}
<div class="metro-footer">
<button class="btn-metro btn-sm" onclick="viewLogs(${cont.host_id}, '${escapeAttr(cont.name)}', '${escapeAttr(cont.name)}')">📋 Logs</button>
${isRunning ? `
<button class="btn-metro btn-sm warning" onclick="restartContainer(${cont.host_id}, '${escapeAttr(cont.id)}', '${escapeAttr(cont.name)}')">🔄 Restart</button>
<button class="btn-metro btn-sm warning" onclick="stopContainer(${cont.host_id}, '${escapeAttr(cont.id)}', '${escapeAttr(cont.name)}')">⏹ Stop</button>
` : ''}
${isStopped ? `
<button class="btn-metro btn-sm success" onclick="startContainer(${cont.host_id}, '${escapeAttr(cont.id)}', '${escapeAttr(cont.name)}')">▶ Start</button>
<button class="btn-metro btn-sm danger" onclick="removeContainer(${cont.host_id}, '${escapeAttr(cont.id)}', '${escapeAttr(cont.name)}')">🗑 Remove</button>
` : ''}
${isPaused ? `
<button class="btn-metro btn-sm success" onclick="startContainer(${cont.host_id}, '${escapeAttr(cont.id)}', '${escapeAttr(cont.name)}')">▶ Resume</button>
<button class="btn-metro btn-sm warning" onclick="stopContainer(${cont.host_id}, '${escapeAttr(cont.id)}', '${escapeAttr(cont.name)}')">⏹ Stop</button>
` : ''}
</div>
</div>
</div>
`;
}
function renderMaterialCard(cont) {
const isRunning = cont.state === 'running';
const isStopped = cont.state === 'exited';
const isPaused = cont.state === 'paused';
const hasStats = cont.cpu_percent > 0 || cont.memory_usage > 0;
const cpuDisplay = cont.cpu_percent > 0 ? cont.cpu_percent.toFixed(1) : '0';
const memoryMB = cont.memory_usage > 0 ? (cont.memory_usage / 1024 / 1024).toFixed(0) : '0';
const limitMB = cont.memory_limit > 0 ? (cont.memory_limit / 1024 / 1024).toFixed(0) : '?';
const memoryGB = cont.memory_limit > 0 ? (cont.memory_limit / 1024 / 1024 / 1024).toFixed(1) : '?';
const memoryPercent = cont.memory_percent > 0 ? cont.memory_percent.toFixed(1) : '0';
const stateIcon = isRunning ? '✅' : isStopped ? '⏹️' : isPaused ? '⏸️' : '❓';
const createdTime = formatDate(cont.created);
const statusText = cont.status || '-';
const uptime = isRunning && cont.started_at ? formatUptime(cont.started_at) : '';
return `
<div class="container-card-modern theme-material ${cont.state}">
<div class="material-header ${cont.state}">
<div class="material-header-content">
<div class="material-status-icon">${stateIcon}</div>
<div class="material-title-section">
<h3 class="material-name">${escapeHtml(cont.name)}</h3>
<div class="material-meta">
<span class="material-meta-item">📍 ${escapeHtml(cont.host_name)}</span>
<span class="material-meta-separator">•</span>
<span class="material-meta-item" title="${escapeHtml(cont.image)}">🏷️ ${escapeHtml(extractImageTag(cont.image, cont.image_tags))}</span>
<span class="material-meta-separator">•</span>
<span class="material-meta-item">${uptime ? `⏱️ ${uptime}` : `📅 ${createdTime}`}</span>
</div>
</div>
</div>
<div class="material-header-actions">
${hasStats && isRunning ? `
<button class="material-fab" onclick="openStatsModal(${cont.host_id}, '${escapeAttr(cont.id)}', '${escapeAttr(cont.name)}')" title="View Stats">📊</button>
` : ''}
${hasStats && isRunning ? `
<button class="material-fab" onclick="viewContainerTimeline(${cont.host_id}, '${escapeAttr(cont.id)}', '${escapeAttr(cont.name)}')" title="Timeline">📈</button>
` : ''}
</div>
</div>
<div class="material-body">
<div class="material-section">
<div class="material-label">Image</div>
<div class="material-value">
<code>${escapeHtml(cont.image)}</code>
${cont.update_available ? '<span class="material-chip update">⬆️ Update Available</span>' : ''}
</div>
${(cont.image.endsWith(':latest') || !cont.image.includes(':')) && isRunning ? `
<button class="btn btn-xs btn-primary" onclick="checkContainerUpdate(${cont.host_id}, '${escapeAttr(cont.name)}', '${escapeAttr(cont.name)}')" title="Check for updates">
🔍 Check
</button>
` : ''}
${cont.update_available ? `
<button class="btn btn-xs btn-success" onclick="updateContainer(${cont.host_id}, '${escapeAttr(cont.name)}', '${escapeAttr(cont.name)}', '${escapeAttr(cont.image)}')" title="Update image">
⬆️ Update
</button>
` : ''}
</div>
${cont.ports && cont.ports.length > 0 && cont.ports.some(p => p.public_port > 0) ? `
<div class="material-section">
<div class="material-label">Ports</div>
<div class="material-value">
${cont.ports.filter(p => p.public_port > 0).map(p =>
`<span class="port-badge">${p.public_port}:${p.private_port}</span>`
).join('')}
</div>
</div>
` : ''}
${statusText !== '-' ? `
<div class="material-section">
<div class="material-label">Exit Status</div>
<div class="material-value">
<span class="${isStopped ? 'status-success' : ''}">${escapeHtml(statusText)}</span>
</div>
</div>
` : ''}
${hasStats ? `
<div class="material-metrics-grid">
<div class="material-metric-card cpu">
<div class="metric-header-row">
<span class="metric-icon-large">💻</span>
</div>
<div class="metric-label-text">CPU Usage</div>
<div class="metric-value-large">${cpuDisplay}<span class="metric-unit">%</span></div>
<div class="metric-progress">
<div class="metric-progress-fill" style="width: ${cpuDisplay}%"></div>
</div>
</div>
<div class="material-metric-card memory">
<div class="metric-header-row">
<span class="metric-icon-large">💾</span>
</div>
<div class="metric-label-text">Memory Usage</div>
<div class="metric-value-large">${memoryMB}<span class="metric-unit">MB</span></div>
<div class="metric-secondary">of ${memoryGB}GB (${memoryPercent}%)</div>
<div class="metric-progress">
<div class="metric-progress-fill" style="width: ${memoryPercent}%"></div>
</div>
</div>
</div>
` : ''}
</div>
<div class="material-footer">
<button class="material-btn outlined" onclick="viewLogs(${cont.host_id}, '${escapeAttr(cont.name)}', '${escapeAttr(cont.name)}')">📋 View Logs</button>
${isRunning ? `
<button class="material-btn outlined warning" onclick="restartContainer(${cont.host_id}, '${escapeAttr(cont.id)}', '${escapeAttr(cont.name)}')">🔄 Restart</button>
<button class="material-btn outlined warning" onclick="stopContainer(${cont.host_id}, '${escapeAttr(cont.id)}', '${escapeAttr(cont.name)}')">⏹ Stop</button>
` : ''}
${isStopped ? `
<button class="material-btn filled success" onclick="startContainer(${cont.host_id}, '${escapeAttr(cont.id)}', '${escapeAttr(cont.name)}')">▶ Start</button>
<button class="material-btn text danger" onclick="removeContainer(${cont.host_id}, '${escapeAttr(cont.id)}', '${escapeAttr(cont.name)}')">🗑 Remove</button>
` : ''}
${isPaused ? `
<button class="material-btn filled success" onclick="startContainer(${cont.host_id}, '${escapeAttr(cont.id)}', '${escapeAttr(cont.name)}')">▶ Resume</button>
<button class="material-btn outlined warning" onclick="stopContainer(${cont.host_id}, '${escapeAttr(cont.id)}', '${escapeAttr(cont.name)}')">⏹ Stop</button>
` : ''}
</div>
</div>
`;
}
function renderDashboardCard(cont) {
const isRunning = cont.state === 'running';
const isStopped = cont.state === 'exited';
const isPaused = cont.state === 'paused';
const hasStats = cont.cpu_percent > 0 || cont.memory_usage > 0;
const cpuDisplay = cont.cpu_percent > 0 ? cont.cpu_percent.toFixed(1) + '%' : '-';
const memoryMB = cont.memory_usage > 0 ? (cont.memory_usage / 1024 / 1024).toFixed(0) : '-';
const limitMB = cont.memory_limit > 0 ? (cont.memory_limit / 1024 / 1024).toFixed(0) : '?';
const createdTime = formatDate(cont.created);
const statusText = cont.status || '-';
const uptime = isRunning && cont.started_at ? formatUptime(cont.started_at) : '';
return `
<div class="container-card-modern theme-dashboard ${cont.state}">
<div class="dashboard-header">
<div class="dashboard-title-row">
<div class="dashboard-status-dot"></div>
<h3 class="dashboard-name">${escapeHtml(cont.name)}</h3>
<span class="dashboard-tag">${escapeHtml(cont.host_name)}</span>
<span class="dashboard-tag" title="${escapeHtml(cont.image)}">🏷️ ${escapeHtml(extractImageTag(cont.image, cont.image_tags))}</span>
<span class="dashboard-tag time">${uptime ? `⏱️ ${uptime}` : createdTime}</span>
${cont.update_available ? '<span class="dashboard-tag alert">⬆️ Update</span>' : ''}
</div>
<div class="dashboard-actions-menu">
${hasStats && isRunning ? `
<button class="dashboard-icon-btn" onclick="openStatsModal(${cont.host_id}, '${escapeAttr(cont.id)}', '${escapeAttr(cont.name)}')" title="View Stats">📊</button>
` : ''}
${hasStats && isRunning ? `
<button class="dashboard-icon-btn" onclick="viewContainerTimeline(${cont.host_id}, '${escapeAttr(cont.id)}', '${escapeAttr(cont.name)}')" title="Timeline">📈</button>
` : ''}
</div>
</div>
<div class="dashboard-body">
<div class="dashboard-info-row">
<div class="info-item">
<span class="info-icon">🖼️</span>
<code class="info-code">${escapeHtml(cont.image)}</code>
</div>
${(cont.image.endsWith(':latest') || !cont.image.includes(':')) && isRunning ? `
<button class="btn btn-xs btn-primary" onclick="checkContainerUpdate(${cont.host_id}, '${escapeAttr(cont.name)}', '${escapeAttr(cont.name)}')" title="Check for updates">
🔍 Check
</button>
` : ''}
${cont.update_available ? `
<button class="btn btn-xs btn-success" onclick="updateContainer(${cont.host_id}, '${escapeAttr(cont.name)}', '${escapeAttr(cont.name)}', '${escapeAttr(cont.image)}')" title="Update image">
⬆️ Update
</button>
` : ''}
${cont.ports && cont.ports.length > 0 && cont.ports.some(p => p.public_port > 0) ? `
<div class="info-item">
<span class="info-icon">🔌</span>
<span class="info-text">${formatPorts(cont.ports)}</span>
</div>
` : ''}
${statusText !== '-' && isStopped ? `
<div class="info-item">
<span class="info-icon">📝</span>
<span class="info-text">${escapeHtml(statusText)}</span>
</div>
` : ''}
</div>
${hasStats ? `
<div class="dashboard-metrics-row">
<div class="dashboard-metric">
<div class="metric-top">
<span class="metric-label-small">💻 CPU</span>
<span class="metric-value-small">${cpuDisplay}</span>
</div>
</div>
<div class="dashboard-metric">
<div class="metric-top">
<span class="metric-label-small">💾 Memory</span>
<span class="metric-value-small">${memoryMB}MB</span>
</div>
</div>
</div>
` : ''}
</div>
<div class="dashboard-footer">
<button class="dashboard-btn" onclick="viewLogs(${cont.host_id}, '${escapeAttr(cont.name)}', '${escapeAttr(cont.name)}')">📋 Logs</button>
${isRunning ? `
<button class="dashboard-btn" onclick="restartContainer(${cont.host_id}, '${escapeAttr(cont.id)}', '${escapeAttr(cont.name)}')">🔄 Restart</button>
<button class="dashboard-btn" onclick="stopContainer(${cont.host_id}, '${escapeAttr(cont.id)}', '${escapeAttr(cont.name)}')">⏹ Stop</button>
` : ''}
${isStopped ? `
<button class="dashboard-btn primary" onclick="startContainer(${cont.host_id}, '${escapeAttr(cont.id)}', '${escapeAttr(cont.name)}')">▶ Start</button>
<button class="dashboard-btn danger" onclick="removeContainer(${cont.host_id}, '${escapeAttr(cont.id)}', '${escapeAttr(cont.name)}')">🗑 Remove</button>
` : ''}
${isPaused ? `
<button class="dashboard-btn primary" onclick="startContainer(${cont.host_id}, '${escapeAttr(cont.id)}', '${escapeAttr(cont.name)}')">▶ Resume</button>
<button class="dashboard-btn" onclick="stopContainer(${cont.host_id}, '${escapeAttr(cont.id)}', '${escapeAttr(cont.name)}')">⏹ Stop</button>
` : ''}
</div>
</div>
`;
}
// Rendering
function renderContainers(containersToRender) {
const container = document.getElementById('containersBody');
if (containersToRender.length === 0) {
container.innerHTML = '<div class="loading">No containers found</div>';
return;
}
container.innerHTML = containersToRender.map(cont => {
// Choose renderer based on theme
if (cardDesignTheme === 'compact') {
return renderCompactCard(cont);
} else if (cardDesignTheme === 'material') {
return renderMaterialCard(cont);
} else if (cardDesignTheme === 'dashboard') {
return renderDashboardCard(cont);
} else {
return renderMaterialCard(cont); // fallback
}
}).join('');
}
function renderImages(imagesData) {
const tbody = document.getElementById('imagesBody');
try {
const allImages = [];
for (const [hostName, hostData] of Object.entries(imagesData)) {
const hostId = hostData.host_id;
const images = hostData.images || [];
images.forEach(img => {
allImages.push({
hostId,
hostName,
...img
});
});
}
if (allImages.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="loading">No images found</td></tr>';
return;
}
// Group by host to add prune button
const hostButtons = {};
for (const [hostName, hostData] of Object.entries(imagesData)) {
const hostId = hostData.host_id;
hostButtons[hostName] = `
<button class="btn btn-sm btn-warning" onclick="pruneImages(${hostId}, '${escapeAttr(hostName)}')">
Prune Unused Images (${escapeHtml(hostName)})
</button>
`;
}
// Add prune buttons above table (one button per host)
const imagesSection = document.querySelector('.images-section h2');
let pruneContainer = document.querySelector('.prune-buttons');
if (!pruneContainer) {
pruneContainer = document.createElement('div');
pruneContainer.className = 'prune-buttons';
imagesSection.parentNode.insertBefore(pruneContainer, imagesSection.nextSibling);
}
// Update buttons - will show one "Prune Unused Images" button per host
pruneContainer.innerHTML = Object.values(hostButtons).join(' ');
tbody.innerHTML = allImages.map(img => {
const repoTags = (img.RepoTags && img.RepoTags.length > 0) ? img.RepoTags : ['<none>:<none>'];
const tagParts = repoTags[0].split(':');
const tag = tagParts.pop() || 'none';
const repo = tagParts.join(':') || 'none';
const imageId = img.Id ? img.Id.replace('sha256:', '').substring(0, 12) : 'unknown';
const sizeMB = (img.Size / (1024 * 1024)).toFixed(2);
const created = new Date(img.Created * 1000);
return `
<tr>
<td><strong>${escapeHtml(img.hostName || 'unknown')}</strong></td>
<td><code>${escapeHtml(repo)}</code></td>
<td><code>${escapeHtml(tag)}</code></td>
<td><code>${imageId}</code></td>
<td>${sizeMB} MB</td>
<td class="time-ago">${formatDate(created.toISOString())}</td>
<td class="actions">
<button class="btn-icon btn-delete" onclick="removeImage(${img.hostId}, '${escapeAttr(img.Id || '')}', '${escapeAttr(repoTags[0] || '')}')" title="Remove">🗑</button>
</td>
</tr>
`;
}).join('');
} catch (error) {
console.error('Error rendering images:', error);
tbody.innerHTML = '<tr><td colspan="7" class="error">Error rendering images. Check console for details.</td></tr>';
}
}
function renderActivityLog(activities) {
const tbody = document.getElementById('activityLogBody');
if (!activities || activities.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="loading">No activity logged yet</td></tr>';
return;
}
tbody.innerHTML = activities.map(activity => {
const durationText = `${activity.duration.toFixed(2)}s`;
const typeIcon = activity.type === 'scan' ? '🔍' : '📊';
const typeLabel = activity.type === 'scan' ? 'Scan' : 'Telemetry';
// Build details based on activity type
let details = '';
if (activity.type === 'scan') {
details = `${activity.details.containers_found || 0} containers`;
} else {
const parts = [];
if (activity.details.hosts_count) parts.push(`${activity.details.hosts_count} hosts`);
if (activity.details.containers_count) parts.push(`${activity.details.containers_count} containers`);
if (activity.details.images_count) parts.push(`${activity.details.images_count} images`);
details = parts.join(', ');
}
return `
<tr class="activity-${activity.type}">
<td>${typeIcon} <strong>${typeLabel}</strong></td>
<td><strong>${escapeHtml(activity.target)}</strong></td>
<td class="time-ago">${formatDateTime(activity.timestamp)}</td>
<td>${durationText}</td>
<td class="${activity.success ? 'scan-success' : 'scan-failed'}">
${activity.success ? '✓ Success' : '✗ Failed'}
${activity.error ? `<br><small>${escapeHtml(activity.error)}</small>` : ''}
</td>
<td><small>${details}</small></td>
</tr>
`;
}).join('');
}
function renderHosts(hostsData) {
const tbody = document.getElementById('hostsBody');
if (!hostsData || hostsData.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" class="loading">No hosts configured</td></tr>';
return;
}
tbody.innerHTML = hostsData.map(host => {
let statusBadge;
if (!host.enabled) {
statusBadge = '<span class="badge badge-secondary">Disabled</span>';
} else if (host.host_type === 'agent') {
if (host.agent_status === 'online') {
statusBadge = '<span class="badge badge-success">Online</span>';
} else if (host.agent_status === 'auth_failed') {
statusBadge = '<span class="badge badge-error" title="API token mismatch">Auth Failed</span>';
} else {
statusBadge = '<span class="badge badge-warning">Offline</span>';
}
} else {
statusBadge = '<span class="badge badge-success">Enabled</span>';
}
// For agents, show precise datetime; for others, show relative time
const lastSeen = host.last_seen
? (host.host_type === 'agent' ? formatDateTime(host.last_seen) : formatDate(host.last_seen))
: '-';
const hostType = host.host_type || 'unknown';
const typeIcon = {
'agent': '🤖',
'unix': '🐳',
'tcp': '🌐',
'ssh': '🔐',
'unknown': '❓'
}[hostType] || '❓';
// Show agent version for agent hosts
const agentVersion = host.host_type === 'agent' && host.agent_version
? `<span class="badge badge-info" title="Agent version">v${escapeHtml(host.agent_version)}</span>`
: '';
const statsCollectionBadge = host.collect_stats
? '<span class="badge badge-success" style="cursor: pointer;" onclick="toggleStatsCollection(' + host.id + ', false)" title="Click to disable stats collection">✓ Enabled</span>'
: '<span class="badge badge-secondary" style="cursor: pointer;" onclick="toggleStatsCollection(' + host.id + ', true)" title="Click to enable stats collection">Disabled</span>';
const vulnScanningBadge = host.enable_vulnerability_scanning
? '<span class="badge badge-success" style="cursor: pointer;" onclick="toggleVulnScanning(' + host.id + ', false)" title="Click to disable vulnerability scanning">✓ Enabled</span>'
: '<span class="badge badge-secondary" style="cursor: pointer;" onclick="toggleVulnScanning(' + host.id + ', true)" title="Click to enable vulnerability scanning">Disabled</span>';
// Show Trivy actions only for agent hosts with Trivy capability
const hasTrivyActions = host.host_type === 'agent' && host.agent_status === 'online';
const trivyActions = hasTrivyActions ? `
<div class="dropdown" style="display: inline-block;">
<button class="btn-icon" onclick="toggleHostActionsDropdown(${host.id})" title="Trivy Actions">⚙️</button>
<div id="hostActions${host.id}" class="dropdown-menu" style="display: none;">
<a class="dropdown-item" onclick="updateHostTrivyDB(${host.id})">Update Trivy DB</a>
<a class="dropdown-item" onclick="clearHostTrivyCache(${host.id})">Clear Trivy Cache</a>
</div>
</div>
` : '';
return `
<tr>
<td><strong>${escapeHtml(host.name)}</strong></td>
<td>${typeIcon} ${escapeHtml(hostType)} ${agentVersion}</td>
<td><code>${escapeHtml(host.address)}</code></td>
<td>${statusBadge}</td>
<td>${statsCollectionBadge}</td>
<td>${vulnScanningBadge}</td>
<td>${escapeHtml(host.description || '-')}</td>
<td class="time-ago">${lastSeen}</td>
<td class="actions">
${host.enabled
? `<button class="btn-icon btn-warning" onclick="toggleHost(${host.id}, false)" title="Disable">⏸</button>`
: `<button class="btn-icon btn-success" onclick="toggleHost(${host.id}, true)" title="Enable">▶</button>`
}
${trivyActions}
<button class="btn-icon btn-delete" onclick="deleteHost(${host.id}, '${escapeAttr(host.name)}')" title="Delete">🗑</button>
</td>
</tr>
`;
}).join('');
}
async function toggleHost(hostId, enable) {
try {
const host = hosts.find(h => h.id === hostId);
if (!host) return;
const response = await fetch(`/api/hosts/${hostId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...host, enabled: enable })
});
if (response.ok) {
showNotification(`Host ${enable ? 'enabled' : 'disabled'} successfully`, 'success');
loadData();
} else {
const error = await response.json();
showNotification('Error: ' + (error.error || 'Failed to update host'), 'error');
}
} catch (error) {
showNotification('Error: ' + error.message, 'error');
}
}
async function toggleStatsCollection(hostId, enable) {
try {
const host = hosts.find(h => h.id === hostId);
if (!host) return;
const response = await fetch(`/api/hosts/${hostId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...host, collect_stats: enable })
});
if (response.ok) {
showNotification(`Stats collection ${enable ? 'enabled' : 'disabled'} successfully`, 'success');
loadData();
} else {
const error = await response.json();
showNotification('Error: ' + (error.error || 'Failed to update host'), 'error');
}
} catch (error) {
showNotification('Error: ' + error.message, 'error');
}
}
async function toggleVulnScanning(hostId, enable) {
try {
const host = hosts.find(h => h.id === hostId);
if (!host) return;
const response = await fetch(`/api/hosts/${hostId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...host, enable_vulnerability_scanning: enable })
});
if (response.ok) {
showNotification(`Vulnerability scanning ${enable ? 'enabled' : 'disabled'} successfully`, 'success');
loadData();
} else {
const error = await response.json();
showNotification('Error: ' + (error.error || 'Failed to update host'), 'error');
}
} catch (error) {
showNotification('Error: ' + error.message, 'error');
}
}
function toggleHostActionsDropdown(hostId) {
const dropdown = document.getElementById(`hostActions${hostId}`);
if (dropdown) {
dropdown.style.display = dropdown.style.display === 'none' ? 'block' : 'none';
}
}
async function updateHostTrivyDB(hostId) {
const host = hosts.find(h => h.id === hostId);
if (!host) return;
showNotification(`Updating Trivy database on ${host.name}...`, 'info');
try {
const response = await fetchWithAuth(`/api/hosts/${hostId}/trivy-update`, {
method: 'POST'
});
if (response.ok) {
const result = await response.json();
showNotification(result.message || 'Trivy database updated successfully', 'success');
} else {
const error = await response.json();
showNotification(`Failed: ${error.error || 'Unknown error'}`, 'error');
}
} catch (error) {
showNotification('Error: ' + error.message, 'error');
}
}
async function clearHostTrivyCache(hostId) {
const host = hosts.find(h => h.id === hostId);
if (!host) return;
if (!confirm(`Clear Trivy cache on ${host.name}?\n\nNext scan will download the database again (~500MB).`)) {
return;
}
try {
const response = await fetchWithAuth(`/api/hosts/${hostId}/trivy-clear-cache`, {
method: 'POST'
});
if (response.ok) {
showNotification('Trivy cache cleared successfully', 'success');
} else {
const error = await response.json();
showNotification(`Failed: ${error.error || 'Unknown error'}`, 'error');
}
} catch (error) {
showNotification('Error: ' + error.message, 'error');
}
}
async function updateAllAgentTrivyDBs() {
if (!confirm('Update Trivy databases on all agents? This may take several minutes.')) {
return;
}
showNotification('Updating Trivy databases on all agents...', 'info');
try {
const response = await fetchWithAuth('/api/hosts/bulk-trivy-update', {
method: 'POST'
});
if (response.ok) {
const result = await response.json();
showNotification(`Trivy DB update initiated on ${result.updated} agent(s)`, 'success');
setTimeout(loadHosts, 2000);
} else {
const error = await response.json();
showNotification(`Failed: ${error.error || 'Unknown error'}`, 'error');
}
} catch (error) {
showNotification('Error: ' + error.message, 'error');
}
}
async function deleteHost(hostId, hostName) {
if (!confirm(`Are you sure you want to delete host "${hostName}"?\n\nThis will remove all associated container history.`)) {
return;
}
try {
const response = await fetch(`/api/hosts/${hostId}`, {
method: 'DELETE'
});
if (response.ok) {
showNotification(`Host "${hostName}" deleted successfully`, 'success');
loadData();
} else {
const error = await response.json();
showNotification('Error: ' + (error.error || 'Failed to delete host'), 'error');
}
} catch (error) {
showNotification('Error: ' + error.message, 'error');
}
}
function updateStats() {
const safeHosts = hosts || [];
const safeContainers = containers || [];
const safeActivities = activities || [];
document.getElementById('totalHosts').textContent = safeHosts.length;
document.getElementById('totalContainers').textContent = safeContainers.length;
const running = safeContainers.filter(c => c.state === 'running').length;
document.getElementById('runningContainers').textContent = running;
// Find most recent scan activity
const scanActivities = safeActivities.filter(a => a.type === 'scan');
if (scanActivities.length > 0) {
const lastScan = new Date(scanActivities[0].timestamp);
document.getElementById('lastScan').textContent = formatTimeAgo(lastScan);
} else {
document.getElementById('lastScan').textContent = 'Never';
}
// Update vulnerability stats (if available)
updateVulnerabilityStats();
}
// Update vulnerability statistics in sidebar
async function updateVulnerabilityStats() {
const criticalElem = document.getElementById('criticalVulns');
if (!criticalElem) return;
// If we don't have summary yet, fetch it
if (!vulnerabilitySummary) {
try {
vulnerabilitySummary = await loadVulnerabilitySummary();
} catch (error) {
console.error('Error loading vulnerability summary:', error);
criticalElem.textContent = '-';
return;
}
}
if (vulnerabilitySummary && vulnerabilitySummary.summary) {
const s = vulnerabilitySummary.summary;
const critical = s.severity_counts?.critical || 0;
criticalElem.textContent = critical;
// Add visual indication for high counts
if (critical > 0) {
criticalElem.style.fontWeight = 'bold';
} else {
criticalElem.style.fontWeight = 'normal';
}
}
}
function updateHostFilter() {
// Update both the main host filter and the monitoring tab host filter
const selects = ['hostFilter', 'monitoringHostFilter'];
selects.forEach(selectId => {
const select = document.getElementById(selectId);
if (select) {
const currentValue = select.value;
select.innerHTML = '<option value="">All Hosts</option>' +
hosts.map(host => `<option value="${host.id}">${escapeHtml(host.name)}</option>`).join('');
select.value = currentValue;
}
});
}
// Filtering
function filterContainers() {
const searchTerm = document.getElementById('searchInput')?.value.toLowerCase() || '';
const hostFilter = document.getElementById('hostFilter')?.value || '';
const stateFilter = document.getElementById('stateFilter')?.value || '';
const filtered = containers.filter(container => {
const matchesSearch = searchTerm === '' ||
container.name.toLowerCase().includes(searchTerm) ||
container.image.toLowerCase().includes(searchTerm) ||
container.host_name.toLowerCase().includes(searchTerm);
const matchesHost = hostFilter === '' || container.host_id.toString() === hostFilter;
const matchesState = stateFilter === '' || container.state === stateFilter;
return matchesSearch && matchesHost && matchesState;
});
renderContainers(filtered);
// Load vulnerability badges asynchronously
loadAllVulnerabilityBadges();
}
function filterImages() {
const searchTerm = document.getElementById('searchInput')?.value.toLowerCase() || '';
const hostFilter = document.getElementById('hostFilter')?.value || '';
if (!images || Object.keys(images).length === 0) {
return;
}
// Filter images data structure
const filteredImages = {};
for (const [hostName, hostData] of Object.entries(images)) {
const hostId = hostData.host_id;
const matchesHost = hostFilter === '' || hostId?.toString() === hostFilter;
if (matchesHost) {
const filteredHostImages = hostData.images.filter(img => {
const matchesSearch = searchTerm === '' ||
(img.repository && img.repository.toLowerCase().includes(searchTerm)) ||
(img.tag && img.tag.toLowerCase().includes(searchTerm)) ||
(img.id && img.id.toLowerCase().includes(searchTerm));
return matchesSearch;
});
if (filteredHostImages.length > 0) {
filteredImages[hostName] = {
...hostData,
images: filteredHostImages
};
}
}
}
renderImages(filteredImages);
}
function filterMonitoring() {
const searchTerm = document.getElementById('searchInput')?.value.toLowerCase() || '';
const hostFilter = document.getElementById('hostFilter')?.value || '';
// Get running containers from enabled hosts
const enabledHostIds = new Set(hosts.filter(h => h.enabled).map(h => h.id));
let runningContainers = containers.filter(c =>
c.state === 'running' && enabledHostIds.has(c.host_id)
);
// Apply filters
runningContainers = runningContainers.filter(container => {
const matchesSearch = searchTerm === '' ||
container.name.toLowerCase().includes(searchTerm) ||
container.image.toLowerCase().includes(searchTerm) ||
container.host_name.toLowerCase().includes(searchTerm);
const matchesHost = hostFilter === '' || container.host_id.toString() === hostFilter;
return matchesSearch && matchesHost;
});
renderMonitoringGrid(runningContainers);
}
function filterHistory() {
const searchTerm = document.getElementById('searchInput')?.value.toLowerCase() || '';
const hostFilter = document.getElementById('hostFilter')?.value || '';
if (!lifecycles || lifecycles.length === 0) {
return;
}
// Apply filters to lifecycles
const filteredLifecycles = lifecycles.filter(lifecycle => {
const matchesSearch = searchTerm === '' ||
lifecycle.container_name.toLowerCase().includes(searchTerm) ||
lifecycle.image.toLowerCase().includes(searchTerm) ||
lifecycle.host_name.toLowerCase().includes(searchTerm);
const matchesHost = hostFilter === '' || lifecycle.host_id.toString() === hostFilter;
return matchesSearch && matchesHost;
});
renderContainerHistory(filteredLifecycles);
updateHistoryStats(filteredLifecycles);
}
// Modal Functions
function closeLogModal() {
document.getElementById('logModal').classList.remove('show');
clearLogSearch();
}
// Log Search Functionality
let logSearchMatches = [];
let currentMatchIndex = -1;
let originalLogContent = '';
function searchLogs(direction) {
const searchInput = document.getElementById('logSearchInput');
const logContent = document.getElementById('logContent');
const searchStatus = document.getElementById('logSearchStatus');
const searchTerm = searchInput.value.trim();
if (!searchTerm) {
searchStatus.textContent = '';
return;
}
// If this is a new search, find all matches
if (originalLogContent === '') {
originalLogContent = logContent.textContent;
}
// Find all matches (case-insensitive)
const lines = originalLogContent.split('\n');
logSearchMatches = [];
lines.forEach((line, lineIndex) => {
const lowerLine = line.toLowerCase();
const lowerTerm = searchTerm.toLowerCase();
let index = 0;
while ((index = lowerLine.indexOf(lowerTerm, index)) !== -1) {
logSearchMatches.push({ lineIndex, charIndex: index, length: searchTerm.length });
index += searchTerm.length;
}
});
if (logSearchMatches.length === 0) {
searchStatus.textContent = 'No matches';
logContent.innerHTML = escapeHtml(originalLogContent);
return;
}
// Navigate through matches
if (direction === 'next') {
currentMatchIndex = (currentMatchIndex + 1) % logSearchMatches.length;
} else if (direction === 'prev') {
currentMatchIndex = currentMatchIndex <= 0 ? logSearchMatches.length - 1 : currentMatchIndex - 1;
} else {
currentMatchIndex = 0;
}
// Update status
searchStatus.textContent = `${currentMatchIndex + 1} of ${logSearchMatches.length}`;
// Highlight all matches and mark current one
highlightMatches(lines, searchTerm);
// Scroll to current match
scrollToCurrentMatch();
}
function highlightMatches(lines, searchTerm) {
const logContent = document.getElementById('logContent');
const lowerTerm = searchTerm.toLowerCase();
let html = '';
let globalMatchIndex = 0;
lines.forEach((line, lineIndex) => {
let highlightedLine = '';
let lastIndex = 0;
const lowerLine = line.toLowerCase();
let index = 0;
while ((index = lowerLine.indexOf(lowerTerm, index)) !== -1) {
// Add text before match
highlightedLine += escapeHtml(line.substring(lastIndex, index));
// Add highlighted match
const isCurrent = globalMatchIndex === currentMatchIndex;
const matchClass = isCurrent ? 'current-match' : '';
const matchId = isCurrent ? ' id="current-log-match"' : '';
highlightedLine += `<mark class="${matchClass}"${matchId}>${escapeHtml(line.substring(index, index + searchTerm.length))}</mark>`;
lastIndex = index + searchTerm.length;
index = lastIndex;
globalMatchIndex++;
}
// Add remaining text
highlightedLine += escapeHtml(line.substring(lastIndex));
html += highlightedLine + '\n';
});
logContent.innerHTML = html;
}
function scrollToCurrentMatch() {
const currentMatch = document.getElementById('current-log-match');
if (currentMatch) {
currentMatch.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
function clearLogSearch() {
const searchInput = document.getElementById('logSearchInput');
const logContent = document.getElementById('logContent');
const searchStatus = document.getElementById('logSearchStatus');
searchInput.value = '';
searchStatus.textContent = '';
if (originalLogContent) {
logContent.textContent = originalLogContent;
}
logSearchMatches = [];
currentMatchIndex = -1;
originalLogContent = '';
}
// Add event listener for Enter key in search input
document.addEventListener('DOMContentLoaded', function() {
const searchInput = document.getElementById('logSearchInput');
if (searchInput) {
searchInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
searchLogs(e.shiftKey ? 'prev' : 'next');
} else if (e.key === 'Escape') {
clearLogSearch();
}
});
// Trigger new search when input changes
searchInput.addEventListener('input', function() {
originalLogContent = '';
currentMatchIndex = -1;
if (this.value.trim()) {
searchLogs('next');
} else {
clearLogSearch();
}
});
}
});
function showConfirmDialog(title, message, onConfirm, type = 'warning') {
document.getElementById('confirmTitle').textContent = title;
document.getElementById('confirmMessage').innerHTML = message;
document.getElementById('confirmModal').classList.add('show');
const okBtn = document.getElementById('confirmOkBtn');
okBtn.className = type === 'danger' ? 'btn btn-danger' : 'btn btn-warning';
okBtn.onclick = () => {
closeConfirmModal();
onConfirm();
};
}
function closeConfirmModal() {
document.getElementById('confirmModal').classList.remove('show');
}
// Notification
function showNotification(message, type = 'info') {
// Create notification element
const notification = document.createElement('div');
notification.className = `notification notification-${type}`;
notification.textContent = message;
// Add to page
document.body.appendChild(notification);
// Show with animation
setTimeout(() => notification.classList.add('show'), 10);
// Remove after 5 seconds
setTimeout(() => {
notification.classList.remove('show');
setTimeout(() => notification.remove(), 300);
}, 5000);
}
// Formatting Helpers
function formatPorts(ports) {
if (!ports || ports.length === 0) return '-';
return ports
.filter(p => p.public_port > 0)
.map(p => `${p.public_port}:${p.private_port}/${p.type}`)
.join('<br>') || '-';
}
function formatDate(dateStr) {
if (!dateStr) return '-';
const date = new Date(dateStr);
// Check if date is valid
if (isNaN(date.getTime())) return '-';
// Check if date is zero/epoch or in the far future/past (invalid)
const year = date.getFullYear();
if (year < 1970 || year > 2100) return '-';
const now = new Date();
const diffMs = now - date;
// If date is in the future, return '-'
if (diffMs < 0) return '-';
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'Today';
if (diffDays === 1) return 'Yesterday';
if (diffDays < 7) return `${diffDays} days ago`;
if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`;
if (diffDays < 365) return `${Math.floor(diffDays / 30)} months ago`;
return `${Math.floor(diffDays / 365)} years ago`;
}
function formatDateTime(dateStr) {
const date = new Date(dateStr);
return date.toLocaleString();
}
function formatTimeAgo(date) {
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / (1000 * 60));
if (diffMins < 1) return 'Just now';
if (diffMins === 1) return '1 min ago';
if (diffMins < 60) return `${diffMins} mins ago`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours === 1) return '1 hour ago';
if (diffHours < 24) return `${diffHours} hours ago`;
const diffDays = Math.floor(diffHours / 24);
if (diffDays === 1) return '1 day ago';
return `${diffDays} days ago`;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function escapeAttr(text) {
return text.replace(/'/g, "\\'").replace(/"/g, '&quot;');
}
function extractImageTag(imageName, allTags) {
// If we have all tags for this image, show them (excluding the one already displayed)
// This helps when an image is tagged as both 'latest' and a version number
if (allTags && allTags.length > 0) {
// Extract just the tags from full image names (remove registry/repo parts)
const tags = allTags.map(fullTag => {
const parts = fullTag.split(':');
return parts[parts.length - 1];
}).filter(tag => tag && tag !== '<none>');
// Remove duplicates
const uniqueTags = [...new Set(tags)];
// If we have multiple tags, prioritize showing version number over 'latest'
if (uniqueTags.length > 1) {
// Filter out 'latest' if we have other tags
const nonLatestTags = uniqueTags.filter(t => t !== 'latest');
if (nonLatestTags.length > 0) {
// Show non-latest tags first, then indicate it's also 'latest'
return nonLatestTags.join(', ') + (uniqueTags.includes('latest') ? ' (latest)' : '');
}
return uniqueTags.join(', ');
} else if (uniqueTags.length === 1) {
return uniqueTags[0];
}
}
// Fallback to extracting tag from the image name
if (!imageName || !imageName.includes(':')) {
return 'latest';
}
const parts = imageName.split(':');
return parts[parts.length - 1]; // Get last part after colon
}
// Add Agent Host Modal Functions
function openAddAgentModal() {
console.log('Opening add agent modal...');
const modal = document.getElementById('addAgentModal');
const form = document.getElementById('addAgentForm');
const result = document.getElementById('agentTestResult');
if (!modal) {
console.error('Modal element not found!');
return;
}
modal.classList.add('show');
form.reset();
result.style.display = 'none';
console.log('Modal opened');
}
function closeAddAgentModal() {
const modal = document.getElementById('addAgentModal');
if (modal) {
modal.classList.remove('show');
}
}
async function testAgentConnection() {
const address = document.getElementById('agentAddress').value;
const token = document.getElementById('agentToken').value;
const testBtn = document.getElementById('testAgentBtn');
const result = document.getElementById('agentTestResult');
if (!address || !token) {
result.className = 'alert alert-error';
result.textContent = 'Please enter both address and token';
result.style.display = 'block';
return;
}
testBtn.disabled = true;
testBtn.textContent = 'Testing...';
try {
const response = await fetch('/api/hosts/agent/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address, agent_token: token })
});
const data = await response.json();
if (data.success) {
result.className = 'alert alert-success';
result.textContent = '✓ Connection successful! Agent is reachable.';
} else {
result.className = 'alert alert-error';
result.textContent = '✗ Connection failed: ' + (data.error || 'Unknown error');
}
result.style.display = 'block';
} catch (error) {
result.className = 'alert alert-error';
result.textContent = '✗ Error: ' + error.message;
result.style.display = 'block';
} finally {
testBtn.disabled = false;
testBtn.textContent = 'Test Connection';
}
}
async function handleAddAgent(e) {
e.preventDefault();
const addressInput = document.getElementById('agentAddress');
const address = addressInput.value.trim();
// Validate address format
const validProtocols = /^(https?|agent):\/\/.+/;
if (!validProtocols.test(address)) {
const result = document.getElementById('agentTestResult');
result.className = 'alert alert-error';
result.textContent = 'Invalid address format. Must start with http://, https://, or agent:// followed by hostname/IP and optional port (e.g., http://192.168.1.100:9876)';
result.style.display = 'block';
addressInput.focus();
return;
}
const data = {
name: document.getElementById('agentName').value,
address: address,
agent_token: document.getElementById('agentToken').value,
description: document.getElementById('agentDescription').value,
collect_stats: document.getElementById('agentCollectStats').checked
};
const saveBtn = document.getElementById('saveAgentBtn');
const result = document.getElementById('agentTestResult');
saveBtn.disabled = true;
saveBtn.textContent = 'Adding...';
try {
const response = await fetch('/api/hosts/agent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (response.ok) {
showNotification('Agent host added successfully!', 'success');
closeAddAgentModal();
loadData(); // Refresh the data
} else {
const error = await response.json();
result.className = 'alert alert-error';
result.textContent = 'Error: ' + (error.error || 'Failed to add agent');
result.style.display = 'block';
}
} catch (error) {
result.className = 'alert alert-error';
result.textContent = 'Error: ' + error.message;
result.style.display = 'block';
} finally {
saveBtn.disabled = false;
saveBtn.textContent = 'Add Agent';
}
}
// Settings Management
async function loadTelemetrySettings() {
try {
// Load from new database-first settings API
const response = await fetch('/api/settings');
const settings = await response.json();
const intervalHours = settings.telemetry?.interval_hours || 168;
const dropdown = document.getElementById('telemetryFrequency');
if (dropdown) {
dropdown.value = intervalHours.toString();
console.log('Loaded telemetry frequency from database:', intervalHours, 'hours');
}
} catch (error) {
console.error('Failed to load telemetry settings:', error);
}
}
async function loadScannerSettings() {
try {
// Load from new database-first settings API
const response = await fetch('/api/settings');
const settings = await response.json();
const intervalSeconds = settings.scanner?.interval_seconds || 300;
const dropdown = document.getElementById('scanInterval');
if (dropdown) {
dropdown.value = intervalSeconds.toString();
console.log('Loaded scanner interval from database:', intervalSeconds, 'seconds');
}
} catch (error) {
console.error('Failed to load scanner settings:', error);
}
}
async function saveScanInterval() {
const status = document.getElementById('scanIntervalSaveStatus');
const intervalSeconds = parseInt(document.getElementById('scanInterval').value);
status.textContent = 'Saving...';
status.className = 'save-status-inline saving';
try {
// Load current settings first
const currentResponse = await fetchWithAuth('/api/settings');
const currentSettings = await currentResponse.json();
// Update only the scanner interval, preserve other settings
const updatedSettings = {
scanner: {
interval_seconds: intervalSeconds,
timeout_seconds: currentSettings.scanner?.timeout_seconds || 30
},
telemetry: {
interval_hours: currentSettings.telemetry?.interval_hours || 168
},
notification: currentSettings.notification || {
rate_limit_max: 100,
rate_limit_batch_interval: 600,
threshold_duration: 120,
cooldown_period: 300
},
ui: currentSettings.ui || {
card_design: 'material'
}
};
const response = await fetchWithAuth('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedSettings)
});
if (response.ok) {
status.textContent = '✓ Saved & Reloaded';
status.className = 'save-status-inline success';
showNotification('Scan interval updated successfully (hot-reloaded)', 'success');
} else {
const error = await response.json();
status.textContent = '✗ Failed';
status.className = 'save-status-inline error';
showNotification('Failed to update scan interval: ' + (error.error || 'Unknown error'), 'error');
}
} catch (error) {
status.textContent = '✗ Error';
status.className = 'save-status-inline error';
console.error('Failed to save scan interval:', error);
}
setTimeout(() => {
status.textContent = '';
status.className = 'save-status-inline';
}, 3000);
}
async function saveTelemetryFrequency() {
const status = document.getElementById('frequencySaveStatus');
const intervalHours = parseInt(document.getElementById('telemetryFrequency').value);
status.textContent = 'Saving...';
status.className = 'save-status-inline saving';
try {
// Load current settings first
const currentResponse = await fetchWithAuth('/api/settings');
const currentSettings = await currentResponse.json();
// Update only the telemetry interval, preserve other settings
const updatedSettings = {
scanner: {
interval_seconds: currentSettings.scanner?.interval_seconds || 300,
timeout_seconds: currentSettings.scanner?.timeout_seconds || 30
},
telemetry: {
interval_hours: intervalHours
},
notification: currentSettings.notification || {
rate_limit_max: 100,
rate_limit_batch_interval: 600,
threshold_duration: 120,
cooldown_period: 300
},
ui: currentSettings.ui || {
card_design: 'material'
}
};
const response = await fetchWithAuth('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedSettings)
});
if (response.ok) {
status.textContent = '✓ Saved & Reloaded';
status.className = 'save-status-inline success';
showNotification('Telemetry frequency updated successfully (hot-reloaded)', 'success');
} else {
const error = await response.json();
status.textContent = '✗ Failed';
status.className = 'save-status-inline error';
showNotification('Failed to update telemetry frequency: ' + (error.error || 'Unknown error'), 'error');
}
} catch (error) {
status.textContent = '✗ Error';
status.className = 'save-status-inline error';
console.error('Failed to save telemetry frequency:', error);
}
setTimeout(() => {
status.textContent = '';
status.className = 'save-status-inline';
}, 3000);
}
async function saveCardDesign() {
const status = document.getElementById('cardDesignSaveStatus');
const cardDesign = document.getElementById('cardDesignTheme').value;
console.log('Saving card design:', cardDesign);
status.textContent = 'Saving...';
status.className = 'save-status-inline saving';
try {
// Load current settings first
const currentResponse = await fetchWithAuth('/api/settings');
const currentSettings = await currentResponse.json();
console.log('Current settings:', currentSettings);
// Update only the card design, preserve other settings
const updatedSettings = {
scanner: {
interval_seconds: currentSettings.scanner?.interval_seconds || 300,
timeout_seconds: currentSettings.scanner?.timeout_seconds || 30
},
telemetry: {
interval_hours: currentSettings.telemetry?.interval_hours || 168
},
notification: currentSettings.notification || {
rate_limit_max: 100,
rate_limit_batch_interval: 600,
threshold_duration: 120,
cooldown_period: 300
},
ui: {
card_design: cardDesign
}
};
console.log('Sending updated settings:', updatedSettings);
const response = await fetchWithAuth('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedSettings)
});
console.log('Response status:', response.status, response.statusText);
if (response.ok) {
console.log('Settings saved successfully');
status.textContent = '✓ Saved';
status.className = 'save-status-inline success';
showNotification('Card design updated successfully', 'success');
// Update the global theme variable and re-render containers
cardDesignTheme = cardDesign;
if (currentTab === 'containers') {
filterContainers(); // This will re-render with the new theme
}
} else {
const error = await response.json();
status.textContent = '✗ Failed';
status.className = 'save-status-inline error';
showNotification('Failed to update card design: ' + (error.error || 'Unknown error'), 'error');
}
} catch (error) {
status.textContent = '✗ Error';
status.className = 'save-status-inline error';
console.error('Failed to save card design:', error);
}
setTimeout(() => {
status.textContent = '';
status.className = 'save-status-inline';
}, 3000);
}
async function loadUISettings() {
try {
const response = await fetchWithAuth('/api/settings');
const settings = await response.json();
// Update global variable (with fallback to default)
if (settings.ui && settings.ui.card_design) {
cardDesignTheme = settings.ui.card_design;
} else {
cardDesignTheme = 'material'; // default if not set
}
// Update dropdown if on settings page
const dropdown = document.getElementById('cardDesignTheme');
if (dropdown) {
dropdown.value = cardDesignTheme;
}
} catch (error) {
console.log('Error loading UI settings:', error);
cardDesignTheme = 'material'; // fallback to default
}
}
// Initialize settings when switching to settings tab
document.addEventListener('DOMContentLoaded', () => {
// Load settings immediately on page load
loadScannerSettings();
loadTelemetrySettings();
loadUISettings();
// Load settings when settings tab is clicked
const settingsTab = document.querySelector('[data-tab="settings"]');
if (settingsTab) {
settingsTab.addEventListener('click', () => {
setTimeout(() => {
loadScannerSettings();
loadTelemetrySettings();
loadUISettings();
loadCollectors();
}, 100);
});
}
});
// Custom Collectors Management
async function loadCollectors() {
try {
// Fetch telemetry status which includes all endpoints with status info
const [statusResponse, debugResponse] = await Promise.all([
fetch('/api/telemetry/status'),
fetch('/api/telemetry/debug-enabled')
]);
if (!statusResponse.ok) {
console.error('Failed to fetch telemetry status, status:', statusResponse.status);
throw new Error('Failed to load collectors');
}
const collectors = await statusResponse.json();
const debugInfo = debugResponse.ok ? await debugResponse.json() : { debug_enabled: false };
console.log('Loaded collectors with status:', collectors);
console.log('Debug mode:', debugInfo.debug_enabled);
renderCollectors(collectors, debugInfo.debug_enabled);
} catch (error) {
console.error('Error loading collectors:', error);
showNotification('Failed to load collectors', 'error');
}
}
function renderCollectors(collectors, debugEnabled = false) {
const collectorsList = document.getElementById('collectorsList');
// Separate community and custom collectors
const communityCollector = collectors.find(c => c.name === 'community');
const customCollectors = collectors.filter(c => c.name !== 'community');
let html = '';
// Render community collector if exists
if (communityCollector) {
const lastSuccess = communityCollector.last_success ? new Date(communityCollector.last_success) : null;
const lastFailure = communityCollector.last_failure ? new Date(communityCollector.last_failure) : null;
const statusText = formatTelemetryStatus(lastSuccess, lastFailure);
const statusClass = getStatusClass(lastSuccess, lastFailure);
html += `
<div class="collector-item community-collector" style="background: #f8f9fa; border: 2px solid #667eea; margin-bottom: 20px; padding: 20px;">
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
<div style="flex: 1;">
<div class="collector-name" style="font-size: 16px; margin-bottom: 8px;">
<strong>📊 Community Collector</strong>
<span class="collector-status ${communityCollector.enabled ? 'enabled' : 'disabled'}">
${communityCollector.enabled ? 'Enabled' : 'Disabled'}
</span>
</div>
<div class="collector-url" style="margin: 8px 0; color: #666; font-size: 13px;">${escapeHtml(communityCollector.url)}</div>
<p style="margin: 10px 0; color: #555; font-size: 14px;">
Help improve Container Census by sharing anonymous usage statistics.
</p>
<div class="telemetry-info" style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin: 15px 0; padding: 15px; background: white; border-radius: 6px;">
<div class="info-column">
<h4 style="margin: 0 0 8px 0; font-size: 13px; color: #2e7d32;">✓ What gets shared:</h4>
<ul style="margin: 0; padding-left: 20px; font-size: 12px; color: #666;">
<li>Container Census version</li>
<li>Number of containers and hosts</li>
<li>Popular container images (names only)</li>
<li>Container registry distribution</li>
<li>Geographic region (timezone-based)</li>
</ul>
</div>
<div class="info-column">
<h4 style="margin: 0 0 8px 0; font-size: 13px; color: #c62828;">✗ What is NOT shared:</h4>
<ul style="margin: 0; padding-left: 20px; font-size: 12px; color: #666;">
<li>Host names or IP addresses</li>
<li>Container names or env variables</li>
<li>Any credentials or secrets</li>
<li>Personal information</li>
</ul>
</div>
</div>
${statusText ? `<div class="telemetry-status ${statusClass}">${statusText}</div>` : ''}
${lastFailure && communityCollector.last_failure_reason ?
`<div class="telemetry-error" title="${escapeHtml(communityCollector.last_failure_reason)}">
${escapeHtml(communityCollector.last_failure_reason.substring(0, 80))}${communityCollector.last_failure_reason.length > 80 ? '...' : ''}
</div>` : ''}
${debugEnabled && lastFailure ?
`<div style="margin-top: 10px;">
<button class="btn btn-sm btn-secondary" onclick="resetCircuitBreaker('${escapeAttr(communityCollector.name)}')" style="font-size: 12px;">
🔧 Reset Circuit Breaker
</button>
</div>` : ''}
</div>
<div style="margin-left: 20px;">
<button class="btn ${communityCollector.enabled ? 'btn-warning' : 'btn-primary'}"
onclick="toggleCollector('${escapeAttr(communityCollector.name)}', ${!communityCollector.enabled})"
style="min-width: 100px; white-space: nowrap;">
${communityCollector.enabled ? 'Disable' : 'Enable'}
</button>
</div>
</div>
</div>
`;
}
// Add separator before custom collectors
if (customCollectors.length > 0) {
html += '<h4 style="margin: 30px 0 15px 0; color: #666;">Custom Collectors</h4>';
}
if (customCollectors.length === 0) {
html += '<p style="color: #666; font-style: italic; margin-top: 20px;">No custom collectors configured.</p>';
} else {
html += customCollectors.map(collector => {
const lastSuccess = collector.last_success ? new Date(collector.last_success) : null;
const lastFailure = collector.last_failure ? new Date(collector.last_failure) : null;
const statusText = formatTelemetryStatus(lastSuccess, lastFailure);
const statusClass = getStatusClass(lastSuccess, lastFailure);
return `
<div class="collector-item">
<div class="collector-info">
<div class="collector-name">
${escapeHtml(collector.name)}
<span class="collector-status ${collector.enabled ? 'enabled' : 'disabled'}">
${collector.enabled ? 'Enabled' : 'Disabled'}
</span>
</div>
<div class="collector-url">${escapeHtml(collector.url)}</div>
${collector.api_key ? '<div style="font-size: 12px; color: #999;">🔑 API Key configured</div>' : ''}
${statusText ? `<div class="telemetry-status ${statusClass}">${statusText}</div>` : ''}
${lastFailure && collector.last_failure_reason ?
`<div class="telemetry-error" title="${escapeHtml(collector.last_failure_reason)}">
${escapeHtml(collector.last_failure_reason.substring(0, 60))}${collector.last_failure_reason.length > 60 ? '...' : ''}
</div>` : ''}
${debugEnabled && lastFailure ?
`<div style="margin-top: 8px;">
<button class="btn btn-sm" onclick="resetCircuitBreaker('${escapeAttr(collector.name)}')" style="font-size: 11px; padding: 4px 8px;">
🔧 Reset Circuit Breaker
</button>
</div>` : ''}
</div>
<div class="collector-actions">
<button class="btn btn-sm btn-secondary" onclick="toggleCollector('${escapeAttr(collector.name)}', ${!collector.enabled})">
${collector.enabled ? 'Disable' : 'Enable'}
</button>
<button class="btn btn-sm btn-danger" onclick="deleteCollector('${escapeAttr(collector.name)}')">
Delete
</button>
</div>
</div>
`;
}).join('');
}
collectorsList.innerHTML = html;
}
function formatTelemetryStatus(lastSuccess, lastFailure) {
if (!lastSuccess && !lastFailure) {
return 'No telemetry submitted yet';
}
if (!lastFailure || (lastSuccess && lastSuccess > lastFailure)) {
return `✓ Last success: ${formatTimeAgo(lastSuccess)}`;
} else {
return `✗ Last failure: ${formatTimeAgo(lastFailure)}`;
}
}
function getStatusClass(lastSuccess, lastFailure) {
if (!lastSuccess && !lastFailure) {
return 'status-unknown';
}
if (!lastFailure || (lastSuccess && lastSuccess > lastFailure)) {
return 'status-success';
} else {
return 'status-error';
}
}
async function testCollector() {
const url = document.getElementById('collectorURL').value.trim();
const apiKey = document.getElementById('collectorAPIKey').value.trim();
const status = document.getElementById('collectorSaveStatus');
if (!url) {
status.textContent = '✗ URL is required to test';
status.className = 'save-status-inline error';
setTimeout(() => status.textContent = '', 3000);
return;
}
status.textContent = 'Testing connection...';
status.className = 'save-status-inline saving';
const testData = { url };
if (apiKey) {
testData.api_key = apiKey;
}
try {
const response = await fetch('/api/telemetry/test-endpoint', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(testData)
});
const result = await response.json();
if (response.ok) {
status.textContent = '✓ Connection successful!';
status.className = 'save-status-inline success';
} else {
status.textContent = '✗ ' + (result.error || 'Connection failed');
status.className = 'save-status-inline error';
}
} catch (error) {
status.textContent = '✗ Connection failed: ' + error.message;
status.className = 'save-status-inline error';
}
setTimeout(() => status.textContent = '', 5000);
}
async function addCollector() {
const name = document.getElementById('collectorName').value.trim();
const url = document.getElementById('collectorURL').value.trim();
const apiKey = document.getElementById('collectorAPIKey').value.trim();
const enabled = document.getElementById('collectorEnabled').checked;
const status = document.getElementById('collectorSaveStatus');
// Validate inputs
if (!name) {
status.textContent = '✗ Name is required';
status.className = 'save-status-inline error';
setTimeout(() => status.textContent = '', 3000);
return;
}
if (!url) {
status.textContent = '✗ URL is required';
status.className = 'save-status-inline error';
setTimeout(() => status.textContent = '', 3000);
return;
}
// Show saving status
status.textContent = 'Saving...';
status.className = 'save-status-inline saving';
const endpoint = {
name,
url,
enabled
};
if (apiKey) {
endpoint.api_key = apiKey;
}
try {
const response = await fetch('/api/telemetry/endpoints', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(endpoint)
});
if (response.ok) {
status.textContent = '✓ Collector added successfully';
status.className = 'save-status-inline success';
// Clear form
document.getElementById('collectorName').value = '';
document.getElementById('collectorURL').value = '';
document.getElementById('collectorAPIKey').value = '';
document.getElementById('collectorEnabled').checked = true;
// Reload collectors list
await loadCollectors();
showNotification('Collector added successfully', 'success');
} else {
const error = await response.json();
status.textContent = '✗ Failed: ' + (error.error || 'Unknown error');
status.className = 'save-status-inline error';
showNotification('Failed to add collector', 'error');
}
} catch (error) {
status.textContent = '✗ Error: ' + error.message;
status.className = 'save-status-inline error';
showNotification('Error adding collector', 'error');
}
// Clear status after 3 seconds
setTimeout(() => {
status.textContent = '';
status.className = 'save-status-inline';
}, 3000);
}
async function toggleCollector(name, enabled) {
try {
const response = await fetch(`/api/telemetry/endpoints/${encodeURIComponent(name)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled })
});
if (response.ok) {
await loadCollectors();
showNotification(`Collector ${enabled ? 'enabled' : 'disabled'} successfully`, 'success');
} else {
const error = await response.json();
showNotification('Failed to update collector: ' + (error.error || 'Unknown error'), 'error');
}
} catch (error) {
showNotification('Error updating collector', 'error');
}
}
async function deleteCollector(name) {
if (!confirm(`Are you sure you want to delete the collector "${name}"?`)) {
return;
}
try {
const response = await fetch(`/api/telemetry/endpoints/${encodeURIComponent(name)}`, {
method: 'DELETE'
});
if (response.ok) {
await loadCollectors();
showNotification('Collector deleted successfully', 'success');
} else {
const error = await response.json();
showNotification('Failed to delete collector: ' + (error.error || 'Unknown error'), 'error');
}
} catch (error) {
showNotification('Error deleting collector', 'error');
}
}
async function resetCircuitBreaker(name) {
try {
const response = await fetch(`/api/telemetry/reset-circuit-breaker/${encodeURIComponent(name)}`, {
method: 'POST'
});
if (response.ok) {
await loadCollectors();
showNotification('Circuit breaker reset successfully - endpoint will retry on next submission', 'success');
} else {
const error = await response.json();
showNotification('Failed to reset circuit breaker: ' + (error.error || 'Unknown error'), 'error');
}
} catch (error) {
showNotification('Error resetting circuit breaker', 'error');
}
}
// Container History Functions
async function loadContainerHistory() {
try {
const response = await fetch('/api/containers/lifecycle?limit=200');
lifecycles = await response.json() || [];
// Apply filters if any are active
applyCurrentFilters();
} catch (error) {
console.error('Error loading container history:', error);
document.getElementById('historyBody').innerHTML =
'<div class="error">Failed to load container history</div>';
}
}
function updateHistoryHostFilter() {
const select = document.getElementById('historyHostFilter');
if (!select || hosts.length === 0) return;
const currentValue = select.value;
select.innerHTML = '<option value="">All Hosts</option>' +
hosts.map(host => `<option value="${host.id}">${escapeHtml(host.name)}</option>`).join('');
select.value = currentValue;
}
function updateHistoryStats(lifecycles) {
const total = lifecycles.length;
const active = lifecycles.filter(l => l.is_active).length;
const inactive = total - active;
document.getElementById('historyTotalContainers').textContent = total;
document.getElementById('historyActiveContainers').textContent = active;
document.getElementById('historyInactiveContainers').textContent = inactive;
}
function renderContainerHistory(lifecycles) {
const container = document.getElementById('historyBody');
if (!lifecycles || lifecycles.length === 0) {
container.innerHTML = '<div class="loading">No container history available</div>';
return;
}
container.innerHTML = lifecycles.map(lifecycle => {
const firstSeen = new Date(lifecycle.first_seen);
const lastSeen = new Date(lifecycle.last_seen);
const lifetime = formatDuration(lastSeen - firstSeen);
const statusBadge = lifecycle.is_active
? '<span class="state-badge state-running">Active</span>'
: '<span class="state-badge state-exited">Inactive</span>';
// State changes includes the initial detection (first_seen) + actual state changes
const stateChanges = 1 + (lifecycle.state_changes || 0);
const imageUpdates = lifecycle.image_updates || 0;
const restartEvents = lifecycle.restart_events || 0;
return `
<div class="history-card-modern ${lifecycle.is_active ? 'active' : 'inactive'}">
<div class="history-card-header-modern">
<div class="history-card-left">
<div class="history-status-indicator ${lifecycle.is_active ? 'active' : 'inactive'}">
${lifecycle.is_active ? '✅' : '⏸️'}
</div>
<div class="history-card-info">
<div class="history-card-name">${escapeHtml(lifecycle.container_name)}</div>
<div class="history-card-meta">
<span class="meta-item">📍 ${escapeHtml(lifecycle.host_name)}</span>
<span class="meta-item">⏱️ ${lifetime}</span>
</div>
</div>
</div>
<button class="btn btn-primary btn-timeline" onclick="viewContainerTimeline(${lifecycle.host_id}, '${escapeAttr(lifecycle.container_id)}', '${escapeAttr(lifecycle.container_name)}')" title="View detailed timeline">
<span class="timeline-icon">📅</span>
<span class="timeline-text">View Timeline</span>
</button>
</div>
<div class="history-card-content">
<div class="history-detail-row">
<span class="detail-label">🖼️ Image</span>
<code class="detail-value image-value" title="${escapeHtml(lifecycle.image)}">${escapeHtml(lifecycle.image)}</code>
</div>
<div class="history-metrics-grid">
<div class="metric-box">
<div class="metric-icon">👁️</div>
<div class="metric-content">
<div class="metric-label">First Seen</div>
<div class="metric-value">${formatTimeAgo(firstSeen)}</div>
<div class="metric-subtext">${formatDateTime(lifecycle.first_seen)}</div>
</div>
</div>
<div class="metric-box">
<div class="metric-icon">🕐</div>
<div class="metric-content">
<div class="metric-label">Last Seen</div>
<div class="metric-value">${formatTimeAgo(lastSeen)}</div>
<div class="metric-subtext">${formatDateTime(lifecycle.last_seen)}</div>
</div>
</div>
<div class="metric-box ${stateChanges > 5 ? 'metric-warning' : ''}">
<div class="metric-icon">🔄</div>
<div class="metric-content">
<div class="metric-label">State Changes</div>
<div class="metric-value">${stateChanges}</div>
</div>
</div>
<div class="metric-box ${imageUpdates > 0 ? 'metric-info' : ''}">
<div class="metric-icon">⬆️</div>
<div class="metric-content">
<div class="metric-label">Image Updates</div>
<div class="metric-value">${imageUpdates}</div>
</div>
</div>
<div class="metric-box ${restartEvents > 10 ? 'metric-alert' : restartEvents > 0 ? 'metric-warning' : ''}">
<div class="metric-icon">🔁</div>
<div class="metric-content">
<div class="metric-label">Restarts</div>
<div class="metric-value">${restartEvents}</div>
</div>
</div>
</div>
</div>
</div>
`;
}).join('');
}
async function viewContainerTimeline(hostId, containerId, containerName) {
document.getElementById('timelineContainerName').textContent = containerName;
document.getElementById('timelineContent').innerHTML = '<div class="loading">Loading timeline...</div>';
document.getElementById('timelineModal').classList.add('show');
try {
const response = await fetch(`/api/containers/lifecycle/${hostId}/${encodeURIComponent(containerName)}`);
const events = await response.json();
if (!events || events.length === 0) {
document.getElementById('timelineContent').innerHTML = '<p>No lifecycle events found for this container.</p>';
return;
}
renderTimeline(events);
} catch (error) {
console.error('Error loading timeline:', error);
document.getElementById('timelineContent').innerHTML = '<p class="error">Failed to load timeline events</p>';
}
}
function renderTimeline(events) {
if (!events || events.length === 0) {
document.getElementById('timelineContent').innerHTML = '<p>No lifecycle events found.</p>';
return;
}
// Calculate summary statistics
const firstEvent = events[0];
const lastEvent = events[events.length - 1];
// State changes includes first_seen + actual state transitions
const actualStateChanges = events.filter(e => e.event_type === 'started' || e.event_type === 'stopped' || e.event_type === 'state_change').length;
const stateChanges = 1 + actualStateChanges; // +1 for first_seen
const imageUpdates = events.filter(e => e.event_type === 'image_updated').length;
// Extract scan count from last_seen event if present
let totalScans = 'N/A';
if (lastEvent && lastEvent.event_type === 'last_seen') {
const match = lastEvent.description.match(/seen (\d+) times/);
if (match) {
totalScans = parseInt(match[1]);
}
}
// Calculate duration
const firstTime = new Date(firstEvent.timestamp);
const lastTime = new Date(lastEvent.timestamp);
const durationMs = lastTime - firstTime;
const durationDays = Math.floor(durationMs / (1000 * 60 * 60 * 24));
const durationText = durationDays > 0 ? `${durationDays} days` : 'same day';
// Determine status
const isActive = lastEvent.new_state === 'running';
const statusText = isActive ? 'Active (running)' : lastEvent.new_state === 'exited' ? 'Inactive (stopped)' : 'Unknown';
const statusClass = isActive ? 'badge-success' : 'badge-warning';
// Build summary banner
const summaryHTML = `
<div class="timeline-summary" style="background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 8px; padding: 16px; margin-bottom: 20px;">
<div style="display: flex; align-items: center; margin-bottom: 12px;">
<span style="font-size: 24px; margin-right: 10px;">📊</span>
<div>
<strong style="font-size: 16px;">Container History Summary</strong>
<div style="color: #666; font-size: 13px; margin-top: 4px;">
${formatDate(firstEvent.timestamp)} to ${formatDate(lastEvent.timestamp)} (${durationText})
</div>
</div>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; font-size: 14px;">
<div><strong>Total Observations:</strong> ${totalScans}</div>
<div><strong>State Changes:</strong> ${stateChanges}</div>
<div><strong>Image Updates:</strong> ${imageUpdates}</div>
<div><strong>Current Status:</strong> <span class="badge ${statusClass}">${statusText}</span></div>
</div>
</div>
`;
const timelineHTML = events.map(event => {
const eventIcon = getEventIcon(event.event_type);
const eventClass = getEventClass(event.event_type);
let details = '';
if (event.old_state && event.new_state) {
details = `<span class="state-badge state-${event.old_state}">${event.old_state}</span> → <span class="state-badge state-${event.new_state}">${event.new_state}</span>`;
} else if (event.old_image_tag && event.new_image_tag) {
// New format: show both tag and SHA
details = `<code>${event.old_image_tag}</code> <span class="text-muted">(${event.old_image_sha})</span> → <code>${event.new_image_tag}</code> <span class="text-muted">(${event.new_image_sha})</span>`;
} else if (event.old_image && event.new_image) {
// Fallback to old format for backward compatibility
details = `<code>${event.old_image}</code> → <code>${event.new_image}</code>`;
} else if (event.restart_count) {
details = `<strong>${event.restart_count} restart(s)</strong>`;
}
return `
<div class="timeline-event ${eventClass}">
<div class="timeline-marker">${eventIcon}</div>
<div class="timeline-content-box">
<div class="timeline-time">${formatDateTime(event.timestamp)}</div>
<div class="timeline-description">
<strong>${event.description}</strong>
${details ? `<div class="timeline-details">${details}</div>` : ''}
</div>
</div>
</div>
`;
}).join('');
document.getElementById('timelineContent').innerHTML = `${summaryHTML}<div class="timeline">${timelineHTML}</div>`;
}
function getEventIcon(eventType) {
const icons = {
'first_seen': '🎉',
'started': '▶️',
'stopped': '⏹️',
'paused': '⏸️',
'resumed': '▶️',
'restarted': '⟳',
'image_updated': '📦',
'disappeared': '👻',
'reappeared': '✨',
'state_change': '🔄',
'last_seen': '📍'
};
return icons[eventType] || '•';
}
function getEventClass(eventType) {
const classes = {
'first_seen': 'event-success',
'started': 'event-success',
'stopped': 'event-warning',
'paused': 'event-info',
'resumed': 'event-success',
'restarted': 'event-warning',
'image_updated': 'event-info',
'disappeared': 'event-error',
'reappeared': 'event-success',
'state_change': 'event-info',
'last_seen': 'event-info'
};
return classes[eventType] || 'event-default';
}
function formatDuration(ms) {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) {
return `${days}d ${hours % 24}h`;
} else if (hours > 0) {
return `${hours}h ${minutes % 60}m`;
} else if (minutes > 0) {
return `${minutes}m`;
} else {
return `${seconds}s`;
}
}
function closeTimelineModal() {
document.getElementById('timelineModal').classList.remove('show');
}
// Graph Visualization Functions
async function loadGraph() {
const container = document.getElementById('graphContainer');
container.innerHTML = '<div class="graph-loading">Loading graph...</div>';
try {
const response = await fetch('/api/containers/graph');
graphData = await response.json();
renderGraph(graphData);
} catch (error) {
console.error('Error loading graph:', error);
container.innerHTML = '<div class="graph-error">Failed to load container graph</div>';
}
}
function renderGraph(data) {
const container = document.getElementById('graphContainer');
if (!data.nodes || data.nodes.length === 0) {
container.innerHTML = '<div class="graph-empty">No containers to display</div>';
return;
}
// Clear loading message
container.innerHTML = '';
// Build lists for dropdowns
buildGraphDropdowns(data);
// Count edge types
updateEdgeCounts(data.edges);
// Assign colors to compose projects
const composeProjects = [...new Set(data.nodes.map(n => n.compose_project).filter(p => p))];
const projectColors = {};
const colors = ['#3498db', '#9b59b6', '#e67e22', '#1abc9c', '#e74c3c', '#f39c12', '#2ecc71', '#34495e'];
composeProjects.forEach((project, i) => {
projectColors[project] = colors[i % colors.length];
});
// Build Cytoscape elements
const elements = {
nodes: data.nodes.map(node => ({
data: {
id: node.id,
label: node.name,
nodeType: node.node_type || 'container',
state: node.state,
image: node.image,
host: node.host_name,
composeProject: node.compose_project || '',
projectColor: projectColors[node.compose_project] || null
}
})),
edges: data.edges.map(edge => ({
data: {
id: `${edge.source}-${edge.target}-${edge.type}`,
source: edge.source,
target: edge.target,
label: edge.label,
type: edge.type
}
}))
};
// Initialize Cytoscape
cy = cytoscape({
container: container,
elements: elements,
style: [
// Node styles
{
selector: 'node',
style: {
'label': 'data(label)',
'text-valign': 'center',
'text-halign': 'center',
'background-color': '#95a5a6',
'color': '#fff',
'text-outline-color': '#2c3e50',
'text-outline-width': 2,
'font-size': '12px',
'width': 50,
'height': 50,
'shape': 'ellipse'
}
},
// Network nodes - different shape and color
{
selector: 'node[nodeType="network"]',
style: {
'shape': 'diamond',
'background-color': '#3498db',
'border-width': 3,
'border-color': '#2980b9',
'width': 60,
'height': 60,
'font-size': '11px',
'font-weight': 'bold'
}
},
{
selector: 'node[state="running"]',
style: {
'background-color': '#2ecc71',
'border-width': 3,
'border-color': '#27ae60'
}
},
{
selector: 'node[state="exited"]',
style: {
'background-color': '#95a5a6',
'border-width': 3,
'border-color': '#7f8c8d'
}
},
{
selector: 'node[state="paused"]',
style: {
'background-color': '#f39c12',
'border-width': 3,
'border-color': '#e67e22'
}
},
{
selector: 'node.project-colored',
style: {
'background-color': 'data(projectColor)',
'border-color': 'data(projectColor)',
'border-width': 4
}
},
{
selector: 'node.dimmed',
style: {
'opacity': 0.2
}
},
{
selector: 'node.highlighted',
style: {
'border-width': 6,
'border-color': '#f1c40f',
'z-index': 999
}
},
{
selector: 'node:selected',
style: {
'border-width': 5,
'border-color': '#3498db'
}
},
// Edge styles
{
selector: 'edge',
style: {
'curve-style': 'bezier',
'target-arrow-shape': 'none',
'line-color': '#bdc3c7',
'width': 2,
'label': 'data(label)',
'font-size': '10px',
'text-rotation': 'autorotate',
'text-margin-y': -10,
'color': '#34495e',
'text-background-color': '#fff',
'text-background-opacity': 0.8,
'text-background-padding': '3px'
}
},
{
selector: 'edge[type="network"]',
style: {
'line-color': '#3498db',
'width': 3
}
},
{
selector: 'edge[type="volume"]',
style: {
'line-color': '#e74c3c',
'width': 3
}
},
{
selector: 'edge[type="depends"]',
style: {
'line-color': '#16a085',
'width': 3,
'target-arrow-shape': 'triangle',
'target-arrow-color': '#16a085',
'curve-style': 'bezier'
}
},
{
selector: 'edge[type="link"]',
style: {
'line-color': '#9b59b6',
'width': 2,
'target-arrow-shape': 'triangle'
}
},
{
selector: 'edge:selected',
style: {
'width': 4,
'line-color': '#2c3e50'
}
},
{
selector: 'edge.dimmed',
style: {
'opacity': 0.15
}
},
{
selector: 'edge.no-label',
style: {
'label': ''
}
}
],
layout: {
name: 'cose',
animate: true,
animationDuration: 1000,
idealEdgeLength: 100,
nodeOverlap: 20,
refresh: 20,
fit: true,
padding: 30,
randomize: false,
componentSpacing: 100,
nodeRepulsion: 400000,
edgeElasticity: 100,
nestingFactor: 5,
gravity: 80,
numIter: 1000,
initialTemp: 200,
coolingFactor: 0.95,
minTemp: 1.0
},
minZoom: 0.1,
maxZoom: 5,
wheelSensitivity: 0.1 // Slower, more controlled zoom with mouse wheel
});
// Add event handlers
cy.on('tap', 'node', function(evt) {
const node = evt.target;
const data = node.data();
if (data.nodeType === 'network') {
// Show network node info
const connectedContainers = cy.edges(`[source="${data.id}"], [target="${data.id}"]`).length;
showGraphInfo(`
<strong>🌐 Network: ${data.label}</strong><br>
Host: ${data.host}<br>
Connected Containers: ${connectedContainers}
`);
} else {
// Show container node info
showGraphInfo(`
<strong>${data.label}</strong><br>
Host: ${data.host}<br>
Image: ${data.image}<br>
State: <span class="state-badge state-${data.state}">${data.state}</span><br>
${data.composeProject ? `Compose Project: ${data.composeProject}<br>` : ''}
`);
}
});
cy.on('tap', 'edge', function(evt) {
const edge = evt.target;
const data = edge.data();
const sourceNode = cy.getElementById(data.source).data();
const targetNode = cy.getElementById(data.target).data();
let typeDescription = '';
switch(data.type) {
case 'network': typeDescription = 'Network Connection'; break;
case 'volume': typeDescription = 'Shared Volume'; break;
case 'depends': typeDescription = 'Dependency'; break;
case 'link': typeDescription = 'Container Link'; break;
default: typeDescription = 'Connection';
}
showGraphInfo(`
<strong>${typeDescription}</strong><br>
From: ${sourceNode.label}<br>
To: ${targetNode.label}<br>
${data.label}
`);
});
cy.on('tap', function(evt) {
if (evt.target === cy) {
showGraphInfo('<p>Click on containers or connections to see details</p>');
}
});
// Apply initial filters
applyGraphFilters();
}
function applyGraphFilters() {
if (!cy) return;
const showNetworks = document.getElementById('showNetworks').checked;
const showVolumes = document.getElementById('showVolumes').checked;
const showDepends = document.getElementById('showDepends').checked;
const showLinks = document.getElementById('showLinks').checked;
const colorByProject = document.getElementById('colorByProject').checked;
// Apply color-by-project styling
if (colorByProject) {
cy.nodes().forEach(node => {
if (node.data('projectColor')) {
node.addClass('project-colored');
}
});
} else {
cy.nodes().removeClass('project-colored');
}
// Show/hide edges based on filters
cy.edges().forEach(edge => {
const type = edge.data('type');
// Check if edge should be visible based on type filters
let visibleByType = true;
if (type === 'network' && !showNetworks) visibleByType = false;
if (type === 'volume' && !showVolumes) visibleByType = false;
if (type === 'depends' && !showDepends) visibleByType = false;
if (type === 'link' && !showLinks) visibleByType = false;
// Check if edge is dimmed by project/network selector
const isDimmed = edge.hasClass('dimmed');
// Show edge if it passes type filter and is not dimmed
// OR if we're re-enabling a type (even if dimmed, show it)
if (visibleByType && !isDimmed) {
edge.show();
} else if (!visibleByType) {
edge.hide();
} else if (visibleByType && isDimmed) {
// Show but keep dimmed
edge.show();
}
});
}
function showGraphInfo(html) {
const infoDiv = document.getElementById('graphInfo');
infoDiv.innerHTML = html;
}
// Graph zoom control functions
function zoomIn() {
if (!cy) return;
const currentZoom = cy.zoom();
const newZoom = currentZoom * 1.2; // 20% increase
cy.zoom({
level: newZoom,
renderedPosition: {
x: cy.width() / 2,
y: cy.height() / 2
}
});
}
function zoomOut() {
if (!cy) return;
const currentZoom = cy.zoom();
const newZoom = currentZoom * 0.8; // 20% decrease
cy.zoom({
level: newZoom,
renderedPosition: {
x: cy.width() / 2,
y: cy.height() / 2
}
});
}
function zoomReset() {
if (!cy) return;
cy.zoom(1);
cy.center();
}
function fitGraph() {
if (!cy) return;
cy.fit(null, 30); // Fit all elements with 30px padding
}
// Helper functions for graph enhancements
function buildGraphDropdowns(data) {
// Build compose project dropdown
const composeProjects = [...new Set(data.nodes.map(n => n.compose_project).filter(p => p))].sort();
const composeSelect = document.getElementById('composeProjectSelect');
composeSelect.innerHTML = '<option value="">Compose: All Projects</option>';
composeProjects.forEach(project => {
const optGroup1 = document.createElement('optgroup');
optGroup1.label = project;
optGroup1.innerHTML = `
<option value="highlight:${project}">Highlight: ${project}</option>
<option value="isolate:${project}">Isolate: ${project}</option>
`;
composeSelect.appendChild(optGroup1);
});
// Build network dropdown - get network names from network nodes
const networks = [...new Set(data.nodes.filter(n => n.node_type === 'network').map(n => n.name))].sort();
const networkSelect = document.getElementById('networkSelect');
networkSelect.innerHTML = '<option value="">Networks: Show All</option>';
networks.forEach(network => {
const optGroup = document.createElement('optgroup');
optGroup.label = network;
optGroup.innerHTML = `
<option value="highlight:${network}">Highlight: ${network}</option>
<option value="isolate:${network}">Isolate: ${network}</option>
`;
networkSelect.appendChild(optGroup);
});
}
function updateEdgeCounts(edges) {
const counts = {
network: 0,
volume: 0,
depends: 0,
link: 0
};
edges.forEach(edge => {
if (counts.hasOwnProperty(edge.type)) {
counts[edge.type]++;
}
});
document.getElementById('networkCount').textContent = `(${counts.network})`;
document.getElementById('volumeCount').textContent = `(${counts.volume})`;
document.getElementById('dependsCount').textContent = `(${counts.depends})`;
document.getElementById('linksCount').textContent = `(${counts.link})`;
}
function handleComposeProjectChange(event) {
if (!cy) return;
const value = event.target.value;
// Reset all nodes and edges
cy.nodes().removeClass('dimmed highlighted').show();
cy.edges().removeClass('dimmed').show();
if (!value) {
applyGraphFilters();
return;
}
const [mode, project] = value.split(':');
if (mode === 'highlight') {
// Dim non-matching nodes
cy.nodes().forEach(node => {
if (node.data('composeProject') !== project) {
node.addClass('dimmed');
}
});
// Dim edges not connected to this project
cy.edges().forEach(edge => {
const source = cy.getElementById(edge.data('source'));
const target = cy.getElementById(edge.data('target'));
if (source.data('composeProject') !== project && target.data('composeProject') !== project) {
edge.addClass('dimmed');
}
});
} else if (mode === 'isolate') {
// Hide non-matching nodes
cy.nodes().forEach(node => {
if (node.data('composeProject') !== project) {
node.hide();
}
});
// Hide edges where both ends are not in project
cy.edges().forEach(edge => {
const source = cy.getElementById(edge.data('source'));
const target = cy.getElementById(edge.data('target'));
if (source.data('composeProject') !== project || target.data('composeProject') !== project) {
edge.hide();
}
});
// Fit to show isolated project
setTimeout(() => cy.fit(null, 30), 100);
}
applyGraphFilters();
}
function handleNetworkChange(event) {
if (!cy) return;
const value = event.target.value;
// Reset all
cy.nodes().removeClass('dimmed highlighted').show();
cy.edges().removeClass('dimmed').show();
if (!value) {
applyGraphFilters();
return;
}
const [mode, network] = value.split(':');
// Find the network node with this name
let networkNodeId = null;
cy.nodes().forEach(node => {
if (node.data('nodeType') === 'network' && node.data('label') === network) {
networkNodeId = node.id();
}
});
if (!networkNodeId) {
applyGraphFilters();
return;
}
// Find all container nodes connected to this network node
const connectedContainerIds = new Set();
cy.edges().forEach(edge => {
if (edge.data('type') === 'network' &&
(edge.data('source') === networkNodeId || edge.data('target') === networkNodeId)) {
// Add the other end (the container)
const containerId = edge.data('source') === networkNodeId ?
edge.data('target') : edge.data('source');
connectedContainerIds.add(containerId);
}
});
// Also add the network node itself to the set of nodes to keep visible
connectedContainerIds.add(networkNodeId);
if (mode === 'highlight') {
// Dim nodes not connected to this network
cy.nodes().forEach(node => {
if (!connectedContainerIds.has(node.id())) {
node.addClass('dimmed');
}
});
// Dim edges not connected to this network node
cy.edges().forEach(edge => {
if (!(edge.data('source') === networkNodeId || edge.data('target') === networkNodeId)) {
edge.addClass('dimmed');
}
});
} else if (mode === 'isolate') {
// Hide nodes not connected to this network
cy.nodes().forEach(node => {
if (!connectedContainerIds.has(node.id())) {
node.hide();
}
});
// Hide edges not connected to this network node
cy.edges().forEach(edge => {
if (!(edge.data('source') === networkNodeId || edge.data('target') === networkNodeId)) {
edge.hide();
}
});
setTimeout(() => cy.fit(null, 30), 100);
}
applyGraphFilters();
}
function handleLayoutChange(event) {
if (!cy) return;
const layoutName = event.target.value;
let layoutOptions = { name: layoutName, animate: true, animationDuration: 500 };
// Customize options for different layouts
if (layoutName === 'dagre') {
layoutOptions.rankDir = 'TB'; // Top to bottom
layoutOptions.nodeSep = 50;
layoutOptions.rankSep = 100;
} else if (layoutName === 'cose') {
layoutOptions.idealEdgeLength = 100;
layoutOptions.nodeOverlap = 20;
layoutOptions.refresh = 20;
layoutOptions.fit = true;
layoutOptions.padding = 30;
layoutOptions.randomize = false;
layoutOptions.componentSpacing = 100;
layoutOptions.nodeRepulsion = 400000;
layoutOptions.edgeElasticity = 100;
layoutOptions.nestingFactor = 5;
layoutOptions.gravity = 80;
layoutOptions.numIter = 1000;
layoutOptions.initialTemp = 200;
layoutOptions.coolingFactor = 0.95;
layoutOptions.minTemp = 1.0;
} else if (layoutName === 'circle') {
layoutOptions.radius = 250;
} else if (layoutName === 'grid') {
layoutOptions.rows = Math.ceil(Math.sqrt(cy.nodes().length));
} else if (layoutName === 'concentric') {
layoutOptions.concentric = node => node.degree();
layoutOptions.levelWidth = () => 2;
}
const layout = cy.layout(layoutOptions);
layout.run();
}
function handleGraphSearch(event) {
if (!cy) return;
const searchTerm = event.target.value.toLowerCase().trim();
// Reset all highlighting
cy.nodes().removeClass('highlighted');
if (!searchTerm) {
return;
}
// Find matching nodes
const matchingNodes = cy.nodes().filter(node => {
return node.data('label').toLowerCase().includes(searchTerm);
});
if (matchingNodes.length > 0) {
// Highlight matches
matchingNodes.addClass('highlighted');
// Center on first match
const firstMatch = matchingNodes[0];
cy.animate({
center: { eles: firstMatch },
zoom: Math.max(cy.zoom(), 1.5)
}, {
duration: 500
});
// Update info
if (matchingNodes.length === 1) {
showGraphInfo(`Found: <strong>${firstMatch.data('label')}</strong>`);
} else {
showGraphInfo(`Found ${matchingNodes.length} containers matching "${searchTerm}"`);
}
} else {
showGraphInfo(`No containers found matching "${searchTerm}"`);
}
}
function toggleEdgeLabels() {
if (!cy) return;
const hideLabels = document.getElementById('hideEdgeLabels').checked;
if (hideLabels) {
cy.edges().addClass('no-label');
} else {
cy.edges().removeClass('no-label');
}
}
// Stats Modal
let statsCharts = { cpu: null, memory: null };
let currentStatsContainer = null;
let currentStatsRange = '1h';
function openStatsModal(hostId, containerId, containerName) {
console.log('openStatsModal called with:', { hostId, containerId, containerName });
currentStatsContainer = { hostId, containerId, containerName };
currentStatsRange = '1h';
const modal = document.getElementById('statsModal');
const nameElement = document.getElementById('statsContainerName');
if (!modal) {
console.error('Stats modal element not found!');
return;
}
if (!nameElement) {
console.error('Stats container name element not found!');
return;
}
nameElement.textContent = containerName;
// Use the 'show' class instead of style.display
modal.classList.add('show');
modal.style.display = ''; // Clear any inline style
console.log('Modal displayed with show class');
// Reset range buttons
document.querySelectorAll('.stats-range-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.range === '1h');
});
// Add click handlers for range buttons
document.querySelectorAll('.stats-range-btn').forEach(btn => {
btn.onclick = () => changeStatsRange(btn.dataset.range);
});
loadStatsData();
}
function closeStatsModal() {
const modal = document.getElementById('statsModal');
if (modal) {
modal.classList.remove('show');
}
// Destroy charts
if (statsCharts.cpu) {
statsCharts.cpu.destroy();
statsCharts.cpu = null;
}
if (statsCharts.memory) {
statsCharts.memory.destroy();
statsCharts.memory = null;
}
currentStatsContainer = null;
}
function changeStatsRange(range) {
currentStatsRange = range;
// Update active button
document.querySelectorAll('.stats-range-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.range === range);
});
loadStatsData();
}
async function loadStatsData() {
if (!currentStatsContainer) {
console.error('No current stats container set');
return;
}
const { hostId, containerId } = currentStatsContainer;
const url = `/api/containers/${hostId}/${containerId}/stats?range=${currentStatsRange}`;
console.log('Loading stats from:', url);
try {
const response = await fetch(url);
console.log('Stats response status:', response.status);
if (!response.ok) {
const errorText = await response.text();
console.error('Stats API error:', errorText);
throw new Error(`Failed to load stats: ${response.status} ${errorText}`);
}
const stats = await response.json();
console.log('Stats data received:', stats);
if (!stats || !Array.isArray(stats) || stats.length === 0) {
document.getElementById('statsMessage').textContent = 'No stats data available for this time range. Stats collection may need more time to gather data.';
document.getElementById('statsMessage').className = 'loading';
document.getElementById('statsMessage').style.display = 'block';
document.getElementById('statsChartArea').style.display = 'none';
return;
}
// Hide message and show charts
document.getElementById('statsMessage').style.display = 'none';
document.getElementById('statsChartArea').style.display = 'block';
renderStatsCharts(stats);
updateStatsSummary(stats);
} catch (error) {
console.error('Error loading stats:', error);
document.getElementById('statsMessage').textContent = `Failed to load stats data: ${error.message}`;
document.getElementById('statsMessage').className = 'error';
document.getElementById('statsMessage').style.display = 'block';
document.getElementById('statsChartArea').style.display = 'none';
}
}
function renderStatsCharts(stats) {
// Destroy existing charts
if (statsCharts.cpu) statsCharts.cpu.destroy();
if (statsCharts.memory) statsCharts.memory.destroy();
// Prepare data
const labels = stats.map(s => new Date(s.timestamp).toLocaleString());
const cpuData = stats.map(s => s.cpu_percent || 0);
const memoryData = stats.map(s => (s.memory_usage || 0) / 1024 / 1024); // Convert to MB
const memoryLimitData = stats.map(s => (s.memory_limit || 0) / 1024 / 1024);
// CPU Chart
const cpuCanvas = document.getElementById('cpuChart');
const cpuCtx = cpuCanvas.getContext('2d');
statsCharts.cpu = new Chart(cpuCtx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: 'CPU %',
data: cpuData,
borderColor: 'rgb(75, 192, 192)',
backgroundColor: 'rgba(75, 192, 192, 0.2)',
tension: 0.4,
fill: true
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: true,
text: 'CPU Usage Over Time'
},
legend: {
display: false
}
},
scales: {
y: {
beginAtZero: true,
max: 100,
title: {
display: true,
text: 'CPU %'
}
},
x: {
ticks: {
maxTicksLimit: 10
}
}
}
}
});
// Memory Chart
const memoryCanvas = document.getElementById('memoryChart');
const memoryCtx = memoryCanvas.getContext('2d');
const datasets = [{
label: 'Memory Usage (MB)',
data: memoryData,
borderColor: 'rgb(255, 99, 132)',
backgroundColor: 'rgba(255, 99, 132, 0.2)',
tension: 0.4,
fill: true
}];
// Add memory limit line if available
const hasLimit = memoryLimitData.some(l => l > 0);
if (hasLimit) {
datasets.push({
label: 'Memory Limit (MB)',
data: memoryLimitData,
borderColor: 'rgb(255, 159, 64)',
backgroundColor: 'rgba(255, 159, 64, 0.1)',
borderDash: [5, 5],
tension: 0,
fill: false
});
}
statsCharts.memory = new Chart(memoryCtx, {
type: 'line',
data: {
labels: labels,
datasets: datasets
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: true,
text: 'Memory Usage Over Time'
}
},
scales: {
y: {
beginAtZero: true,
title: {
display: true,
text: 'Memory (MB)'
}
},
x: {
ticks: {
maxTicksLimit: 10
}
}
}
}
});
}
function updateStatsSummary(stats) {
const cpuValues = stats.map(s => s.cpu_percent || 0).filter(v => v > 0);
const memoryValues = stats.map(s => s.memory_usage || 0).filter(v => v > 0);
// CPU stats
const avgCpu = cpuValues.length > 0 ? cpuValues.reduce((a, b) => a + b, 0) / cpuValues.length : 0;
const maxCpu = cpuValues.length > 0 ? Math.max(...cpuValues) : 0;
document.getElementById('avgCpu').textContent = avgCpu.toFixed(1) + '%';
document.getElementById('maxCpu').textContent = maxCpu.toFixed(1) + '%';
// Memory stats
const avgMemory = memoryValues.length > 0 ? memoryValues.reduce((a, b) => a + b, 0) / memoryValues.length : 0;
const maxMemory = memoryValues.length > 0 ? Math.max(...memoryValues) : 0;
const formatMemory = (bytes) => {
const mb = bytes / 1024 / 1024;
if (mb > 1024) {
return (mb / 1024).toFixed(2) + ' GB';
}
return mb.toFixed(0) + ' MB';
};
document.getElementById('avgMemory').textContent = formatMemory(avgMemory);
document.getElementById('maxMemory').textContent = formatMemory(maxMemory);
}
// Close modal when clicking outside
document.getElementById('statsModal')?.addEventListener('click', (e) => {
if (e.target.classList.contains('modal')) closeStatsModal();
});
// ==================== REPORTS TAB ====================
let currentReport = null;
let changesTimelineChart = null;
// Initialize reports tab
function initializeReportsTab() {
// Set default date range to last 7 days
const end = new Date();
const start = new Date(end - 7 * 24 * 60 * 60 * 1000);
document.getElementById('reportStartDate').value = formatDateTimeLocal(start);
document.getElementById('reportEndDate').value = formatDateTimeLocal(end);
// Load hosts for filter
loadHostsForReportFilter();
// Set up event listeners
setupReportEventListeners();
}
// Set up event listeners for reports tab
function setupReportEventListeners() {
document.getElementById('generateReportBtn').addEventListener('click', generateReport);
document.getElementById('report7d').addEventListener('click', () => setReportRange(7));
document.getElementById('report30d').addEventListener('click', () => setReportRange(30));
document.getElementById('report90d').addEventListener('click', () => setReportRange(90));
document.getElementById('exportReportBtn').addEventListener('click', exportReport);
}
// Navigate to History tab with container filter
function goToContainerHistory(containerName, hostId) {
// Switch to history tab
switchTab('history');
// Set the search filter to the container name
const searchInput = document.getElementById('searchInput');
if (searchInput) {
searchInput.value = containerName;
}
// Set the host filter if provided
const hostFilter = document.getElementById('hostFilter');
if (hostFilter && hostId) {
hostFilter.value = hostId.toString();
}
// Apply the filters
setTimeout(() => {
applyCurrentFilters();
}, 100);
}
// Format date for datetime-local input
function formatDateTimeLocal(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
// Load hosts for report filter dropdown
async function loadHostsForReportFilter() {
try {
const response = await fetch('/api/hosts');
const data = await response.json();
const select = document.getElementById('reportHostFilter');
select.innerHTML = '<option value="">All Hosts</option>';
data.forEach(host => {
const option = document.createElement('option');
option.value = host.id;
option.textContent = host.name;
select.appendChild(option);
});
} catch (error) {
console.error('Failed to load hosts for report filter:', error);
}
}
// Set report date range preset
function setReportRange(days) {
const end = new Date();
const start = new Date(end - days * 24 * 60 * 60 * 1000);
document.getElementById('reportStartDate').value = formatDateTimeLocal(start);
document.getElementById('reportEndDate').value = formatDateTimeLocal(end);
}
// Generate report
async function generateReport() {
const startInput = document.getElementById('reportStartDate').value;
const endInput = document.getElementById('reportEndDate').value;
const hostFilter = document.getElementById('reportHostFilter').value;
if (!startInput || !endInput) {
alert('Please select both start and end dates');
return;
}
const start = new Date(startInput).toISOString();
const end = new Date(endInput).toISOString();
// Show loading, hide results and empty state
document.getElementById('reportLoading').style.display = 'block';
document.getElementById('reportResults').style.display = 'none';
document.getElementById('reportEmptyState').style.display = 'none';
try {
let url = `/api/reports/changes?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}`;
if (hostFilter) {
url += `&host_id=${hostFilter}`;
}
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
}
currentReport = await response.json();
renderReport(currentReport);
// Hide loading, show results
document.getElementById('reportLoading').style.display = 'none';
document.getElementById('reportResults').style.display = 'block';
} catch (error) {
console.error('Failed to generate report:', error);
alert('Failed to generate report: ' + error.message);
document.getElementById('reportLoading').style.display = 'none';
document.getElementById('reportEmptyState').style.display = 'block';
}
}
// Render report
function renderReport(report) {
// Render summary cards
renderReportSummary(report.summary);
// Render timeline chart
renderTimelineChart(report);
// Render details sections
renderNewContainers(report.new_containers);
renderRemovedContainers(report.removed_containers);
renderImageUpdates(report.image_updates);
renderStateChanges(report.state_changes);
renderTopRestarted(report.top_restarted);
}
// Render summary cards
function renderReportSummary(summary) {
const cardsHTML = `
<div class="stat-card">
<div class="stat-icon">🖥️</div>
<div class="stat-content">
<div class="stat-value">${summary.total_hosts}</div>
<div class="stat-label">Total Hosts</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">📦</div>
<div class="stat-content">
<div class="stat-value">${summary.total_containers}</div>
<div class="stat-label">Total Containers</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">🆕</div>
<div class="stat-content">
<div class="stat-value">${summary.new_containers}</div>
<div class="stat-label">New Containers</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">❌</div>
<div class="stat-content">
<div class="stat-value">${summary.removed_containers}</div>
<div class="stat-label">Removed</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">🔄</div>
<div class="stat-content">
<div class="stat-value">${summary.image_updates}</div>
<div class="stat-label">Image Updates</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">🔀</div>
<div class="stat-content">
<div class="stat-value">${summary.state_changes}</div>
<div class="stat-label">State Changes</div>
</div>
</div>
`;
document.getElementById('reportSummaryCards').innerHTML = cardsHTML;
}
// Render timeline chart
function renderTimelineChart(report) {
// Destroy existing chart if it exists
if (changesTimelineChart) {
changesTimelineChart.destroy();
}
// Aggregate changes by day
const changesByDay = {};
// Helper to get day key
const getDayKey = (timestamp) => {
const date = new Date(timestamp);
return date.toISOString().split('T')[0];
};
// Count new containers
report.new_containers.forEach(c => {
const day = getDayKey(c.timestamp);
if (!changesByDay[day]) changesByDay[day] = { new: 0, removed: 0, imageUpdates: 0, stateChanges: 0 };
changesByDay[day].new++;
});
// Count removed containers
report.removed_containers.forEach(c => {
const day = getDayKey(c.timestamp);
if (!changesByDay[day]) changesByDay[day] = { new: 0, removed: 0, imageUpdates: 0, stateChanges: 0 };
changesByDay[day].removed++;
});
// Count image updates
report.image_updates.forEach(u => {
const day = getDayKey(u.updated_at);
if (!changesByDay[day]) changesByDay[day] = { new: 0, removed: 0, imageUpdates: 0, stateChanges: 0 };
changesByDay[day].imageUpdates++;
});
// Count state changes
report.state_changes.forEach(s => {
const day = getDayKey(s.changed_at);
if (!changesByDay[day]) changesByDay[day] = { new: 0, removed: 0, imageUpdates: 0, stateChanges: 0 };
changesByDay[day].stateChanges++;
});
// Sort days
const days = Object.keys(changesByDay).sort();
const ctx = document.getElementById('changesTimelineChart').getContext('2d');
changesTimelineChart = new Chart(ctx, {
type: 'line',
data: {
labels: days.map(d => new Date(d).toLocaleDateString()),
datasets: [
{
label: 'New Containers',
data: days.map(d => changesByDay[d].new),
borderColor: '#2ecc71',
backgroundColor: 'rgba(46, 204, 113, 0.1)',
tension: 0.4
},
{
label: 'Removed Containers',
data: days.map(d => changesByDay[d].removed),
borderColor: '#e74c3c',
backgroundColor: 'rgba(231, 76, 60, 0.1)',
tension: 0.4
},
{
label: 'Image Updates',
data: days.map(d => changesByDay[d].imageUpdates),
borderColor: '#3498db',
backgroundColor: 'rgba(52, 152, 219, 0.1)',
tension: 0.4
},
{
label: 'State Changes',
data: days.map(d => changesByDay[d].stateChanges),
borderColor: '#f39c12',
backgroundColor: 'rgba(243, 156, 18, 0.1)',
tension: 0.4
}
]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: {
display: true,
position: 'bottom'
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
stepSize: 1
}
}
}
}
});
}
// Render new containers table
function renderNewContainers(containers) {
document.getElementById('newContainersCount').textContent = containers.length;
if (containers.length === 0) {
document.getElementById('newContainersTable').innerHTML = '<p class="empty-message">No new containers in this period</p>';
return;
}
const tableHTML = `
<table class="report-table">
<thead>
<tr>
<th>Container Name</th>
<th>Image</th>
<th>Host</th>
<th>First Seen</th>
<th>State</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${containers.map(c => `
<tr>
<td>
<code class="container-link" onclick="goToContainerHistory('${escapeHtml(c.container_name)}', ${c.host_id})" title="View in History">
${escapeHtml(c.container_name)} 🔗
</code>
${c.is_transient ? '<span class="transient-badge" title="This container appeared and disappeared within the reporting period">⚡ Transient</span>' : ''}
</td>
<td>${escapeHtml(c.image)}</td>
<td>${escapeHtml(c.host_name)}</td>
<td>${formatDateTime(c.timestamp)}</td>
<td><span class="status-badge status-${c.state}">${c.state}</span></td>
<td>
<button class="btn-icon" onclick="openStatsModal(${c.host_id}, '${escapeHtml(c.container_id)}', '${escapeHtml(c.container_name)}')" title="View Stats & Timeline">
📊
</button>
<button class="btn-icon" onclick="viewContainerTimeline(${c.host_id}, '${escapeHtml(c.container_id)}', '${escapeHtml(c.container_name)}')" title="View Lifecycle Timeline">
📜
</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
`;
document.getElementById('newContainersTable').innerHTML = tableHTML;
}
// Render removed containers table
function renderRemovedContainers(containers) {
document.getElementById('removedContainersCount').textContent = containers.length;
if (containers.length === 0) {
document.getElementById('removedContainersTable').innerHTML = '<p class="empty-message">No removed containers in this period</p>';
return;
}
const tableHTML = `
<table class="report-table">
<thead>
<tr>
<th>Container Name</th>
<th>Image</th>
<th>Host</th>
<th>Last Seen</th>
<th>Final State</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${containers.map(c => `
<tr>
<td>
<code class="container-link" onclick="goToContainerHistory('${escapeHtml(c.container_name)}', ${c.host_id})" title="View in History">
${escapeHtml(c.container_name)} 🔗
</code>
${c.is_transient ? '<span class="transient-badge" title="This container appeared and disappeared within the reporting period">⚡ Transient</span>' : ''}
</td>
<td>${escapeHtml(c.image)}</td>
<td>${escapeHtml(c.host_name)}</td>
<td>${formatDateTime(c.timestamp)}</td>
<td><span class="status-badge status-${c.state}">${c.state}</span></td>
<td>
<button class="btn-icon" onclick="openStatsModal(${c.host_id}, '${escapeHtml(c.container_id)}', '${escapeHtml(c.container_name)}')" title="View Stats & Timeline">
📊
</button>
<button class="btn-icon" onclick="viewContainerTimeline(${c.host_id}, '${escapeHtml(c.container_id)}', '${escapeHtml(c.container_name)}')" title="View Lifecycle Timeline">
📜
</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
`;
document.getElementById('removedContainersTable').innerHTML = tableHTML;
}
// Render image updates table
function renderImageUpdates(updates) {
document.getElementById('imageUpdatesCount').textContent = updates.length;
if (updates.length === 0) {
document.getElementById('imageUpdatesTable').innerHTML = '<p class="empty-message">No image updates in this period</p>';
return;
}
const tableHTML = `
<table class="report-table">
<thead>
<tr>
<th>Container Name</th>
<th>Host</th>
<th>Old Image</th>
<th>New Image</th>
<th>Updated At</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${updates.map(u => `
<tr>
<td>
<code class="container-link" onclick="goToContainerHistory('${escapeHtml(u.container_name)}', ${u.host_id})" title="View in History">
${escapeHtml(u.container_name)} 🔗
</code>
</td>
<td>${escapeHtml(u.host_name)}</td>
<td>${escapeHtml(u.old_image)}<br><small>${u.old_image_id.substring(0, 12)}</small></td>
<td>${escapeHtml(u.new_image)}<br><small>${u.new_image_id.substring(0, 12)}</small></td>
<td>${formatDateTime(u.updated_at)}</td>
<td>
<button class="btn-icon" onclick="openStatsModal(${u.host_id}, '${escapeHtml(u.container_id)}', '${escapeHtml(u.container_name)}')" title="View Stats & Timeline">
📊
</button>
<button class="btn-icon" onclick="viewContainerTimeline(${u.host_id}, '${escapeHtml(u.container_id)}', '${escapeHtml(u.container_name)}')" title="View Lifecycle Timeline">
📜
</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
`;
document.getElementById('imageUpdatesTable').innerHTML = tableHTML;
}
// Render state changes table
function renderStateChanges(changes) {
document.getElementById('stateChangesCount').textContent = changes.length;
if (changes.length === 0) {
document.getElementById('stateChangesTable').innerHTML = '<p class="empty-message">No state changes in this period</p>';
return;
}
const tableHTML = `
<table class="report-table">
<thead>
<tr>
<th>Container Name</th>
<th>Host</th>
<th>Old State</th>
<th>New State</th>
<th>Changed At</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${changes.map(s => `
<tr>
<td>
<code class="container-link" onclick="goToContainerHistory('${escapeHtml(s.container_name)}', ${s.host_id})" title="View in History">
${escapeHtml(s.container_name)} 🔗
</code>
</td>
<td>${escapeHtml(s.host_name)}</td>
<td><span class="status-badge status-${s.old_state}">${s.old_state}</span></td>
<td><span class="status-badge status-${s.new_state}">${s.new_state}</span></td>
<td>${formatDateTime(s.changed_at)}</td>
<td>
<button class="btn-icon" onclick="openStatsModal(${s.host_id}, '${escapeHtml(s.container_id)}', '${escapeHtml(s.container_name)}')" title="View Stats & Timeline">
📊
</button>
<button class="btn-icon" onclick="viewContainerTimeline(${s.host_id}, '${escapeHtml(s.container_id)}', '${escapeHtml(s.container_name)}')" title="View Lifecycle Timeline">
📜
</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
`;
document.getElementById('stateChangesTable').innerHTML = tableHTML;
}
// Render top restarted containers table
function renderTopRestarted(containers) {
document.getElementById('topRestartedCount').textContent = containers.length;
if (containers.length === 0) {
document.getElementById('topRestartedTable').innerHTML = '<p class="empty-message">No active containers in this period</p>';
return;
}
const tableHTML = `
<table class="report-table">
<thead>
<tr>
<th>Container Name</th>
<th>Image</th>
<th>Host</th>
<th>Activity Count</th>
<th>Current State</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${containers.map(r => `
<tr>
<td>
<code class="container-link" onclick="goToContainerHistory('${escapeHtml(r.container_name)}', ${r.host_id})" title="View in History">
${escapeHtml(r.container_name)} 🔗
</code>
</td>
<td>${escapeHtml(r.image)}</td>
<td>${escapeHtml(r.host_name)}</td>
<td>${r.restart_count}</td>
<td><span class="status-badge status-${r.current_state}">${r.current_state}</span></td>
<td>
<button class="btn-icon" onclick="openStatsModal(${r.host_id}, '${escapeHtml(r.container_id)}', '${escapeHtml(r.container_name)}')" title="View Stats & Timeline">
📊
</button>
<button class="btn-icon" onclick="viewContainerTimeline(${r.host_id}, '${escapeHtml(r.container_id)}', '${escapeHtml(r.container_name)}')" title="View Lifecycle Timeline">
📜
</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
`;
document.getElementById('topRestartedTable').innerHTML = tableHTML;
}
// Toggle report section visibility
window.toggleReportSection = function(section) {
const sectionElement = document.getElementById(`${section}Section`);
const isVisible = sectionElement.style.display !== 'none';
sectionElement.style.display = isVisible ? 'none' : 'block';
// Toggle collapse icon
const header = sectionElement.previousElementSibling;
const icon = header.querySelector('.collapse-icon');
if (icon) {
icon.textContent = isVisible ? '▶' : '▼';
}
};
// Export report as JSON
function exportReport() {
if (!currentReport) {
alert('No report to export. Please generate a report first.');
return;
}
const dataStr = JSON.stringify(currentReport, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(dataBlob);
const link = document.createElement('a');
link.href = url;
link.download = `container-census-report-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
// Helper: Escape HTML
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Helper: Format date/time
function formatDateTime(timestamp) {
if (!timestamp) return '-';
const date = new Date(timestamp);
return date.toLocaleString();
}
// ===== Vulnerability Scanning =====
// Fetch vulnerability scan for an image
async function getVulnerabilityScan(imageID) {
// Check cache first
if (vulnerabilityCache[imageID]) {
return vulnerabilityCache[imageID];
}
// If not in cache, try loading from the pre-loaded scans map
if (vulnerabilityScansMap && vulnerabilityScansMap[imageID]) {
vulnerabilityCache[imageID] = vulnerabilityScansMap[imageID];
return vulnerabilityScansMap[imageID];
}
// Mark as null in cache to avoid repeated 404 requests
vulnerabilityCache[imageID] = null;
return null;
}
// Pre-load all vulnerability scans to avoid 404 requests
async function preloadVulnerabilityScans() {
try {
const response = await fetch('/api/vulnerabilities/scans?limit=1000');
if (response.ok) {
const scans = await response.json();
// Build a map of imageID -> scan data
vulnerabilityScansMap = {};
scans.forEach(scan => {
vulnerabilityScansMap[scan.image_id] = {
scan: scan,
vulnerabilities: scan.vulnerabilities || []
};
});
return vulnerabilityScansMap;
}
} catch (error) {
console.error('Error preloading vulnerability scans:', error);
}
return {};
}
// Fetch vulnerability summary (all images)
// Note: loadVulnerabilitySummary() is defined later in the file with security-enabled check
// Generate vulnerability badge HTML
function getVulnerabilityBadgeHTML(scan) {
if (!scan) {
// No scan found
return '<span class="vulnerability-badge not-scanned" title="Not scanned">🛡️ Not Scanned</span>';
}
if (!scan.scan.success) {
// Check if it's a remote image (not available for scanning)
const error = scan.scan.error || '';
if (error.includes('image not available for scanning') || error.includes('not available')) {
return '<span class="vulnerability-badge remote" title="Remote image - not available for scanning">🌐 Remote</span>';
}
// Other scan failures
return '<span class="vulnerability-badge not-scanned" title="Scan failed">⚠️ Scan Failed</span>';
}
const counts = scan.scan.severity_counts || {};
const total = scan.scan.total_vulnerabilities || 0;
const critical = counts.critical || 0;
const high = counts.high || 0;
const medium = counts.medium || 0;
const low = counts.low || 0;
if (total === 0) {
return '<span class="vulnerability-badge clean" title="No vulnerabilities found">✓ Clean</span>';
}
// Determine severity class based on highest severity found
let badgeClass = 'low';
let icon = '🛡️';
if (critical > 0) {
badgeClass = 'critical';
icon = '🚨';
} else if (high > 0) {
badgeClass = 'high';
icon = '⚠️';
} else if (medium > 0) {
badgeClass = 'medium';
icon = '⚡';
}
// Format badge text
let badgeText = `${icon} ${total}`;
if (critical > 0 || high > 0) {
badgeText += ` (${critical}C ${high}H)`;
}
const titleParts = [];
if (critical > 0) titleParts.push(`${critical} Critical`);
if (high > 0) titleParts.push(`${high} High`);
if (medium > 0) titleParts.push(`${medium} Medium`);
if (low > 0) titleParts.push(`${low} Low`);
const title = `Total: ${total} vulnerabilities - ${titleParts.join(', ')}`;
return `<span class="vulnerability-badge ${badgeClass}" title="${title}">${badgeText}</span>`;
}
// Add vulnerability badge to container card (called asynchronously)
async function addVulnerabilityBadge(containerElement, imageID) {
const scan = await getVulnerabilityScan(imageID);
const badgeHTML = getVulnerabilityBadgeHTML(scan, imageID);
// Find the image row in the container card
const imageRow = containerElement.querySelector('.detail-value.image-value');
if (imageRow && imageRow.parentElement) {
// Add badge after the image name
const badgeContainer = document.createElement('span');
badgeContainer.innerHTML = badgeHTML;
const badge = badgeContainer.firstChild;
// Make badge clickable if it has vulnerabilities
if (scan && scan.scan && scan.scan.success) {
const imageName = scan.scan.image_name || imageID;
badge.style.cursor = 'pointer';
badge.onclick = () => viewVulnerabilityDetails(imageID, imageName);
}
imageRow.parentElement.appendChild(badge);
}
}
// Load vulnerability badges for all visible containers
async function loadAllVulnerabilityBadges() {
const containerCards = document.querySelectorAll('.container-card-modern');
// Get the current filtered containers being displayed
const searchTerm = document.getElementById('searchInput')?.value.toLowerCase() || '';
const hostFilter = document.getElementById('hostFilter')?.value || '';
const stateFilter = document.getElementById('stateFilter')?.value || '';
const filtered = containers.filter(container => {
const matchesSearch = searchTerm === '' ||
container.name.toLowerCase().includes(searchTerm) ||
container.image.toLowerCase().includes(searchTerm) ||
container.host_name.toLowerCase().includes(searchTerm);
const matchesHost = hostFilter === '' || container.host_id.toString() === hostFilter;
const matchesState = stateFilter === '' || container.state === stateFilter;
return matchesSearch && matchesHost && matchesState;
});
// Pre-load all vulnerability scans to avoid 404 errors
await preloadVulnerabilityScans();
// Now add badges to each card
containerCards.forEach((card, index) => {
if (filtered[index] && filtered[index].image_id) {
addVulnerabilityBadge(card, filtered[index].image_id);
}
});
}
// ===== Security Tab =====
let allVulnerabilityScans = [];
let securityChart = null;
let scanningImages = new Set(); // Track images currently being scanned
// Load the security tab
async function loadSecurityTab() {
try {
// Load summary and all scans in parallel
const [summary, scans] = await Promise.all([
loadVulnerabilitySummary(),
loadAllVulnerabilityScans()
]);
allVulnerabilityScans = scans || [];
// Update summary cards
updateSecuritySummaryCards(summary, allVulnerabilityScans);
// Render security chart
renderSecurityChart(summary);
// Render vulnerability trends chart
renderVulnerabilityTrendsChart(allVulnerabilityScans);
// Update queue status
updateQueueStatus(summary?.queue_status);
// Update scan count badge (use allVulnerabilityScans for total, not filtered)
const scanCountBadge = document.getElementById('scanCountBadge');
if (scanCountBadge && allVulnerabilityScans) {
scanCountBadge.textContent = `${allVulnerabilityScans.length} scan${allVulnerabilityScans.length !== 1 ? 's' : ''}`;
}
// Render scans table
filterSecurityScans();
// Start periodic queue status updates (every 3 seconds)
startQueueStatusPolling();
} catch (error) {
console.error('Error loading security tab:', error);
}
}
// Poll queue status periodically to update button states
let queueStatusInterval = null;
function startQueueStatusPolling() {
// Clear existing interval
if (queueStatusInterval) {
clearInterval(queueStatusInterval);
}
// Poll every 3 seconds
queueStatusInterval = setInterval(async () => {
try {
const summary = await loadVulnerabilitySummary();
updateQueueStatus(summary?.queue_status);
} catch (error) {
console.error('Error polling queue status:', error);
}
}, 3000);
}
// Stop polling when leaving security tab
function stopQueueStatusPolling() {
if (queueStatusInterval) {
clearInterval(queueStatusInterval);
queueStatusInterval = null;
}
}
// Load all vulnerability scans
async function loadAllVulnerabilityScans() {
try {
const response = await fetch('/api/vulnerabilities/scans?limit=1000');
if (response.ok) {
return await response.json();
}
} catch (error) {
console.error('Error fetching vulnerability scans:', error);
}
return [];
}
// Update security summary cards
function updateSecuritySummaryCards(summary, scans) {
if (!summary) {
document.getElementById('totalScannedImages').textContent = '-';
document.getElementById('totalCriticalVulns').textContent = '-';
document.getElementById('totalHighVulns').textContent = '-';
document.getElementById('atRiskImages').textContent = '-';
return;
}
// Handle both wrapped (summary.summary) and direct summary objects
const s = summary.summary || summary;
const totalScans = scans ? scans.length : 0;
const uniqueImages = s.total_images_scanned || 0;
// Show both unique images and total scans for clarity
const displayText = totalScans > 0 ? `${uniqueImages} (${totalScans} scans)` : `${uniqueImages}`;
document.getElementById('totalScannedImages').textContent = displayText;
document.getElementById('totalCriticalVulns').textContent = s.severity_counts?.critical || 0;
document.getElementById('totalHighVulns').textContent = s.severity_counts?.high || 0;
document.getElementById('atRiskImages').textContent = s.images_with_vulnerabilities || 0;
}
// Render security severity chart
function renderSecurityChart(summary) {
const ctx = document.getElementById('vulnerabilitySeverityChart');
if (!ctx) return;
// Handle both wrapped (summary.summary) and direct summary objects
const s = summary?.summary || summary || {};
const severityCounts = s.severity_counts || {};
const data = {
labels: ['Critical', 'High', 'Medium', 'Low'],
datasets: [{
data: [
severityCounts.critical || 0,
severityCounts.high || 0,
severityCounts.medium || 0,
severityCounts.low || 0
],
backgroundColor: [
'#ff1744', // Critical
'#ff9800', // High
'#ffc107', // Medium
'#4caf50' // Low
],
borderWidth: 2,
borderColor: 'white'
}]
};
if (securityChart) {
securityChart.destroy();
}
securityChart = new Chart(ctx, {
type: 'doughnut',
data: data,
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: {
color: '#333',
font: { size: 13 },
padding: 12,
usePointStyle: true
}
},
title: {
display: false
}
}
}
});
}
// Global variable for trends chart
let trendsChart = null;
// Render vulnerability trends chart
function renderVulnerabilityTrendsChart(scans) {
const ctx = document.getElementById('vulnerabilityTrendsChart');
if (!ctx) return;
try {
// Use provided scans data
if (!scans || scans.length === 0) {
console.log('No scan data available for trends chart');
return;
}
// Group scans by date (last 30 days) and calculate aggregates
const now = new Date();
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
const dailyData = {};
scans.forEach(scan => {
if (!scan.success || !scan.scanned_at) return;
const scanDate = new Date(scan.scanned_at);
if (scanDate < thirtyDaysAgo) return;
const dateKey = scanDate.toISOString().split('T')[0];
if (!dailyData[dateKey]) {
dailyData[dateKey] = {
critical: 0,
high: 0,
medium: 0,
low: 0,
total: 0,
count: 0
};
}
const counts = scan.severity_counts || {};
dailyData[dateKey].critical += counts.critical || 0;
dailyData[dateKey].high += counts.high || 0;
dailyData[dateKey].medium += counts.medium || 0;
dailyData[dateKey].low += counts.low || 0;
dailyData[dateKey].total += scan.total_vulnerabilities || 0;
dailyData[dateKey].count++;
});
// Sort dates and create labels
const sortedDates = Object.keys(dailyData).sort();
const labels = sortedDates.map(date => {
const d = new Date(date);
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
});
const criticalData = sortedDates.map(date => dailyData[date].critical);
const highData = sortedDates.map(date => dailyData[date].high);
const mediumData = sortedDates.map(date => dailyData[date].medium);
const lowData = sortedDates.map(date => dailyData[date].low);
if (trendsChart) {
trendsChart.destroy();
}
trendsChart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: 'Critical',
data: criticalData,
borderColor: '#ff1744',
backgroundColor: 'rgba(255, 23, 68, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.4
},
{
label: 'High',
data: highData,
borderColor: '#ff9800',
backgroundColor: 'rgba(255, 152, 0, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.4
},
{
label: 'Medium',
data: mediumData,
borderColor: '#ffc107',
backgroundColor: 'rgba(255, 193, 7, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.4
},
{
label: 'Low',
data: lowData,
borderColor: '#4caf50',
backgroundColor: 'rgba(76, 175, 80, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.4
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false
},
plugins: {
legend: {
position: 'bottom',
labels: {
color: '#333',
font: { size: 13 },
padding: 12,
usePointStyle: true
}
},
title: {
display: false
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
padding: 12,
titleFont: { size: 14, weight: 'bold' },
bodyFont: { size: 13 },
callbacks: {
footer: function(context) {
let total = 0;
context.forEach(item => {
total += item.parsed.y;
});
return 'Total: ' + total;
}
}
}
},
scales: {
x: {
grid: {
display: false
},
ticks: {
color: '#666',
font: { size: 11 }
}
},
y: {
beginAtZero: true,
grid: {
color: 'rgba(0, 0, 0, 0.05)'
},
ticks: {
color: '#666',
font: { size: 11 },
precision: 0
}
}
}
}
});
} catch (error) {
console.error('Error rendering trends chart:', error);
}
}
// Update queue status
function updateQueueStatus(queueStatus) {
const queueDiv = document.getElementById('securityQueueStatus');
if (!queueDiv) return;
if (!queueStatus) {
queueDiv.style.display = 'none';
scanningImages.clear();
return;
}
// Update set of images currently being scanned
scanningImages.clear();
if (queueStatus.queue_items && Array.isArray(queueStatus.queue_items)) {
queueStatus.queue_items.forEach(item => {
if (item.image_id) {
scanningImages.add(item.image_id);
}
});
}
// Show status if there's any activity OR worker info
const hasActivity = queueStatus.in_progress > 0 || queueStatus.queued > 0;
const hasWorkerInfo = queueStatus.total_workers && queueStatus.total_workers > 0;
if (!hasActivity && !hasWorkerInfo) {
queueDiv.style.display = 'none';
return;
}
queueDiv.style.display = 'flex';
const statusText = document.getElementById('queueStatusText');
let text = '';
if (hasWorkerInfo) {
text = `${queueStatus.total_workers} workers (${queueStatus.active_workers || 0} active)`;
if (hasActivity) {
text += ` - ${queueStatus.in_progress} scanning, ${queueStatus.queued} queued`;
}
} else if (hasActivity) {
text = `${queueStatus.in_progress} scanning, ${queueStatus.queued} queued`;
}
statusText.textContent = text;
// Re-render table to update button states
filterSecurityScans();
}
// Filter security scans table
function filterSecurityScans() {
const searchTerm = document.getElementById('securitySearchInput')?.value.toLowerCase() || '';
const severityFilter = document.getElementById('securitySeverityFilter')?.value || '';
const statusFilter = document.getElementById('securityStatusFilter')?.value || '';
const filtered = allVulnerabilityScans.filter(scan => {
const matchesSearch = searchTerm === '' ||
scan.image_name.toLowerCase().includes(searchTerm) ||
scan.image_id.toLowerCase().includes(searchTerm);
let matchesSeverity = true;
if (severityFilter) {
if (severityFilter === 'clean') {
matchesSeverity = scan.total_vulnerabilities === 0 && scan.success;
} else if (severityFilter === 'critical') {
matchesSeverity = (scan.severity_counts?.critical || 0) > 0;
} else if (severityFilter === 'high') {
matchesSeverity = (scan.severity_counts?.high || 0) > 0;
} else if (severityFilter === 'medium') {
matchesSeverity = (scan.severity_counts?.medium || 0) > 0;
} else if (severityFilter === 'low') {
matchesSeverity = (scan.severity_counts?.low || 0) > 0;
}
}
let matchesStatus = true;
if (statusFilter) {
const error = scan.error || '';
if (statusFilter === 'scanned') {
matchesStatus = scan.success;
} else if (statusFilter === 'remote') {
matchesStatus = !scan.success && (error.includes('image not available for scanning') || error.includes('not available'));
} else if (statusFilter === 'failed') {
matchesStatus = !scan.success && !(error.includes('image not available for scanning') || error.includes('not available'));
}
}
return matchesSearch && matchesSeverity && matchesStatus;
});
renderSecurityScansTable(filtered);
}
// Render security scans table
function renderSecurityScansTable(scans) {
const tbody = document.getElementById('securityScansBody');
if (!tbody) return;
if (scans.length === 0) {
tbody.innerHTML = '<tr><td colspan="9" class="loading">No scans found</td></tr>';
return;
}
tbody.innerHTML = scans.map(scan => {
const counts = scan.severity_counts || {};
const total = scan.total_vulnerabilities || 0;
const critical = counts.critical || 0;
const high = counts.high || 0;
const medium = counts.medium || 0;
const low = counts.low || 0;
const scannedTime = formatTimeAgo(new Date(scan.scanned_at));
// Determine status badge
let statusBadge = '';
if (!scan.success) {
const error = scan.error || '';
if (error.includes('image not available for scanning') || error.includes('not available')) {
statusBadge = '<span class="vulnerability-badge remote" title="Remote image - not available for scanning">🌐 Remote</span>';
} else {
statusBadge = '<span class="vulnerability-badge not-scanned" title="Scan failed">⚠️ Failed</span>';
}
} else if (total === 0) {
statusBadge = '<span class="vulnerability-badge clean" title="No vulnerabilities found">✓ Clean</span>';
} else if (critical > 0) {
statusBadge = '<span class="vulnerability-badge critical" title="Has critical vulnerabilities">🚨 Critical</span>';
} else if (high > 0) {
statusBadge = '<span class="vulnerability-badge high" title="Has high vulnerabilities">⚠️ High</span>';
} else {
statusBadge = '<span class="vulnerability-badge medium" title="Has vulnerabilities">⚡ Vuln</span>';
}
// Check if this image is currently being scanned
const isScanning = scan.image_id && scanningImages.has(scan.image_id);
const rescanBtnClass = 'btn btn-sm btn-secondary';
const rescanBtnDisabled = isScanning ? 'disabled' : '';
const rescanBtnText = isScanning ? '⏳ Scanning...' : '🔄 Rescan';
// Determine row class based on highest severity
let rowClass = '';
if (critical > 0) rowClass = 'severity-critical';
else if (high > 0) rowClass = 'severity-high';
else if (medium > 0) rowClass = 'severity-medium';
return `
<tr class="${rowClass}">
<td><code>${escapeHtml(scan.image_name)}</code></td>
<td>${statusBadge}</td>
<td>${total}</td>
<td><span class="severity-badge critical">${critical}</span></td>
<td><span class="severity-badge high">${high}</span></td>
<td><span class="severity-badge medium">${medium}</span></td>
<td><span class="severity-badge low">${low}</span></td>
<td>${scannedTime}</td>
<td>
<button class="btn btn-sm btn-primary" onclick="viewVulnerabilityDetails('${escapeAttr(scan.image_id)}', '${escapeAttr(scan.image_name)}')">
🔍 Details
</button>
<button class="${rescanBtnClass}" onclick="rescanImage('${escapeAttr(scan.image_id)}', '${escapeAttr(scan.image_name)}')" ${rescanBtnDisabled}>
${rescanBtnText}
</button>
</td>
</tr>
`;
}).join('');
}
// Trigger scan for all images
async function scanAllImages() {
try {
const response = await fetch('/api/vulnerabilities/scan-all', {
method: 'POST'
});
if (response.ok) {
const data = await response.json();
showNotification(`Queued ${data.images_queued} images for scanning`, 'success');
// Reload security tab after a short delay
setTimeout(loadSecurityTab, 2000);
} else {
const error = await response.json();
showNotification(`Failed to queue scans: ${error.error}`, 'error');
}
} catch (error) {
console.error('Error triggering scan-all:', error);
showNotification('Failed to queue scans', 'error');
}
}
// Trigger scan for a specific image
async function rescanImage(imageID, imageName) {
try {
const response = await fetch(`/api/vulnerabilities/scan/${encodeURIComponent(imageID)}`, {
method: 'POST'
});
if (response.ok) {
showNotification(`Queued ${imageName} for scanning`, 'success');
// Add to scanning set and update UI immediately
scanningImages.add(imageID);
renderSecurityScansTable(allVulnerabilityScans);
// Update the queue status
const summary = await loadVulnerabilitySummary();
updateQueueStatus(summary?.queue_status);
// Poll for scan completion
pollForScanCompletion(imageID);
} else {
const error = await response.json();
showNotification(`Failed to queue scan: ${error.error}`, 'error');
}
} catch (error) {
console.error('Error triggering scan:', error);
showNotification('Failed to queue scan', 'error');
}
}
// Poll for scan completion and refresh data when done
async function pollForScanCompletion(imageID) {
const maxAttempts = 60; // Poll for up to 10 minutes (60 * 10s)
let attempts = 0;
const pollInterval = setInterval(async () => {
attempts++;
// Check queue status
const summary = await loadVulnerabilitySummary();
updateQueueStatus(summary?.queue_status);
// Check if this image is still in the queue
const stillScanning = scanningImages.has(imageID);
if (!stillScanning || attempts >= maxAttempts) {
clearInterval(pollInterval);
// Reload scan data to show updated results
await preloadVulnerabilityScans();
if (currentTab === 'security') {
filterSecurityScans();
}
// Clear vulnerability scan cache for this image
if (vulnScanCache.has(imageID)) {
vulnScanCache.delete(imageID);
}
}
}, 10000); // Poll every 10 seconds
}
// Update Trivy database
async function updateTrivyDB() {
try {
showNotification('Updating Trivy database... This may take a few minutes.', 'info');
const response = await fetch('/api/vulnerabilities/update-db', {
method: 'POST'
});
if (response.ok) {
showNotification('Trivy database updated successfully', 'success');
} else {
const error = await response.json();
showNotification(`Failed to update database: ${error.error}`, 'error');
}
} catch (error) {
console.error('Error updating Trivy DB:', error);
showNotification('Failed to update database', 'error');
}
}
// View vulnerability details
async function viewVulnerabilityDetails(imageID, imageName) {
document.getElementById('vulnDetailsImageName').textContent = imageName;
document.getElementById('vulnerabilityDetailsModal').classList.add('show');
document.getElementById('vulnDetailsContent').innerHTML = '<div class="loading">Loading vulnerabilities...</div>';
try {
const response = await fetch(`/api/vulnerabilities/image/${encodeURIComponent(imageID)}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
renderVulnerabilityDetails(data);
} catch (error) {
console.error('Error loading vulnerability details:', error);
document.getElementById('vulnDetailsContent').innerHTML = `<div class="error">Failed to load vulnerability details: ${error.message}</div>`;
}
}
function closeVulnerabilityDetailsModal() {
document.getElementById('vulnerabilityDetailsModal').classList.remove('show');
}
function renderVulnerabilityDetails(data) {
if (!data || !data.vulnerabilities || data.vulnerabilities.length === 0) {
document.getElementById('vulnDetailsContent').innerHTML = '<p class="empty-message">No vulnerabilities found for this image.</p>';
return;
}
const vulns = data.vulnerabilities;
// Group by severity
const bySeverity = {
CRITICAL: vulns.filter(v => v.severity === 'CRITICAL'),
HIGH: vulns.filter(v => v.severity === 'HIGH'),
MEDIUM: vulns.filter(v => v.severity === 'MEDIUM'),
LOW: vulns.filter(v => v.severity === 'LOW'),
UNKNOWN: vulns.filter(v => v.severity === 'UNKNOWN' || !v.severity)
};
const html = `
<div class="vuln-details-summary">
<div class="vuln-stat">
<span class="severity-badge severity-critical">${bySeverity.CRITICAL.length}</span> Critical
</div>
<div class="vuln-stat">
<span class="severity-badge severity-high">${bySeverity.HIGH.length}</span> High
</div>
<div class="vuln-stat">
<span class="severity-badge severity-medium">${bySeverity.MEDIUM.length}</span> Medium
</div>
<div class="vuln-stat">
<span class="severity-badge severity-low">${bySeverity.LOW.length}</span> Low
</div>
</div>
${['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'UNKNOWN'].map(severity => {
const items = bySeverity[severity];
if (items.length === 0) return '';
return `
<div class="vuln-severity-section">
<h3><span class="severity-badge severity-${severity.toLowerCase()}">${severity}</span> (${items.length})</h3>
<table class="vuln-table">
<thead>
<tr>
<th>CVE ID</th>
<th>Package</th>
<th>Installed</th>
<th>Fixed In</th>
<th>Title</th>
</tr>
</thead>
<tbody>
${items.map(v => `
<tr>
<td>
<a href="https://nvd.nist.gov/vuln/detail/${escapeHtml(v.vulnerability_id)}" target="_blank" rel="noopener">
${escapeHtml(v.vulnerability_id)}
</a>
</td>
<td><code>${escapeHtml(v.pkg_name)}</code></td>
<td><code>${escapeHtml(v.installed_version || 'N/A')}</code></td>
<td><code>${escapeHtml(v.fixed_version || 'Not Fixed')}</code></td>
<td class="vuln-title">${escapeHtml(v.title || 'No description')}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
}).join('')}
`;
document.getElementById('vulnDetailsContent').innerHTML = html;
}
// Export vulnerabilities (placeholder)
function exportVulnerabilities() {
showNotification('Export functionality coming soon!', 'info');
// TODO: Export vulnerability data as CSV/JSON
}
// ===== Vulnerability Settings Modal =====
let currentVulnerabilitySettings = null;
// Open vulnerability settings modal
async function openVulnerabilitySettingsModal() {
try {
const response = await fetch('/api/vulnerabilities/settings');
if (response.ok) {
currentVulnerabilitySettings = await response.json();
populateVulnerabilitySettingsForm(currentVulnerabilitySettings);
document.getElementById('vulnerabilitySettingsModal').classList.add('show');
} else {
showNotification('Failed to load vulnerability settings', 'error');
}
} catch (error) {
console.error('Error loading vulnerability settings:', error);
showNotification('Failed to load vulnerability settings', 'error');
}
}
// Close vulnerability settings modal
function closeVulnerabilitySettingsModal() {
document.getElementById('vulnerabilitySettingsModal').classList.remove('show');
}
// Populate vulnerability settings form
function populateVulnerabilitySettingsForm(settings) {
document.getElementById('vulnEnabled').checked = settings.enabled || false;
document.getElementById('vulnAutoScan').checked = settings.auto_scan_new_images || false;
document.getElementById('vulnWorkerPoolSize').value = settings.worker_pool_size || 5;
document.getElementById('vulnScanTimeout').value = settings.scan_timeout_minutes || 10;
document.getElementById('vulnMaxQueueSize').value = settings.max_queue_size || 100;
document.getElementById('vulnCacheTTL').value = settings.cache_ttl_hours || 24;
document.getElementById('vulnRescanInterval').value = settings.rescan_interval_hours || 168;
document.getElementById('vulnDBUpdateInterval').value = settings.db_update_interval_hours || 24;
document.getElementById('vulnRetentionDays').value = settings.retention_days || 90;
document.getElementById('vulnDetailedRetentionDays').value = settings.detailed_retention_days || 30;
document.getElementById('vulnAlertCritical').checked = settings.alert_on_critical || false;
document.getElementById('vulnAlertHigh').checked = settings.alert_on_high || false;
document.getElementById('vulnCacheDir').value = settings.cache_dir || '/app/data/.trivy';
}
// Save vulnerability settings
async function saveVulnerabilitySettings(event) {
event.preventDefault();
const settings = {
enabled: document.getElementById('vulnEnabled').checked,
auto_scan_new_images: document.getElementById('vulnAutoScan').checked,
worker_pool_size: parseInt(document.getElementById('vulnWorkerPoolSize').value),
scan_timeout_minutes: parseInt(document.getElementById('vulnScanTimeout').value),
max_queue_size: parseInt(document.getElementById('vulnMaxQueueSize').value),
cache_ttl_hours: parseInt(document.getElementById('vulnCacheTTL').value),
rescan_interval_hours: parseInt(document.getElementById('vulnRescanInterval').value),
db_update_interval_hours: parseInt(document.getElementById('vulnDBUpdateInterval').value),
retention_days: parseInt(document.getElementById('vulnRetentionDays').value),
detailed_retention_days: parseInt(document.getElementById('vulnDetailedRetentionDays').value),
alert_on_critical: document.getElementById('vulnAlertCritical').checked,
alert_on_high: document.getElementById('vulnAlertHigh').checked,
cache_dir: document.getElementById('vulnCacheDir').value
};
try {
const response = await fetch('/api/vulnerabilities/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings)
});
if (response.ok) {
showNotification('Vulnerability settings saved successfully', 'success');
closeVulnerabilitySettingsModal();
} else {
const error = await response.json();
showNotification(`Failed to save settings: ${error.error}`, 'error');
}
} catch (error) {
console.error('Error saving vulnerability settings:', error);
showNotification('Failed to save settings', 'error');
}
}
// ============================================
// Onboarding and Help Functions
// ============================================
// Global onboarding tour instance
let onboardingTourInstance = null;
// Start the onboarding tour
async function startOnboardingTour() {
if (!window.OnboardingTour) {
showToast('Error', 'Onboarding tour not loaded', 'error');
return;
}
if (!onboardingTourInstance) {
onboardingTourInstance = new OnboardingTour();
}
await onboardingTourInstance.start();
closeHelpMenu();
}
// Initialize onboarding check on load
async function checkAndShowOnboarding() {
// Wait a bit for page to fully load
setTimeout(async () => {
if (window.OnboardingTour) {
const shouldShow = await OnboardingTour.shouldShow();
if (shouldShow) {
startOnboardingTour();
}
}
}, 1000);
}
// Show changelog modal
async function showChangelogModal() {
const modal = document.getElementById('changelogModal');
const content = document.getElementById('changelogContent');
if (!modal || !content) return;
modal.classList.add('show');
content.innerHTML = '<div class="loading">Loading changelog...</div>';
try {
const response = await fetch('/api/changelog');
if (response.ok) {
const markdown = await response.text();
content.innerHTML = renderMarkdownChangelog(markdown);
} else {
content.innerHTML = '<div class="error">Changelog not available</div>';
}
} catch (error) {
console.error('Error loading changelog:', error);
content.innerHTML = '<div class="error">Failed to load changelog</div>';
}
closeHelpMenu();
}
// Close changelog modal
function closeChangelogModal() {
const modal = document.getElementById('changelogModal');
if (modal) {
modal.classList.remove('show');
}
}
// Simple markdown to HTML converter for changelog
function renderMarkdownChangelog(markdown) {
let html = markdown
// Headers
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
.replace(/^# (.*$)/gim, '<h1>$1</h1>')
// Bold
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
// Links
.replace(/\[([^\]]+)\]\(([^\)]+)\)/g, '<a href="$2" target="_blank">$1</a>')
// Lists
.replace(/^\* (.+)$/gim, '<li>$1</li>')
.replace(/^- (.+)$/gim, '<li>$1</li>')
// Paragraphs
.replace(/\n\n/g, '</p><p>')
// Code blocks
.replace(/`([^`]+)`/g, '<code>$1</code>');
// Wrap lists
html = html.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>');
// Wrap in paragraph if not already wrapped
if (!html.startsWith('<h1>') && !html.startsWith('<h2>')) {
html = '<p>' + html + '</p>';
}
return '<div class="changelog-rendered">' + html + '</div>';
}
// Help menu dropdown handling
function setupHelpMenu() {
const helpBtn = document.getElementById('helpMenuBtn');
const helpDropdown = document.getElementById('helpDropdown');
if (helpBtn && helpDropdown) {
helpBtn.addEventListener('click', (e) => {
e.stopPropagation();
helpDropdown.classList.toggle('show');
});
// Close when clicking outside
document.addEventListener('click', (e) => {
if (!helpBtn.contains(e.target) && !helpDropdown.contains(e.target)) {
helpDropdown.classList.remove('show');
}
});
}
}
function closeHelpMenu() {
const helpDropdown = document.getElementById('helpDropdown');
if (helpDropdown) {
helpDropdown.classList.remove('show');
}
}
// ===== DASHBOARD FUNCTIONS =====
async function loadDashboard() {
try {
// Load all required data in parallel with individual error handling
const results = await Promise.allSettled([
loadContainers(),
loadHosts(),
loadVulnerabilitySummary()
]);
// Log any failures but don't stop rendering
results.forEach((result, index) => {
if (result.status === 'rejected') {
const names = ['loadContainers', 'loadHosts', 'loadVulnerabilitySummary'];
console.error(`${names[index]} failed:`, result.reason);
}
});
// Render all dashboard sections (await async ones to catch errors)
renderDashboardMetrics();
renderDashboardHostStatus();
renderDashboardResourceStatus();
await renderDashboardRecentActivity();
await renderDashboardSecurity();
await renderDashboardTelemetry();
markRefresh();
} catch (error) {
console.error('Error loading dashboard:', error);
showToast('Error', 'Failed to load dashboard data', 'error');
}
}
function renderDashboardMetrics() {
const safeHosts = hosts || [];
const safeContainers = containers || [];
// Total hosts
const totalHosts = safeHosts.length;
document.getElementById('dashTotalHosts').textContent = totalHosts;
// Running containers
const runningContainers = safeContainers.filter(c => c.state === 'running').length;
document.getElementById('dashRunningContainers').textContent = runningContainers;
// Total containers
const totalContainers = safeContainers.length;
document.getElementById('dashTotalContainers').textContent = totalContainers;
}
function renderDashboardHostStatus() {
const container = document.getElementById('dashHostStatus');
const safeHosts = hosts || [];
if (safeHosts.length === 0) {
container.innerHTML = '<p class="text-secondary">No hosts configured. <a href="#" onclick="switchTab(\'hosts\', true)">Add a host</a> to get started.</p>';
return;
}
// Separate local host from agents
const localHost = safeHosts.find(h => h.host_type === 'unix');
const agents = safeHosts.filter(h => h.host_type === 'agent');
// Agent status counts
const onlineAgents = agents.filter(h => h.agent_status === 'online').length;
const offlineAgents = agents.filter(h => h.agent_status === 'offline' || h.agent_status === 'auth_failed').length;
const unknownAgents = agents.filter(h => h.agent_status === 'unknown').length;
let html = '<div style="display: flex; flex-direction: column; gap: 1rem;">';
// Local host status
if (localHost) {
const isOnline = localHost.enabled !== false; // Local host is online if enabled
html += `
<div>
<div style="font-size: 0.75rem; font-weight: 600; color: var(--text-secondary); text-transform: uppercase; margin-bottom: 0.5rem;">Local Host</div>
<div class="status-indicator ${isOnline ? 'online' : 'offline'}">
<span style="width: 8px; height: 8px; background: currentColor; border-radius: 50%; display: inline-block;"></span>
<span>${isOnline ? 'Online' : 'Offline'}</span>
</div>
</div>
`;
}
// Agents status
if (agents.length > 0 || localHost) {
html += `<div style="border-top: 1px solid var(--border); padding-top: 0.5rem;">`;
}
html += `<div style="font-size: 0.75rem; font-weight: 600; color: var(--text-secondary); text-transform: uppercase; margin-bottom: 0.5rem;">Agents</div>`;
if (agents.length === 0) {
html += `<div class="text-secondary" style="font-size: 0.875rem;">0 agents configured</div>`;
} else {
// Show online agents
if (onlineAgents > 0) {
html += `
<div class="status-indicator online">
<span style="width: 8px; height: 8px; background: currentColor; border-radius: 50%; display: inline-block;"></span>
<span>${onlineAgents} Online</span>
</div>
`;
}
// Show offline agents
if (offlineAgents > 0) {
html += `
<div class="status-indicator offline">
<span style="width: 8px; height: 8px; background: currentColor; border-radius: 50%; display: inline-block;"></span>
<span>${offlineAgents} Offline</span>
</div>
`;
}
// Show unknown agents
if (unknownAgents > 0) {
html += `
<div class="status-indicator warning">
<span style="width: 8px; height: 8px; background: currentColor; border-radius: 50%; display: inline-block;"></span>
<span>${unknownAgents} Unknown</span>
</div>
`;
}
// If all agents are online, show compact version
if (offlineAgents === 0 && unknownAgents === 0 && onlineAgents === agents.length) {
// Already showing the online count above
}
}
if (agents.length > 0 || localHost) {
html += `</div>`;
}
html += `
<div style="margin-top: 0.5rem; padding-top: 0.5rem; border-top: 1px solid var(--border);">
<button onclick="switchTab('hosts', true)" class="btn btn-sm btn-secondary" style="width: 100%;">
Manage Hosts
</button>
</div>
</div>
`;
container.innerHTML = html;
}
async function renderDashboardSecurity() {
const container = document.getElementById('dashSecurityContent');
const toggle = document.getElementById('dashSecurityEnabled');
try {
// Load vulnerability settings to check if enabled
const settingsResponse = await fetchWithAuth('/api/vulnerabilities/settings');
if (!settingsResponse.ok) {
console.error('Failed to load vulnerability settings:', settingsResponse.status);
container.innerHTML = '<p class="text-secondary">Security scanning unavailable</p>';
return;
}
const settings = await settingsResponse.json();
const isEnabled = settings.enabled;
toggle.checked = isEnabled;
// Setup toggle event listener
toggle.onchange = async (e) => {
const newState = e.target.checked;
await toggleSecurityScanning(newState);
};
if (!isEnabled) {
container.innerHTML = `
<div style="padding: 1rem; background: rgba(245, 158, 11, 0.1); border-radius: var(--radius); border: 1px solid var(--warning);">
<p style="color: var(--warning); font-weight: 600; margin-bottom: 0.5rem;">Security Scanning Disabled</p>
<p style="font-size: 0.875rem; color: var(--text-secondary);">Enable security scanning to detect vulnerabilities in your container images using Trivy.</p>
</div>
`;
return;
}
// If enabled, show vulnerability summary
if (!vulnerabilitySummary) {
container.innerHTML = '<p class="text-secondary">Loading security data...</p>';
return;
}
const severityCounts = vulnerabilitySummary.severity_counts || vulnerabilitySummary;
const { critical = 0, high = 0, medium = 0, low = 0 } = severityCounts;
const total = critical + high + medium + low;
if (total === 0) {
container.innerHTML = `
<div style="text-align: center; padding: 1rem;">
<div style="font-size: 3rem; margin-bottom: 0.5rem;">✅</div>
<p style="color: var(--success); font-weight: 600;">No vulnerabilities detected</p>
<p class="text-secondary" style="font-size: 0.875rem; margin-top: 0.5rem;">All scanned images are clean</p>
</div>
`;
return;
}
container.innerHTML = `
<div style="display: flex; flex-direction: column; gap: 0.75rem;">
${critical > 0 ? `
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="color: var(--danger); font-weight: 600;">Critical</span>
<span style="background: rgba(239, 68, 68, 0.1); color: var(--danger); padding: 0.25rem 0.75rem; border-radius: var(--radius); font-weight: 600;">${critical}</span>
</div>
` : ''}
${high > 0 ? `
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="color: var(--warning); font-weight: 600;">High</span>
<span style="background: rgba(245, 158, 11, 0.1); color: var(--warning); padding: 0.25rem 0.75rem; border-radius: var(--radius); font-weight: 600;">${high}</span>
</div>
` : ''}
${medium > 0 ? `
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="color: var(--info); font-weight: 600;">Medium</span>
<span style="background: rgba(59, 130, 246, 0.1); color: var(--info); padding: 0.25rem 0.75rem; border-radius: var(--radius); font-weight: 600;">${medium}</span>
</div>
` : ''}
<div style="margin-top: 0.5rem; padding-top: 0.5rem; border-top: 1px solid var(--border);">
<p style="font-size: 0.875rem; color: var(--text-secondary);">
<strong>${total}</strong> total vulnerabilities found across scanned images
</p>
</div>
</div>
`;
} catch (error) {
console.error('Error loading security status:', error);
container.innerHTML = `<p class="text-secondary" style="padding: 1rem; text-align: center;">Security status unavailable</p>`;
}
}
async function toggleSecurityScanning(newState) {
const container = document.getElementById('dashSecurityContent');
const toggle = document.getElementById('dashSecurityEnabled');
try {
if (newState) {
// Show loading message
container.innerHTML = `
<div style="padding: 1rem; background: rgba(59, 130, 246, 0.1); border-radius: var(--radius); border: 1px solid var(--info);">
<p style="color: var(--info); font-weight: 600; margin-bottom: 0.5rem;">🔄 Enabling Security Scanning...</p>
<p style="font-size: 0.875rem; color: var(--text-secondary);">Downloading Trivy vulnerability database. This may take a few minutes...</p>
</div>
`;
// First, update the Trivy database
const updateResponse = await fetchWithAuth('/api/vulnerabilities/update-db', {
method: 'POST'
});
if (!updateResponse.ok) {
throw new Error('Failed to update Trivy database');
}
showToast('Success', 'Trivy database updated successfully', 'success');
// Then enable security scanning
const settingsResponse = await fetchWithAuth('/api/vulnerabilities/settings');
const settings = await settingsResponse.json();
settings.enabled = true;
const updateSettingsResponse = await fetchWithAuth('/api/vulnerabilities/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings)
});
if (!updateSettingsResponse.ok) {
throw new Error('Failed to enable security scanning');
}
showToast('Success', 'Security scanning enabled', 'success');
// Reload vulnerability summary and refresh dashboard
await loadVulnerabilitySummary();
await renderDashboardSecurity();
} else {
// Disable security scanning
const settingsResponse = await fetchWithAuth('/api/vulnerabilities/settings');
const settings = await settingsResponse.json();
settings.enabled = false;
const updateSettingsResponse = await fetchWithAuth('/api/vulnerabilities/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings)
});
if (!updateSettingsResponse.ok) {
throw new Error('Failed to disable security scanning');
}
showToast('Success', 'Security scanning disabled', 'success');
await renderDashboardSecurity();
}
} catch (error) {
console.error('Error toggling security scanning:', error);
showToast('Error', 'Failed to toggle security scanning: ' + error.message, 'error');
// Revert toggle state
toggle.checked = !newState;
// Show error message
container.innerHTML = `
<div style="padding: 1rem; background: rgba(239, 68, 68, 0.1); border-radius: var(--radius); border: 1px solid var(--danger);">
<p style="color: var(--danger); font-weight: 600; margin-bottom: 0.5rem;">❌ Error</p>
<p style="font-size: 0.875rem; color: var(--text-secondary);">${escapeHtml(error.message)}</p>
</div>
`;
}
}
function renderDashboardResourceStatus() {
const container = document.getElementById('dashResourceStatus');
const runningContainers = containers.filter(c => c.state === 'running');
const containersWithStats = runningContainers.filter(c => c.memory_limit > 0);
if (containersWithStats.length === 0) {
container.innerHTML = '<p class="text-secondary">No resource stats available. Enable stats collection in host settings.</p>';
return;
}
// Find top consumers
const topCPU = [...containersWithStats].sort((a, b) => b.cpu_percent - a.cpu_percent).slice(0, 3);
const topMemory = [...containersWithStats].sort((a, b) => b.memory_percent - a.memory_percent).slice(0, 3);
container.innerHTML = `
<div style="display: flex; flex-direction: column; gap: 1rem;">
<div>
<h4 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-secondary);">Top CPU Usage</h4>
${topCPU.map(c => `
<div style="display: flex; justify-content: space-between; align-items: center; padding: 0.5rem 0;">
<span style="font-size: 0.875rem; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 150px;">${escapeHtml(c.name)}</span>
<span style="font-weight: 600; color: ${c.cpu_percent > 80 ? 'var(--danger)' : c.cpu_percent > 50 ? 'var(--warning)' : 'var(--success)'};">${c.cpu_percent.toFixed(1)}%</span>
</div>
`).join('')}
</div>
<div style="padding-top: 0.5rem; border-top: 1px solid var(--border);">
<h4 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-secondary);">Top Memory Usage</h4>
${topMemory.map(c => `
<div style="display: flex; justify-content: space-between; align-items: center; padding: 0.5rem 0;">
<span style="font-size: 0.875rem; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 150px;">${escapeHtml(c.name)}</span>
<span style="font-weight: 600; color: ${c.memory_percent > 80 ? 'var(--danger)' : c.memory_percent > 50 ? 'var(--warning)' : 'var(--success)'};">${c.memory_percent.toFixed(1)}%</span>
</div>
`).join('')}
</div>
<div style="padding-top: 0.5rem; border-top: 1px solid var(--border);">
<button onclick="switchTab('monitoring', true)" class="btn btn-sm btn-secondary" style="width: 100%;">
View All
</button>
</div>
</div>
`;
}
async function renderDashboardRecentActivity() {
const container = document.getElementById('dashRecentActivity');
try {
// Fetch recent activity
const response = await fetchWithAuth('/api/activity-log?limit=10');
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
const activities = Array.isArray(data) ? data : [];
if (activities.length === 0) {
container.innerHTML = '<p class="text-secondary" style="padding: 1rem; text-align: center;">No recent activity</p>';
return;
}
container.innerHTML = activities.map(activity => {
const icon = activity.type === 'scan' ? '🔄' : '📡';
const status = activity.success ? 'Success' : 'Failed';
const statusColor = activity.success ? 'var(--success)' : 'var(--danger)';
const timestamp = new Date(activity.timestamp).toLocaleString();
return `
<div style="display: flex; align-items: flex-start; gap: 1rem; padding: 0.75rem 0; border-bottom: 1px solid var(--border-light);">
<div style="font-size: 1.5rem;">${icon}</div>
<div style="flex: 1;">
<div style="font-weight: 600; color: var(--text-primary);">${escapeHtml(activity.type === 'scan' ? 'Scan' : 'Telemetry')}: ${escapeHtml(activity.target || 'All Hosts')}</div>
<div style="font-size: 0.8125rem; color: var(--text-secondary); margin-top: 0.25rem;">${timestamp}</div>
</div>
<div style="font-size: 0.875rem; font-weight: 600; color: ${statusColor};">${status}</div>
</div>
`;
}).join('');
} catch (error) {
console.error('Error loading activity:', error);
container.innerHTML = `<p class="text-secondary" style="padding: 1rem; text-align: center;">Activity log unavailable</p>`;
}
}
async function renderDashboardTelemetry() {
const container = document.getElementById('dashTelemetryContent');
const toggle = document.getElementById('dashTelemetryEnabled');
try {
// Load telemetry endpoints
const response = await fetchWithAuth('/api/telemetry/endpoints');
if (!response.ok) {
console.error('Failed to load telemetry endpoints:', response.status);
container.innerHTML = '<p class="text-secondary">Telemetry status unavailable</p>';
return;
}
const endpoints = await response.json();
// Check if community endpoint is enabled
const communityEndpoint = endpoints.find(e => e.name === 'community');
const isEnabled = communityEndpoint && communityEndpoint.enabled;
toggle.checked = isEnabled;
if (!isEnabled) {
container.innerHTML = `
<div style="padding: 1rem; background: rgba(245, 158, 11, 0.1); border-radius: var(--radius); border: 1px solid var(--warning);">
<p style="color: var(--warning); font-weight: 600; margin-bottom: 0.5rem;">Telemetry Disabled</p>
<p style="font-size: 0.875rem; color: var(--text-secondary);">Enable telemetry to contribute anonymous usage statistics and help improve Container Census.</p>
</div>
`;
} else {
// Load telemetry schedule
const schedResponse = await fetchWithAuth('/api/telemetry/schedule');
const schedule = await schedResponse.json();
container.innerHTML = `
<div style="display: flex; flex-direction: column; gap: 0.75rem;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="color: var(--text-secondary);">Status</span>
<span class="status-indicator online">Active</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="color: var(--text-secondary);">Next Submission</span>
<span style="font-weight: 600;">${schedule.next_submission ? new Date(schedule.next_submission).toLocaleString() : 'Unknown'}</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="color: var(--text-secondary);">Frequency</span>
<span style="font-weight: 600;">${schedule.interval_hours ? schedule.interval_hours + 'h' : 'Unknown'}</span>
</div>
</div>
`;
}
// Add toggle event listener
toggle.addEventListener('change', async (e) => {
const newState = e.target.checked;
await toggleTelemetry(newState);
});
} catch (error) {
console.error('Error loading telemetry status:', error);
container.innerHTML = '<p class="text-secondary">Failed to load telemetry status</p>';
}
}
async function toggleTelemetry(newState) {
const container = document.getElementById('dashTelemetryContent');
const toggle = document.getElementById('dashTelemetryEnabled');
try {
// Show loading message
container.innerHTML = `
<div style="padding: 1rem; text-align: center;">
<p style="color: var(--text-secondary);">${newState ? 'Enabling' : 'Disabling'} telemetry...</p>
</div>
`;
// Load telemetry endpoints
const response = await fetchWithAuth('/api/telemetry/endpoints');
if (!response.ok) {
throw new Error('Failed to load telemetry endpoints');
}
const endpoints = await response.json();
// Find community endpoint
const communityEndpoint = endpoints.find(e => e.name === 'community');
if (!communityEndpoint) {
throw new Error('Community endpoint not found');
}
// Update the endpoint
const updateResponse = await fetchWithAuth(`/api/telemetry/endpoints/${encodeURIComponent(communityEndpoint.name)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled: newState
})
});
if (!updateResponse.ok) {
const error = await updateResponse.json();
throw new Error(error.error || 'Failed to update telemetry');
}
showToast('Success', `Telemetry ${newState ? 'enabled' : 'disabled'}`, 'success');
// Reload the telemetry section
await renderDashboardTelemetry();
} catch (error) {
console.error('Error toggling telemetry:', error);
showToast('Error', 'Failed to toggle telemetry: ' + error.message, 'error');
// Revert toggle
toggle.checked = !newState;
container.innerHTML = '<p class="text-secondary">Failed to update telemetry</p>';
}
}
async function loadVulnerabilitySummary() {
try {
// First check if security scanning is enabled
const settingsResponse = await fetchWithAuth('/api/vulnerabilities/settings');
const settings = await settingsResponse.json();
if (!settings.enabled) {
// Security scanning is disabled
vulnerabilitySummary = null;
// Update sidebar stats to show N/A or hide
const criticalVulnsEl = document.getElementById('criticalVulns');
if (criticalVulnsEl) {
criticalVulnsEl.textContent = '-';
criticalVulnsEl.style.fontWeight = '600';
}
return null;
}
// Load summary if enabled
const response = await fetchWithAuth('/api/vulnerabilities/summary');
const data = await response.json();
// Extract summary from response (might be nested in 'summary' field)
vulnerabilitySummary = data.summary || data;
// Update sidebar stats
const criticalVulnsEl = document.getElementById('criticalVulns');
if (criticalVulnsEl) {
const criticalCount = vulnerabilitySummary.severity_counts?.critical || 0;
criticalVulnsEl.textContent = criticalCount;
criticalVulnsEl.style.fontWeight = criticalCount > 0 ? '700' : '600';
}
// Return the full data object (includes summary and queue_status)
return data;
} catch (error) {
console.error('Error loading vulnerability summary:', error);
vulnerabilitySummary = { severity_counts: { critical: 0, high: 0, medium: 0, low: 0 } };
return vulnerabilitySummary;
}
}
// ======= IMPORT/EXPORT FUNCTIONS =======
async function exportSettings() {
try {
const response = await fetchWithAuth('/api/settings/export');
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
// Get the YAML content
const yamlContent = await response.text();
// Create a blob and download it
const blob = new Blob([yamlContent], { type: 'application/x-yaml' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'container-census-config.yaml';
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
showToast('Success', 'Settings exported successfully', 'success');
} catch (error) {
console.error('Error exporting settings:', error);
showToast('Error', 'Failed to export settings: ' + error.message, 'error');
}
}
async function handleImportFile(event) {
const file = event.target.files[0];
if (!file) return;
const statusEl = document.getElementById('importStatus');
statusEl.textContent = '⏳ Importing...';
statusEl.className = 'save-status-inline';
try {
const formData = new FormData();
formData.append('file', file);
const response = await fetchWithAuth('/api/settings/import', {
method: 'POST',
body: formData
});
const result = await response.json();
if (response.ok) {
statusEl.textContent = '✓ Import successful';
statusEl.className = 'save-status-inline success';
showToast('Success', 'Settings imported successfully. Reloading...', 'success');
// Reload the page after 2 seconds to apply new settings
setTimeout(() => {
window.location.reload();
}, 2000);
} else {
statusEl.textContent = '✗ Import failed';
statusEl.className = 'save-status-inline error';
showToast('Error', 'Failed to import settings: ' + (result.error || 'Unknown error'), 'error');
}
} catch (error) {
console.error('Error importing settings:', error);
statusEl.textContent = '✗ Error';
statusEl.className = 'save-status-inline error';
showToast('Error', 'Error importing settings: ' + error.message, 'error');
}
// Clear the file input
event.target.value = '';
// Clear status after 5 seconds
setTimeout(() => {
statusEl.textContent = '';
statusEl.className = 'save-status-inline';
}, 5000);
}
// ======= DANGER ZONE FUNCTIONS =======
async function resetAllSettings() {
if (!confirm('⚠️ Are you sure you want to reset ALL settings to defaults?\n\nThis will:\n- Delete all system settings\n- Delete all telemetry endpoints\n- Trigger auto-import from config.yaml if available\n\nThis action cannot be undone.')) {
return;
}
try {
const response = await fetch('/api/settings/reset', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const result = await response.json();
if (response.ok) {
showNotification(result.message, 'success');
// Reload the page after 2 seconds to trigger auto-import
setTimeout(() => {
window.location.reload();
}, 2000);
} else {
showNotification('Failed to reset settings: ' + (result.error || result.message), 'error');
}
} catch (error) {
console.error('Error resetting settings:', error);
showNotification('Error resetting settings: ' + error.message, 'error');
}
}
async function clearContainerHistory() {
if (!confirm('⚠️ Are you sure you want to clear container history?\n\nThis will:\n- Delete all historical container scan data\n- Keep only the most recent snapshot\n- Clear historical charts and trends\n\nThis action cannot be undone.')) {
return;
}
try {
const response = await fetch('/api/settings/clear-history', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const result = await response.json();
if (response.ok) {
showNotification(result.message, 'success');
} else {
showNotification('Failed to clear history: ' + (result.error || result.message), 'error');
}
} catch (error) {
console.error('Error clearing history:', error);
showNotification('Error clearing history: ' + error.message, 'error');
}
}
async function clearVulnerabilities() {
if (!confirm('⚠️ Are you sure you want to clear all vulnerability data?\n\nThis will:\n- Delete all vulnerability scan results\n- Delete all CVE data\n- Images will be rescanned on next scheduled scan\n\nThis action cannot be undone.')) {
return;
}
try {
const response = await fetch('/api/settings/clear-vulnerabilities', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const result = await response.json();
if (response.ok) {
showNotification(result.message, 'success');
// Reload vulnerability tab if currently viewing it
const currentTab = document.querySelector('.tab-content.active')?.id;
if (currentTab === 'vulnerabilityTab') {
loadVulnerabilitySummary();
}
} else {
showNotification('Failed to clear vulnerabilities: ' + (result.error || result.message), 'error');
}
} catch (error) {
console.error('Error clearing vulnerabilities:', error);
showNotification('Error clearing vulnerabilities: ' + error.message, 'error');
}
}
async function clearActivityLog() {
if (!confirm('⚠️ Are you sure you want to clear the activity log?\n\nThis will:\n- Delete all lifecycle events\n- Delete all container state change history\n- Clear the activity tab\n\nNew events will be logged as they occur. This action cannot be undone.')) {
return;
}
try {
const response = await fetch('/api/settings/clear-activity', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const result = await response.json();
if (response.ok) {
showNotification(result.message, 'success');
// Reload activity tab if currently viewing it
const currentTab = document.querySelector('.tab-content.active')?.id;
if (currentTab === 'activity') {
loadActivityLog();
}
} else {
showNotification('Failed to clear activity log: ' + (result.error || result.message), 'error');
}
} catch (error) {
console.error('Error clearing activity log:', error);
showNotification('Error clearing activity log: ' + error.message, 'error');
}
}
async function nuclearReset() {
// First confirmation
if (!confirm('💀 NUCLEAR OPTION: DELETE EVERYTHING 💀\n\nThis will permanently delete:\n- ALL settings\n- ALL container history\n- ALL vulnerability scans\n- ALL activity logs\n- ALL hosts\n- ALL telemetry endpoints\n- ALL notifications\n\nYour database will be completely reset to a fresh installation state.\n\nAre you ABSOLUTELY SURE?')) {
return;
}
// Second confirmation to prevent accidents
const confirmation = prompt('Type "DELETE EVERYTHING" (in all caps) to confirm nuclear reset:');
if (confirmation !== 'DELETE EVERYTHING') {
showNotification('Nuclear reset cancelled', 'info');
return;
}
try {
const response = await fetch('/api/settings/nuclear-reset', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const result = await response.json();
if (response.ok) {
showNotification('💀 Nuclear reset complete. Reloading in 3 seconds...', 'success');
// Reload the page after 3 seconds
setTimeout(() => {
window.location.reload();
}, 3000);
} else {
showNotification('Failed to perform nuclear reset: ' + (result.error || result.message), 'error');
}
} catch (error) {
console.error('Error performing nuclear reset:', error);
showNotification('Error performing nuclear reset: ' + error.message, 'error');
}
}
// ===== Image Update Functions =====
// Check if a single container has an image update available
async function checkContainerUpdate(hostId, containerId, containerName) {
try {
const response = await fetch(`/api/containers/${hostId}/${containerId}/check-update`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const result = await response.json();
if (response.ok) {
if (result.available) {
showNotification(`Update available for ${containerName}`, 'success');
} else if (result.message) {
showNotification(result.message, 'info');
} else {
showNotification(`${containerName} is up to date`, 'info');
}
// Reload containers to update UI badges
await loadData();
} else {
showNotification('Failed to check for update: ' + (result.error || 'Unknown error'), 'error');
}
} catch (error) {
console.error('Error checking for update:', error);
showNotification('Error checking for update: ' + error.message, 'error');
}
}
// Check all :latest containers for updates
async function checkAllUpdates() {
// Get all containers with :latest tag
const latestContainers = containers.filter(c =>
c.image.endsWith(':latest') || (!c.image.includes(':') && c.state === 'running')
);
if (latestContainers.length === 0) {
showNotification('No containers with :latest tag found', 'info');
return;
}
// Open modal and show loading state with count
showUpdateResultsModal();
const loadingDiv = document.getElementById('updateResultsLoading');
loadingDiv.style.display = 'block';
loadingDiv.innerHTML = `
<div style="text-align: center;">
<div style="font-size: 48px; margin-bottom: 10px;">🔍</div>
<div style="font-size: 18px; font-weight: 600; margin-bottom: 10px;">Checking for updates...</div>
<div style="color: var(--text-secondary);">Checking ${latestContainers.length} container(s) against their registries</div>
<div style="margin-top: 20px;">
<div class="spinner"></div>
</div>
</div>
`;
document.getElementById('noUpdatesFound').style.display = 'none';
document.getElementById('updatesFound').style.display = 'none';
const containerList = latestContainers.map(c => ({
host_id: c.host_id,
container_id: c.id
}));
try {
const response = await fetch('/api/containers/bulk-check-updates', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ containers: containerList })
});
const results = await response.json();
if (response.ok) {
// Collect containers with updates
const containersWithUpdates = [];
for (const key in results) {
if (results[key].available) {
const [hostId, containerId] = key.split('-');
const container = latestContainers.find(c =>
c.host_id == hostId && c.id === containerId
);
if (container) {
containersWithUpdates.push({
...container,
updateInfo: results[key]
});
}
}
}
// Hide loading, show results
document.getElementById('updateResultsLoading').style.display = 'none';
// Show info banner with counts
const infoBanner = document.getElementById('updateCheckInfo');
infoBanner.style.display = 'block';
document.getElementById('checkedCount').textContent = latestContainers.length;
document.getElementById('totalCount').textContent = containers.length;
if (containersWithUpdates.length > 0) {
displayUpdateResults(containersWithUpdates);
} else {
document.getElementById('noUpdatesFound').style.display = 'block';
document.getElementById('noUpdatesCheckedCount').textContent = latestContainers.length;
}
// Reload containers to update UI badges
await loadData();
} else {
closeUpdateResultsModal();
showNotification('Failed to check for updates', 'error');
}
} catch (error) {
console.error('Error checking for updates:', error);
closeUpdateResultsModal();
showNotification('Error checking for updates: ' + error.message, 'error');
}
}
// Update a single container (pull new image and recreate)
async function updateContainer(hostId, containerId, containerName, imageName) {
// Show confirmation dialog with dry-run preview
showConfirmDialog(
'Update Container',
`
<div style="text-align: left;">
<p><strong>Container:</strong> ${escapeHtml(containerName)}</p>
<p><strong>Image:</strong> ${escapeHtml(imageName)}</p>
<p style="margin-top: 15px;">This will:</p>
<ul style="margin: 10px 0;">
<li>Pull the latest <code>${escapeHtml(imageName)}</code> image</li>
<li>Stop and remove the current container</li>
<li>Create a new container with the same configuration</li>
<li>Start the new container</li>
</ul>
<p style="margin-top: 15px; padding: 10px; background-color: #fff3cd; border-radius: 4px;">
⚠️ <strong>Note:</strong> The old image will be kept for rollback. Container configuration (env vars, volumes, ports, networks) will be preserved.
</p>
<p style="margin-top: 10px; color: #856404;">
Non-volume data will be lost. Ensure important data is in volumes!
</p>
</div>
`,
async () => {
// Show progress modal
showProgressModal('Updating Container', 'Pulling new image...');
try {
// First, do a dry-run to preview
updateProgressModal('Validating container configuration...');
const dryRunResponse = await fetch(`/api/containers/${hostId}/${containerId}/update?dry_run=true`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const dryRunResult = await dryRunResponse.json();
if (!dryRunResponse.ok) {
hideProgressModal();
showNotification('Dry-run failed: ' + (dryRunResult.error || 'Unknown error'), 'error');
return;
}
// Now perform the actual update
updateProgressModal(`Pulling latest ${imageName} image...`);
const response = await fetch(`/api/containers/${hostId}/${containerId}/update`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const result = await response.json();
if (response.ok && result.success) {
updateProgressModal('Container updated! Refreshing data...');
// Immediate refresh to get new container data
await loadData();
hideProgressModal();
showNotification(`Container ${containerName} updated successfully! New ID: ${result.new_container_id?.substring(0, 12)}`, 'success');
} else {
hideProgressModal();
showNotification('Failed to update container: ' + (result.error || 'Unknown error'), 'error');
}
} catch (error) {
hideProgressModal();
console.error('Error updating container:', error);
showNotification('Error updating container: ' + error.message, 'error');
}
},
'warning'
);
}
// Load image update settings
async function loadImageUpdateSettings() {
try {
const response = await fetch('/api/image-updates/settings');
const settings = await response.json();
if (response.ok) {
// Populate settings form
document.getElementById('autoCheckEnabled').checked = settings.auto_check_enabled;
document.getElementById('checkIntervalHours').value = settings.check_interval_hours;
document.getElementById('onlyCheckLatestTags').checked = settings.only_check_latest_tags;
}
} catch (error) {
console.error('Error loading image update settings:', error);
}
}
// Save image update settings
async function saveImageUpdateSettings() {
const settings = {
auto_check_enabled: document.getElementById('autoCheckEnabled').checked,
check_interval_hours: parseInt(document.getElementById('checkIntervalHours').value),
only_check_latest_tags: document.getElementById('onlyCheckLatestTags').checked
};
const statusEl = document.getElementById('imageUpdateSaveStatus');
try {
const response = await fetch('/api/image-updates/settings', {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(settings)
});
const result = await response.json();
if (response.ok) {
statusEl.textContent = '✓ Settings saved successfully';
statusEl.style.color = 'green';
setTimeout(() => { statusEl.textContent = ''; }, 3000);
} else {
statusEl.textContent = '✗ Failed to save: ' + (result.error || 'Unknown error');
statusEl.style.color = 'red';
}
} catch (error) {
console.error('Error saving settings:', error);
statusEl.textContent = '✗ Error: ' + error.message;
statusEl.style.color = 'red';
}
}
// Show progress modal
function showProgressModal(title, message) {
const modal = document.getElementById('progressModal');
if (!modal) {
// Create modal if it doesn't exist
const modalHtml = `
<div id="progressModal" class="modal">
<div class="modal-content">
<h2 id="progressTitle">Progress</h2>
<p id="progressMessage">Please wait...</p>
<div class="progress-bar">
<div class="progress-bar-fill"></div>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
}
document.getElementById('progressTitle').textContent = title;
document.getElementById('progressMessage').textContent = message;
document.getElementById('progressModal').classList.add('show');
}
// Update progress modal message
function updateProgressModal(message) {
const messageEl = document.getElementById('progressMessage');
if (messageEl) {
messageEl.textContent = message;
}
}
// Hide progress modal
function hideProgressModal() {
const modal = document.getElementById('progressModal');
if (modal) {
modal.classList.remove('show');
}
}
// ===== UPDATE RESULTS MODAL FUNCTIONS =====
// Global variable to store containers with updates
let containersWithUpdates = [];
// Show update results modal
function showUpdateResultsModal() {
document.getElementById('updateResultsModal').classList.add('show');
}
// Close update results modal
function closeUpdateResultsModal() {
document.getElementById('updateResultsModal').classList.remove('show');
}
// Display update results in the modal
function displayUpdateResults(containers) {
containersWithUpdates = containers;
document.getElementById('updatesFound').style.display = 'block';
document.getElementById('updateCount').textContent = containers.length;
const tbody = document.getElementById('updatesTableBody');
tbody.innerHTML = '';
containers.forEach((container, index) => {
const row = document.createElement('tr');
row.dataset.index = index;
row.dataset.hostId = container.host_id;
row.dataset.containerId = container.id;
// Truncate digest for display
const truncateDigest = (digest) => {
if (!digest) return 'N/A';
return digest.substring(0, 12) + '...';
};
// Format date
const formatDate = (dateStr) => {
if (!dateStr || dateStr === '0001-01-01T00:00:00Z') return 'Unknown';
const date = new Date(dateStr);
if (isNaN(date.getTime())) return 'Invalid Date';
return date.toLocaleString();
};
row.className = 'update-row-card';
row.innerHTML = `
<td style="width: 40px; vertical-align: top; padding-top: 20px;">
<input type="checkbox" class="update-checkbox" data-index="${index}" onchange="updateSelectedButton()">
</td>
<td>
<div class="update-card-content">
<div class="update-card-header">
<strong class="update-container-name">${escapeHtml(container.name)}</strong>
<span class="update-host-badge">${escapeHtml(container.host_name || 'Unknown')}</span>
</div>
<div class="update-card-image">
<strong>Image:</strong> <span class="digest-text">${escapeHtml(container.image)}</span>
</div>
<div class="update-card-digests">
<div><strong>Current:</strong> <span class="digest-text">${truncateDigest(container.updateInfo.local_digest)}</span></div>
<div><strong>New:</strong> <span class="digest-text">${truncateDigest(container.updateInfo.remote_digest)}</span></div>
</div>
<div class="update-card-date">
<strong>Remote Created:</strong> ${formatDate(container.updateInfo.remote_created)}
</div>
</div>
</td>
`;
tbody.appendChild(row);
});
// Reset selection state
document.getElementById('selectAllCheckbox').checked = false;
updateSelectedButton();
}
// Toggle all updates
function toggleAllUpdates(checked) {
const checkboxes = document.querySelectorAll('.update-checkbox');
checkboxes.forEach(cb => {
cb.checked = checked;
const row = cb.closest('tr');
if (checked) {
row.classList.add('selected');
} else {
row.classList.remove('selected');
}
});
updateSelectedButton();
}
// Select all updates
function selectAllUpdates() {
document.getElementById('selectAllCheckbox').checked = true;
toggleAllUpdates(true);
}
// Deselect all updates
function deselectAllUpdates() {
document.getElementById('selectAllCheckbox').checked = false;
toggleAllUpdates(false);
}
// Update the "Update Selected" button state
function updateSelectedButton() {
const checkboxes = document.querySelectorAll('.update-checkbox:checked');
const button = document.getElementById('updateSelectedBtn');
button.disabled = checkboxes.length === 0;
// Update row selection styles
document.querySelectorAll('.update-checkbox').forEach(cb => {
const row = cb.closest('tr');
if (cb.checked) {
row.classList.add('selected');
} else {
row.classList.remove('selected');
}
});
}
// Start batch update
async function startBatchUpdate() {
const selectedCheckboxes = document.querySelectorAll('.update-checkbox:checked');
const selectedContainers = [];
selectedCheckboxes.forEach(cb => {
const index = parseInt(cb.dataset.index);
selectedContainers.push(containersWithUpdates[index]);
});
if (selectedContainers.length === 0) {
showNotification('Please select at least one container to update', 'warning');
return;
}
// Close results modal and open progress modal
closeUpdateResultsModal();
showUpdateProgressModal(selectedContainers);
// Start updating containers
await performBatchUpdate(selectedContainers);
}
// ===== UPDATE PROGRESS MODAL FUNCTIONS =====
let updateProgressData = {
total: 0,
completed: 0,
failed: 0,
containers: []
};
// Show update progress modal
function showUpdateProgressModal(containers) {
updateProgressData = {
total: containers.length,
completed: 0,
failed: 0,
containers: containers.map(c => ({
hostId: c.host_id,
containerId: c.id,
name: c.name,
image: c.image,
status: 'pending'
}))
};
document.getElementById('updateProgressModal').classList.add('show');
document.getElementById('updateProgressCloseBtn').disabled = true;
document.getElementById('updateProgressDoneBtn').style.display = 'none';
// Initialize UI
updateProgressUI();
renderUpdateContainersList();
// Clear logs
document.getElementById('updateLogs').innerHTML = '<div style="color: #4CAF50;">▶ Starting batch update...</div>';
}
// Close update progress modal
function closeUpdateProgressModal() {
document.getElementById('updateProgressModal').classList.remove('show');
}
// Update progress UI
function updateProgressUI() {
const percentage = updateProgressData.total > 0 ? (updateProgressData.completed / updateProgressData.total) * 100 : 0;
document.getElementById('updateProgressBar').style.width = percentage + '%';
document.getElementById('updateProgressText').textContent = `${updateProgressData.completed} / ${updateProgressData.total}`;
const inProgress = updateProgressData.containers.filter(c => c.status === 'pulling' || c.status === 'recreating').length;
if (inProgress > 0) {
document.getElementById('updateStatusText').textContent = `Updating ${inProgress} container(s)...`;
} else if (updateProgressData.completed === updateProgressData.total) {
const successCount = updateProgressData.total - updateProgressData.failed;
document.getElementById('updateStatusText').textContent = `Completed: ${successCount} successful, ${updateProgressData.failed} failed`;
} else {
document.getElementById('updateStatusText').textContent = 'Preparing...';
}
}
// Render containers list
function renderUpdateContainersList() {
const list = document.getElementById('updateContainersList');
list.innerHTML = '';
updateProgressData.containers.forEach((container, index) => {
const item = document.createElement('div');
item.className = `update-container-item ${container.status}`;
item.id = `update-item-${index}`;
let statusIcon = '⏸️';
let statusText = 'Pending';
switch (container.status) {
case 'pulling':
statusIcon = '<span class="spinning">🔄</span>';
statusText = 'Pulling image...';
break;
case 'recreating':
statusIcon = '<span class="spinning">⚙️</span>';
statusText = 'Recreating container...';
break;
case 'complete':
statusIcon = '✅';
statusText = 'Complete';
break;
case 'failed':
statusIcon = '❌';
statusText = 'Failed';
break;
}
item.innerHTML = `
<div class="update-container-status">
<span class="status-icon">${statusIcon}</span>
<span>${statusText}</span>
</div>
<div class="update-container-name">${escapeHtml(container.name)}</div>
<div class="update-container-image">${escapeHtml(container.image)}</div>
`;
list.appendChild(item);
});
}
// Add log entry
function addUpdateLog(message, color = '#d4d4d4') {
const logsDiv = document.getElementById('updateLogs');
const timestamp = new Date().toLocaleTimeString();
const logEntry = document.createElement('div');
logEntry.style.color = color;
logEntry.textContent = `[${timestamp}] ${message}`;
logsDiv.appendChild(logEntry);
logsDiv.scrollTop = logsDiv.scrollHeight;
}
// Perform batch update
async function performBatchUpdate(containers) {
for (let i = 0; i < containers.length; i++) {
const container = containers[i];
updateProgressData.containers[i].status = 'pulling';
renderUpdateContainersList();
updateProgressUI();
addUpdateLog(`Starting update for: ${container.name}`, '#60a5fa');
addUpdateLog(` → Pulling image (this may take a few minutes)...`, '#fbbf24');
try {
// The /update endpoint handles both pulling and recreating
const updateResponse = await fetch(`/api/containers/${container.host_id}/${container.id}/update`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
if (!updateResponse.ok) {
const errorData = await updateResponse.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(errorData.error || `HTTP ${updateResponse.status}`);
}
const result = await updateResponse.json();
addUpdateLog(` ✓ Image pulled: ${container.image}`, '#4CAF50');
addUpdateLog(` ✓ Container recreated with ID: ${result.new_container_id ? result.new_container_id.substring(0, 12) : 'unknown'}`, '#4CAF50');
updateProgressData.containers[i].status = 'complete';
updateProgressData.completed++;
addUpdateLog(` ✓ Container updated successfully: ${container.name}`, '#4CAF50');
} catch (error) {
updateProgressData.containers[i].status = 'failed';
updateProgressData.completed++;
updateProgressData.failed++;
addUpdateLog(` ✗ Failed to update ${container.name}: ${error.message}`, '#ef4444');
}
renderUpdateContainersList();
updateProgressUI();
}
// All done
addUpdateLog(`\n▶ Batch update complete!`, '#4CAF50');
addUpdateLog(` Total: ${updateProgressData.total} | Successful: ${updateProgressData.total - updateProgressData.failed} | Failed: ${updateProgressData.failed}`, '#60a5fa');
document.getElementById('updateProgressCloseBtn').disabled = false;
document.getElementById('updateProgressDoneBtn').style.display = 'block';
}
// Finish batch update
async function finishBatchUpdate() {
closeUpdateProgressModal();
await loadData(); // Reload containers to reflect updates
showNotification(`Update complete: ${updateProgressData.total - updateProgressData.failed} successful, ${updateProgressData.failed} failed`,
updateProgressData.failed > 0 ? 'warning' : 'success');
}