diff --git a/app/containers_page.js b/app/containers_page.js
new file mode 100644
index 0000000..7d2e92e
--- /dev/null
+++ b/app/containers_page.js
@@ -0,0 +1,1042 @@
+/**
+ * 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,
+
+ // View settings
+ viewMode: 'comfortable', // 'comfortable', 'compact', 'grouped'
+ currentPage: 1,
+ perPage: 50,
+
+ // Filter state
+ filters: {
+ search: '',
+ status: '',
+ host: '',
+ health: ''
+ },
+ sortBy: 'name-asc',
+
+ // ========== INITIALIZATION ==========
+
+ async init() {
+ this.setupEventListeners();
+ this.setupKeyboardShortcuts();
+ await this.loadData();
+ },
+
+ 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));
+ }
+ });
+
+ // 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);
+ }
+ }
+
+ // 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 stateColor = stateColors[c.state] || 'gray';
+ const isCompact = this.viewMode === 'compact';
+
+ 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 `
+
+
+
${this.escapeHtml(c.name)}
+
${this.escapeHtml(c.host_name)}
+
${this.escapeHtml(c.image || '—')}
+
+ ${this.renderQuickActions(c)}
+
+
+ `;
+ }
+
+ return `
+
+
+
+
+
+ ${this.escapeHtml(c.name)}
+ ${projectBadge}
+ ${healthBadge}
+ ${portLinks}
+
+
+
+ ${this.escapeHtml(c.host_name)}
+
+
+ ${this.escapeHtml(c.image || '—')}
+
+
+
${c.status || c.state}
+
+
+ ${this.renderQuickActions(c)}
+
+
+
+ `;
+ },
+
+ 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') {
+ this.currentContainer = this.containers.find(
+ c => c.host_id === hostId && c.container_id === containerId
+ );
+
+ 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'}`;
+
+ // 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;
diff --git a/app/crud/docker_container.py b/app/crud/docker_container.py
index c087848..83ef662 100644
--- a/app/crud/docker_container.py
+++ b/app/crud/docker_container.py
@@ -138,3 +138,48 @@ class DockerContainerRepository:
)
)
return result.rowcount
+
+ async def list_all(
+ self,
+ state: Optional[str] = None,
+ compose_project: Optional[str] = None,
+ health: Optional[str] = None,
+ host_ids: Optional[List[str]] = None
+ ) -> List[DockerContainer]:
+ """List all containers across all hosts with optional filters."""
+ query = select(DockerContainer)
+
+ if state:
+ query = query.where(DockerContainer.state == state)
+ if compose_project:
+ query = query.where(DockerContainer.compose_project == compose_project)
+ if health:
+ query = query.where(DockerContainer.health == health)
+ if host_ids:
+ query = query.where(DockerContainer.host_id.in_(host_ids))
+
+ query = query.order_by(DockerContainer.host_id, DockerContainer.name)
+ result = await self.session.execute(query)
+ return list(result.scalars().all())
+
+ async def count_all(self) -> dict:
+ """Count all containers by state across all hosts."""
+ result = await self.session.execute(
+ select(
+ func.count(DockerContainer.id).label('total'),
+ func.sum(case((DockerContainer.state == 'running', 1), else_=0)).label('running'),
+ func.sum(case((DockerContainer.state == 'exited', 1), else_=0)).label('stopped'),
+ func.sum(case((DockerContainer.state == 'paused', 1), else_=0)).label('paused'),
+ func.count(func.distinct(DockerContainer.host_id)).label('hosts_count'),
+ func.max(DockerContainer.last_update_at).label('last_update')
+ )
+ )
+ row = result.one()
+ return {
+ "total": row.total or 0,
+ "running": row.running or 0,
+ "stopped": row.stopped or 0,
+ "paused": row.paused or 0,
+ "hosts_count": row.hosts_count or 0,
+ "last_update": row.last_update
+ }
diff --git a/app/crud/host.py b/app/crud/host.py
index b0bfdc0..64d43d9 100644
--- a/app/crud/host.py
+++ b/app/crud/host.py
@@ -82,3 +82,11 @@ class HostRepository:
)
result = await self.session.execute(stmt)
return result.scalars().all()
+
+ async def list_all(self, include_deleted: bool = False) -> list[Host]:
+ """List all hosts without pagination."""
+ stmt = select(Host).order_by(Host.name)
+ if not include_deleted:
+ stmt = stmt.where(Host.deleted_at.is_(None))
+ result = await self.session.execute(stmt)
+ return result.scalars().all()
diff --git a/app/docker_section.js b/app/docker_section.js
index a6fa129..7d6868f 100644
--- a/app/docker_section.js
+++ b/app/docker_section.js
@@ -307,8 +307,7 @@ const dockerSection = {
container.innerHTML = '';
try {
- const response = await this.fetchAPI(`/api/docker/hosts/${hostId}/containers`);
- const containers = response.containers || [];
+ const containers = await this.fetchContainers(hostId);
if (containers.length === 0) {
container.innerHTML = 'Aucun container
';
@@ -321,6 +320,24 @@ const dockerSection = {
}
},
+ async fetchContainers(hostId) {
+ const response = await this.fetchAPI(`/api/docker/hosts/${hostId}/containers`);
+ if (!response) return [];
+ return response.containers || [];
+ },
+
+ async collectHostDockerInfo(hostId) {
+ try {
+ const response = await this.fetchAPI(`/api/docker/hosts/${hostId}/collect`, {
+ method: 'POST'
+ });
+ if (!response) return false; // 401 handled
+ return !!response.success;
+ } catch (error) {
+ return false;
+ }
+ },
+
renderContainerRow(c, hostId) {
const stateColors = {
running: 'green',
@@ -397,6 +414,7 @@ const dockerSection = {
// Extract port mappings like "0.0.0.0:8080->80/tcp" or "8080/tcp"
const portRegex = /(?:([\d.]+):)?(\d+)->\d+\/tcp/g;
const links = [];
+ const seenHostPorts = new Set();
let match;
while ((match = portRegex.exec(portStr)) !== null) {
@@ -404,7 +422,10 @@ const dockerSection = {
const hostPort = match[2];
// Skip if bound to 127.0.0.1 only (not accessible externally)
- if (bindIp === '127.0.0.1') continue;
+ if (bindIp === '127.0.0.1' || bindIp === '::1') continue;
+
+ if (seenHostPorts.has(hostPort)) continue;
+ seenHostPorts.add(hostPort);
// Determine protocol (https for common secure ports)
const protocol = ['443', '8443', '9443'].includes(hostPort) ? 'https' : 'http';
@@ -570,11 +591,15 @@ const dockerSection = {
const response = await this.fetchAPI(`/api/docker/containers/${hostId}/${containerId}/${action}`, {
method: 'POST'
});
+
+ if (!response) return; // 401 handled
if (response.success) {
this.showToast(`Container ${action} successful`, 'success');
- // Refresh containers list
- setTimeout(() => this.loadContainers(hostId), 1000);
+ await this.collectHostDockerInfo(hostId);
+ await this.refreshContainersAfterAction(hostId, containerId, action);
+ await this.loadDockerHosts();
+ await this.loadStats();
} else {
this.showToast(`Failed: ${response.error || response.message}`, 'error');
}
@@ -583,6 +608,35 @@ const dockerSection = {
}
},
+ async refreshContainersAfterAction(hostId, containerId, action) {
+ const expectedRunning = action === 'start' || action === 'restart';
+ const expectedStopped = action === 'stop';
+
+ const delays = [0, 500, 1000, 1500, 2000];
+ for (const delayMs of delays) {
+ if (delayMs) {
+ await new Promise(resolve => setTimeout(resolve, delayMs));
+ }
+
+ const containers = await this.fetchContainers(hostId);
+ const found = containers.find(c => String(c.container_id) === String(containerId));
+ if (!found) {
+ // If the container is not returned, still re-render latest list.
+ await this.loadContainers(hostId);
+ return;
+ }
+
+ const isRunning = found.state === 'running' || found.state === 'restarting';
+ if ((expectedRunning && isRunning) || (expectedStopped && !isRunning)) {
+ await this.loadContainers(hostId);
+ return;
+ }
+ }
+
+ // Fallback: render whatever we have
+ await this.loadContainers(hostId);
+ },
+
async showLogs(hostId, containerId, containerName) {
document.getElementById('docker-logs-title').innerHTML =
`${containerName} - Logs`;
diff --git a/app/index.html b/app/index.html
index 01f956b..9453b6b 100644
--- a/app/index.html
+++ b/app/index.html
@@ -139,6 +139,20 @@
transform: translateY(-2px);
}
+ .metric-card-clickable {
+ cursor: pointer;
+ position: relative;
+ }
+
+ .metric-card-clickable:hover {
+ border-color: rgba(16, 185, 129, 0.5);
+ box-shadow: 0 4px 15px rgba(16, 185, 129, 0.15);
+ }
+
+ .metric-card-clickable:active {
+ transform: translateY(0);
+ }
+
.host-card {
background: rgba(42, 42, 42, 0.4);
border: 1px solid rgba(255, 255, 255, 0.1);
@@ -290,6 +304,38 @@
background-color: rgba(255, 255, 255, 0.1);
}
+ /* Drawer tabs styling */
+ .drawer-tab {
+ border-bottom: 2px solid transparent;
+ margin-bottom: -1px;
+ transition: all 0.2s ease;
+ }
+
+ .drawer-tab:hover {
+ background-color: rgba(255, 255, 255, 0.05);
+ }
+
+ .drawer-tab.active {
+ border-bottom-color: var(--accent-color);
+ color: var(--accent-color);
+ }
+
+ /* Docker tabs styling */
+ .docker-tab {
+ border-bottom: 2px solid transparent;
+ margin-bottom: -1px;
+ transition: all 0.2s ease;
+ }
+
+ .docker-tab:hover {
+ background-color: rgba(255, 255, 255, 0.05);
+ }
+
+ .docker-tab.active {
+ border-bottom-color: var(--accent-color);
+ color: var(--accent-color);
+ }
+
/* Category filter buttons */
.category-filter-btn {
cursor: pointer;
@@ -2881,10 +2927,6 @@
Configuration
-
+
+
+ Centre d'aide
+
Déconnexion
@@ -3710,9 +3748,18 @@
0
Hosts Docker
-
+
0
-
Containers
+
+ Containers
+
+
0
@@ -3821,6 +3868,307 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ Containers
+
+
+
Tous les containers de vos Docker hosts
+
+
+ —
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0 sélectionné(s)
+
+
+
+ Start
+
+
+ Stop
+
+
+ Restart
+
+
+
+
+
+
+
+
+
+
Chargement des containers...
+
+
+
+
+
+
+
Aucun container trouvé
+
Modifiez vos filtres ou collectez des données Docker
+
+
+
+
+
+
Erreur lors du chargement
+
+
+ Réessayer
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -3902,24 +4250,26 @@
Logs Récentes
+
+
+
-
-
- Console
+
+
-
-
- BD
+
+
-
-
- Effacer
+
+
-
-
- Exporter
+
+
+
+
+
@@ -4690,5 +5040,8 @@
+
+
+