Add comprehensive Docker containers management page with filtering, search, bulk actions, and enhanced container control with state verification
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
+59
-5
@@ -307,8 +307,7 @@ const dockerSection = {
|
||||
container.innerHTML = '<div class="loading-spinner mx-auto"></div>';
|
||||
|
||||
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 = '<p class="text-gray-400 text-center py-4">Aucun container</p>';
|
||||
@@ -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 =
|
||||
`<i class="fas fa-file-alt mr-2 text-green-400"></i>${containerName} - Logs`;
|
||||
|
||||
+379
-26
@@ -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 @@
|
||||
<i class="fas fa-sliders-h"></i>
|
||||
<span>Configuration</span>
|
||||
</button>
|
||||
<button type="button" data-page="help" class="mobile-nav-link" onclick="mobileNavigateTo('help'); return false;">
|
||||
<i class="fas fa-question-circle"></i>
|
||||
<span>Aide</span>
|
||||
</button>
|
||||
</nav>
|
||||
<div class="p-4 border-t border-gray-700 space-y-3">
|
||||
<button type="button" id="mobile-theme-toggle" onclick="toggleTheme()" class="w-full flex items-center justify-center gap-2 p-3 bg-gray-800 rounded-lg hover:bg-gray-700 transition-colors">
|
||||
@@ -2941,18 +2983,10 @@
|
||||
<i class="fab fa-docker"></i>
|
||||
<span class="sr-only">Docker</span>
|
||||
</a>
|
||||
<a href="#" data-page="alerts" class="nav-link text-gray-300 hover:text-white transition-colors" title="Alertes" aria-label="Alertes">
|
||||
<i class="fas fa-bell"></i>
|
||||
<span class="sr-only">Alertes</span>
|
||||
</a>
|
||||
<a href="#" data-page="configuration" class="nav-link text-gray-300 hover:text-white transition-colors" title="Configuration" aria-label="Configuration">
|
||||
<i class="fas fa-sliders-h"></i>
|
||||
<span class="sr-only">Configuration</span>
|
||||
</a>
|
||||
<a href="#" data-page="help" class="nav-link text-gray-300 hover:text-white transition-colors" title="Aide" aria-label="Aide">
|
||||
<i class="fas fa-question-circle"></i>
|
||||
<span class="sr-only">Aide</span>
|
||||
</a>
|
||||
<button id="theme-toggle" class="p-2 rounded-lg bg-gray-800 hover:bg-gray-700 transition-colors touch-target">
|
||||
<i class="fas fa-moon text-gray-300"></i>
|
||||
</button>
|
||||
@@ -2977,6 +3011,10 @@
|
||||
<i class="fas fa-sliders-h text-gray-300"></i>
|
||||
<span>Configuration</span>
|
||||
</button>
|
||||
<button type="button" onclick="navigateTo('help')" class="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-200 hover:bg-gray-700 rounded-lg transition-colors">
|
||||
<i class="fas fa-question-circle text-gray-300"></i>
|
||||
<span>Centre d'aide</span>
|
||||
</button>
|
||||
<button onclick="handleLogout()" class="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-400 hover:bg-gray-700 rounded-lg transition-colors">
|
||||
<i class="fas fa-sign-out-alt"></i>
|
||||
<span>Déconnexion</span>
|
||||
@@ -3710,9 +3748,18 @@
|
||||
<div class="text-2xl font-bold text-purple-400" id="docker-stat-hosts">0</div>
|
||||
<div class="text-gray-400 text-sm">Hosts Docker</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card metric-card-clickable cursor-pointer hover:border-green-500/50 focus:outline-none focus:ring-2 focus:ring-green-500/50"
|
||||
onclick="navigateTo('docker-containers')"
|
||||
onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();navigateTo('docker-containers');}"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
aria-label="Voir tous les containers"
|
||||
title="Cliquer pour voir tous les containers">
|
||||
<div class="text-2xl font-bold text-green-400" id="docker-stat-containers">0</div>
|
||||
<div class="text-gray-400 text-sm">Containers</div>
|
||||
<div class="text-gray-400 text-sm flex items-center justify-center gap-1">
|
||||
Containers
|
||||
<i class="fas fa-external-link-alt text-xs opacity-50"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="text-2xl font-bold text-blue-400" id="docker-stat-images">0</div>
|
||||
@@ -3821,6 +3868,307 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== PAGE: DOCKER CONTAINERS (ALL HOSTS) ==================== -->
|
||||
<section id="page-docker-containers" class="page-section">
|
||||
<div class="pt-20 sm:pt-24 pb-8 sm:pb-16 min-h-screen bg-gradient-to-b from-gray-900 to-black">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button onclick="navigateTo('docker')" class="p-2 hover:bg-gray-800 rounded-lg transition-colors" title="Retour aux Docker Hosts">
|
||||
<i class="fas fa-arrow-left text-gray-400"></i>
|
||||
</button>
|
||||
<h2 class="text-2xl sm:text-3xl font-bold gradient-text">
|
||||
<i class="fas fa-box mr-2"></i>Containers
|
||||
</h2>
|
||||
</div>
|
||||
<p class="text-gray-400 mt-1 ml-11">Tous les containers de vos Docker hosts</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm text-gray-500">
|
||||
<span id="containers-last-update">—</span>
|
||||
<button onclick="containersPage.refresh()" class="p-2 hover:bg-gray-800 rounded-lg transition-colors" title="Actualiser">
|
||||
<i class="fas fa-sync" id="containers-refresh-icon"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Summary -->
|
||||
<div class="grid grid-cols-2 sm:grid-cols-5 gap-3 mb-6">
|
||||
<div class="bg-gray-800/50 rounded-lg p-3 text-center">
|
||||
<div class="text-xl font-bold text-white" id="containers-total">0</div>
|
||||
<div class="text-xs text-gray-400">Total</div>
|
||||
</div>
|
||||
<div class="bg-gray-800/50 rounded-lg p-3 text-center">
|
||||
<div class="text-xl font-bold text-green-400" id="containers-running">0</div>
|
||||
<div class="text-xs text-gray-400">Running</div>
|
||||
</div>
|
||||
<div class="bg-gray-800/50 rounded-lg p-3 text-center">
|
||||
<div class="text-xl font-bold text-red-400" id="containers-stopped">0</div>
|
||||
<div class="text-xs text-gray-400">Stopped</div>
|
||||
</div>
|
||||
<div class="bg-gray-800/50 rounded-lg p-3 text-center">
|
||||
<div class="text-xl font-bold text-yellow-400" id="containers-paused">0</div>
|
||||
<div class="text-xs text-gray-400">Paused</div>
|
||||
</div>
|
||||
<div class="bg-gray-800/50 rounded-lg p-3 text-center">
|
||||
<div class="text-xl font-bold text-purple-400" id="containers-hosts-count">0</div>
|
||||
<div class="text-xs text-gray-400">Hosts</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="glass-card p-4 mb-4 sticky top-16 z-30">
|
||||
<div class="flex flex-col lg:flex-row gap-4">
|
||||
<!-- Search -->
|
||||
<div class="flex-1 relative">
|
||||
<i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-gray-500"></i>
|
||||
<input type="text" id="containers-search"
|
||||
placeholder="Rechercher... (/ pour focus, ex: host:dev status:running)"
|
||||
class="w-full pl-10 pr-10 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-purple-500"
|
||||
aria-label="Rechercher des containers">
|
||||
<button id="containers-search-clear" class="hidden absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<!-- Status Filter -->
|
||||
<select id="containers-filter-status" class="px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-purple-500" aria-label="Filtrer par statut">
|
||||
<option value="">Tous les statuts</option>
|
||||
<option value="running">Running</option>
|
||||
<option value="exited">Exited</option>
|
||||
<option value="paused">Paused</option>
|
||||
<option value="restarting">Restarting</option>
|
||||
<option value="created">Created</option>
|
||||
<option value="dead">Dead</option>
|
||||
</select>
|
||||
|
||||
<!-- Host Filter -->
|
||||
<select id="containers-filter-host" class="px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-purple-500" aria-label="Filtrer par host">
|
||||
<option value="">Tous les hosts</option>
|
||||
</select>
|
||||
|
||||
<!-- Health Filter -->
|
||||
<select id="containers-filter-health" class="px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-purple-500" aria-label="Filtrer par santé">
|
||||
<option value="">Toute santé</option>
|
||||
<option value="healthy">Healthy</option>
|
||||
<option value="unhealthy">Unhealthy</option>
|
||||
<option value="starting">Starting</option>
|
||||
<option value="none">No healthcheck</option>
|
||||
</select>
|
||||
|
||||
<!-- Sort -->
|
||||
<select id="containers-sort" class="px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-purple-500" aria-label="Trier par">
|
||||
<option value="name-asc">Nom A-Z</option>
|
||||
<option value="name-desc">Nom Z-A</option>
|
||||
<option value="host-asc">Host A-Z</option>
|
||||
<option value="status-asc">Statut</option>
|
||||
<option value="updated-desc">Récent</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- View Toggle -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button id="containers-view-comfortable" class="p-2 bg-purple-600 rounded-lg transition-colors" title="Vue confortable" aria-pressed="true">
|
||||
<i class="fas fa-th-large"></i>
|
||||
</button>
|
||||
<button id="containers-view-compact" class="p-2 bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors" title="Vue compacte" aria-pressed="false">
|
||||
<i class="fas fa-list"></i>
|
||||
</button>
|
||||
<button id="containers-view-grouped" class="p-2 bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors" title="Grouper par host" aria-pressed="false">
|
||||
<i class="fas fa-layer-group"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Filters Tags -->
|
||||
<div id="containers-active-filters" class="hidden flex flex-wrap gap-2 mt-3 pt-3 border-t border-gray-700">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Actions Bar (hidden by default) -->
|
||||
<div id="containers-bulk-actions" class="hidden glass-card p-3 mb-4 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<input type="checkbox" id="containers-select-all" class="w-4 h-4 rounded" aria-label="Sélectionner tout">
|
||||
<span class="text-sm text-gray-400"><span id="containers-selected-count">0</span> sélectionné(s)</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="containersPage.bulkAction('start')" class="px-3 py-1.5 bg-green-600/20 text-green-400 hover:bg-green-600/30 rounded text-sm transition-colors">
|
||||
<i class="fas fa-play mr-1"></i>Start
|
||||
</button>
|
||||
<button onclick="containersPage.bulkAction('stop')" class="px-3 py-1.5 bg-red-600/20 text-red-400 hover:bg-red-600/30 rounded text-sm transition-colors">
|
||||
<i class="fas fa-stop mr-1"></i>Stop
|
||||
</button>
|
||||
<button onclick="containersPage.bulkAction('restart')" class="px-3 py-1.5 bg-yellow-600/20 text-yellow-400 hover:bg-yellow-600/30 rounded text-sm transition-colors">
|
||||
<i class="fas fa-redo mr-1"></i>Restart
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Containers List -->
|
||||
<div id="containers-list" class="space-y-2" role="list" aria-label="Liste des containers">
|
||||
<!-- Loading state -->
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div id="containers-empty" class="hidden glass-card p-8 text-center">
|
||||
<i class="fas fa-box-open text-4xl text-gray-600 mb-4"></i>
|
||||
<p class="text-gray-400 mb-2">Aucun container trouvé</p>
|
||||
<p class="text-gray-500 text-sm">Modifiez vos filtres ou collectez des données Docker</p>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div id="containers-error" class="hidden glass-card p-8 text-center border border-red-500/30">
|
||||
<i class="fas fa-exclamation-triangle text-4xl text-red-400 mb-4"></i>
|
||||
<p class="text-red-400 mb-2">Erreur lors du chargement</p>
|
||||
<p class="text-gray-500 text-sm mb-4" id="containers-error-message"></p>
|
||||
<button onclick="containersPage.refresh()" class="px-4 py-2 bg-red-600/20 text-red-400 hover:bg-red-600/30 rounded-lg transition-colors">
|
||||
<i class="fas fa-redo mr-2"></i>Réessayer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div id="containers-pagination" class="hidden flex items-center justify-between mt-6">
|
||||
<div class="text-sm text-gray-500">
|
||||
Affichage <span id="containers-showing-start">1</span>-<span id="containers-showing-end">50</span> sur <span id="containers-showing-total">0</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<select id="containers-per-page" class="px-2 py-1 bg-gray-800 border border-gray-700 rounded text-sm">
|
||||
<option value="25">25</option>
|
||||
<option value="50" selected>50</option>
|
||||
<option value="100">100</option>
|
||||
<option value="200">200</option>
|
||||
</select>
|
||||
<span class="text-sm text-gray-500">par page</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- END PAGE: DOCKER CONTAINERS -->
|
||||
|
||||
<!-- Container Detail Drawer -->
|
||||
<div id="container-drawer" class="fixed inset-y-0 right-0 w-full sm:w-[500px] lg:w-[600px] bg-gray-900 border-l border-gray-700 transform translate-x-full transition-transform duration-300 z-50 flex flex-col" role="dialog" aria-labelledby="drawer-title" aria-modal="true">
|
||||
<div class="flex items-center justify-between p-4 border-b border-gray-700">
|
||||
<h3 id="drawer-title" class="text-lg font-semibold truncate flex items-center gap-2">
|
||||
<span id="drawer-container-state" class="w-3 h-3 rounded-full bg-gray-500"></span>
|
||||
<span id="drawer-container-name">Container</span>
|
||||
</h3>
|
||||
<button onclick="containersPage.closeDrawer()" class="p-2 hover:bg-gray-800 rounded-lg transition-colors" aria-label="Fermer">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Drawer Tabs -->
|
||||
<div class="flex border-b border-gray-700">
|
||||
<button class="drawer-tab active px-4 py-3 text-sm font-medium" data-tab="overview">
|
||||
<i class="fas fa-info-circle mr-2"></i>Overview
|
||||
</button>
|
||||
<button class="drawer-tab px-4 py-3 text-sm font-medium text-gray-400" data-tab="logs">
|
||||
<i class="fas fa-file-alt mr-2"></i>Logs
|
||||
</button>
|
||||
<button class="drawer-tab px-4 py-3 text-sm font-medium text-gray-400" data-tab="inspect">
|
||||
<i class="fas fa-code mr-2"></i>Inspect
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Drawer Content -->
|
||||
<div class="flex-1 overflow-auto">
|
||||
<!-- Overview Tab -->
|
||||
<div id="drawer-tab-overview" class="drawer-tab-content p-4 space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="bg-gray-800/50 p-3 rounded-lg">
|
||||
<div class="text-xs text-gray-500 mb-1">Host</div>
|
||||
<div class="font-medium" id="drawer-host-name">—</div>
|
||||
<div class="text-xs text-gray-500" id="drawer-host-ip">—</div>
|
||||
</div>
|
||||
<div class="bg-gray-800/50 p-3 rounded-lg">
|
||||
<div class="text-xs text-gray-500 mb-1">Status</div>
|
||||
<div class="font-medium" id="drawer-status">—</div>
|
||||
<div class="text-xs text-gray-500" id="drawer-health">—</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-800/50 p-3 rounded-lg">
|
||||
<div class="text-xs text-gray-500 mb-1">Image</div>
|
||||
<div class="font-mono text-sm break-all" id="drawer-image">—</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-800/50 p-3 rounded-lg">
|
||||
<div class="text-xs text-gray-500 mb-1">Ports</div>
|
||||
<div id="drawer-ports" class="flex flex-wrap gap-2">—</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-800/50 p-3 rounded-lg">
|
||||
<div class="text-xs text-gray-500 mb-1">Labels</div>
|
||||
<div id="drawer-labels" class="flex flex-wrap gap-2 text-xs">—</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-800/50 p-3 rounded-lg">
|
||||
<div class="text-xs text-gray-500 mb-1">Container ID</div>
|
||||
<div class="font-mono text-sm break-all" id="drawer-container-id">—</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex flex-wrap gap-2 pt-4 border-t border-gray-700">
|
||||
<button onclick="containersPage.drawerAction('start')" id="drawer-btn-start" class="flex-1 px-4 py-2 bg-green-600/20 text-green-400 hover:bg-green-600/30 rounded-lg transition-colors">
|
||||
<i class="fas fa-play mr-2"></i>Start
|
||||
</button>
|
||||
<button onclick="containersPage.drawerAction('stop')" id="drawer-btn-stop" class="flex-1 px-4 py-2 bg-red-600/20 text-red-400 hover:bg-red-600/30 rounded-lg transition-colors">
|
||||
<i class="fas fa-stop mr-2"></i>Stop
|
||||
</button>
|
||||
<button onclick="containersPage.drawerAction('restart')" class="flex-1 px-4 py-2 bg-yellow-600/20 text-yellow-400 hover:bg-yellow-600/30 rounded-lg transition-colors">
|
||||
<i class="fas fa-redo mr-2"></i>Restart
|
||||
</button>
|
||||
<button onclick="containersPage.drawerAction('redeploy')" class="px-4 py-2 bg-purple-600/20 text-purple-400 hover:bg-purple-600/30 rounded-lg transition-colors" title="Pull latest image and recreate">
|
||||
<i class="fas fa-rocket"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logs Tab -->
|
||||
<div id="drawer-tab-logs" class="drawer-tab-content hidden p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<select id="drawer-logs-tail" class="px-2 py-1 bg-gray-800 border border-gray-700 rounded text-sm">
|
||||
<option value="100">100 lignes</option>
|
||||
<option value="200" selected>200 lignes</option>
|
||||
<option value="500">500 lignes</option>
|
||||
<option value="1000">1000 lignes</option>
|
||||
</select>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-400">
|
||||
<input type="checkbox" id="drawer-logs-timestamps" class="rounded">
|
||||
Timestamps
|
||||
</label>
|
||||
</div>
|
||||
<button onclick="containersPage.loadLogs()" class="px-3 py-1 bg-gray-700 hover:bg-gray-600 rounded text-sm transition-colors">
|
||||
<i class="fas fa-sync mr-1"></i>Actualiser
|
||||
</button>
|
||||
</div>
|
||||
<pre id="drawer-logs-content" class="bg-black/50 p-4 rounded-lg text-xs font-mono text-gray-300 whitespace-pre-wrap overflow-x-auto max-h-[60vh]">Chargement...</pre>
|
||||
</div>
|
||||
|
||||
<!-- Inspect Tab -->
|
||||
<div id="drawer-tab-inspect" class="drawer-tab-content hidden p-4">
|
||||
<div class="flex justify-end mb-3">
|
||||
<button onclick="containersPage.copyInspect()" class="px-3 py-1 bg-gray-700 hover:bg-gray-600 rounded text-sm transition-colors">
|
||||
<i class="fas fa-copy mr-1"></i>Copier JSON
|
||||
</button>
|
||||
</div>
|
||||
<pre id="drawer-inspect-content" class="bg-black/50 p-4 rounded-lg text-xs font-mono text-gray-300 whitespace-pre-wrap overflow-x-auto max-h-[60vh]">Chargement...</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Drawer Backdrop -->
|
||||
<div id="container-drawer-backdrop" class="fixed inset-0 bg-black/50 z-40 hidden" onclick="containersPage.closeDrawer()"></div>
|
||||
|
||||
<section id="page-configuration" class="page-section">
|
||||
<div class="pt-20 sm:pt-24 pb-8 sm:pb-16 min-h-screen bg-gradient-to-b from-gray-900 to-black">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6">
|
||||
@@ -3902,24 +4250,26 @@
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4 sm:mb-6">
|
||||
<h3 class="text-lg sm:text-xl font-semibold">Logs Récentes</h3>
|
||||
<div class="flex flex-col sm:flex-row gap-2 sm:gap-3">
|
||||
<div class="flex-1 sm:flex-none">
|
||||
<input id="logs-search" type="search" placeholder="Rechercher…" class="w-full sm:w-64 px-3 sm:px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm" />
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button type="button" class="flex-1 sm:flex-none px-3 sm:px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg hover:bg-gray-700 transition-colors text-sm touch-target" onclick="dashboard.setLogsView('server')">
|
||||
<i class="fas fa-terminal mr-1 sm:mr-2"></i>
|
||||
<span class="hidden sm:inline">Console</span>
|
||||
<button type="button" class="flex-1 sm:flex-none px-3 sm:px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg hover:bg-gray-700 transition-colors text-sm touch-target" onclick="dashboard.setLogsView('server')" title="Console" aria-label="Console">
|
||||
<i class="fas fa-terminal"></i>
|
||||
</button>
|
||||
<button type="button" class="flex-1 sm:flex-none px-3 sm:px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg hover:bg-gray-700 transition-colors text-sm touch-target" onclick="dashboard.setLogsView('db')">
|
||||
<i class="fas fa-database mr-1 sm:mr-2"></i>
|
||||
<span class="hidden sm:inline">BD</span>
|
||||
<button type="button" class="flex-1 sm:flex-none px-3 sm:px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg hover:bg-gray-700 transition-colors text-sm touch-target" onclick="dashboard.setLogsView('db')" title="BD" aria-label="BD">
|
||||
<i class="fas fa-database"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex gap-2 sm:gap-3">
|
||||
<button type="button" class="flex-1 sm:flex-none px-3 sm:px-4 py-2 bg-gray-700 rounded-lg hover:bg-gray-600 transition-colors text-sm touch-target" onclick="dashboard.clearLogs()">
|
||||
<i class="fas fa-trash mr-1 sm:mr-2"></i>
|
||||
<span class="hidden sm:inline">Effacer</span>
|
||||
<button type="button" class="flex-1 sm:flex-none px-3 sm:px-4 py-2 bg-gray-700 rounded-lg hover:bg-gray-600 transition-colors text-sm touch-target" onclick="dashboard.refreshSystemLogs()" title="Mettre à jour" aria-label="Mettre à jour">
|
||||
<i class="fas fa-sync"></i>
|
||||
</button>
|
||||
<button type="button" class="flex-1 sm:flex-none px-3 sm:px-4 py-2 bg-gray-700 rounded-lg hover:bg-gray-600 transition-colors text-sm touch-target" onclick="dashboard.exportLogs()">
|
||||
<i class="fas fa-download mr-1 sm:mr-2"></i>
|
||||
<span class="hidden sm:inline">Exporter</span>
|
||||
<button type="button" class="flex-1 sm:flex-none px-3 sm:px-4 py-2 bg-gray-700 rounded-lg hover:bg-gray-600 transition-colors text-sm touch-target" onclick="dashboard.clearLogs()" title="Effacer" aria-label="Effacer">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
<button type="button" class="flex-1 sm:flex-none px-3 sm:px-4 py-2 bg-gray-700 rounded-lg hover:bg-gray-600 transition-colors text-sm touch-target" onclick="dashboard.exportLogs()" title="Exporter" aria-label="Exporter">
|
||||
<i class="fas fa-download"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -4690,5 +5040,8 @@
|
||||
|
||||
<!-- Docker Section JavaScript -->
|
||||
<script src="/static/docker_section.js"></script>
|
||||
|
||||
<!-- Containers Page JavaScript -->
|
||||
<script src="/static/containers_page.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+54
-3
@@ -14,6 +14,7 @@ class DashboardManager {
|
||||
this.logs = [];
|
||||
this.serverLogs = [];
|
||||
this.logsView = 'server';
|
||||
this.currentLogsSearch = '';
|
||||
this.ansibleHosts = [];
|
||||
this.ansibleGroups = [];
|
||||
this.playbooks = [];
|
||||
@@ -957,6 +958,27 @@ class DashboardManager {
|
||||
this.loadLogs();
|
||||
}
|
||||
}
|
||||
|
||||
async refreshSystemLogs() {
|
||||
try {
|
||||
if (this.logsView === 'server') {
|
||||
await this.loadServerLogs();
|
||||
} else {
|
||||
await this.loadLogs();
|
||||
}
|
||||
this.scrollLogsToTop();
|
||||
this.showNotification('Logs mis à jour', 'success');
|
||||
} catch (error) {
|
||||
console.error('Erreur rafraîchissement logs:', error);
|
||||
this.showNotification('Erreur lors de la mise à jour des logs', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
scrollLogsToTop() {
|
||||
const container = document.getElementById('logs-container');
|
||||
if (!container) return;
|
||||
container.scrollTop = 0;
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Theme toggle (desktop + mobile)
|
||||
@@ -971,6 +993,15 @@ class DashboardManager {
|
||||
// Expose for inline handlers (e.g. mobile nav button)
|
||||
window.toggleTheme = onToggleTheme;
|
||||
|
||||
const logsSearchInput = document.getElementById('logs-search');
|
||||
if (logsSearchInput) {
|
||||
logsSearchInput.addEventListener('input', (e) => {
|
||||
const val = e.target && typeof e.target.value === 'string' ? e.target.value : '';
|
||||
this.currentLogsSearch = val;
|
||||
this.renderLogs();
|
||||
});
|
||||
}
|
||||
|
||||
// Metrics collection configuration (Configuration page)
|
||||
const configPage = document.getElementById('page-configuration');
|
||||
if (configPage) {
|
||||
@@ -6644,6 +6675,18 @@ class DashboardManager {
|
||||
container.innerHTML = '';
|
||||
|
||||
const items = this.logsView === 'db' ? (this.logs || []) : (this.serverLogs || []);
|
||||
const q = (this.currentLogsSearch || '').trim().toLowerCase();
|
||||
const filteredItems = q
|
||||
? items.filter(log => {
|
||||
const timestamp = (log.timestamp || '').toString();
|
||||
const level = (log.level || '').toString();
|
||||
const message = (log.message || '').toString();
|
||||
const source = (log.source || '').toString();
|
||||
const host = (log.host || '').toString();
|
||||
const haystack = `${timestamp} ${level} ${message} ${source} ${host}`.toLowerCase();
|
||||
return haystack.includes(q);
|
||||
})
|
||||
: items;
|
||||
|
||||
if (items.length === 0) {
|
||||
container.innerHTML = `
|
||||
@@ -6654,7 +6697,16 @@ class DashboardManager {
|
||||
return;
|
||||
}
|
||||
|
||||
items.forEach(log => {
|
||||
if (filteredItems.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="text-center text-gray-500 py-4">
|
||||
<p>Aucun résultat</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
filteredItems.forEach(log => {
|
||||
const levelColor = this.getLogLevelColor(log.level);
|
||||
// Formater le timestamp depuis l'API (format ISO)
|
||||
const timestamp = log.timestamp
|
||||
@@ -6676,8 +6728,7 @@ class DashboardManager {
|
||||
container.appendChild(logEntry);
|
||||
});
|
||||
|
||||
// Auto-scroll to bottom
|
||||
container.scrollTop = container.scrollHeight;
|
||||
this.scrollLogsToTop();
|
||||
}
|
||||
|
||||
getStatusBadge(status) {
|
||||
|
||||
@@ -16,6 +16,8 @@ from app.schemas.docker import (
|
||||
EnableDockerRequest,
|
||||
DockerContainerListResponse,
|
||||
DockerContainerResponse,
|
||||
DockerContainerAggregatedResponse,
|
||||
DockerContainerAggregatedListResponse,
|
||||
DockerImageListResponse,
|
||||
DockerImageExtendedListResponse,
|
||||
DockerImageExtendedResponse,
|
||||
@@ -130,6 +132,68 @@ async def collect_all_docker_hosts(
|
||||
|
||||
# === Containers ===
|
||||
|
||||
@router.get("/containers", response_model=DockerContainerAggregatedListResponse)
|
||||
async def get_all_containers(
|
||||
state: Optional[str] = Query(None, description="Filter by state (running, exited, paused, etc.)"),
|
||||
compose_project: Optional[str] = Query(None, description="Filter by compose project"),
|
||||
health: Optional[str] = Query(None, description="Filter by health status"),
|
||||
host_id: Optional[str] = Query(None, description="Filter by specific host"),
|
||||
api_key_valid: bool = Depends(verify_api_key),
|
||||
db_session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all containers across all Docker hosts."""
|
||||
container_repo = DockerContainerRepository(db_session)
|
||||
host_repo = HostRepository(db_session)
|
||||
|
||||
# Get host filter if provided
|
||||
host_ids = [host_id] if host_id else None
|
||||
|
||||
# Fetch containers
|
||||
containers = await container_repo.list_all(
|
||||
state=state,
|
||||
compose_project=compose_project,
|
||||
health=health,
|
||||
host_ids=host_ids
|
||||
)
|
||||
counts = await container_repo.count_all()
|
||||
|
||||
# Build host lookup for names/IPs
|
||||
hosts = await host_repo.list_all()
|
||||
host_map = {h.id: {"name": h.name, "ip": h.ip_address} for h in hosts}
|
||||
|
||||
# Build response with host info
|
||||
container_responses = []
|
||||
for c in containers:
|
||||
host_info = host_map.get(c.host_id, {"name": "Unknown", "ip": "Unknown"})
|
||||
container_responses.append(DockerContainerAggregatedResponse(
|
||||
id=c.id,
|
||||
host_id=c.host_id,
|
||||
host_name=host_info["name"],
|
||||
host_ip=host_info["ip"],
|
||||
container_id=c.container_id,
|
||||
name=c.name,
|
||||
image=c.image,
|
||||
state=c.state,
|
||||
status=c.status,
|
||||
health=c.health,
|
||||
ports=c.ports,
|
||||
labels=c.labels,
|
||||
compose_project=c.compose_project,
|
||||
created_at=c.created_at,
|
||||
last_update_at=c.last_update_at
|
||||
))
|
||||
|
||||
return DockerContainerAggregatedListResponse(
|
||||
containers=container_responses,
|
||||
total=counts.get("total", 0),
|
||||
running=counts.get("running", 0),
|
||||
stopped=counts.get("stopped", 0),
|
||||
paused=counts.get("paused", 0),
|
||||
hosts_count=counts.get("hosts_count", 0),
|
||||
last_update=counts.get("last_update")
|
||||
)
|
||||
|
||||
|
||||
@router.get("/hosts/{host_id}/containers", response_model=DockerContainerListResponse)
|
||||
async def get_containers(
|
||||
host_id: str,
|
||||
|
||||
@@ -273,3 +273,28 @@ class DockerImageExtendedListResponse(BaseModel):
|
||||
total: int
|
||||
total_size: int
|
||||
unused_count: int = 0
|
||||
|
||||
|
||||
# === Aggregated Containers Response ===
|
||||
|
||||
class DockerContainerAggregatedResponse(DockerContainerBase):
|
||||
"""Response schema for Docker container with host info (for aggregated view)."""
|
||||
id: int
|
||||
host_id: str
|
||||
host_name: str
|
||||
host_ip: str
|
||||
created_at: Optional[datetime] = None
|
||||
last_update_at: Optional[datetime] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class DockerContainerAggregatedListResponse(BaseModel):
|
||||
"""Response for listing all containers across all hosts."""
|
||||
containers: List[DockerContainerAggregatedResponse]
|
||||
total: int
|
||||
running: int
|
||||
stopped: int
|
||||
paused: int
|
||||
hosts_count: int
|
||||
last_update: Optional[datetime] = None
|
||||
|
||||
Reference in New Issue
Block a user