homelab_automation/app/docker_section.js
Bruno Charest 68a9b0f390
Some checks failed
Tests / Backend Tests (Python) (3.10) (push) Has been cancelled
Tests / Backend Tests (Python) (3.11) (push) Has been cancelled
Tests / Backend Tests (Python) (3.12) (push) Has been cancelled
Tests / Frontend Tests (JS) (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / All Tests Passed (push) Has been cancelled
Remove Node.js cache files containing npm vulnerability data for vitest and vite packages
2025-12-15 20:36:06 -05:00

815 lines
32 KiB
JavaScript

/**
* Docker Section Management
* Handles Docker hosts, containers, images, volumes, and alerts
*/
const dockerSection = {
hosts: [],
currentHostId: null,
_initialized: false,
stats: {
total_hosts: 0,
enabled_hosts: 0,
total_containers: 0,
running_containers: 0,
total_images: 0,
open_alerts: 0
},
async init() {
await this.loadDockerHosts();
await this.loadStats();
this.setupEventListeners();
this.setupWebSocket();
},
async ensureInit() {
if (this._initialized) return;
this._initialized = true;
await this.init();
},
setupEventListeners() {
// Search filter
const searchInput = document.getElementById('docker-search');
if (searchInput) {
searchInput.addEventListener('input', (e) => {
this.filterHosts(e.target.value);
});
}
// Tab switching
document.querySelectorAll('.docker-tab').forEach(tab => {
tab.addEventListener('click', (e) => {
const tabName = e.currentTarget.dataset.tab;
this.switchTab(tabName);
});
});
},
setupWebSocket() {
if (window.dashboard && window.dashboard.ws) {
const originalOnMessage = window.dashboard.ws.onmessage;
window.dashboard.ws.onmessage = (event) => {
if (originalOnMessage) originalOnMessage(event);
try {
const data = JSON.parse(event.data);
this.handleWebSocketMessage(data);
} catch (e) {
console.error('Docker WS parse error:', e);
}
};
}
},
handleWebSocketMessage(data) {
switch (data.type) {
case 'docker_host_updated':
this.updateHostCard(data.host || data.data);
break;
case 'docker_alert_opened':
this.showAlertNotification(data.data);
this.updateAlertsBadge();
break;
case 'docker_alert_closed':
case 'docker_alert_acknowledged':
this.updateAlertsBadge();
break;
}
},
async fetchAPI(endpoint, options = {}) {
const token = localStorage.getItem('accessToken');
const apiKey = localStorage.getItem('apiKey');
const headers = {
'Content-Type': 'application/json',
...options.headers
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
} else if (apiKey) {
headers['X-API-Key'] = apiKey;
}
const response = await fetch(`${window.location.origin}${endpoint}`, {
...options,
headers
});
if (!response.ok) {
// Handle 401 Unauthorized - redirect to login
if (response.status === 401) {
this.showToast('Session expirée, reconnexion requise', 'error');
if (window.dashboard && typeof window.dashboard.logout === 'function') {
window.dashboard.logout();
}
return null;
}
throw new Error(`API Error: ${response.status}`);
}
return response.json();
},
async loadDockerHosts() {
try {
const response = await this.fetchAPI('/api/docker/hosts');
this.hosts = response.hosts || [];
this.renderHostsGrid();
} catch (error) {
console.error('Error loading Docker hosts:', error);
this.renderError('Erreur lors du chargement des hosts Docker');
}
},
async loadStats() {
try {
const stats = await this.fetchAPI('/api/docker/stats');
this.stats = stats;
this.updateStatsDisplay();
} catch (error) {
console.error('Error loading Docker stats:', error);
}
},
updateStatsDisplay() {
const el = (id, value) => {
const elem = document.getElementById(id);
if (elem) elem.textContent = value;
};
el('docker-stat-hosts', this.stats.enabled_hosts || 0);
el('docker-stat-containers', this.stats.running_containers || 0);
el('docker-stat-images', this.stats.total_images || 0);
el('docker-stat-alerts', this.stats.open_alerts || 0);
// Update alerts badge
const badge = document.getElementById('docker-alerts-badge');
if (badge) {
if (this.stats.open_alerts > 0) {
badge.textContent = this.stats.open_alerts;
badge.classList.remove('hidden');
} else {
badge.classList.add('hidden');
}
}
},
renderHostsGrid() {
const grid = document.getElementById('docker-hosts-grid');
if (!grid) return;
if (this.hosts.length === 0) {
grid.innerHTML = `
<div class="glass-card p-6 text-center col-span-full">
<i class="fab fa-docker text-4xl text-gray-600 mb-4"></i>
<p class="text-gray-400">Aucun host Docker configuré</p>
<p class="text-gray-500 text-sm mt-2">Activez Docker sur un host dans la section Hosts</p>
</div>
`;
return;
}
grid.innerHTML = this.hosts.map(host => this.renderHostCard(host)).join('');
},
renderHostCard(host) {
const statusColor = host.docker_status === 'online' ? 'green' :
host.docker_status === 'offline' ? 'red' : 'yellow';
const statusText = host.docker_status || 'unknown';
const lastCollect = host.docker_last_collect_at
? this.formatRelativeTime(host.docker_last_collect_at)
: 'Jamais';
return `
<div class="glass-card p-4 cursor-pointer hover:border-purple-500/50 transition-all"
data-host-id="${host.host_id}" onclick="dockerSection.viewDetails('${host.host_id}')">
<div class="flex items-center justify-between mb-3">
<h3 class="font-semibold text-lg truncate">${host.host_name}</h3>
<span class="px-2 py-1 rounded-full text-xs font-medium bg-${statusColor}-500/20 text-${statusColor}-400">
${statusText}
</span>
</div>
<div class="text-sm text-gray-400 mb-3">${host.host_ip}</div>
<div class="grid grid-cols-2 gap-2 mb-3 text-sm">
<div class="flex items-center gap-2">
<i class="fas fa-box text-blue-400"></i>
<span>${host.containers_running}/${host.containers_total} containers</span>
</div>
<div class="flex items-center gap-2">
<i class="fas fa-layer-group text-purple-400"></i>
<span>${host.images_total} images</span>
</div>
</div>
${host.open_alerts > 0 ? `
<div class="flex items-center gap-2 text-sm text-red-400 mb-3">
<i class="fas fa-exclamation-triangle"></i>
<span>${host.open_alerts} alerte(s)</span>
</div>
` : ''}
<div class="flex items-center justify-between text-xs text-gray-500">
<span><i class="fas fa-clock mr-1"></i>${lastCollect}</span>
${host.docker_version ? `<span>v${host.docker_version}</span>` : ''}
</div>
<div class="flex gap-2 mt-3 pt-3 border-t border-gray-700">
<button onclick="event.stopPropagation(); dockerSection.collectNow('${host.host_id}')"
class="flex-1 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded text-sm transition-colors">
<i class="fas fa-sync mr-1"></i>Collecter
</button>
<button onclick="event.stopPropagation(); dockerSection.toggleDockerEnabled('${host.host_id}', ${!host.docker_enabled})"
class="px-3 py-1.5 ${host.docker_enabled ? 'bg-red-600/20 text-red-400 hover:bg-red-600/30' : 'bg-green-600/20 text-green-400 hover:bg-green-600/30'} rounded text-sm transition-colors">
<i class="fas fa-${host.docker_enabled ? 'pause' : 'play'}"></i>
</button>
</div>
</div>
`;
},
filterHosts(query) {
const cards = document.querySelectorAll('#docker-hosts-grid > div[data-host-id]');
const lowerQuery = query.toLowerCase();
cards.forEach(card => {
const hostId = card.dataset.hostId;
const host = this.hosts.find(h => h.host_id === hostId);
if (host) {
const matches = host.host_name.toLowerCase().includes(lowerQuery) ||
host.host_ip.toLowerCase().includes(lowerQuery);
card.style.display = matches ? '' : 'none';
}
});
},
async viewDetails(hostId) {
this.currentHostId = hostId;
const host = this.hosts.find(h => h.host_id === hostId);
if (!host) return;
document.getElementById('docker-host-name').innerHTML =
`<i class="fab fa-docker mr-2 text-blue-400"></i>${host.host_name}`;
// Show modal
document.getElementById('docker-detail-modal').classList.remove('hidden');
// Load containers by default
this.switchTab('containers');
await this.loadContainers(hostId);
},
closeModal() {
document.getElementById('docker-detail-modal').classList.add('hidden');
this.currentHostId = null;
},
switchTab(tabName) {
// Update tab buttons
document.querySelectorAll('.docker-tab').forEach(tab => {
tab.classList.toggle('active', tab.dataset.tab === tabName);
tab.classList.toggle('text-gray-400', tab.dataset.tab !== tabName);
});
// Update tab content
document.querySelectorAll('.docker-tab-content').forEach(content => {
content.classList.toggle('hidden', !content.id.endsWith(tabName));
});
// Load content for the tab
if (this.currentHostId) {
switch (tabName) {
case 'containers':
this.loadContainers(this.currentHostId);
break;
case 'images':
this.loadImages(this.currentHostId);
break;
case 'volumes':
this.loadVolumes(this.currentHostId);
break;
case 'host-alerts':
this.loadHostAlerts(this.currentHostId);
break;
}
}
},
async loadContainers(hostId) {
const container = document.getElementById('docker-containers-list');
container.innerHTML = '<div class="loading-spinner mx-auto"></div>';
try {
const response = await this.fetchAPI(`/api/docker/hosts/${hostId}/containers`);
const containers = response.containers || [];
if (containers.length === 0) {
container.innerHTML = '<p class="text-gray-400 text-center py-4">Aucun container</p>';
return;
}
container.innerHTML = containers.map(c => this.renderContainerRow(c, hostId)).join('');
} catch (error) {
container.innerHTML = `<p class="text-red-400 text-center py-4">Erreur: ${error.message}</p>`;
}
},
renderContainerRow(c, hostId) {
const stateColors = {
running: 'green',
exited: 'red',
paused: 'yellow',
created: 'blue',
dead: 'red'
};
const stateColor = stateColors[c.state] || 'gray';
const healthBadge = c.health ? `
<span class="px-2 py-0.5 rounded text-xs bg-${c.health === 'healthy' ? 'green' : 'red'}-500/20 text-${c.health === 'healthy' ? 'green' : 'red'}-400">
${c.health}
</span>
` : '';
// Parse ports and create clickable links
const portLinks = this.parsePortLinks(c.ports, hostId);
return `
<div class="bg-gray-800/50 rounded-lg p-3 flex items-center justify-between gap-4">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="w-2 h-2 rounded-full bg-${stateColor}-500"></span>
<span class="font-medium truncate">${c.name}</span>
${c.compose_project ? `<span class="px-2 py-0.5 rounded text-xs bg-purple-500/20 text-purple-400">${c.compose_project}</span>` : ''}
${healthBadge}
${portLinks}
</div>
<div class="text-sm text-gray-400 truncate mt-1">${c.image}</div>
<div class="text-xs text-gray-500 mt-1">${c.status || c.state}</div>
</div>
<div class="flex items-center gap-1">
${c.state !== 'running' ? `
<button onclick="dockerSection.startContainer('${hostId}', '${c.container_id}')"
class="p-2 hover:bg-gray-700 rounded transition-colors text-green-400" title="Démarrer">
<i class="fas fa-play"></i>
</button>
` : `
<button onclick="dockerSection.stopContainer('${hostId}', '${c.container_id}')"
class="p-2 hover:bg-gray-700 rounded transition-colors text-red-400" title="Arrêter">
<i class="fas fa-stop"></i>
</button>
`}
<button onclick="dockerSection.restartContainer('${hostId}', '${c.container_id}')"
class="p-2 hover:bg-gray-700 rounded transition-colors text-yellow-400" title="Redémarrer">
<i class="fas fa-redo"></i>
</button>
<button onclick="dockerSection.showLogs('${hostId}', '${c.container_id}', '${c.name}')"
class="p-2 hover:bg-gray-700 rounded transition-colors text-blue-400" title="Logs">
<i class="fas fa-file-alt"></i>
</button>
<button onclick="dockerSection.showInspect('${hostId}', '${c.container_id}', '${c.name}')"
class="p-2 hover:bg-gray-700 rounded transition-colors text-cyan-400" title="Inspect">
<i class="fas fa-search"></i>
</button>
</div>
</div>
`;
},
parsePortLinks(ports, hostId) {
if (!ports) return '';
// Get host IP for building URLs
const host = this.hosts.find(h => h.host_id === hostId);
const hostIp = host ? host.host_ip : 'localhost';
// Parse ports from the raw string or object
const portStr = ports.raw || (typeof ports === 'string' ? ports : '');
if (!portStr) return '';
// Extract port mappings like "0.0.0.0:8080->80/tcp" or "8080/tcp"
const portRegex = /(?:([\d.]+):)?(\d+)->\d+\/tcp/g;
const links = [];
let match;
while ((match = portRegex.exec(portStr)) !== null) {
const bindIp = match[1] || '0.0.0.0';
const hostPort = match[2];
// Skip if bound to 127.0.0.1 only (not accessible externally)
if (bindIp === '127.0.0.1') continue;
// Determine protocol (https for common secure ports)
const protocol = ['443', '8443', '9443'].includes(hostPort) ? 'https' : 'http';
const url = `${protocol}://${hostIp}:${hostPort}`;
links.push(`
<a href="${url}" target="_blank" rel="noopener noreferrer"
onclick="event.stopPropagation()"
class="px-2 py-0.5 rounded text-xs bg-blue-500/20 text-blue-400 hover:bg-blue-500/30 transition-colors"
title="Ouvrir ${url}">
<i class="fas fa-external-link-alt mr-1"></i>${hostPort}
</a>
`);
}
return links.join('');
},
async loadImages(hostId) {
const container = document.getElementById('docker-images-list');
container.innerHTML = '<div class="loading-spinner mx-auto"></div>';
try {
const response = await this.fetchAPI(`/api/docker/hosts/${hostId}/images`);
if (!response) return; // 401 handled
const images = response.images || [];
if (images.length === 0) {
container.innerHTML = '<p class="text-gray-400 text-center py-4">Aucune image</p>';
return;
}
// Show unused count if any
const unusedInfo = response.unused_count > 0
? `<div class="text-sm text-yellow-400 mb-3"><i class="fas fa-info-circle mr-1"></i>${response.unused_count} image(s) non utilisée(s)</div>`
: '';
container.innerHTML = unusedInfo + images.map(img => {
const unusedBadge = !img.in_use
? `<span class="px-2 py-0.5 rounded text-xs bg-yellow-500/20 text-yellow-400 cursor-help" title="Cette image n'est utilisée par aucun container">Unused</span>`
: '';
return `
<div class="bg-gray-800/50 rounded-lg p-3 flex items-center justify-between gap-4">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-medium">${img.repo_tags?.[0] || img.image_id.substring(0, 12)}</span>
${unusedBadge}
</div>
<div class="text-sm text-gray-400 mt-1">
<span class="mr-4"><i class="fas fa-hdd mr-1"></i>${this.formatBytes(img.size)}</span>
<span><i class="fas fa-clock mr-1"></i>${this.formatRelativeTime(img.created)}</span>
</div>
</div>
<div class="flex items-center gap-1">
<button onclick="dockerSection.removeImage('${hostId}', '${img.image_id}', ${!img.in_use})"
class="p-2 hover:bg-gray-700 rounded transition-colors ${img.in_use ? 'text-gray-500' : 'text-red-400'}"
title="${img.in_use ? 'Supprimer (forcé car en cours d\'utilisation)' : 'Supprimer l\'image'}">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`;
}).join('');
} catch (error) {
container.innerHTML = `<p class="text-red-400 text-center py-4">Erreur: ${error.message}</p>`;
}
},
async loadVolumes(hostId) {
const container = document.getElementById('docker-volumes-list');
container.innerHTML = '<div class="loading-spinner mx-auto"></div>';
try {
const response = await this.fetchAPI(`/api/docker/hosts/${hostId}/volumes`);
const volumes = response.volumes || [];
if (volumes.length === 0) {
container.innerHTML = '<p class="text-gray-400 text-center py-4">Aucun volume</p>';
return;
}
container.innerHTML = volumes.map(vol => `
<div class="bg-gray-800/50 rounded-lg p-3">
<div class="font-medium">${vol.name}</div>
<div class="text-sm text-gray-400 mt-1">
<span class="mr-4"><i class="fas fa-cog mr-1"></i>${vol.driver}</span>
<span class="truncate"><i class="fas fa-folder mr-1"></i>${vol.mountpoint}</span>
</div>
</div>
`).join('');
} catch (error) {
container.innerHTML = `<p class="text-red-400 text-center py-4">Erreur: ${error.message}</p>`;
}
},
async loadHostAlerts(hostId) {
const container = document.getElementById('docker-host-alerts-list');
container.innerHTML = '<div class="loading-spinner mx-auto"></div>';
try {
const response = await this.fetchAPI(`/api/docker/alerts?host_id=${hostId}`);
const alerts = response.alerts || [];
if (alerts.length === 0) {
container.innerHTML = '<p class="text-gray-400 text-center py-4">Aucune alerte</p>';
return;
}
container.innerHTML = alerts.map(alert => this.renderAlertRow(alert)).join('');
} catch (error) {
container.innerHTML = `<p class="text-red-400 text-center py-4">Erreur: ${error.message}</p>`;
}
},
renderAlertRow(alert) {
const severityColors = {
warning: 'yellow',
error: 'red',
critical: 'red'
};
const color = severityColors[alert.severity] || 'gray';
return `
<div class="bg-gray-800/50 rounded-lg p-3 border-l-4 border-${color}-500">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<i class="fas fa-exclamation-triangle text-${color}-400"></i>
<span class="font-medium">${alert.container_name}</span>
<span class="px-2 py-0.5 rounded text-xs bg-${color}-500/20 text-${color}-400">${alert.severity}</span>
</div>
${alert.state === 'open' ? `
<button onclick="dockerSection.acknowledgeAlert(${alert.id})"
class="px-2 py-1 text-xs bg-gray-700 hover:bg-gray-600 rounded transition-colors">
Acquitter
</button>
` : `
<span class="text-xs text-gray-500">${alert.state}</span>
`}
</div>
<div class="text-sm text-gray-400 mt-2">${alert.message}</div>
<div class="text-xs text-gray-500 mt-1">${this.formatRelativeTime(alert.opened_at)}</div>
</div>
`;
},
// Container Actions
async startContainer(hostId, containerId) {
await this.executeAction(hostId, containerId, 'start');
},
async stopContainer(hostId, containerId) {
await this.executeAction(hostId, containerId, 'stop');
},
async restartContainer(hostId, containerId) {
await this.executeAction(hostId, containerId, 'restart');
},
async executeAction(hostId, containerId, action) {
try {
const response = await this.fetchAPI(`/api/docker/containers/${hostId}/${containerId}/${action}`, {
method: 'POST'
});
if (response.success) {
this.showToast(`Container ${action} successful`, 'success');
// Refresh containers list
setTimeout(() => this.loadContainers(hostId), 1000);
} else {
this.showToast(`Failed: ${response.error || response.message}`, 'error');
}
} catch (error) {
this.showToast(`Error: ${error.message}`, 'error');
}
},
async showLogs(hostId, containerId, containerName) {
document.getElementById('docker-logs-title').innerHTML =
`<i class="fas fa-file-alt mr-2 text-green-400"></i>${containerName} - Logs`;
document.getElementById('docker-logs-content').textContent = 'Chargement des logs...';
document.getElementById('docker-logs-modal').classList.remove('hidden');
try {
const response = await this.fetchAPI(`/api/docker/containers/${hostId}/${containerId}/logs?tail=500`);
document.getElementById('docker-logs-content').textContent = response.logs || 'Aucun log disponible';
} catch (error) {
document.getElementById('docker-logs-content').textContent = `Erreur: ${error.message}`;
}
},
closeLogsModal() {
document.getElementById('docker-logs-modal').classList.add('hidden');
},
async showInspect(hostId, containerId, containerName) {
document.getElementById('docker-logs-title').innerHTML =
`<i class="fas fa-search mr-2 text-cyan-400"></i>${containerName} - Inspect`;
document.getElementById('docker-logs-content').textContent = 'Chargement des informations...';
document.getElementById('docker-logs-modal').classList.remove('hidden');
try {
const response = await this.fetchAPI(`/api/docker/containers/${hostId}/${containerId}/inspect`);
if (!response) return; // 401 handled
// Format JSON nicely
const inspectData = response.inspect_data || {};
document.getElementById('docker-logs-content').textContent =
JSON.stringify(inspectData, null, 2);
} catch (error) {
document.getElementById('docker-logs-content').textContent = `Erreur: ${error.message}`;
}
},
async removeImage(hostId, imageId, canDeleteDirectly = false) {
const force = !canDeleteDirectly;
const confirmMsg = force
? 'Cette image est utilisée par un ou plusieurs containers. Voulez-vous forcer la suppression ?'
: 'Voulez-vous supprimer cette image ?';
if (!confirm(confirmMsg)) return;
try {
const response = await this.fetchAPI(`/api/docker/images/${hostId}/${imageId}?force=${force}`, {
method: 'DELETE'
});
if (!response) return; // 401 handled
if (response.success) {
this.showToast('Image supprimée avec succès', 'success');
// Refresh images list
await this.loadImages(hostId);
} else {
this.showToast(`Erreur: ${response.error || response.message}`, 'error');
}
} catch (error) {
this.showToast(`Erreur: ${error.message}`, 'error');
}
},
async collectNow(hostId) {
try {
this.showToast('Collection en cours...', 'info');
const response = await this.fetchAPI(`/api/docker/hosts/${hostId}/collect`, {
method: 'POST'
});
if (response.success) {
this.showToast(`Collection terminée: ${response.containers_count} containers`, 'success');
await this.loadDockerHosts();
await this.loadStats();
} else {
this.showToast(`Erreur: ${response.error}`, 'error');
}
} catch (error) {
this.showToast(`Erreur: ${error.message}`, 'error');
}
},
async collectAll() {
try {
this.showToast('Collection de tous les hosts...', 'info');
const response = await this.fetchAPI('/api/docker/collect-all', {
method: 'POST'
});
this.showToast(`Collecte terminée: ${response.successful}/${response.total_hosts} hosts`,
response.failed > 0 ? 'warning' : 'success');
await this.loadDockerHosts();
await this.loadStats();
} catch (error) {
this.showToast(`Erreur: ${error.message}`, 'error');
}
},
async toggleDockerEnabled(hostId, enabled) {
try {
await this.fetchAPI(`/api/docker/hosts/${hostId}/enable`, {
method: 'POST',
body: JSON.stringify({ enabled })
});
this.showToast(`Docker monitoring ${enabled ? 'activé' : 'désactivé'}`, 'success');
await this.loadDockerHosts();
await this.loadStats();
} catch (error) {
this.showToast(`Erreur: ${error.message}`, 'error');
}
},
async acknowledgeAlert(alertId) {
try {
await this.fetchAPI(`/api/docker/alerts/${alertId}/acknowledge`, {
method: 'POST'
});
this.showToast('Alerte acquittée', 'success');
if (this.currentHostId) {
await this.loadHostAlerts(this.currentHostId);
}
await this.loadStats();
} catch (error) {
this.showToast(`Erreur: ${error.message}`, 'error');
}
},
showAlerts() {
// Navigate to alerts tab in modal or show alerts modal
if (this.currentHostId) {
this.switchTab('host-alerts');
}
},
updateHostCard(hostData) {
const card = document.querySelector(`[data-host-id="${hostData.host_id}"]`);
if (card) {
card.outerHTML = this.renderHostCard(hostData);
}
},
async updateAlertsBadge() {
await this.loadStats();
},
showAlertNotification(alert) {
this.showToast(`Alerte Docker: ${alert.container_name} - ${alert.message}`, 'warning');
},
renderError(message) {
const grid = document.getElementById('docker-hosts-grid');
if (grid) {
grid.innerHTML = `
<div class="glass-card p-6 text-center col-span-full">
<i class="fas fa-exclamation-circle text-4xl text-red-500 mb-4"></i>
<p class="text-red-400">${message}</p>
</div>
`;
}
},
showToast(message, type = 'info') {
// Use existing toast system or create simple one
if (window.dashboard && window.dashboard.showToast) {
window.dashboard.showToast(message, type);
} else {
console.log(`[${type.toUpperCase()}] ${message}`);
}
},
formatRelativeTime(dateStr) {
if (!dateStr) return 'N/A';
const date = new Date(dateStr);
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMins < 1) return 'À l\'instant';
if (diffMins < 60) return `Il y a ${diffMins}min`;
if (diffHours < 24) return `Il y a ${diffHours}h`;
return `Il y a ${diffDays}j`;
},
formatBytes(bytes) {
if (!bytes) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
};
function setupDockerAutoInit() {
// Initialize when Docker page is shown
const dockerPage = document.getElementById('page-docker');
if (!dockerPage) return;
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (
mutation.target.id === 'page-docker' &&
mutation.target.classList.contains('active')
) {
dockerSection.ensureInit();
}
});
});
observer.observe(dockerPage, { attributes: true, attributeFilter: ['class'] });
// Also initialize if already on Docker page
if (dockerPage.classList.contains('active')) {
dockerSection.ensureInit();
}
}
// If this script is loaded after DOMContentLoaded (common when injected late),
// a DOMContentLoaded listener would never fire. Handle both cases.
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', setupDockerAutoInit);
} else {
setupDockerAutoInit();
}