diff --git a/alembic/versions/0019_add_container_customizations.py b/alembic/versions/0019_add_container_customizations.py new file mode 100644 index 0000000..40c2bd9 --- /dev/null +++ b/alembic/versions/0019_add_container_customizations.py @@ -0,0 +1,81 @@ +"""Add container_customizations table + +Revision ID: 0019_add_container_customizations +Revises: 0018 +Create Date: 2025-12-24 +""" + +from __future__ import annotations + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect + + +revision: str = '0019_add_container_customizations' +down_revision: Union[str, None] = '0018' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + insp = inspect(bind) + + def _table_exists(name: str) -> bool: + try: + return insp.has_table(name) + except Exception: + return name in insp.get_table_names() + + def _index_exists(table: str, index: str) -> bool: + try: + return any(i.get('name') == index for i in insp.get_indexes(table)) + except Exception: + return False + + if not _table_exists('container_customizations'): + op.create_table( + 'container_customizations', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('host_id', sa.String(), nullable=False), + sa.Column('container_id', sa.String(length=64), nullable=False), + sa.Column('icon_key', sa.String(length=100), nullable=True), + sa.Column('icon_color', sa.String(length=20), nullable=True), + sa.Column('bg_color', sa.String(length=20), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['host_id'], ['hosts.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + ) + + if _table_exists('container_customizations'): + if not _index_exists('container_customizations', 'ix_container_customizations_user_id'): + op.create_index('ix_container_customizations_user_id', 'container_customizations', ['user_id']) + if not _index_exists('container_customizations', 'ix_container_customizations_host_id'): + op.create_index('ix_container_customizations_host_id', 'container_customizations', ['host_id']) + if not _index_exists('container_customizations', 'ix_container_customizations_host_container'): + op.create_index( + 'ix_container_customizations_host_container', + 'container_customizations', + ['host_id', 'container_id'], + ) + if not _index_exists('container_customizations', 'uq_container_customizations_user_host_container'): + op.create_index( + 'uq_container_customizations_user_host_container', + 'container_customizations', + ['user_id', 'host_id', 'container_id'], + unique=True, + ) + + +def downgrade() -> None: + op.drop_index('uq_container_customizations_user_host_container', table_name='container_customizations') + op.drop_index('ix_container_customizations_host_container', table_name='container_customizations') + op.drop_index('ix_container_customizations_host_id', table_name='container_customizations') + op.drop_index('ix_container_customizations_user_id', table_name='container_customizations') + op.drop_table('container_customizations') diff --git a/ansible/playbooks/install_container_portainer.yml b/ansible/playbooks/install_container_portainer.yml new file mode 100644 index 0000000..aa42b7f --- /dev/null +++ b/ansible/playbooks/install_container_portainer.yml @@ -0,0 +1,67 @@ +--- +# Playbook pour l'installation et la configuration de Portainer CE +# Utilise l'approche basée sur les scripts init.sh et maj.sh du dépôt Git + +- name: "Déploiement de Portainer CE" + hosts: all + become: true + vars: + # Configuration du dépôt Git + portainer_repo_url: "https://git.dracodev.net/Dockers/portainer.git" + portainer_repo_dir: "{{ ansible_user_dir }}/dev/git/outils/dockers/portainer" + repo_user: "bruno" + repo_pwd: "chab30" + + # Option de mise à jour (true/false) + portainer_update: true + + tasks: + # Créer le répertoire parent + - name: "Créer le répertoire parent pour le dépôt Portainer" + file: + path: "{{ portainer_repo_dir | dirname }}" + state: directory + mode: '0755' + recurse: true + + # Cloner le dépôt Git + - name: "Cloner le dépôt Portainer" + git: + repo: "https://{{ repo_user }}:{{ repo_pwd }}@git.dracodev.net/Dockers/portainer.git" + dest: "{{ portainer_repo_dir }}" + version: main + update: yes + force: yes + + # Installation initiale + - name: "Exécuter le script d'initialisation Portainer" + command: "bash ./init.sh" + args: + chdir: "{{ portainer_repo_dir }}" + when: not portainer_update + + # Mise à jour + - name: "Exécuter le script de mise à jour Portainer" + command: "bash ./maj.sh" + args: + chdir: "{{ portainer_repo_dir }}" + when: portainer_update + + # Vérification finale + - name: "Vérifier que Portainer est en cours d'exécution" + command: "docker ps --filter name=portainer --format {% raw %}'table {{.Names}}\t{{.Status}}\t{{.Ports}}'{% endraw %}" + register: portainer_status + changed_when: false + + - name: "Afficher le statut de Portainer" + debug: + msg: "{{ portainer_status.stdout_lines }}" + + - name: "Message de succès" + debug: + msg: | + Portainer CE a été déployé avec succès ! + Le script maj.sh a configuré automatiquement le firewall (UFW/Firewalld/iptables). + Interface Web HTTPS : https://{{ ansible_default_ipv4.address }}:9443 + Interface Web HTTP : http://{{ ansible_default_ipv4.address }}:9000 + Edge Agent : {{ ansible_default_ipv4.address }}:8000 \ No newline at end of file diff --git a/ansible/playbooks/remove_container_portainer.yml b/ansible/playbooks/remove_container_portainer.yml new file mode 100644 index 0000000..fc0f5e2 --- /dev/null +++ b/ansible/playbooks/remove_container_portainer.yml @@ -0,0 +1,156 @@ +--- +# Playbook pour la suppression de Portainer CE +# Ce playbook annule les actions du playbook d'installation + +- name: "Suppression de Portainer CE" + hosts: all + become: true + vars: + # Configuration identique au playbook d'installation + portainer_repo_dir: "dev/git/outils/dockers/portainer" + portainer_data_dir: "/DOCKER_CONFIG/portainer" + + # Ports à fermer dans le firewall + firewall_ports: + - 8000 + - 9000 + - 9443 + + tasks: + # Arrêter et supprimer le conteneur Portainer + - name: "Vérifier si le conteneur Portainer existe" + command: "docker ps -a --filter name=portainer --format {% raw %}'{{.Names}}'{% endraw %}" + register: portainer_containers + changed_when: false + ignore_errors: true + + - name: "Arrêter le conteneur Portainer s'il est en cours d'exécution" + command: "docker stop portainer" + when: portainer_containers.stdout | length > 0 + ignore_errors: true + + - name: "Supprimer le conteneur Portainer" + command: "docker rm portainer" + when: portainer_containers.stdout | length > 0 + ignore_errors: true + + - name: "Vérifier si l'image Portainer existe" + command: "docker images -q portainer/portainer-ce:latest" + register: portainer_images + changed_when: false + failed_when: false + + - name: "Supprimer l'image Portainer (optionnel)" + command: "docker rmi portainer/portainer-ce:latest" + when: portainer_images.stdout | length > 0 + ignore_errors: true + + # Supprimer les règles firewall + # Support pour UFW (Ubuntu/Debian) + - name: "Détecter si UFW est installé" + command: "which ufw" + register: ufw_check + changed_when: false + failed_when: false + + - name: "Supprimer les règles UFW pour Portainer" + ufw: + rule: reject + port: "{{ item }}" + proto: tcp + delete: yes + loop: "{{ firewall_ports }}" + when: ufw_check.rc == 0 + ignore_errors: true + + - name: "Recharger UFW" + ufw: + state: reloaded + when: ufw_check.rc == 0 + + # Support pour Firewalld (CentOS/RHEL/Fedora) + - name: "Détecter si Firewalld est installé" + command: "which firewall-cmd" + register: firewalld_check + changed_when: false + failed_when: false + + - name: "Supprimer les règles Firewalld pour Portainer" + firewalld: + port: "{{ item }}/tcp" + permanent: true + state: disabled + loop: "{{ firewall_ports }}" + when: firewalld_check.rc == 0 + ignore_errors: true + + - name: "Recharger Firewalld" + command: "firewall-cmd --reload" + when: firewalld_check.rc == 0 + ignore_errors: true + + # Support pour iptables (fallback générique) + - name: "Détecter si iptables est disponible" + command: "which iptables" + register: iptables_check + changed_when: false + failed_when: false + + - name: "Vérifier si la règle iptables existe pour Portainer" + command: "iptables -C INPUT -p tcp --dport {{ item }} -j DROP" + register: iptables_rule_check + loop: "{{ firewall_ports }}" + when: iptables_check.rc == 0 and ufw_check.rc != 0 and firewalld_check.rc != 0 + changed_when: false + failed_when: false + + - name: "Supprimer les règles iptables pour Portainer" + command: "iptables -D INPUT -p tcp --dport {{ item.item }} -j DROP" + loop: "{{ iptables_rule_check.results | default([]) }}" + when: + - iptables_check.rc == 0 + - ufw_check.rc != 0 + - firewalld_check.rc != 0 + - item.rc == 0 + changed_when: true + failed_when: false + + # Supprimer les répertoires et fichiers + - name: "Supprimer le répertoire du dépôt Git Portainer" + file: + path: "{{ portainer_repo_dir }}" + state: absent + ignore_errors: true + + - name: "Supprimer le répertoire de données Portainer" + file: + path: "{{ portainer_data_dir }}" + state: absent + ignore_errors: true + + - name: "Supprimer le répertoire parent s'il est vide" + file: + path: "{{ portainer_repo_dir | dirname }}" + state: absent + ignore_errors: true + + # Vérification finale + - name: "Vérifier que Portainer a été complètement supprimé" + command: "docker ps -a --filter name=portainer --format {% raw %}'{{.Names}}'{% endraw %}" + register: final_check + changed_when: false + ignore_errors: true + + - name: "Message de confirmation de suppression" + debug: + msg: | + Portainer CE a été complètement supprimé ! + - Conteneur arrêté et supprimé + - Règles firewall supprimées (UFW/Firewalld/iptables) + - Répertoires de données supprimés + - Dépôt Git supprimé + {% if final_check.stdout | length == 0 %} + [SUCCESS] Suppression réussie : Aucun conteneur Portainer trouvé + {% else %} + [WARNING] Attention : Certains conteneurs Portainer peuvent encore exister + {% endif %} \ No newline at end of file diff --git a/app/container_customizations_manager.js b/app/container_customizations_manager.js new file mode 100644 index 0000000..5e4daa8 --- /dev/null +++ b/app/container_customizations_manager.js @@ -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; diff --git a/app/containers_page.js b/app/containers_page.js index 40084d0..0bd89a5 100644 --- a/app/containers_page.js +++ b/app/containers_page.js @@ -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 + ? `` + : ''; const healthBadge = c.health && c.health !== 'none' ? ` @@ -473,6 +482,7 @@ const containersPage = { role="listitem" tabindex="0" data-container-id="${c.container_id}"> + ${iconHtml} ${this.escapeHtml(c.name)} ${this.escapeHtml(c.host_name)} @@ -481,6 +491,9 @@ const containersPage = { + ${this.renderQuickActions(c)} @@ -496,6 +509,7 @@ const containersPage = {
+ ${iconHtml} ${this.escapeHtml(c.name)} ${projectBadge} @@ -516,6 +530,9 @@ const containersPage = { + ${this.renderQuickActions(c)}
@@ -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 + ? `` + : ''; + } // Overview document.getElementById('drawer-host-name').textContent = c.host_name; diff --git a/app/crud/container_customization.py b/app/crud/container_customization.py new file mode 100644 index 0000000..614eb37 --- /dev/null +++ b/app/crud/container_customization.py @@ -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() diff --git a/app/docker_section.js b/app/docker_section.js index 597531e..ea06909 100644 --- a/app/docker_section.js +++ b/app/docker_section.js @@ -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 + ? `` + : ''; const healthBadge = c.health ? ` @@ -385,6 +394,7 @@ const dockerSection = {
+ ${iconHtml} ${c.name} ${c.compose_project ? `${c.compose_project}` : ''} @@ -400,6 +410,10 @@ const dockerSection = { class="p-2 hover:bg-gray-700 rounded transition-colors ${favColorClass}" title="${favTitle}" aria-pressed="false"> + ${c.state !== 'running' ? ` +
+
+ ${palette.map(c => ` + + `).join('')} +
+
+ `; + } + + 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 ` +
+
+ + + +
+
+ ${palette.map(c => { + const label = c || 'Aucune'; + const style = c ? `background:${c}` : 'background:transparent'; + return ``; + }).join('')} +
+
+ `; + } + + 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 ` + +
+
+ ${iconKey ? `` : ''} +
+ + ${iconKey ? `` : ''} +
+ `; + } + + 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 + ? `` + : ''; + } + + 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, ` +
+
+
+ + ${this.renderContainerCustomizationColorPicker(this.escapeHtml(iconColor))} +
+
+ + ${this.renderContainerCustomizationBgColorPicker(this.escapeHtml(bgColor))} +
+
+
+ + ${this.renderContainerCustomizationIconPicker(this.escapeHtml(iconKey))} +
+
+ +
+ + +
+
+
+ `); + } 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 { ? `${this.escapeHtml(dc.health)}` : ''; + 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 + ? `` + : ''; + return `
+ ${iconHtml} ${this.escapeHtml(dc.name)} ${healthBadge} @@ -9304,6 +9599,10 @@ class DashboardManager { onclick="dashboard.showMoveFavoriteModal('${this.escapeHtml(dc.host_id)}','${this.escapeHtml(dc.container_id)}')"> +