/** * Containers Page - All containers across all Docker hosts * Features: search, filter, sort, group, actions, drawer details */ const containersPage = { // State containers: [], filteredContainers: [], hosts: [], selectedIds: new Set(), currentContainer: null, inspectData: null, _initialized: false, _initPromise: null, // View settings viewMode: 'comfortable', // 'comfortable', 'compact', 'grouped' currentPage: 1, perPage: 50, // Filter state filters: { search: '', status: '', host: '', health: '' }, sortBy: 'name-asc', favoritesOnly: false, // ========== INITIALIZATION ========== async init() { if (this._initPromise) return await this._initPromise; this._initPromise = (async () => { this.setupEventListeners(); this.setupKeyboardShortcuts(); if (window.favoritesManager) { await window.favoritesManager.ensureInit(); } await this.loadData(); this._initialized = true; })(); return await this._initPromise; }, async ensureInit() { return await this.init(); }, setupEventListeners() { // Search input const searchInput = document.getElementById('containers-search'); if (searchInput) { searchInput.addEventListener('input', this.debounce((e) => { this.filters.search = e.target.value; this.applyFilters(); }, 200)); } // Clear search button const clearBtn = document.getElementById('containers-search-clear'); if (clearBtn) { clearBtn.addEventListener('click', () => { document.getElementById('containers-search').value = ''; this.filters.search = ''; this.applyFilters(); }); } // Filter dropdowns ['status', 'host', 'health'].forEach(filter => { const el = document.getElementById(`containers-filter-${filter}`); if (el) { el.addEventListener('change', (e) => { this.filters[filter] = e.target.value; this.applyFilters(); }); } }); // Sort dropdown const sortEl = document.getElementById('containers-sort'); if (sortEl) { sortEl.addEventListener('change', (e) => { this.sortBy = e.target.value; this.applyFilters(); }); } // View mode buttons ['comfortable', 'compact', 'grouped'].forEach(mode => { const btn = document.getElementById(`containers-view-${mode}`); if (btn) { btn.addEventListener('click', () => this.setViewMode(mode)); } }); // Favorites-only toggle const favOnlyBtn = document.getElementById('containers-filter-favorites'); if (favOnlyBtn) { favOnlyBtn.addEventListener('click', async () => { if (window.favoritesManager) { await window.favoritesManager.ensureInit(); } this.favoritesOnly = !this.favoritesOnly; favOnlyBtn.classList.toggle('bg-purple-600', this.favoritesOnly); favOnlyBtn.classList.toggle('bg-gray-700', !this.favoritesOnly); favOnlyBtn.setAttribute('aria-pressed', this.favoritesOnly ? 'true' : 'false'); const icon = favOnlyBtn.querySelector('i'); if (icon) { icon.classList.toggle('fas', this.favoritesOnly); icon.classList.toggle('far', !this.favoritesOnly); } this.applyFilters(); }); } // Per page select const perPageEl = document.getElementById('containers-per-page'); if (perPageEl) { perPageEl.addEventListener('change', (e) => { this.perPage = parseInt(e.target.value); this.currentPage = 1; this.render(); }); } // Select all checkbox const selectAllEl = document.getElementById('containers-select-all'); if (selectAllEl) { selectAllEl.addEventListener('change', (e) => { this.selectAll(e.target.checked); }); } // Drawer tabs document.querySelectorAll('.drawer-tab').forEach(tab => { tab.addEventListener('click', (e) => { const tabName = e.currentTarget.dataset.tab; this.switchDrawerTab(tabName); }); }); }, setupKeyboardShortcuts() { document.addEventListener('keydown', (e) => { // Only active when on containers page if (currentPage !== 'docker-containers') return; // Ignore if in input if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT' || e.target.tagName === 'TEXTAREA') { // Escape clears search if (e.key === 'Escape') { e.target.blur(); } return; } switch (e.key) { case '/': e.preventDefault(); document.getElementById('containers-search')?.focus(); break; case 'Escape': if (this.isDrawerOpen()) { this.closeDrawer(); } break; case 'r': if (!e.ctrlKey && !e.metaKey) { e.preventDefault(); this.refresh(); } break; } }); }, // ========== DATA LOADING ========== async loadData() { this.showLoading(); try { const [containersRes, hostsRes] = await Promise.all([ this.fetchAPI('/api/docker/containers'), this.fetchAPI('/api/docker/hosts') ]); this.containers = containersRes.containers || []; this.hosts = hostsRes.hosts || []; // Update host filter dropdown this.populateHostFilter(); // Update stats this.updateStats(containersRes); // Apply filters and render this.applyFilters(); } catch (error) { console.error('Error loading containers:', error); this.showError(error.message); } }, async refresh() { const icon = document.getElementById('containers-refresh-icon'); if (icon) icon.classList.add('fa-spin'); await this.loadData(); if (icon) icon.classList.remove('fa-spin'); this.showToast('Données actualisées', 'success'); }, async fetchAPI(endpoint, options = {}) { const token = localStorage.getItem('accessToken'); const headers = { 'Content-Type': 'application/json', ...options.headers }; if (token) { headers['Authorization'] = `Bearer ${token}`; } const response = await fetch(`${window.location.origin}${endpoint}`, { ...options, headers }); if (!response.ok) { if (response.status === 401) { this.showToast('Session expirée', 'error'); if (window.dashboard?.logout) window.dashboard.logout(); throw new Error('Unauthorized'); } throw new Error(`API Error: ${response.status}`); } return response.json(); }, // ========== FILTERING & SORTING ========== applyFilters() { let result = [...this.containers]; // Text search with smart tokens if (this.filters.search) { result = this.smartSearch(result, this.filters.search); } // Status filter if (this.filters.status) { result = result.filter(c => c.state === this.filters.status); } // Host filter if (this.filters.host) { result = result.filter(c => c.host_id === this.filters.host); } // Health filter if (this.filters.health) { if (this.filters.health === 'none') { result = result.filter(c => !c.health || c.health === 'none'); } else { result = result.filter(c => c.health === this.filters.health); } } // Favorites-only if (this.favoritesOnly && window.favoritesManager) { result = result.filter(c => window.favoritesManager.isFavorite(c.host_id, c.container_id)); } // Sort result = this.sortContainers(result, this.sortBy); this.filteredContainers = result; this.currentPage = 1; this.updateActiveFilters(); this.render(); }, smartSearch(containers, query) { const tokens = this.parseSearchTokens(query); return containers.filter(c => { // Check token filters for (const token of tokens.filters) { switch (token.key) { case 'host': if (!c.host_name.toLowerCase().includes(token.value.toLowerCase())) return false; break; case 'status': case 'state': if (c.state !== token.value.toLowerCase()) return false; break; case 'health': if (c.health !== token.value.toLowerCase()) return false; break; case 'image': if (!c.image?.toLowerCase().includes(token.value.toLowerCase())) return false; break; case 'port': if (!this.containerHasPort(c, token.value)) return false; break; case 'project': case 'stack': if (!c.compose_project?.toLowerCase().includes(token.value.toLowerCase())) return false; break; } } // Free text search if (tokens.freeText) { const searchStr = tokens.freeText.toLowerCase(); const searchable = [ c.name, c.host_name, c.image, c.compose_project, c.container_id?.substring(0, 12) ].filter(Boolean).join(' ').toLowerCase(); if (!searchable.includes(searchStr)) return false; } return true; }); }, parseSearchTokens(query) { const filters = []; let freeText = query; // Match tokens like "host:value" or "status:running" const tokenRegex = /(\w+):(\S+)/g; let match; while ((match = tokenRegex.exec(query)) !== null) { filters.push({ key: match[1], value: match[2] }); freeText = freeText.replace(match[0], '').trim(); } return { filters, freeText }; }, containerHasPort(container, port) { if (!container.ports) return false; const portStr = container.ports.raw || JSON.stringify(container.ports); return portStr.includes(port); }, sortContainers(containers, sortBy) { const [field, direction] = sortBy.split('-'); const mult = direction === 'desc' ? -1 : 1; return containers.sort((a, b) => { let valA, valB; switch (field) { case 'name': valA = a.name.toLowerCase(); valB = b.name.toLowerCase(); break; case 'host': valA = a.host_name.toLowerCase(); valB = b.host_name.toLowerCase(); break; case 'status': const statusOrder = { running: 0, restarting: 1, paused: 2, created: 3, exited: 4, dead: 5 }; valA = statusOrder[a.state] ?? 99; valB = statusOrder[b.state] ?? 99; break; case 'updated': valA = new Date(a.last_update_at || 0).getTime(); valB = new Date(b.last_update_at || 0).getTime(); break; default: valA = a.name.toLowerCase(); valB = b.name.toLowerCase(); } if (valA < valB) return -1 * mult; if (valA > valB) return 1 * mult; return 0; }); }, // ========== RENDERING ========== render() { const list = document.getElementById('containers-list'); const empty = document.getElementById('containers-empty'); const error = document.getElementById('containers-error'); const pagination = document.getElementById('containers-pagination'); // Hide error error?.classList.add('hidden'); // Check empty if (this.filteredContainers.length === 0) { list.innerHTML = ''; empty?.classList.remove('hidden'); pagination?.classList.add('hidden'); return; } empty?.classList.add('hidden'); // Paginate const start = (this.currentPage - 1) * this.perPage; const end = Math.min(start + this.perPage, this.filteredContainers.length); const pageContainers = this.filteredContainers.slice(start, end); // Render based on view mode if (this.viewMode === 'grouped') { list.innerHTML = this.renderGroupedView(pageContainers); } else { list.innerHTML = pageContainers.map(c => this.renderContainerRow(c)).join(''); } // Update pagination this.updatePagination(start, end); // Update search clear button visibility const clearBtn = document.getElementById('containers-search-clear'); if (clearBtn) { clearBtn.classList.toggle('hidden', !this.filters.search); } }, renderContainerRow(c) { const stateColors = { running: 'green', exited: 'red', paused: 'yellow', created: 'blue', restarting: 'orange', dead: 'red' }; const isCompact = this.viewMode === 'compact'; const favKey = `${c.host_id}::${c.container_id}`; const favIconClass = window.favoritesManager?.isFavorite(c.host_id, c.container_id) ? 'fas' : 'far'; const favTitle = window.favoritesManager?.isFavorite(c.host_id, c.container_id) ? 'Retirer des favoris' : 'Ajouter aux favoris'; const favColorClass = window.favoritesManager?.isFavorite(c.host_id, c.container_id) ? 'text-purple-400' : 'text-gray-400'; const custom = c.customization || window.containerCustomizationsManager?.get(c.host_id, c.container_id); const iconKey = custom?.icon_key || ''; const iconColor = custom?.icon_color || '#9ca3af'; const bgColor = custom?.bg_color || ''; const bgStyle = bgColor ? `background:${this.escapeHtml(bgColor)};` : ''; const iconHtml = iconKey ? `` : ''; const healthBadge = c.health && c.health !== 'none' ? ` ${c.health} ` : ''; const projectBadge = c.compose_project ? ` ${this.escapeHtml(c.compose_project)} ` : ''; const portLinks = this.renderPortLinks(c); if (isCompact) { return `
${iconHtml} ${this.escapeHtml(c.name)} ${this.escapeHtml(c.host_name)} ${this.escapeHtml(c.image || '—')}
${this.renderQuickActions(c)}
`; } return `
${iconHtml} ${this.escapeHtml(c.name)} ${projectBadge} ${healthBadge} ${portLinks}
${this.escapeHtml(c.host_name)}
${this.escapeHtml(c.image || '—')}
${c.status || c.state}
${this.renderQuickActions(c)}
`; }, async toggleFavorite(hostId, containerId) { if (!window.favoritesManager) return; try { await window.favoritesManager.ensureInit(); await window.favoritesManager.toggleFavorite(hostId, containerId); this.showToast('Favoris mis à jour', 'success'); // Refresh current list rendering quickly this.render(); } catch (e) { this.showToast(`Erreur favoris: ${e.message}`, 'error'); } }, renderGroupedView(containers) { // Group by host const groups = {}; containers.forEach(c => { if (!groups[c.host_id]) { groups[c.host_id] = { host_name: c.host_name, host_ip: c.host_ip, containers: [] }; } groups[c.host_id].containers.push(c); }); return Object.entries(groups).map(([hostId, group]) => `
${this.escapeHtml(group.host_name)} ${this.escapeHtml(group.host_ip)}
${group.containers.length} container(s)
${group.containers.map(c => this.renderContainerRow(c)).join('')}
`).join(''); }, renderQuickActions(c) { const isRunning = c.state === 'running'; return ` ${!isRunning ? ` ` : ` `} `; }, renderPortLinks(c) { if (!c.ports) return ''; const portStr = c.ports.raw || (typeof c.ports === 'string' ? c.ports : ''); if (!portStr) return ''; const portRegex = /(?:([\d.]+):)?(\d+)->\d+\/tcp/g; const links = []; const seenPorts = new Set(); let match; while ((match = portRegex.exec(portStr)) !== null) { const bindIp = match[1] || '0.0.0.0'; const hostPort = match[2]; if (bindIp === '127.0.0.1' || bindIp === '::1') continue; if (seenPorts.has(hostPort)) continue; seenPorts.add(hostPort); const protocol = ['443', '8443', '9443'].includes(hostPort) ? 'https' : 'http'; const url = `${protocol}://${c.host_ip}:${hostPort}`; links.push(` ${hostPort} `); } return links.slice(0, 3).join(''); // Limit to 3 port links }, // ========== UI STATE UPDATES ========== showLoading() { const list = document.getElementById('containers-list'); if (list) { list.innerHTML = `

Chargement des containers...

`; } document.getElementById('containers-empty')?.classList.add('hidden'); document.getElementById('containers-error')?.classList.add('hidden'); }, showError(message) { document.getElementById('containers-list').innerHTML = ''; document.getElementById('containers-empty')?.classList.add('hidden'); const errorEl = document.getElementById('containers-error'); if (errorEl) { errorEl.classList.remove('hidden'); document.getElementById('containers-error-message').textContent = message; } }, updateStats(data) { const el = (id, val) => { const elem = document.getElementById(id); if (elem) elem.textContent = val; }; el('containers-total', data.total || 0); el('containers-running', data.running || 0); el('containers-stopped', data.stopped || 0); el('containers-paused', data.paused || 0); el('containers-hosts-count', data.hosts_count || 0); // Update last update time if (data.last_update) { const date = new Date(data.last_update); document.getElementById('containers-last-update').textContent = `Mis à jour ${this.formatRelativeTime(date)}`; } }, updatePagination(start, end) { const pagination = document.getElementById('containers-pagination'); if (!pagination) return; if (this.filteredContainers.length <= this.perPage) { pagination.classList.add('hidden'); return; } pagination.classList.remove('hidden'); document.getElementById('containers-showing-start').textContent = start + 1; document.getElementById('containers-showing-end').textContent = end; document.getElementById('containers-showing-total').textContent = this.filteredContainers.length; }, updateActiveFilters() { const container = document.getElementById('containers-active-filters'); if (!container) return; const activeFilters = []; if (this.filters.status) { activeFilters.push({ key: 'status', value: this.filters.status, label: `Status: ${this.filters.status}` }); } if (this.filters.host) { const host = this.hosts.find(h => h.host_id === this.filters.host); activeFilters.push({ key: 'host', value: this.filters.host, label: `Host: ${host?.host_name || this.filters.host}` }); } if (this.filters.health) { activeFilters.push({ key: 'health', value: this.filters.health, label: `Health: ${this.filters.health}` }); } if (activeFilters.length === 0) { container.classList.add('hidden'); return; } container.classList.remove('hidden'); container.innerHTML = activeFilters.map(f => ` ${f.label} `).join('') + ` `; }, populateHostFilter() { const select = document.getElementById('containers-filter-host'); if (!select) return; // Keep first option select.innerHTML = ''; this.hosts.forEach(host => { const option = document.createElement('option'); option.value = host.host_id; option.textContent = host.host_name; select.appendChild(option); }); }, setViewMode(mode) { this.viewMode = mode; // Update button states ['comfortable', 'compact', 'grouped'].forEach(m => { const btn = document.getElementById(`containers-view-${m}`); if (btn) { btn.classList.toggle('bg-purple-600', m === mode); btn.classList.toggle('bg-gray-700', m !== mode); btn.setAttribute('aria-pressed', m === mode ? 'true' : 'false'); } }); this.render(); }, clearFilter(key) { this.filters[key] = ''; const el = document.getElementById(`containers-filter-${key}`); if (el) el.value = ''; this.applyFilters(); }, clearAllFilters() { this.filters = { search: '', status: '', host: '', health: '' }; document.getElementById('containers-search').value = ''; document.getElementById('containers-filter-status').value = ''; document.getElementById('containers-filter-host').value = ''; document.getElementById('containers-filter-health').value = ''; this.applyFilters(); }, // ========== CONTAINER ACTIONS ========== async containerAction(hostId, containerId, action) { try { this.showToast(`${action}...`, 'info'); const response = await this.fetchAPI(`/api/docker/containers/${hostId}/${containerId}/${action}`, { method: 'POST' }); if (response.success) { this.showToast(`Container ${action} réussi`, 'success'); // Refresh after a short delay setTimeout(() => this.refresh(), 1000); } else { this.showToast(`Erreur: ${response.error || response.message}`, 'error'); } } catch (error) { this.showToast(`Erreur: ${error.message}`, 'error'); } }, async bulkAction(action) { if (this.selectedIds.size === 0) { this.showToast('Aucun container sélectionné', 'warning'); return; } const confirmMsg = `Êtes-vous sûr de vouloir ${action} ${this.selectedIds.size} container(s) ?`; if (!confirm(confirmMsg)) return; this.showToast(`${action} en cours...`, 'info'); const results = await Promise.allSettled( Array.from(this.selectedIds).map(id => { const [hostId, containerId] = id.split('::'); return this.fetchAPI(`/api/docker/containers/${hostId}/${containerId}/${action}`, { method: 'POST' }); }) ); const successful = results.filter(r => r.status === 'fulfilled' && r.value.success).length; this.showToast(`${successful}/${this.selectedIds.size} container(s) ${action}`, successful === this.selectedIds.size ? 'success' : 'warning'); this.selectedIds.clear(); this.updateBulkActionsBar(); setTimeout(() => this.refresh(), 1000); }, selectAll(checked) { if (checked) { this.filteredContainers.forEach(c => { this.selectedIds.add(`${c.host_id}::${c.container_id}`); }); } else { this.selectedIds.clear(); } this.updateBulkActionsBar(); }, toggleSelection(hostId, containerId) { const id = `${hostId}::${containerId}`; if (this.selectedIds.has(id)) { this.selectedIds.delete(id); } else { this.selectedIds.add(id); } this.updateBulkActionsBar(); }, updateBulkActionsBar() { const bar = document.getElementById('containers-bulk-actions'); const count = document.getElementById('containers-selected-count'); const selectAll = document.getElementById('containers-select-all'); if (bar) { bar.classList.toggle('hidden', this.selectedIds.size === 0); } if (count) { count.textContent = this.selectedIds.size; } if (selectAll) { selectAll.checked = this.selectedIds.size > 0 && this.selectedIds.size === this.filteredContainers.length; } }, // ========== DRAWER ========== async openDrawer(hostId, containerId, tab = 'overview') { const wantedHostId = String(hostId); const wantedContainerId = String(containerId); if (!this._initialized || !this.containers || this.containers.length === 0) { await this.ensureInit(); } this.currentContainer = this.containers.find( c => String(c.host_id) === wantedHostId && String(c.container_id) === wantedContainerId ); if (!this.currentContainer) { this.showToast('Container non trouvé', 'error'); return; } const drawer = document.getElementById('container-drawer'); const backdrop = document.getElementById('container-drawer-backdrop'); // Populate drawer this.populateDrawer(); // Show drawer drawer.classList.remove('translate-x-full'); backdrop.classList.remove('hidden'); document.body.style.overflow = 'hidden'; // Switch to requested tab this.switchDrawerTab(tab); // Load tab-specific data if (tab === 'logs') { await this.loadLogs(); } else if (tab === 'inspect') { await this.loadInspect(); } }, closeDrawer() { const drawer = document.getElementById('container-drawer'); const backdrop = document.getElementById('container-drawer-backdrop'); drawer.classList.add('translate-x-full'); backdrop.classList.add('hidden'); document.body.style.overflow = ''; this.currentContainer = null; this.inspectData = null; }, isDrawerOpen() { const drawer = document.getElementById('container-drawer'); return drawer && !drawer.classList.contains('translate-x-full'); }, populateDrawer() { const c = this.currentContainer; if (!c) return; const stateColors = { running: 'bg-green-500', exited: 'bg-red-500', paused: 'bg-yellow-500', created: 'bg-blue-500', restarting: 'bg-orange-500', dead: 'bg-red-500' }; // Header document.getElementById('drawer-container-name').textContent = c.name; document.getElementById('drawer-container-state').className = `w-3 h-3 rounded-full ${stateColors[c.state] || 'bg-gray-500'}`; const custom = c.customization || window.containerCustomizationsManager?.get(c.host_id, c.container_id); const iconKey = custom?.icon_key || ''; const iconColor = custom?.icon_color || '#9ca3af'; const bgColor = custom?.bg_color || ''; const bgStyle = bgColor ? `background:${this.escapeHtml(bgColor)};` : ''; const iconEl = document.getElementById('drawer-container-icon'); if (iconEl) { iconEl.innerHTML = iconKey ? `` : ''; } // Overview document.getElementById('drawer-host-name').textContent = c.host_name; document.getElementById('drawer-host-ip').textContent = c.host_ip; document.getElementById('drawer-status').textContent = c.status || c.state; document.getElementById('drawer-health').textContent = c.health || 'No healthcheck'; document.getElementById('drawer-image').textContent = c.image || '—'; document.getElementById('drawer-container-id').textContent = c.container_id; // Ports const portsEl = document.getElementById('drawer-ports'); if (c.ports) { portsEl.innerHTML = this.renderPortLinks(c) || 'Aucun port exposé'; } else { portsEl.innerHTML = 'Aucun port exposé'; } // Labels const labelsEl = document.getElementById('drawer-labels'); if (c.labels && Object.keys(c.labels).length > 0) { labelsEl.innerHTML = Object.entries(c.labels) .slice(0, 10) .map(([k, v]) => ` ${this.escapeHtml(k.split('.').pop())} `).join(''); } else { labelsEl.innerHTML = 'Aucun label'; } // Update action buttons based on state const isRunning = c.state === 'running'; document.getElementById('drawer-btn-start')?.classList.toggle('hidden', isRunning); document.getElementById('drawer-btn-stop')?.classList.toggle('hidden', !isRunning); }, switchDrawerTab(tabName) { // Update tab buttons document.querySelectorAll('.drawer-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('.drawer-tab-content').forEach(content => { content.classList.toggle('hidden', !content.id.endsWith(tabName)); }); // Load content if needed if (tabName === 'logs' && this.currentContainer) { this.loadLogs(); } else if (tabName === 'inspect' && this.currentContainer) { this.loadInspect(); } }, async loadLogs() { if (!this.currentContainer) return; const logsEl = document.getElementById('drawer-logs-content'); logsEl.textContent = 'Chargement...'; try { const tail = document.getElementById('drawer-logs-tail')?.value || 200; const timestamps = document.getElementById('drawer-logs-timestamps')?.checked || false; const response = await this.fetchAPI( `/api/docker/containers/${this.currentContainer.host_id}/${this.currentContainer.container_id}/logs?tail=${tail}×tamps=${timestamps}` ); logsEl.textContent = response.logs || 'Aucun log disponible'; } catch (error) { logsEl.textContent = `Erreur: ${error.message}`; } }, async loadInspect() { if (!this.currentContainer) return; const inspectEl = document.getElementById('drawer-inspect-content'); inspectEl.textContent = 'Chargement...'; try { const response = await this.fetchAPI( `/api/docker/containers/${this.currentContainer.host_id}/${this.currentContainer.container_id}/inspect` ); this.inspectData = response.inspect_data || {}; inspectEl.textContent = JSON.stringify(this.inspectData, null, 2); } catch (error) { inspectEl.textContent = `Erreur: ${error.message}`; } }, async copyInspect() { if (!this.inspectData) { this.showToast('Aucune donnée à copier', 'warning'); return; } try { await navigator.clipboard.writeText(JSON.stringify(this.inspectData, null, 2)); this.showToast('JSON copié !', 'success'); } catch (error) { this.showToast('Erreur lors de la copie', 'error'); } }, async drawerAction(action) { if (!this.currentContainer) return; await this.containerAction( this.currentContainer.host_id, this.currentContainer.container_id, action ); }, // ========== UTILITIES ========== debounce(fn, delay) { let timeout; return function (...args) { clearTimeout(timeout); timeout = setTimeout(() => fn.apply(this, args), delay); }; }, escapeHtml(text) { if (!text) return ''; const div = document.createElement('div'); div.textContent = text; return div.innerHTML; }, formatRelativeTime(date) { if (!date) return '—'; const now = new Date(); const d = new Date(date); const diff = Math.floor((now - d) / 1000); if (diff < 60) return 'À l\'instant'; if (diff < 3600) return `il y a ${Math.floor(diff / 60)} min`; if (diff < 86400) return `il y a ${Math.floor(diff / 3600)} h`; return `il y a ${Math.floor(diff / 86400)} j`; }, showToast(message, type = 'info') { // Use existing toast system if available if (window.dockerSection?.showToast) { window.dockerSection.showToast(message, type); } else if (window.showNotification) { window.showNotification(message, type); } else { console.log(`[${type}] ${message}`); } } }; // Initialize when page is shown document.addEventListener('DOMContentLoaded', () => { // Listen for page navigation const originalNavigateTo = window.navigateTo; if (originalNavigateTo) { window.navigateTo = function(pageName) { originalNavigateTo(pageName); if (pageName === 'docker-containers') { containersPage.init(); } }; } }); // Export for global access window.containersPage = containersPage;