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
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:
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
@@ -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
@@ -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],
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user