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
1143 lines
43 KiB
JavaScript
1143 lines
43 KiB
JavaScript
/**
|
|
* 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
|
|
? `<span class="w-5 h-5 rounded flex items-center justify-center border border-gray-700" style="${bgStyle}"><span class="iconify text-sm" data-icon="${this.escapeHtml(iconKey)}" style="color:${this.escapeHtml(iconColor)}"></span></span>`
|
|
: '';
|
|
|
|
const healthBadge = c.health && c.health !== 'none' ? `
|
|
<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>
|
|
` : '';
|
|
|
|
const projectBadge = c.compose_project ? `
|
|
<span class="px-2 py-0.5 rounded text-xs bg-purple-500/20 text-purple-400">${this.escapeHtml(c.compose_project)}</span>
|
|
` : '';
|
|
|
|
const portLinks = this.renderPortLinks(c);
|
|
|
|
if (isCompact) {
|
|
return `
|
|
<div class="bg-gray-800/40 rounded-lg px-3 py-2 flex items-center gap-3 hover:bg-gray-800/60 transition-colors cursor-pointer"
|
|
onclick="containersPage.openDrawer('${c.host_id}', '${c.container_id}')"
|
|
role="listitem"
|
|
tabindex="0"
|
|
data-container-id="${c.container_id}">
|
|
${iconHtml}
|
|
<span class="w-2 h-2 rounded-full bg-${stateColors[c.state]}-500 flex-shrink-0"></span>
|
|
<span class="font-medium truncate flex-1 min-w-0">${this.escapeHtml(c.name)}</span>
|
|
<span class="text-xs text-gray-500 truncate max-w-[120px]">${this.escapeHtml(c.host_name)}</span>
|
|
<span class="text-xs text-gray-500 truncate max-w-[150px]">${this.escapeHtml(c.image || '—')}</span>
|
|
<div class="flex items-center gap-1 flex-shrink-0">
|
|
<button data-fav-key="${favKey}" onclick="event.stopPropagation(); containersPage.toggleFavorite('${c.host_id}','${c.container_id}')" class="p-1.5 hover:bg-gray-700 rounded transition-colors ${favColorClass}" title="${favTitle}" aria-pressed="false">
|
|
<i class="${favIconClass} fa-star text-sm"></i>
|
|
</button>
|
|
<button onclick="event.stopPropagation(); dashboard.showEditContainerModal('${c.host_id}','${c.container_id}','${this.escapeHtml(c.name)}')" class="p-1.5 hover:bg-gray-700 rounded transition-colors text-blue-400" title="Modifier">
|
|
<i class="fas fa-pen text-sm"></i>
|
|
</button>
|
|
${this.renderQuickActions(c)}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
return `
|
|
<div class="bg-gray-800/40 rounded-lg p-4 hover:bg-gray-800/60 transition-colors cursor-pointer"
|
|
onclick="containersPage.openDrawer('${c.host_id}', '${c.container_id}')"
|
|
role="listitem"
|
|
tabindex="0"
|
|
data-container-id="${c.container_id}">
|
|
<div class="flex items-start justify-between gap-4">
|
|
<div class="flex-1 min-w-0">
|
|
<div class="flex items-center gap-2 flex-wrap mb-2">
|
|
${iconHtml}
|
|
<span class="w-2.5 h-2.5 rounded-full bg-${stateColors[c.state]}-500"></span>
|
|
<span class="font-semibold">${this.escapeHtml(c.name)}</span>
|
|
${projectBadge}
|
|
${healthBadge}
|
|
${portLinks}
|
|
</div>
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-1 text-sm">
|
|
<div class="text-gray-400 truncate">
|
|
<i class="fas fa-server mr-1 text-purple-400"></i>${this.escapeHtml(c.host_name)}
|
|
</div>
|
|
<div class="text-gray-500 truncate font-mono text-xs">
|
|
<i class="fas fa-layer-group mr-1 text-blue-400"></i>${this.escapeHtml(c.image || '—')}
|
|
</div>
|
|
</div>
|
|
<div class="text-xs text-gray-500 mt-1">${c.status || c.state}</div>
|
|
</div>
|
|
<div class="flex items-center gap-1 flex-shrink-0">
|
|
<button data-fav-key="${favKey}" onclick="event.stopPropagation(); containersPage.toggleFavorite('${c.host_id}','${c.container_id}')" class="p-1.5 hover:bg-gray-700 rounded transition-colors ${favColorClass}" title="${favTitle}" aria-pressed="false">
|
|
<i class="${favIconClass} fa-star text-sm"></i>
|
|
</button>
|
|
<button onclick="event.stopPropagation(); dashboard.showEditContainerModal('${c.host_id}','${c.container_id}','${this.escapeHtml(c.name)}')" class="p-1.5 hover:bg-gray-700 rounded transition-colors text-blue-400" title="Modifier">
|
|
<i class="fas fa-pen text-sm"></i>
|
|
</button>
|
|
${this.renderQuickActions(c)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
},
|
|
|
|
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]) => `
|
|
<div class="glass-card overflow-hidden mb-4">
|
|
<div class="bg-gray-800/60 px-4 py-3 flex items-center justify-between border-b border-gray-700">
|
|
<div class="flex items-center gap-3">
|
|
<i class="fas fa-server text-purple-400"></i>
|
|
<span class="font-semibold">${this.escapeHtml(group.host_name)}</span>
|
|
<span class="text-xs text-gray-500">${this.escapeHtml(group.host_ip)}</span>
|
|
</div>
|
|
<span class="text-sm text-gray-400">${group.containers.length} container(s)</span>
|
|
</div>
|
|
<div class="divide-y divide-gray-700/50">
|
|
${group.containers.map(c => this.renderContainerRow(c)).join('')}
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
},
|
|
|
|
renderQuickActions(c) {
|
|
const isRunning = c.state === 'running';
|
|
|
|
return `
|
|
${!isRunning ? `
|
|
<button onclick="event.stopPropagation(); containersPage.containerAction('${c.host_id}', '${c.container_id}', 'start')"
|
|
class="p-1.5 hover:bg-gray-700 rounded transition-colors text-green-400" title="Démarrer">
|
|
<i class="fas fa-play text-sm"></i>
|
|
</button>
|
|
` : `
|
|
<button onclick="event.stopPropagation(); containersPage.containerAction('${c.host_id}', '${c.container_id}', 'stop')"
|
|
class="p-1.5 hover:bg-gray-700 rounded transition-colors text-red-400" title="Arrêter">
|
|
<i class="fas fa-stop text-sm"></i>
|
|
</button>
|
|
`}
|
|
<button onclick="event.stopPropagation(); containersPage.containerAction('${c.host_id}', '${c.container_id}', 'restart')"
|
|
class="p-1.5 hover:bg-gray-700 rounded transition-colors text-yellow-400" title="Redémarrer">
|
|
<i class="fas fa-redo text-sm"></i>
|
|
</button>
|
|
<button onclick="event.stopPropagation(); containersPage.openDrawer('${c.host_id}', '${c.container_id}', 'logs')"
|
|
class="p-1.5 hover:bg-gray-700 rounded transition-colors text-blue-400" title="Logs">
|
|
<i class="fas fa-file-alt text-sm"></i>
|
|
</button>
|
|
`;
|
|
},
|
|
|
|
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(`
|
|
<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">
|
|
<i class="fas fa-external-link-alt mr-1"></i>${hostPort}
|
|
</a>
|
|
`);
|
|
}
|
|
|
|
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 = `
|
|
<div class="glass-card p-8 text-center">
|
|
<div class="loading-spinner mx-auto mb-4"></div>
|
|
<p class="text-gray-400">Chargement des containers...</p>
|
|
</div>
|
|
`;
|
|
}
|
|
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 => `
|
|
<span class="inline-flex items-center gap-1 px-2 py-1 bg-purple-500/20 text-purple-300 rounded text-sm">
|
|
${f.label}
|
|
<button onclick="containersPage.clearFilter('${f.key}')" class="hover:text-white ml-1">
|
|
<i class="fas fa-times text-xs"></i>
|
|
</button>
|
|
</span>
|
|
`).join('') + `
|
|
<button onclick="containersPage.clearAllFilters()" class="text-sm text-gray-400 hover:text-white ml-2">
|
|
Effacer tout
|
|
</button>
|
|
`;
|
|
},
|
|
|
|
populateHostFilter() {
|
|
const select = document.getElementById('containers-filter-host');
|
|
if (!select) return;
|
|
|
|
// Keep first option
|
|
select.innerHTML = '<option value="">Tous les hosts</option>';
|
|
|
|
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
|
|
? `<span class="w-6 h-6 rounded flex items-center justify-center border border-gray-700" style="${bgStyle}"><span class="iconify text-base" data-icon="${this.escapeHtml(iconKey)}" style="color:${this.escapeHtml(iconColor)}"></span></span>`
|
|
: '';
|
|
}
|
|
|
|
// 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) || '<span class="text-gray-500">Aucun port exposé</span>';
|
|
} else {
|
|
portsEl.innerHTML = '<span class="text-gray-500">Aucun port exposé</span>';
|
|
}
|
|
|
|
// 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]) => `
|
|
<span class="px-2 py-0.5 bg-gray-700 rounded text-gray-300" title="${this.escapeHtml(k)}=${this.escapeHtml(v)}">
|
|
${this.escapeHtml(k.split('.').pop())}
|
|
</span>
|
|
`).join('');
|
|
} else {
|
|
labelsEl.innerHTML = '<span class="text-gray-500">Aucun label</span>';
|
|
}
|
|
|
|
// 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;
|