feat: Implement container customization and add Portainer installation/removal playbooks.
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

This commit is contained in:
2025-12-27 11:02:24 -05:00
parent 661d005fc7
commit 8affa0f8b7
30 changed files with 1767 additions and 22 deletions
+125
View File
@@ -0,0 +1,125 @@
const containerCustomizationsManager = {
apiBase: window.location.origin,
_initialized: false,
_loadPromise: null,
byKey: new Map(),
listeners: new Set(),
getAuthHeaders() {
const token = localStorage.getItem('accessToken');
const apiKey = localStorage.getItem('apiKey');
const headers = {
'Content-Type': 'application/json'
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
} else if (apiKey) {
headers['X-API-Key'] = apiKey;
}
return headers;
},
async fetchAPI(endpoint, options = {}) {
const response = await fetch(`${this.apiBase}${endpoint}`, {
...options,
headers: {
...this.getAuthHeaders(),
...(options.headers || {})
}
});
if (!response.ok) {
if (response.status === 401) {
if (window.dashboard?.logout) {
window.dashboard.logout();
}
return null;
}
throw new Error(`API Error: ${response.status}`);
}
if (response.status === 204) return null;
return response.json();
},
_key(hostId, containerId) {
return `${hostId}::${containerId}`;
},
setData(items) {
this.byKey.clear();
(items || []).forEach((c) => {
if (!c?.host_id || !c?.container_id) return;
this.byKey.set(this._key(c.host_id, c.container_id), c);
});
this._emitChange();
},
async load() {
const res = await this.fetchAPI('/api/docker/container-customizations');
this.setData(res?.customizations || []);
},
async ensureInit() {
if (this._initialized) return;
if (this._loadPromise) {
await this._loadPromise;
return;
}
this._loadPromise = (async () => {
try {
await this.load();
} finally {
this._initialized = true;
}
})();
await this._loadPromise;
},
onChange(callback) {
this.listeners.add(callback);
return () => this.listeners.delete(callback);
},
_emitChange() {
for (const cb of this.listeners) {
try {
cb();
} catch (e) {
console.error('containerCustomizationsManager listener error:', e);
}
}
},
get(hostId, containerId) {
return this.byKey.get(this._key(hostId, containerId)) || null;
},
async upsert(hostId, containerId, { icon_key = null, icon_color = null, bg_color = null }) {
const payload = { icon_key, icon_color, bg_color };
const res = await this.fetchAPI(`/api/docker/container-customizations/${encodeURIComponent(hostId)}/${encodeURIComponent(containerId)}`, {
method: 'PUT',
body: JSON.stringify(payload)
});
if (!res) return null;
this.byKey.set(this._key(hostId, containerId), res);
this._emitChange();
return res;
},
async remove(hostId, containerId) {
await this.fetchAPI(`/api/docker/container-customizations/${encodeURIComponent(hostId)}/${encodeURIComponent(containerId)}`, {
method: 'DELETE'
});
this.byKey.delete(this._key(hostId, containerId));
this._emitChange();
}
};
window.containerCustomizationsManager = containerCustomizationsManager;
+29
View File
@@ -453,6 +453,15 @@ const containersPage = {
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">
@@ -473,6 +482,7 @@ const containersPage = {
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>
@@ -481,6 +491,9 @@ const containersPage = {
<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>
@@ -496,6 +509,7 @@ const containersPage = {
<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}
@@ -516,6 +530,9 @@ const containersPage = {
<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>
@@ -936,6 +953,18 @@ const containersPage = {
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;
+71
View File
@@ -0,0 +1,71 @@
from __future__ import annotations
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.container_customization import ContainerCustomization
class ContainerCustomizationRepository:
def __init__(self, session: AsyncSession):
self.session = session
async def list_by_user(self, user_id: Optional[int]) -> list[ContainerCustomization]:
stmt = select(ContainerCustomization)
if user_id is None:
stmt = stmt.where(ContainerCustomization.user_id.is_(None))
else:
stmt = stmt.where(ContainerCustomization.user_id == user_id)
stmt = stmt.order_by(ContainerCustomization.host_id.asc(), ContainerCustomization.container_id.asc())
result = await self.session.execute(stmt)
return list(result.scalars().all())
async def get_for_user(
self, *, user_id: Optional[int], host_id: str, container_id: str
) -> Optional[ContainerCustomization]:
stmt = select(ContainerCustomization).where(
ContainerCustomization.host_id == host_id,
ContainerCustomization.container_id == container_id,
)
if user_id is None:
stmt = stmt.where(ContainerCustomization.user_id.is_(None))
else:
stmt = stmt.where(ContainerCustomization.user_id == user_id)
result = await self.session.execute(stmt.limit(1))
return result.scalar_one_or_none()
async def upsert(
self,
*,
user_id: Optional[int],
host_id: str,
container_id: str,
icon_key: Optional[str],
icon_color: Optional[str],
bg_color: Optional[str],
) -> ContainerCustomization:
existing = await self.get_for_user(user_id=user_id, host_id=host_id, container_id=container_id)
if existing:
existing.icon_key = icon_key
existing.icon_color = icon_color
existing.bg_color = bg_color
await self.session.flush()
return existing
item = ContainerCustomization(
user_id=user_id,
host_id=host_id,
container_id=container_id,
icon_key=icon_key,
icon_color=icon_color,
bg_color=bg_color,
)
self.session.add(item)
await self.session.flush()
return item
async def delete(self, item: ContainerCustomization) -> None:
await self.session.delete(item)
await self.session.flush()
+14
View File
@@ -365,6 +365,15 @@ const dockerSection = {
dead: 'red'
};
const stateColor = stateColors[c.state] || 'gray';
const custom = c.customization || window.containerCustomizationsManager?.get(hostId, c.container_id);
const iconKey = custom?.icon_key || '';
const iconColor = custom?.icon_color || '#9ca3af';
const bgColor = custom?.bg_color || '';
const bgStyle = bgColor ? `background:${dashboard.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="${dashboard.escapeHtml(iconKey)}" style="color:${dashboard.escapeHtml(iconColor)}"></span></span>`
: '';
const healthBadge = c.health ? `
<span class="px-2 py-0.5 rounded text-xs bg-${c.health === 'healthy' ? 'green' : 'red'}-500/20 text-${c.health === 'healthy' ? 'green' : 'red'}-400">
@@ -385,6 +394,7 @@ const dockerSection = {
<div class="bg-gray-800/50 rounded-lg p-3 flex items-center justify-between gap-4">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
${iconHtml}
<span class="w-2 h-2 rounded-full bg-${stateColor}-500"></span>
<span class="font-medium truncate">${c.name}</span>
${c.compose_project ? `<span class="px-2 py-0.5 rounded text-xs bg-purple-500/20 text-purple-400">${c.compose_project}</span>` : ''}
@@ -400,6 +410,10 @@ const dockerSection = {
class="p-2 hover:bg-gray-700 rounded transition-colors ${favColorClass}" title="${favTitle}" aria-pressed="false">
<i class="${favIconClass} fa-star"></i>
</button>
<button onclick="event.stopPropagation(); dashboard.showEditContainerModal('${hostId}','${c.container_id}','${dashboard.escapeHtml(c.name)}')"
class="p-2 hover:bg-gray-700 rounded transition-colors text-blue-400" title="Modifier">
<i class="fas fa-pen"></i>
</button>
${c.state !== 'running' ? `
<button onclick="dockerSection.startContainer('${hostId}', '${c.container_id}')"
class="p-2 hover:bg-gray-700 rounded transition-colors text-green-400" title="Démarrer">
+2
View File
@@ -5018,6 +5018,7 @@
<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-icon"></span>
<span id="drawer-container-state" class="w-3 h-3 rounded-full bg-gray-500"></span>
<span id="drawer-container-name">Container</span>
</h3>
@@ -6003,6 +6004,7 @@
<script src="/static/icon_picker.js"></script>
<script src="/static/favorites_manager.js"></script>
<script src="/static/container_customizations_manager.js"></script>
<!-- Docker Section JavaScript -->
<script src="/static/docker_section.js"></script>
+320 -17
View File
@@ -123,6 +123,261 @@ class DashboardManager {
}
renderContainerCustomizationColorPicker(initialColor) {
const defaultColor = '#7c3aed';
const color = (initialColor && initialColor.startsWith('#') && initialColor.length >= 4) ? initialColor : '';
const pickerValue = color || defaultColor;
const palette = [
'#7c3aed', '#3b82f6', '#06b6d4', '#10b981', '#84cc16',
'#f59e0b', '#f97316', '#ef4444', '#ec4899', '#a855f7'
];
return `
<div class="space-y-2">
<div class="flex items-center gap-2">
<input id="container-custom-icon-color-picker" type="color" value="${this.escapeHtml(pickerValue)}" oninput="dashboard.syncContainerCustomIconColorFromPicker()" class="h-10 w-12 p-1 bg-gray-800 border border-gray-700 rounded-lg" />
<input id="container-custom-icon-color-text" type="text" placeholder="${defaultColor}" maxlength="20" value="${this.escapeHtml(color)}" oninput="dashboard.syncContainerCustomIconColorFromText()" class="flex-1 px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-purple-500" />
<button type="button" onclick="dashboard.clearContainerCustomIconColor()" class="px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors text-xs">Défaut</button>
</div>
<div class="flex flex-wrap gap-2">
${palette.map(c => `
<button type="button" onclick="dashboard.setContainerCustomIconColor('${c}')" class="w-7 h-7 rounded border border-gray-600 hover:border-gray-400 transition-colors" style="background:${c}" title="${c}"></button>
`).join('')}
</div>
</div>
`;
}
renderContainerCustomizationBgColorPicker(initialColor) {
const defaultColor = '';
const color = (initialColor && initialColor.startsWith('#') && initialColor.length >= 4) ? initialColor : '';
const pickerValue = color || '#111827';
const palette = [
'', '#111827', '#1f2937', '#0b1220', '#2a2a2a',
'#0f172a', '#1e1b4b', '#3b0764', '#450a0a', '#052e16'
];
return `
<div class="space-y-2">
<div class="flex items-center gap-2">
<input id="container-custom-bg-color-picker" type="color" value="${this.escapeHtml(pickerValue)}" oninput="dashboard.syncContainerCustomBgColorFromPicker()" class="h-10 w-12 p-1 bg-gray-800 border border-gray-700 rounded-lg" />
<input id="container-custom-bg-color-text" type="text" placeholder="${defaultColor}" maxlength="20" value="${this.escapeHtml(color)}" oninput="dashboard.syncContainerCustomBgColorFromText()" class="flex-1 px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-purple-500" />
<button type="button" onclick="dashboard.clearContainerCustomBgColor()" class="px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors text-xs">Aucune</button>
</div>
<div class="flex flex-wrap gap-2">
${palette.map(c => {
const label = c || 'Aucune';
const style = c ? `background:${c}` : 'background:transparent';
return `<button type="button" onclick="dashboard.setContainerCustomBgColor('${c}')" class="w-7 h-7 rounded border border-gray-600 hover:border-gray-400 transition-colors" style="${style}" title="${label}"></button>`;
}).join('')}
</div>
</div>
`;
}
setContainerCustomIconColor(color) {
const text = document.getElementById('container-custom-icon-color-text');
const picker = document.getElementById('container-custom-icon-color-picker');
if (text) text.value = color;
if (picker) picker.value = color;
this.refreshContainerCustomIconPreview();
}
clearContainerCustomIconColor() {
const text = document.getElementById('container-custom-icon-color-text');
const picker = document.getElementById('container-custom-icon-color-picker');
if (text) text.value = '';
if (picker) picker.value = '#7c3aed';
this.refreshContainerCustomIconPreview();
}
syncContainerCustomIconColorFromPicker() {
const text = document.getElementById('container-custom-icon-color-text');
const picker = document.getElementById('container-custom-icon-color-picker');
if (!text || !picker) return;
text.value = picker.value;
this.refreshContainerCustomIconPreview();
}
syncContainerCustomIconColorFromText() {
const text = document.getElementById('container-custom-icon-color-text');
const picker = document.getElementById('container-custom-icon-color-picker');
if (!text || !picker) return;
const v = String(text.value || '').trim();
const isHex = /^#[0-9a-fA-F]{6}$/.test(v) || /^#[0-9a-fA-F]{3}$/.test(v);
if (isHex) picker.value = v;
this.refreshContainerCustomIconPreview();
}
setContainerCustomBgColor(color) {
const text = document.getElementById('container-custom-bg-color-text');
const picker = document.getElementById('container-custom-bg-color-picker');
if (text) text.value = color;
if (picker) picker.value = color || '#111827';
this.refreshContainerCustomIconPreview();
}
clearContainerCustomBgColor() {
const text = document.getElementById('container-custom-bg-color-text');
const picker = document.getElementById('container-custom-bg-color-picker');
if (text) text.value = '';
if (picker) picker.value = '#111827';
this.refreshContainerCustomIconPreview();
}
syncContainerCustomBgColorFromPicker() {
const text = document.getElementById('container-custom-bg-color-text');
const picker = document.getElementById('container-custom-bg-color-picker');
if (!text || !picker) return;
text.value = picker.value;
this.refreshContainerCustomIconPreview();
}
syncContainerCustomBgColorFromText() {
const text = document.getElementById('container-custom-bg-color-text');
const picker = document.getElementById('container-custom-bg-color-picker');
if (!text || !picker) return;
const v = String(text.value || '').trim();
const isHex = /^#[0-9a-fA-F]{6}$/.test(v) || /^#[0-9a-fA-F]{3}$/.test(v);
if (isHex) picker.value = v;
this.refreshContainerCustomIconPreview();
}
renderContainerCustomizationIconPicker(initialIconKey) {
const iconKey = initialIconKey || '';
const iconColor = document.getElementById('container-custom-icon-color-text')?.value || '#7c3aed';
const bgColor = document.getElementById('container-custom-bg-color-text')?.value || '';
const bgStyle = bgColor ? `background:${this.escapeHtml(bgColor)};` : '';
return `
<input type="hidden" id="container-custom-icon-key" value="${this.escapeHtml(iconKey)}" />
<div class="flex items-center gap-3">
<div id="container-custom-icon-preview" class="w-12 h-12 rounded-lg border border-gray-700 bg-gray-800 flex items-center justify-center" style="${bgStyle}">
${iconKey ? `<span class="iconify text-2xl" data-icon="${this.escapeHtml(iconKey)}" style="color:${this.escapeHtml(iconColor)}"></span>` : '<span class="iconify text-2xl text-gray-600" data-icon="lucide:box"></span>'}
</div>
<button type="button" onclick="dashboard.openContainerIconPicker()" class="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors text-sm">
<i class="fas fa-icons mr-2"></i>Choisir une icône
</button>
${iconKey ? `<button type="button" onclick="dashboard.clearContainerCustomIcon()" class="px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors text-xs" title="Retirer l'icône"><i class="fas fa-times"></i></button>` : ''}
</div>
`;
}
openContainerIconPicker() {
const currentIconKey = document.getElementById('container-custom-icon-key')?.value || '';
if (window.iconPicker) {
window.iconPicker.show((selectedIcon) => {
this.setContainerCustomIcon(selectedIcon);
}, currentIconKey);
}
}
setContainerCustomIcon(iconKey) {
const input = document.getElementById('container-custom-icon-key');
if (input) input.value = iconKey;
this.refreshContainerCustomIconPreview();
}
clearContainerCustomIcon() {
const input = document.getElementById('container-custom-icon-key');
if (input) input.value = '';
this.refreshContainerCustomIconPreview();
}
refreshContainerCustomIconPreview() {
const preview = document.getElementById('container-custom-icon-preview');
const iconKey = document.getElementById('container-custom-icon-key')?.value || '';
const iconColor = document.getElementById('container-custom-icon-color-text')?.value?.trim() || '#7c3aed';
const bgColor = document.getElementById('container-custom-bg-color-text')?.value?.trim() || '';
if (!preview) return;
preview.setAttribute('style', bgColor ? `background:${this.escapeHtml(bgColor)};` : '');
preview.innerHTML = iconKey
? `<span class="iconify text-2xl" data-icon="${this.escapeHtml(iconKey)}" style="color:${this.escapeHtml(iconColor)}"></span>`
: '<span class="iconify text-2xl text-gray-600" data-icon="lucide:box"></span>';
}
async showEditContainerModal(hostId, containerId, containerName = '') {
const cm = window.containerCustomizationsManager;
if (!cm) {
this.showNotification('Customisation indisponible', 'error');
return;
}
try {
await cm.ensureInit();
const existing = cm.get(hostId, containerId);
const iconKey = existing?.icon_key || '';
const iconColor = existing?.icon_color || '';
const bgColor = existing?.bg_color || '';
const title = containerName ? `Modifier le container: ${this.escapeHtml(containerName)}` : 'Modifier le container';
this.showModal(title, `
<form onsubmit="dashboard.saveContainerCustomization(event, '${this.escapeHtml(hostId)}', '${this.escapeHtml(containerId)}')" class="space-y-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="block text-sm text-gray-400 mb-2">Couleur de l'icône</label>
${this.renderContainerCustomizationColorPicker(this.escapeHtml(iconColor))}
</div>
<div>
<label class="block text-sm text-gray-400 mb-2">Fond</label>
${this.renderContainerCustomizationBgColorPicker(this.escapeHtml(bgColor))}
</div>
</div>
<div>
<label class="block text-sm text-gray-400 mb-2">Icône</label>
${this.renderContainerCustomizationIconPicker(this.escapeHtml(iconKey))}
</div>
<div class="flex justify-between gap-3 pt-2 border-t border-gray-700">
<button type="button" onclick="dashboard.resetContainerCustomization('${this.escapeHtml(hostId)}','${this.escapeHtml(containerId)}')" class="px-4 py-2 bg-gray-700 rounded-lg hover:bg-gray-600 transition-colors text-sm">
Réinitialiser
</button>
<div class="flex gap-3">
<button type="button" onclick="dashboard.closeModal()" class="px-4 py-2 bg-gray-600 rounded-lg hover:bg-gray-500 transition-colors text-sm">
Annuler
</button>
<button type="submit" class="px-4 py-2 bg-blue-600 rounded-lg hover:bg-blue-500 transition-colors text-sm">
Enregistrer
</button>
</div>
</div>
</form>
`);
} catch (e) {
this.showNotification(`Erreur: ${e.message}`, 'error');
}
}
async saveContainerCustomization(event, hostId, containerId) {
event.preventDefault();
const cm = window.containerCustomizationsManager;
if (!cm) return;
const iconKey = document.getElementById('container-custom-icon-key')?.value?.trim() || null;
const iconColor = document.getElementById('container-custom-icon-color-text')?.value?.trim() || null;
const bgColor = document.getElementById('container-custom-bg-color-text')?.value?.trim() || null;
try {
await cm.upsert(hostId, containerId, { icon_key: iconKey, icon_color: iconColor, bg_color: bgColor });
this.closeModal();
this.showNotification('Container mis à jour', 'success');
if (window.containersPage?.render) window.containersPage.render();
if (window.dockerSection?.currentHostId) window.dockerSection.loadContainers(window.dockerSection.currentHostId);
this.renderFavoriteContainersWidget();
} catch (e) {
this.showNotification(`Erreur: ${e.message}`, 'error');
}
}
async resetContainerCustomization(hostId, containerId) {
const cm = window.containerCustomizationsManager;
if (!cm) return;
try {
await cm.remove(hostId, containerId);
this.closeModal();
this.showNotification('Customisation supprimée', 'success');
if (window.containersPage?.render) window.containersPage.render();
if (window.dockerSection?.currentHostId) window.dockerSection.loadContainers(window.dockerSection.currentHostId);
this.renderFavoriteContainersWidget();
} catch (e) {
this.showNotification(`Erreur: ${e.message}`, 'error');
}
}
renderFavoriteGroupColorPicker(initialColor) {
const defaultColor = '#7c3aed';
const color = (initialColor && initialColor.startsWith('#') && initialColor.length >= 4) ? initialColor : '';
@@ -253,6 +508,18 @@ class DashboardManager {
} catch (e) {
}
}
if (window.containerCustomizationsManager) {
try {
await window.containerCustomizationsManager.ensureInit();
window.containerCustomizationsManager.onChange(() => {
this.renderFavoriteContainersWidget();
if (window.containersPage?.render) window.containersPage.render();
if (window.dockerSection?.currentHostId) window.dockerSection.loadContainers(window.dockerSection.currentHostId);
});
} catch (e) {
}
}
await this.loadAppConfig();
this.setDebugBadgeVisible(this.isDebugEnabled());
@@ -3018,7 +3285,12 @@ class DashboardManager {
const checkMode = checkModeInput?.checked || false;
this.closeModal();
this.showLoading();
if (this._playbookLaunchInFlight) {
this.showNotification('Une exécution de playbook est déjà en cours de lancement', 'info');
return;
}
this._playbookLaunchInFlight = true;
this.showNotification('Lancement du playbook en arrière-plan...', 'info');
try {
const result = await this.apiCall('/api/ansible/execute', {
@@ -3031,16 +3303,12 @@ class DashboardManager {
})
});
this.hideLoading();
this.showNotification(`Playbook "${playbook}" lancé sur ${hostName} (tâche ${result.task_id})`, 'success');
// Aller sur l'onglet Tâches et rafraîchir
this.setActiveNav('tasks');
await this.loadTaskLogsWithFilters();
} catch (error) {
this.hideLoading();
this.showNotification(`Erreur: ${error.message}`, 'error');
} finally {
this._playbookLaunchInFlight = false;
}
}
@@ -3811,7 +4079,12 @@ class DashboardManager {
async runPlaybookOnTarget(playbook, target) {
this.closeModal();
this.showLoading();
if (this._playbookLaunchInFlight) {
this.showNotification('Une exécution de playbook est déjà en cours de lancement', 'info');
return;
}
this._playbookLaunchInFlight = true;
this.showNotification('Lancement du playbook en arrière-plan...', 'info');
try {
const result = await this.apiCall('/api/ansible/execute', {
@@ -3824,16 +4097,12 @@ class DashboardManager {
})
});
this.hideLoading();
this.showNotification(`Playbook ${playbook} lancé sur ${target} (tâche ${result.task_id})`, 'success');
// Aller sur l'onglet Tâches et rafraîchir
this.setActiveNav('tasks');
await this.loadTaskLogsWithFilters();
} catch (error) {
this.hideLoading();
this.showNotification(`Erreur: ${error.message}`, 'error');
} finally {
this._playbookLaunchInFlight = false;
}
}
@@ -5410,12 +5679,28 @@ class DashboardManager {
}
// Extraire les données importantes de l'output
const toPreviewString = (value) => {
if (value === null || value === undefined) return '';
if (typeof value === 'string') return value;
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') return String(value);
try {
return JSON.stringify(value);
} catch (e) {
return String(value);
}
};
let outputPreview = '';
if (result.parsedOutput) {
const po = result.parsedOutput;
if (po.msg) outputPreview = po.msg;
else if (po.stdout) outputPreview = po.stdout.substring(0, 100);
else if (po.cmd) outputPreview = Array.isArray(po.cmd) ? po.cmd.join(' ') : po.cmd;
if (po.msg !== undefined) {
outputPreview = toPreviewString(po.msg);
} else if (po.stdout !== undefined) {
const stdoutStr = toPreviewString(po.stdout);
outputPreview = stdoutStr.substring(0, 100);
} else if (po.cmd !== undefined) {
outputPreview = toPreviewString(Array.isArray(po.cmd) ? po.cmd.join(' ') : po.cmd);
}
}
return `
@@ -9269,10 +9554,20 @@ class DashboardManager {
? `<span class="px-2 py-0.5 rounded text-xs bg-${dc.health === 'healthy' ? 'green' : 'red'}-500/20 text-${dc.health === 'healthy' ? 'green' : 'red'}-400">${this.escapeHtml(dc.health)}</span>`
: '';
const custom = window.containerCustomizationsManager?.get(dc.host_id, dc.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>`
: '';
return `
<div class="px-3 py-2 flex items-center justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 flex-wrap">
${iconHtml}
<span class="w-2 h-2 rounded-full bg-${dotColor}-500"></span>
<span class="font-medium text-sm truncate cursor-pointer hover:text-purple-400 transition-colors" onclick="dashboard.openContainerDrawerFromFavorites('${this.escapeHtml(dc.host_id)}','${this.escapeHtml(dc.container_id)}')" title="Voir les détails">${this.escapeHtml(dc.name)}</span>
${healthBadge}
@@ -9304,6 +9599,10 @@ class DashboardManager {
onclick="dashboard.showMoveFavoriteModal('${this.escapeHtml(dc.host_id)}','${this.escapeHtml(dc.container_id)}')">
<i class="fas fa-folder-open text-sm"></i>
</button>
<button class="p-1.5 hover:bg-gray-700 rounded transition-colors text-blue-400" title="Modifier"
onclick="dashboard.showEditContainerModal('${this.escapeHtml(dc.host_id)}','${this.escapeHtml(dc.container_id)}','${this.escapeHtml(dc.name)}')">
<i class="fas fa-pen text-sm"></i>
</button>
<button class="p-1.5 hover:bg-gray-700 rounded transition-colors text-purple-400" title="Retirer des favoris"
onclick="dashboard.removeFavoriteContainer(${favId})">
<i class="fas fa-star text-sm"></i>
@@ -12429,6 +12728,10 @@ window.showCreateScheduleModal = function(prefilledPlaybook = null) {
}
};
if (typeof window !== 'undefined') {
window.DashboardManager = DashboardManager;
}
// Export for testing (ESM/CommonJS compatible)
if (typeof module !== 'undefined' && module.exports) {
module.exports = { DashboardManager };
+2
View File
@@ -13,6 +13,7 @@ from .docker_container import DockerContainer
from .docker_image import DockerImage
from .docker_volume import DockerVolume
from .docker_alert import DockerAlert
from .container_customization import ContainerCustomization
from .favorite_group import FavoriteGroup
from .favorite_container import FavoriteContainer
from .terminal_session import TerminalSession
@@ -39,6 +40,7 @@ __all__ = [
"DockerImage",
"DockerVolume",
"DockerAlert",
"ContainerCustomization",
"FavoriteGroup",
"FavoriteContainer",
"TerminalSession",
+32
View File
@@ -0,0 +1,32 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional
from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func
from .database import Base
class ContainerCustomization(Base):
__tablename__ = "container_customizations"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True)
host_id: Mapped[str] = mapped_column(String, ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False)
container_id: Mapped[str] = mapped_column(String(64), nullable=False)
icon_key: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
icon_color: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
bg_color: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
__table_args__ = (
UniqueConstraint("user_id", "host_id", "container_id", name="uq_container_customizations_user_host_container"),
{"sqlite_autoincrement": True},
)
+40
View File
@@ -3,11 +3,14 @@ Uses SQLAlchemy 2.x async engine with SQLite + aiosqlite driver.
"""
from __future__ import annotations
import asyncio
import os
from pathlib import Path
from typing import AsyncGenerator
from urllib.parse import urlparse
from alembic import command
from alembic.config import Config
from sqlalchemy import event, MetaData
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import declarative_base
@@ -129,5 +132,42 @@ async def init_db() -> None:
favorite_container,
) # noqa: F401
def _to_sync_database_url(db_url: str) -> str:
return db_url.replace("sqlite+aiosqlite:", "sqlite:")
def _run_alembic_upgrade() -> None:
# Try multiple locations for alembic.ini (dev vs Docker)
alembic_ini_paths = [
ROOT_DIR / "alembic.ini", # Dev: /path/to/project/alembic.ini
Path("/alembic.ini"), # Docker: /alembic.ini
Path("/app/alembic.ini"), # Docker alternative
]
alembic_ini = None
for path in alembic_ini_paths:
if path.exists():
alembic_ini = path
print(f"[DB] Found alembic.ini at: {alembic_ini}")
break
if not alembic_ini:
print(f"[DB] alembic.ini not found in any of: {[str(p) for p in alembic_ini_paths]}")
return
try:
cfg = Config(str(alembic_ini))
cfg.set_main_option("sqlalchemy.url", _to_sync_database_url(DATABASE_URL))
print(f"[DB] Running Alembic upgrade to head...")
command.upgrade(cfg, "head")
print(f"[DB] Alembic upgrade completed successfully")
except Exception as e:
print(f"[DB] Alembic upgrade failed: {e}")
raise
try:
await asyncio.to_thread(_run_alembic_upgrade)
except Exception as e:
print(f"[DB] Exception during Alembic migration: {e}")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
+107 -1
View File
@@ -4,7 +4,14 @@ from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.dependencies import get_db, verify_api_key, get_current_user, require_admin
from app.core.dependencies import (
get_db,
verify_api_key,
get_current_user,
get_current_user_optional,
require_admin,
)
from app.crud.container_customization import ContainerCustomizationRepository
from app.crud.docker_container import DockerContainerRepository
from app.crud.docker_image import DockerImageRepository
from app.crud.docker_volume import DockerVolumeRepository
@@ -18,6 +25,9 @@ from app.schemas.docker import (
DockerContainerResponse,
DockerContainerAggregatedResponse,
DockerContainerAggregatedListResponse,
ContainerCustomizationListResponse,
ContainerCustomizationOut,
ContainerCustomizationUpsert,
DockerImageListResponse,
DockerImageExtendedListResponse,
DockerImageExtendedResponse,
@@ -40,6 +50,72 @@ from app.services.docker_alerts import docker_alerts_service
router = APIRouter()
def _resolve_user_id(user: dict) -> Optional[int]:
# For API-key auth, keep settings in a shared (user_id NULL) namespace.
if user.get("type") == "api_key":
return None
uid = user.get("user_id")
if uid is None:
return None
try:
return int(uid)
except Exception:
return None
@router.get("/container-customizations", response_model=ContainerCustomizationListResponse)
async def list_container_customizations(
user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db),
):
user_id = _resolve_user_id(user)
repo = ContainerCustomizationRepository(db_session)
items = await repo.list_by_user(user_id)
return ContainerCustomizationListResponse(customizations=[ContainerCustomizationOut.model_validate(x) for x in items])
@router.put(
"/container-customizations/{host_id}/{container_id}",
response_model=ContainerCustomizationOut,
)
async def upsert_container_customization(
host_id: str,
container_id: str,
payload: ContainerCustomizationUpsert,
user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db),
):
user_id = _resolve_user_id(user)
repo = ContainerCustomizationRepository(db_session)
item = await repo.upsert(
user_id=user_id,
host_id=host_id,
container_id=container_id,
icon_key=payload.icon_key,
icon_color=payload.icon_color,
bg_color=payload.bg_color,
)
await db_session.commit()
await db_session.refresh(item)
return ContainerCustomizationOut.model_validate(item)
@router.delete("/container-customizations/{host_id}/{container_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_container_customization(
host_id: str,
container_id: str,
user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db),
):
user_id = _resolve_user_id(user)
repo = ContainerCustomizationRepository(db_session)
item = await repo.get_for_user(user_id=user_id, host_id=host_id, container_id=container_id)
if not item:
raise HTTPException(status_code=404, detail="Customisation introuvable")
await repo.delete(item)
await db_session.commit()
# === Docker Hosts ===
@router.get("/hosts", response_model=DockerHostListResponse)
@@ -139,6 +215,7 @@ async def get_all_containers(
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),
user: Optional[dict] = Depends(get_current_user_optional),
db_session: AsyncSession = Depends(get_db),
):
"""List all containers across all Docker hosts."""
@@ -161,6 +238,19 @@ async def get_all_containers(
hosts = await host_repo.list_all()
host_map = {h.id: {"name": h.name, "ip": h.ip_address} for h in hosts}
# Load container customizations for current user (or shared for api_key)
customization_repo = ContainerCustomizationRepository(db_session)
cust_map: dict[tuple[str, str], ContainerCustomizationOut] = {}
try:
user_id = _resolve_user_id(user or {})
customizations = await customization_repo.list_by_user(user_id)
cust_map = {
(c.host_id, c.container_id): ContainerCustomizationOut.model_validate(c)
for c in (customizations or [])
}
except Exception:
cust_map = {}
# Build response with host info
container_responses = []
for c in containers:
@@ -179,6 +269,7 @@ async def get_all_containers(
ports=c.ports,
labels=c.labels,
compose_project=c.compose_project,
customization=cust_map.get((c.host_id, c.container_id)),
created_at=c.created_at,
last_update_at=c.last_update_at
))
@@ -200,6 +291,7 @@ async def get_containers(
state: Optional[str] = Query(None, description="Filter by state"),
compose_project: Optional[str] = Query(None, description="Filter by compose project"),
api_key_valid: bool = Depends(verify_api_key),
user: Optional[dict] = Depends(get_current_user_optional),
db_session: AsyncSession = Depends(get_db),
):
"""List containers for a host."""
@@ -207,6 +299,19 @@ async def get_containers(
containers = await container_repo.list_by_host(host_id, state=state, compose_project=compose_project)
counts = await container_repo.count_by_host(host_id)
customization_repo = ContainerCustomizationRepository(db_session)
cust_map: dict[tuple[str, str], ContainerCustomizationOut] = {}
try:
user_id = _resolve_user_id(user or {})
customizations = await customization_repo.list_by_user(user_id)
cust_map = {
(c.host_id, c.container_id): ContainerCustomizationOut.model_validate(c)
for c in (customizations or [])
if c.host_id == host_id
}
except Exception:
cust_map = {}
return DockerContainerListResponse(
containers=[DockerContainerResponse(
id=c.id,
@@ -220,6 +325,7 @@ async def get_containers(
ports=c.ports,
labels=c.labels,
compose_project=c.compose_project,
customization=cust_map.get((c.host_id, c.container_id)),
created_at=c.created_at,
last_update_at=c.last_update_at
) for c in containers],
+24
View File
@@ -7,6 +7,28 @@ from pydantic import BaseModel, Field, ConfigDict
# === Container Schemas ===
class ContainerCustomizationOut(BaseModel):
id: int
host_id: str
container_id: str
icon_key: Optional[str] = None
icon_color: Optional[str] = None
bg_color: Optional[str] = None
updated_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class ContainerCustomizationUpsert(BaseModel):
icon_key: Optional[str] = None
icon_color: Optional[str] = None
bg_color: Optional[str] = None
class ContainerCustomizationListResponse(BaseModel):
customizations: List[ContainerCustomizationOut]
class DockerContainerBase(BaseModel):
"""Base schema for Docker container."""
container_id: str = Field(..., description="Docker container ID")
@@ -28,6 +50,7 @@ class DockerContainerResponse(DockerContainerBase):
"""Response schema for Docker container."""
id: int
host_id: str
customization: Optional[ContainerCustomizationOut] = None
created_at: Optional[datetime] = None
last_update_at: Optional[datetime] = None
@@ -283,6 +306,7 @@ class DockerContainerAggregatedResponse(DockerContainerBase):
host_id: str
host_name: str
host_ip: str
customization: Optional[ContainerCustomizationOut] = None
created_at: Optional[datetime] = None
last_update_at: Optional[datetime] = None