feat: Implement container customization and add Portainer installation/removal playbooks.
Some checks failed
Tests / Backend Tests (Python) (3.10) (push) Has been cancelled
Tests / Backend Tests (Python) (3.11) (push) Has been cancelled
Tests / Backend Tests (Python) (3.12) (push) Has been cancelled
Tests / Frontend Tests (JS) (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / All Tests Passed (push) Has been cancelled

This commit is contained in:
Bruno Charest 2025-12-27 11:02:24 -05:00
parent 661d005fc7
commit 8affa0f8b7
30 changed files with 1767 additions and 22 deletions

View File

@ -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')

View File

@ -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

View File

@ -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 %}

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;

View File

@ -454,6 +454,15 @@ const containersPage = {
const favTitle = window.favoritesManager?.isFavorite(c.host_id, c.container_id) ? 'Retirer des favoris' : 'Ajouter aux favoris'; 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 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' ? ` 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"> <span class="px-2 py-0.5 rounded text-xs bg-${c.health === 'healthy' ? 'green' : 'red'}-500/20 text-${c.health === 'healthy' ? 'green' : 'red'}-400">
${c.health} ${c.health}
@ -473,6 +482,7 @@ const containersPage = {
role="listitem" role="listitem"
tabindex="0" tabindex="0"
data-container-id="${c.container_id}"> 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="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="font-medium truncate flex-1 min-w-0">${this.escapeHtml(c.name)}</span>
<span class="text-xs text-gray-500 truncate max-w-[120px]">${this.escapeHtml(c.host_name)}</span> <span class="text-xs text-gray-500 truncate max-w-[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"> <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> <i class="${favIconClass} fa-star text-sm"></i>
</button> </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)} ${this.renderQuickActions(c)}
</div> </div>
</div> </div>
@ -496,6 +509,7 @@ const containersPage = {
<div class="flex items-start justify-between gap-4"> <div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap mb-2"> <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="w-2.5 h-2.5 rounded-full bg-${stateColors[c.state]}-500"></span>
<span class="font-semibold">${this.escapeHtml(c.name)}</span> <span class="font-semibold">${this.escapeHtml(c.name)}</span>
${projectBadge} ${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"> <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> <i class="${favIconClass} fa-star text-sm"></i>
</button> </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)} ${this.renderQuickActions(c)}
</div> </div>
</div> </div>
@ -937,6 +954,18 @@ const containersPage = {
document.getElementById('drawer-container-state').className = document.getElementById('drawer-container-state').className =
`w-3 h-3 rounded-full ${stateColors[c.state] || 'bg-gray-500'}`; `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 // Overview
document.getElementById('drawer-host-name').textContent = c.host_name; document.getElementById('drawer-host-name').textContent = c.host_name;
document.getElementById('drawer-host-ip').textContent = c.host_ip; document.getElementById('drawer-host-ip').textContent = c.host_ip;

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()

View File

@ -366,6 +366,15 @@ const dockerSection = {
}; };
const stateColor = stateColors[c.state] || 'gray'; 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 ? ` 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"> <span class="px-2 py-0.5 rounded text-xs bg-${c.health === 'healthy' ? 'green' : 'red'}-500/20 text-${c.health === 'healthy' ? 'green' : 'red'}-400">
${c.health} ${c.health}
@ -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="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-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap"> <div class="flex items-center gap-2 flex-wrap">
${iconHtml}
<span class="w-2 h-2 rounded-full bg-${stateColor}-500"></span> <span class="w-2 h-2 rounded-full bg-${stateColor}-500"></span>
<span class="font-medium truncate">${c.name}</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>` : ''} ${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"> class="p-2 hover:bg-gray-700 rounded transition-colors ${favColorClass}" title="${favTitle}" aria-pressed="false">
<i class="${favIconClass} fa-star"></i> <i class="${favIconClass} fa-star"></i>
</button> </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' ? ` ${c.state !== 'running' ? `
<button onclick="dockerSection.startContainer('${hostId}', '${c.container_id}')" <button onclick="dockerSection.startContainer('${hostId}', '${c.container_id}')"
class="p-2 hover:bg-gray-700 rounded transition-colors text-green-400" title="Démarrer"> class="p-2 hover:bg-gray-700 rounded transition-colors text-green-400" title="Démarrer">

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 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"> <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"> <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-state" class="w-3 h-3 rounded-full bg-gray-500"></span>
<span id="drawer-container-name">Container</span> <span id="drawer-container-name">Container</span>
</h3> </h3>
@ -6003,6 +6004,7 @@
<script src="/static/icon_picker.js"></script> <script src="/static/icon_picker.js"></script>
<script src="/static/favorites_manager.js"></script> <script src="/static/favorites_manager.js"></script>
<script src="/static/container_customizations_manager.js"></script>
<!-- Docker Section JavaScript --> <!-- Docker Section JavaScript -->
<script src="/static/docker_section.js"></script> <script src="/static/docker_section.js"></script>

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) { renderFavoriteGroupColorPicker(initialColor) {
const defaultColor = '#7c3aed'; const defaultColor = '#7c3aed';
const color = (initialColor && initialColor.startsWith('#') && initialColor.length >= 4) ? initialColor : ''; const color = (initialColor && initialColor.startsWith('#') && initialColor.length >= 4) ? initialColor : '';
@ -254,6 +509,18 @@ class DashboardManager {
} }
} }
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(); await this.loadAppConfig();
this.setDebugBadgeVisible(this.isDebugEnabled()); this.setDebugBadgeVisible(this.isDebugEnabled());
@ -3018,7 +3285,12 @@ class DashboardManager {
const checkMode = checkModeInput?.checked || false; const checkMode = checkModeInput?.checked || false;
this.closeModal(); 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 { try {
const result = await this.apiCall('/api/ansible/execute', { 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'); 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) { } catch (error) {
this.hideLoading();
this.showNotification(`Erreur: ${error.message}`, 'error'); this.showNotification(`Erreur: ${error.message}`, 'error');
} finally {
this._playbookLaunchInFlight = false;
} }
} }
@ -3811,7 +4079,12 @@ class DashboardManager {
async runPlaybookOnTarget(playbook, target) { async runPlaybookOnTarget(playbook, target) {
this.closeModal(); 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 { try {
const result = await this.apiCall('/api/ansible/execute', { 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'); 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) { } catch (error) {
this.hideLoading();
this.showNotification(`Erreur: ${error.message}`, 'error'); this.showNotification(`Erreur: ${error.message}`, 'error');
} finally {
this._playbookLaunchInFlight = false;
} }
} }
@ -5410,12 +5679,28 @@ class DashboardManager {
} }
// Extraire les données importantes de l'output // 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 = ''; let outputPreview = '';
if (result.parsedOutput) { if (result.parsedOutput) {
const po = result.parsedOutput; const po = result.parsedOutput;
if (po.msg) outputPreview = po.msg; if (po.msg !== undefined) {
else if (po.stdout) outputPreview = po.stdout.substring(0, 100); outputPreview = toPreviewString(po.msg);
else if (po.cmd) outputPreview = Array.isArray(po.cmd) ? po.cmd.join(' ') : po.cmd; } 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 ` 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>` ? `<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 ` return `
<div class="px-3 py-2 flex items-center justify-between gap-3"> <div class="px-3 py-2 flex items-center justify-between gap-3">
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="flex items-center gap-2 flex-wrap"> <div class="flex items-center gap-2 flex-wrap">
${iconHtml}
<span class="w-2 h-2 rounded-full bg-${dotColor}-500"></span> <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> <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} ${healthBadge}
@ -9304,6 +9599,10 @@ class DashboardManager {
onclick="dashboard.showMoveFavoriteModal('${this.escapeHtml(dc.host_id)}','${this.escapeHtml(dc.container_id)}')"> onclick="dashboard.showMoveFavoriteModal('${this.escapeHtml(dc.host_id)}','${this.escapeHtml(dc.container_id)}')">
<i class="fas fa-folder-open text-sm"></i> <i class="fas fa-folder-open text-sm"></i>
</button> </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" <button class="p-1.5 hover:bg-gray-700 rounded transition-colors text-purple-400" title="Retirer des favoris"
onclick="dashboard.removeFavoriteContainer(${favId})"> onclick="dashboard.removeFavoriteContainer(${favId})">
<i class="fas fa-star text-sm"></i> <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) // Export for testing (ESM/CommonJS compatible)
if (typeof module !== 'undefined' && module.exports) { if (typeof module !== 'undefined' && module.exports) {
module.exports = { DashboardManager }; module.exports = { DashboardManager };

View File

@ -13,6 +13,7 @@ from .docker_container import DockerContainer
from .docker_image import DockerImage from .docker_image import DockerImage
from .docker_volume import DockerVolume from .docker_volume import DockerVolume
from .docker_alert import DockerAlert from .docker_alert import DockerAlert
from .container_customization import ContainerCustomization
from .favorite_group import FavoriteGroup from .favorite_group import FavoriteGroup
from .favorite_container import FavoriteContainer from .favorite_container import FavoriteContainer
from .terminal_session import TerminalSession from .terminal_session import TerminalSession
@ -39,6 +40,7 @@ __all__ = [
"DockerImage", "DockerImage",
"DockerVolume", "DockerVolume",
"DockerAlert", "DockerAlert",
"ContainerCustomization",
"FavoriteGroup", "FavoriteGroup",
"FavoriteContainer", "FavoriteContainer",
"TerminalSession", "TerminalSession",

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},
)

View File

@ -3,11 +3,14 @@ Uses SQLAlchemy 2.x async engine with SQLite + aiosqlite driver.
""" """
from __future__ import annotations from __future__ import annotations
import asyncio
import os import os
from pathlib import Path from pathlib import Path
from typing import AsyncGenerator from typing import AsyncGenerator
from urllib.parse import urlparse from urllib.parse import urlparse
from alembic import command
from alembic.config import Config
from sqlalchemy import event, MetaData from sqlalchemy import event, MetaData
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import declarative_base from sqlalchemy.orm import declarative_base
@ -129,5 +132,42 @@ async def init_db() -> None:
favorite_container, favorite_container,
) # noqa: F401 ) # 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: async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)

View File

@ -4,7 +4,14 @@ from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession 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_container import DockerContainerRepository
from app.crud.docker_image import DockerImageRepository from app.crud.docker_image import DockerImageRepository
from app.crud.docker_volume import DockerVolumeRepository from app.crud.docker_volume import DockerVolumeRepository
@ -18,6 +25,9 @@ from app.schemas.docker import (
DockerContainerResponse, DockerContainerResponse,
DockerContainerAggregatedResponse, DockerContainerAggregatedResponse,
DockerContainerAggregatedListResponse, DockerContainerAggregatedListResponse,
ContainerCustomizationListResponse,
ContainerCustomizationOut,
ContainerCustomizationUpsert,
DockerImageListResponse, DockerImageListResponse,
DockerImageExtendedListResponse, DockerImageExtendedListResponse,
DockerImageExtendedResponse, DockerImageExtendedResponse,
@ -40,6 +50,72 @@ from app.services.docker_alerts import docker_alerts_service
router = APIRouter() 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 === # === Docker Hosts ===
@router.get("/hosts", response_model=DockerHostListResponse) @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"), health: Optional[str] = Query(None, description="Filter by health status"),
host_id: Optional[str] = Query(None, description="Filter by specific host"), host_id: Optional[str] = Query(None, description="Filter by specific host"),
api_key_valid: bool = Depends(verify_api_key), api_key_valid: bool = Depends(verify_api_key),
user: Optional[dict] = Depends(get_current_user_optional),
db_session: AsyncSession = Depends(get_db), db_session: AsyncSession = Depends(get_db),
): ):
"""List all containers across all Docker hosts.""" """List all containers across all Docker hosts."""
@ -161,6 +238,19 @@ async def get_all_containers(
hosts = await host_repo.list_all() hosts = await host_repo.list_all()
host_map = {h.id: {"name": h.name, "ip": h.ip_address} for h in hosts} 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 # Build response with host info
container_responses = [] container_responses = []
for c in containers: for c in containers:
@ -179,6 +269,7 @@ async def get_all_containers(
ports=c.ports, ports=c.ports,
labels=c.labels, labels=c.labels,
compose_project=c.compose_project, compose_project=c.compose_project,
customization=cust_map.get((c.host_id, c.container_id)),
created_at=c.created_at, created_at=c.created_at,
last_update_at=c.last_update_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"), state: Optional[str] = Query(None, description="Filter by state"),
compose_project: Optional[str] = Query(None, description="Filter by compose project"), compose_project: Optional[str] = Query(None, description="Filter by compose project"),
api_key_valid: bool = Depends(verify_api_key), api_key_valid: bool = Depends(verify_api_key),
user: Optional[dict] = Depends(get_current_user_optional),
db_session: AsyncSession = Depends(get_db), db_session: AsyncSession = Depends(get_db),
): ):
"""List containers for a host.""" """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) containers = await container_repo.list_by_host(host_id, state=state, compose_project=compose_project)
counts = await container_repo.count_by_host(host_id) 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( return DockerContainerListResponse(
containers=[DockerContainerResponse( containers=[DockerContainerResponse(
id=c.id, id=c.id,
@ -220,6 +325,7 @@ async def get_containers(
ports=c.ports, ports=c.ports,
labels=c.labels, labels=c.labels,
compose_project=c.compose_project, compose_project=c.compose_project,
customization=cust_map.get((c.host_id, c.container_id)),
created_at=c.created_at, created_at=c.created_at,
last_update_at=c.last_update_at last_update_at=c.last_update_at
) for c in containers], ) for c in containers],

View File

@ -7,6 +7,28 @@ from pydantic import BaseModel, Field, ConfigDict
# === Container Schemas === # === 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): class DockerContainerBase(BaseModel):
"""Base schema for Docker container.""" """Base schema for Docker container."""
container_id: str = Field(..., description="Docker container ID") container_id: str = Field(..., description="Docker container ID")
@ -28,6 +50,7 @@ class DockerContainerResponse(DockerContainerBase):
"""Response schema for Docker container.""" """Response schema for Docker container."""
id: int id: int
host_id: str host_id: str
customization: Optional[ContainerCustomizationOut] = None
created_at: Optional[datetime] = None created_at: Optional[datetime] = None
last_update_at: Optional[datetime] = None last_update_at: Optional[datetime] = None
@ -283,6 +306,7 @@ class DockerContainerAggregatedResponse(DockerContainerBase):
host_id: str host_id: str
host_name: str host_name: str
host_ip: str host_ip: str
customization: Optional[ContainerCustomizationOut] = None
created_at: Optional[datetime] = None created_at: Optional[datetime] = None
last_update_at: Optional[datetime] = None last_update_at: Optional[datetime] = None

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,40 @@
# ❌ Playbook: Install_Container_Portainer
## Informations
| Propriété | Valeur |
|-----------|--------|
| **ID** | `pb_efa9e73df0df` |
| **Nom** | Playbook: Install_Container_Portainer |
| **Cible** | `jump.point.home` |
| **Statut** | failed |
| **Type** | Manuel |
| **Progression** | 100% |
| **Début** | 2025-12-24T00:39:45.125595+00:00 |
| **Fin** | 2025-12-24T00:39:55.119840+00:00 |
| **Durée** | 10.0s |
## Sortie
```
PLAY [Déploiement de Portainer CE] *********************************************
TASK [Gathering Facts] *********************************************************
ok: [jump.point.home]
TASK [Créer le répertoire de données Portainer] ********************************
changed: [jump.point.home]
TASK [Vérifier si Docker est installé] *****************************************
fatal: [jump.point.home]: FAILED! => {"changed": false, "msg": "No package matching 'docker' is available"}
PLAY RECAP *********************************************************************
jump.point.home : ok=2 changed=1 unreachable=0 failed=1 skipped=0 rescued=0 ignored=0
```
---
*Généré automatiquement par Homelab Automation Dashboard*
*Date: 2025-12-24T00:39:55.128714+00:00*

View File

@ -0,0 +1,27 @@
# ✅ Ad-hoc: docker ps
## Informations
| Propriété | Valeur |
|-----------|--------|
| **ID** | `adhoc_35aee6696bec` |
| **Nom** | Ad-hoc: docker ps |
| **Cible** | `jump.point.home` |
| **Statut** | completed |
| **Type** | Ad-hoc |
| **Progression** | 100% |
| **Début** | 2025-12-24T01:52:24.119559+00:00 |
| **Fin** | 2025-12-24T01:52:30.311593+00:00 |
| **Durée** | 6.19s |
## Sortie
```
jump.point.home | CHANGED | rc=0 >>
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
```
---
*Généré automatiquement par Homelab Automation Dashboard*
*Date: 2025-12-24T01:52:30.332157+00:00*

View File

@ -0,0 +1,46 @@
# ❌ Playbook: Install_Container_Portainer
## Informations
| Propriété | Valeur |
|-----------|--------|
| **ID** | `pb_88a9555b6fc5` |
| **Nom** | Playbook: Install_Container_Portainer |
| **Cible** | `jump.point.home` |
| **Statut** | failed |
| **Type** | Manuel |
| **Progression** | 100% |
| **Début** | 2025-12-24T01:52:59.217776+00:00 |
| **Fin** | 2025-12-24T01:53:09.662891+00:00 |
| **Durée** | 10.4s |
## Sortie
```
PLAY [Déploiement de Portainer CE] *********************************************
TASK [Gathering Facts] *********************************************************
ok: [jump.point.home]
TASK [Créer le répertoire parent pour le dépôt Portainer] **********************
changed: [jump.point.home]
TASK [Cloner le dépôt Portainer] ***********************************************
changed: [jump.point.home]
TASK [Exécuter le script d'initialisation Portainer] ***************************
skipping: [jump.point.home]
TASK [Exécuter le script de mise à jour Portainer] *****************************
fatal: [jump.point.home]: FAILED! => {"changed": false, "cmd": ["bash", "./maj.sh"], "delta": null, "end": null, "msg": "Unable to change directory before execution: [Errno 2] No such file or directory: b'dev/git/outils/dockers/portainer'", "rc": null, "start": null, "stderr": "", "stderr_lines": [], "stdout": "", "stdout_lines": []}
PLAY RECAP *********************************************************************
jump.point.home : ok=3 changed=2 unreachable=0 failed=1 skipped=1 rescued=0 ignored=0
```
---
*Généré automatiquement par Homelab Automation Dashboard*
*Date: 2025-12-24T01:53:09.671696+00:00*

View File

@ -0,0 +1,62 @@
# ✅ Playbook: Install_Container_Portainer
## Informations
| Propriété | Valeur |
|-----------|--------|
| **ID** | `pb_39bbd6b654a0` |
| **Nom** | Playbook: Install_Container_Portainer |
| **Cible** | `jump.point.home` |
| **Statut** | completed |
| **Type** | Manuel |
| **Progression** | 100% |
| **Début** | 2025-12-24T02:06:00.567518+00:00 |
| **Fin** | 2025-12-24T02:06:14.262809+00:00 |
| **Durée** | 13.7s |
## Sortie
```
PLAY [Déploiement de Portainer CE] *********************************************
TASK [Gathering Facts] *********************************************************
ok: [jump.point.home]
TASK [Créer le répertoire parent pour le dépôt Portainer] **********************
changed: [jump.point.home]
TASK [Cloner le dépôt Portainer] ***********************************************
changed: [jump.point.home]
TASK [Exécuter le script d'initialisation Portainer] ***************************
skipping: [jump.point.home]
TASK [Exécuter le script de mise à jour Portainer] *****************************
changed: [jump.point.home]
TASK [Vérifier que Portainer est en cours d'exécution] *************************
ok: [jump.point.home]
TASK [Afficher le statut de Portainer] *****************************************
ok: [jump.point.home] => {
"msg": [
"NAMES STATUS PORTS",
"portainer Up Less than a second 0.0.0.0:8000->8000/tcp, [::]:8000->8000/tcp, 0.0.0.0:9000->9000/tcp, [::]:9000->9000/tcp, 0.0.0.0:9443->9443/tcp, [::]:9443->9443/tcp"
]
}
TASK [Message de succès] *******************************************************
ok: [jump.point.home] => {
"msg": "Portainer CE a été déployé avec succès !\nLe script maj.sh a configuré automatiquement le firewall (UFW/Firewalld/iptables).\nInterface Web HTTPS : https://192.168.30.34:9443\nInterface Web HTTP : http://192.168.30.34:9000\nEdge Agent : 192.168.30.34:8000"
}
PLAY RECAP *********************************************************************
jump.point.home : ok=7 changed=3 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0
```
---
*Généré automatiquement par Homelab Automation Dashboard*
*Date: 2025-12-24T02:06:14.271101+00:00*

View File

@ -0,0 +1,28 @@
# ✅ Ad-hoc: docker ps
## Informations
| Propriété | Valeur |
|-----------|--------|
| **ID** | `adhoc_810ecc73a34e` |
| **Nom** | Ad-hoc: docker ps |
| **Cible** | `jump.point.home` |
| **Statut** | completed |
| **Type** | Ad-hoc |
| **Progression** | 100% |
| **Début** | 2025-12-24T02:14:33.759387+00:00 |
| **Fin** | 2025-12-24T02:14:42.198144+00:00 |
| **Durée** | 8.44s |
## Sortie
```
jump.point.home | CHANGED | rc=0 >>
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
4810aaf8606b portainer/portainer-ce:latest "/portainer" 8 minutes ago Up 8 minutes 0.0.0.0:8000->8000/tcp, [::]:8000->8000/tcp, 0.0.0.0:9000->9000/tcp, [::]:9000->9000/tcp, 0.0.0.0:9443->9443/tcp, [::]:9443->9443/tcp portainer
```
---
*Généré automatiquement par Homelab Automation Dashboard*
*Date: 2025-12-24T02:14:42.234195+00:00*

View File

@ -0,0 +1,42 @@
# ❌ Playbook: Install_Container_Portainer
## Informations
| Propriété | Valeur |
|-----------|--------|
| **ID** | `pb_38c8f2a3693e` |
| **Nom** | Playbook: Install_Container_Portainer |
| **Cible** | `jump.point.home` |
| **Statut** | failed |
| **Type** | Manuel |
| **Progression** | 100% |
| **Début** | 2025-12-24T00:49:03.846658+00:00 |
| **Fin** | 2025-12-24T02:23:10.132597+00:00 |
| **Durée** | 5646.3s |
## Sortie
```
PLAY [Déploiement de Portainer CE] *********************************************
TASK [Gathering Facts] *********************************************************
ok: [jump.point.home]
TASK [Créer le répertoire parent pour le dépôt Portainer] **********************
changed: [jump.point.home]
TASK [Cloner le dépôt Portainer] ***********************************************
```
## Erreurs
```
[ERROR]: User interrupted execution
```
---
*Généré automatiquement par Homelab Automation Dashboard*
*Date: 2025-12-24T02:23:10.142189+00:00*

View File

@ -0,0 +1,104 @@
# ✅ Playbook: Remove_Container_Portainer
## Informations
| Propriété | Valeur |
|-----------|--------|
| **ID** | `pb_bfb36e2a22bb` |
| **Nom** | Playbook: Remove_Container_Portainer |
| **Cible** | `jump.point.home` |
| **Statut** | completed |
| **Type** | Manuel |
| **Progression** | 100% |
| **Début** | 2025-12-24T02:28:48.400045+00:00 |
| **Fin** | 2025-12-24T02:29:06.589219+00:00 |
| **Durée** | 18.2s |
## Sortie
```
PLAY [Suppression de Portainer CE] *********************************************
TASK [Gathering Facts] *********************************************************
ok: [jump.point.home]
TASK [Vérifier si le conteneur Portainer existe] *******************************
ok: [jump.point.home]
TASK [Arrêter le conteneur Portainer s'il est en cours d'exécution] ************
changed: [jump.point.home]
TASK [Supprimer le conteneur Portainer] ****************************************
changed: [jump.point.home]
TASK [Vérifier si l'image Portainer existe] ************************************
ok: [jump.point.home]
TASK [Supprimer l'image Portainer (optionnel)] *********************************
changed: [jump.point.home]
TASK [Détecter si UFW est installé] ********************************************
ok: [jump.point.home]
TASK [Supprimer les règles UFW pour Portainer] *********************************
skipping: [jump.point.home] => (item=8000)
skipping: [jump.point.home] => (item=9000)
skipping: [jump.point.home] => (item=9443)
skipping: [jump.point.home]
TASK [Recharger UFW] ***********************************************************
skipping: [jump.point.home]
TASK [Détecter si Firewalld est installé] **************************************
ok: [jump.point.home]
TASK [Supprimer les règles Firewalld pour Portainer] ***************************
skipping: [jump.point.home] => (item=8000)
skipping: [jump.point.home] => (item=9000)
skipping: [jump.point.home] => (item=9443)
skipping: [jump.point.home]
TASK [Recharger Firewalld] *****************************************************
skipping: [jump.point.home]
TASK [Détecter si iptables est disponible] *************************************
ok: [jump.point.home]
TASK [Vérifier si la règle iptables existe pour Portainer] *********************
ok: [jump.point.home] => (item=8000)
ok: [jump.point.home] => (item=9000)
ok: [jump.point.home] => (item=9443)
TASK [Supprimer les règles iptables pour Portainer] ****************************
skipping: [jump.point.home] => (item={'changed': False, 'stdout': '', 'stderr': 'iptables: Bad rule (does a matching rule exist in that chain?).', 'rc': 1, 'cmd': ['iptables', '-C', 'INPUT', '-p', 'tcp', '--dport', '8000', '-j', 'DROP'], 'start': '2025-12-23 21:29:00.143068', 'end': '2025-12-23 21:29:00.146959', 'delta': '0:00:00.003891', 'failed': False, 'msg': 'non-zero return code', 'invocation': {'module_args': {'_raw_params': 'iptables -C INPUT -p tcp --dport 8000 -j DROP', '_uses_shell': False, 'expand_argument_vars': True, 'stdin_add_newline': True, 'strip_empty_ends': True, 'argv': None, 'chdir': None, 'executable': None, 'creates': None, 'removes': None, 'stdin': None}}, 'stdout_lines': [], 'stderr_lines': ['iptables: Bad rule (does a matching rule exist in that chain?).'], 'failed_when_result': False, 'item': 8000, 'ansible_loop_var': 'item'})
skipping: [jump.point.home] => (item={'changed': False, 'stdout': '', 'stderr': 'iptables: Bad rule (does a matching rule exist in that chain?).', 'rc': 1, 'cmd': ['iptables', '-C', 'INPUT', '-p', 'tcp', '--dport', '9000', '-j', 'DROP'], 'start': '2025-12-23 21:29:00.810733', 'end': '2025-12-23 21:29:00.814511', 'delta': '0:00:00.003778', 'failed': False, 'msg': 'non-zero return code', 'invocation': {'module_args': {'_raw_params': 'iptables -C INPUT -p tcp --dport 9000 -j DROP', '_uses_shell': False, 'expand_argument_vars': True, 'stdin_add_newline': True, 'strip_empty_ends': True, 'argv': None, 'chdir': None, 'executable': None, 'creates': None, 'removes': None, 'stdin': None}}, 'stdout_lines': [], 'stderr_lines': ['iptables: Bad rule (does a matching rule exist in that chain?).'], 'failed_when_result': False, 'item': 9000, 'ansible_loop_var': 'item'})
skipping: [jump.point.home] => (item={'changed': False, 'stdout': '', 'stderr': 'iptables: Bad rule (does a matching rule exist in that chain?).', 'rc': 1, 'cmd': ['iptables', '-C', 'INPUT', '-p', 'tcp', '--dport', '9443', '-j', 'DROP'], 'start': '2025-12-23 21:29:01.445419', 'end': '2025-12-23 21:29:01.449416', 'delta': '0:00:00.003997', 'failed': False, 'msg': 'non-zero return code', 'invocation': {'module_args': {'_raw_params': 'iptables -C INPUT -p tcp --dport 9443 -j DROP', '_uses_shell': False, 'expand_argument_vars': True, 'stdin_add_newline': True, 'strip_empty_ends': True, 'argv': None, 'chdir': None, 'executable': None, 'creates': None, 'removes': None, 'stdin': None}}, 'stdout_lines': [], 'stderr_lines': ['iptables: Bad rule (does a matching rule exist in that chain?).'], 'failed_when_result': False, 'item': 9443, 'ansible_loop_var': 'item'})
skipping: [jump.point.home]
TASK [Supprimer le répertoire du dépôt Git Portainer] **************************
ok: [jump.point.home]
TASK [Supprimer le répertoire de données Portainer] ****************************
changed: [jump.point.home]
TASK [Supprimer le répertoire parent s'il est vide] ****************************
ok: [jump.point.home]
TASK [Vérifier que Portainer a été complètement supprimé] **********************
ok: [jump.point.home]
TASK [Message de confirmation de suppression] **********************************
ok: [jump.point.home] => {
"msg": "Portainer CE a été complètement supprimé !\n- Conteneur arrêté et supprimé\n- Règles firewall supprimées (UFW/Firewalld/iptables)\n- Répertoires de données supprimés\n- Dépôt Git supprimé\n[SUCCESS] Suppression réussie : Aucun conteneur Portainer trouvé\n"
}
PLAY RECAP *********************************************************************
jump.point.home : ok=15 changed=4 unreachable=0 failed=0 skipped=5 rescued=0 ignored=0
```
---
*Généré automatiquement par Homelab Automation Dashboard*
*Date: 2025-12-24T02:29:06.609890+00:00*

View File

@ -0,0 +1,27 @@
# ✅ Ad-hoc: docker ps
## Informations
| Propriété | Valeur |
|-----------|--------|
| **ID** | `adhoc_fe96c45b60c1` |
| **Nom** | Ad-hoc: docker ps |
| **Cible** | `jump.point.home` |
| **Statut** | completed |
| **Type** | Ad-hoc |
| **Progression** | 100% |
| **Début** | 2025-12-24T02:29:59.828784+00:00 |
| **Fin** | 2025-12-24T02:30:05.263873+00:00 |
| **Durée** | 5.44s |
## Sortie
```
jump.point.home | CHANGED | rc=0 >>
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
```
---
*Généré automatiquement par Homelab Automation Dashboard*
*Date: 2025-12-24T02:30:05.282655+00:00*

View File

@ -0,0 +1,62 @@
# ✅ Playbook: Install_Container_Portainer
## Informations
| Propriété | Valeur |
|-----------|--------|
| **ID** | `pb_43b609465252` |
| **Nom** | Playbook: Install_Container_Portainer |
| **Cible** | `jump.point.home` |
| **Statut** | completed |
| **Type** | Manuel |
| **Progression** | 100% |
| **Début** | 2025-12-24T02:31:49.269130+00:00 |
| **Fin** | 2025-12-24T02:32:04.049399+00:00 |
| **Durée** | 14.8s |
## Sortie
```
PLAY [Déploiement de Portainer CE] *********************************************
TASK [Gathering Facts] *********************************************************
ok: [jump.point.home]
TASK [Créer le répertoire parent pour le dépôt Portainer] **********************
changed: [jump.point.home]
TASK [Cloner le dépôt Portainer] ***********************************************
changed: [jump.point.home]
TASK [Exécuter le script d'initialisation Portainer] ***************************
skipping: [jump.point.home]
TASK [Exécuter le script de mise à jour Portainer] *****************************
changed: [jump.point.home]
TASK [Vérifier que Portainer est en cours d'exécution] *************************
ok: [jump.point.home]
TASK [Afficher le statut de Portainer] *****************************************
ok: [jump.point.home] => {
"msg": [
"NAMES STATUS PORTS",
"portainer Up Less than a second 0.0.0.0:8000->8000/tcp, [::]:8000->8000/tcp, 0.0.0.0:9000->9000/tcp, [::]:9000->9000/tcp, 0.0.0.0:9443->9443/tcp, [::]:9443->9443/tcp"
]
}
TASK [Message de succès] *******************************************************
ok: [jump.point.home] => {
"msg": "Portainer CE a été déployé avec succès !\nLe script maj.sh a configuré automatiquement le firewall (UFW/Firewalld/iptables).\nInterface Web HTTPS : https://192.168.30.34:9443\nInterface Web HTTP : http://192.168.30.34:9000\nEdge Agent : 192.168.30.34:8000"
}
PLAY RECAP *********************************************************************
jump.point.home : ok=7 changed=3 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0
```
---
*Généré automatiquement par Homelab Automation Dashboard*
*Date: 2025-12-24T02:32:04.060348+00:00*

View File

@ -0,0 +1,80 @@
# ✅ Playbook: Health Check
## Informations
| Propriété | Valeur |
|-----------|--------|
| **ID** | `pb_37fff0d8fc0a` |
| **Nom** | Playbook: Health Check |
| **Cible** | `role_sbc` |
| **Statut** | completed |
| **Type** | Manuel |
| **Progression** | 100% |
| **Début** | 2025-12-24T03:08:37.461767+00:00 |
| **Fin** | 2025-12-24T03:08:56.314886+00:00 |
| **Durée** | 18.7s |
## Sortie
```
Using /mnt/c/dev/git/python/homelab-automation-api-v2/ansible/ansible.cfg as config file
PLAY [Health check on target host] *********************************************
TASK [Check if host is reachable (ping)] ***************************************
ok: [raspi.8gb.home] => {"changed": false, "ping": "pong"}
ok: [raspi.4gb.home] => {"changed": false, "ping": "pong"}
ok: [orangepi.pc.home] => {"changed": false, "ping": "pong"}
TASK [Gather minimal facts] ****************************************************
ok: [raspi.8gb.home]
ok: [raspi.4gb.home]
ok: [orangepi.pc.home]
TASK [Get system uptime] *******************************************************
ok: [raspi.8gb.home] => {"changed": false, "cmd": ["uptime"], "delta": "0:00:00.008447", "end": "2025-12-23 22:08:48.821279", "msg": "", "rc": 0, "start": "2025-12-23 22:08:48.812832", "stderr": "", "stderr_lines": [], "stdout": " 22:08:48 up 200 days, 13:41, 1 user, load average: 0.16, 0.19, 0.21", "stdout_lines": [" 22:08:48 up 200 days, 13:41, 1 user, load average: 0.16, 0.19, 0.21"]}
ok: [raspi.4gb.home] => {"changed": false, "cmd": ["uptime"], "delta": "0:00:00.009082", "end": "2025-12-23 22:08:48.882333", "msg": "", "rc": 0, "start": "2025-12-23 22:08:48.873251", "stderr": "", "stderr_lines": [], "stdout": " 22:08:48 up 200 days, 13:41, 1 user, load average: 0.22, 0.28, 0.27", "stdout_lines": [" 22:08:48 up 200 days, 13:41, 1 user, load average: 0.22, 0.28, 0.27"]}
ok: [orangepi.pc.home] => {"changed": false, "cmd": ["uptime"], "delta": "0:00:00.019966", "end": "2025-12-23 22:08:49.545885", "msg": "", "rc": 0, "start": "2025-12-23 22:08:49.525919", "stderr": "", "stderr_lines": [], "stdout": " 22:08:49 up 21 days, 11:35, 1 user, load average: 0.52, 0.32, 0.28", "stdout_lines": [" 22:08:49 up 21 days, 11:35, 1 user, load average: 0.52, 0.32, 0.28"]}
TASK [Get disk usage] **********************************************************
ok: [raspi.8gb.home] => {"changed": false, "cmd": "df -h / | tail -1 | awk '{print $5}'", "delta": "0:00:00.019262", "end": "2025-12-23 22:08:50.377875", "msg": "", "rc": 0, "start": "2025-12-23 22:08:50.358613", "stderr": "", "stderr_lines": [], "stdout": "3%", "stdout_lines": ["3%"]}
ok: [raspi.4gb.home] => {"changed": false, "cmd": "df -h / | tail -1 | awk '{print $5}'", "delta": "0:00:00.011492", "end": "2025-12-23 22:08:50.404543", "msg": "", "rc": 0, "start": "2025-12-23 22:08:50.393051", "stderr": "", "stderr_lines": [], "stdout": "6%", "stdout_lines": ["6%"]}
ok: [orangepi.pc.home] => {"changed": false, "cmd": "df -h / | tail -1 | awk '{print $5}'", "delta": "0:00:00.025813", "end": "2025-12-23 22:08:51.083122", "msg": "", "rc": 0, "start": "2025-12-23 22:08:51.057309", "stderr": "", "stderr_lines": [], "stdout": "21%", "stdout_lines": ["21%"]}
TASK [Get memory usage (Linux)] ************************************************
ok: [raspi.8gb.home] => {"changed": false, "cmd": "if command -v free >/dev/null 2>&1; then\n free -m | grep Mem | awk '{printf \"%.1f%%\", $3/$2 * 100}'\nelse\n # Fallback for systems without free command\n cat /proc/meminfo | awk '/MemTotal/{total=$2} /MemAvailable/{avail=$2} END{printf \"%.1f%%\", (total-avail)/total*100}'\nfi\n", "delta": "0:00:00.010688", "end": "2025-12-23 22:08:51.901744", "msg": "", "rc": 0, "start": "2025-12-23 22:08:51.891056", "stderr": "", "stderr_lines": [], "stdout": "7.0%", "stdout_lines": ["7.0%"]}
ok: [raspi.4gb.home] => {"changed": false, "cmd": "if command -v free >/dev/null 2>&1; then\n free -m | grep Mem | awk '{printf \"%.1f%%\", $3/$2 * 100}'\nelse\n # Fallback for systems without free command\n cat /proc/meminfo | awk '/MemTotal/{total=$2} /MemAvailable/{avail=$2} END{printf \"%.1f%%\", (total-avail)/total*100}'\nfi\n", "delta": "0:00:00.011853", "end": "2025-12-23 22:08:51.966290", "msg": "", "rc": 0, "start": "2025-12-23 22:08:51.954437", "stderr": "", "stderr_lines": [], "stdout": "13.1%", "stdout_lines": ["13.1%"]}
ok: [orangepi.pc.home] => {"changed": false, "cmd": "if command -v free >/dev/null 2>&1; then\n free -m | grep Mem | awk '{printf \"%.1f%%\", $3/$2 * 100}'\nelse\n # Fallback for systems without free command\n cat /proc/meminfo | awk '/MemTotal/{total=$2} /MemAvailable/{avail=$2} END{printf \"%.1f%%\", (total-avail)/total*100}'\nfi\n", "delta": "0:00:00.026466", "end": "2025-12-23 22:08:52.636830", "msg": "", "rc": 0, "start": "2025-12-23 22:08:52.610364", "stderr": "", "stderr_lines": [], "stdout": "20.1%", "stdout_lines": ["20.1%"]}
TASK [Get CPU temperature (ARM/SBC)] *******************************************
ok: [raspi.8gb.home] => {"changed": false, "cmd": "if [ -f /sys/class/thermal/thermal_zone0/temp ]; then\n temp=$(cat /sys/class/thermal/thermal_zone0/temp)\n # Use awk instead of bc for better compatibility\n echo \"${temp}\" | awk '{printf \"%.1f°C\", $1/1000}'\nelse\n echo \"N/A\"\nfi\n", "delta": "0:00:00.011710", "end": "2025-12-23 22:08:53.447820", "msg": "", "rc": 0, "start": "2025-12-23 22:08:53.436110", "stderr": "", "stderr_lines": [], "stdout": "30.7°C", "stdout_lines": ["30.7°C"]}
ok: [raspi.4gb.home] => {"changed": false, "cmd": "if [ -f /sys/class/thermal/thermal_zone0/temp ]; then\n temp=$(cat /sys/class/thermal/thermal_zone0/temp)\n # Use awk instead of bc for better compatibility\n echo \"${temp}\" | awk '{printf \"%.1f°C\", $1/1000}'\nelse\n echo \"N/A\"\nfi\n", "delta": "0:00:00.013076", "end": "2025-12-23 22:08:53.485620", "msg": "", "rc": 0, "start": "2025-12-23 22:08:53.472544", "stderr": "", "stderr_lines": [], "stdout": "36.5°C", "stdout_lines": ["36.5°C"]}
ok: [orangepi.pc.home] => {"changed": false, "cmd": "if [ -f /sys/class/thermal/thermal_zone0/temp ]; then\n temp=$(cat /sys/class/thermal/thermal_zone0/temp)\n # Use awk instead of bc for better compatibility\n echo \"${temp}\" | awk '{printf \"%.1f°C\", $1/1000}'\nelse\n echo \"N/A\"\nfi\n", "delta": "0:00:00.029255", "end": "2025-12-23 22:08:54.180844", "msg": "", "rc": 0, "start": "2025-12-23 22:08:54.151589", "stderr": "", "stderr_lines": [], "stdout": "36.0°C", "stdout_lines": ["36.0°C"]}
TASK [Get CPU load] ************************************************************
ok: [raspi.8gb.home] => {"changed": false, "cmd": "if [ -f /proc/loadavg ]; then\n cat /proc/loadavg | awk '{print $1}'\nelse\n uptime | awk -F'load average:' '{print $2}' | awk -F',' '{print $1}' | tr -d ' '\nfi\n", "delta": "0:00:00.010275", "end": "2025-12-23 22:08:55.005654", "msg": "", "rc": 0, "start": "2025-12-23 22:08:54.995379", "stderr": "", "stderr_lines": [], "stdout": "0.15", "stdout_lines": ["0.15"]}
ok: [raspi.4gb.home] => {"changed": false, "cmd": "if [ -f /proc/loadavg ]; then\n cat /proc/loadavg | awk '{print $1}'\nelse\n uptime | awk -F'load average:' '{print $2}' | awk -F',' '{print $1}' | tr -d ' '\nfi\n", "delta": "0:00:00.010970", "end": "2025-12-23 22:08:55.030483", "msg": "", "rc": 0, "start": "2025-12-23 22:08:55.019513", "stderr": "", "stderr_lines": [], "stdout": "0.36", "stdout_lines": ["0.36"]}
ok: [orangepi.pc.home] => {"changed": false, "cmd": "if [ -f /proc/loadavg ]; then\n cat /proc/loadavg | awk '{print $1}'\nelse\n uptime | awk -F'load average:' '{print $2}' | awk -F',' '{print $1}' | tr -d ' '\nfi\n", "delta": "0:00:00.024618", "end": "2025-12-23 22:08:55.722161", "msg": "", "rc": 0, "start": "2025-12-23 22:08:55.697543", "stderr": "", "stderr_lines": [], "stdout": "0.56", "stdout_lines": ["0.56"]}
TASK [Display health status] ***************************************************
ok: [orangepi.pc.home] => {
"msg": "═══════════════════════════════════════\nHost: orangepi.pc.home\nStatus: OK\n═══════════════════════════════════════\nUptime: 22:08:49 up 21 days, 11:35, 1 user, load average: 0.52, 0.32, 0.28\nDisk Usage: 21%\nMemory Usage: 20.1%\nCPU Load: 0.56\nCPU Temp: 36.0°C\n═══════════════════════════════════════\n"
}
ok: [raspi.4gb.home] => {
"msg": "═══════════════════════════════════════\nHost: raspi.4gb.home\nStatus: OK\n═══════════════════════════════════════\nUptime: 22:08:48 up 200 days, 13:41, 1 user, load average: 0.22, 0.28, 0.27\nDisk Usage: 6%\nMemory Usage: 13.1%\nCPU Load: 0.36\nCPU Temp: 36.5°C\n═══════════════════════════════════════\n"
}
ok: [raspi.8gb.home] => {
"msg": "═══════════════════════════════════════\nHost: raspi.8gb.home\nStatus: OK\n═══════════════════════════════════════\nUptime: 22:08:48 up 200 days, 13:41, 1 user, load average: 0.16, 0.19, 0.21\nDisk Usage: 3%\nMemory Usage: 7.0%\nCPU Load: 0.15\nCPU Temp: 30.7°C\n═══════════════════════════════════════\n"
}
PLAY RECAP *********************************************************************
orangepi.pc.home : ok=8 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
raspi.4gb.home : ok=8 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
raspi.8gb.home : ok=8 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
```
---
*Généré automatiquement par Homelab Automation Dashboard*
*Date: 2025-12-24T03:08:56.323146+00:00*

View File

@ -0,0 +1,42 @@
# ❌ Ad-hoc: docker ps
## Informations
| Propriété | Valeur |
|-----------|--------|
| **ID** | `adhoc_159581cfb02d` |
| **Nom** | Ad-hoc: docker ps |
| **Cible** | `role_docker` |
| **Statut** | failed |
| **Type** | Ad-hoc |
| **Progression** | 100% |
| **Début** | 2025-12-26T02:23:05.143862+00:00 |
| **Fin** | 2025-12-26T02:23:12.076273+00:00 |
| **Durée** | 6.93s |
## Sortie
```
dev.prod.home | FAILED | rc=1 >>
permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock: Get "http://%2Fvar%2Frun%2Fdocker.sock/v1.47/containers/json": dial unix /var/run/docker.sock: connect: permission deniednon-zero return code
jump.point.home | CHANGED | rc=0 >>
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
1e24b6d26de6 portainer/portainer-ce:latest "/portainer" 2 days ago Up 10 hours 0.0.0.0:8000->8000/tcp, [::]:8000->8000/tcp, 0.0.0.0:9000->9000/tcp, [::]:9000->9000/tcp, 0.0.0.0:9443->9443/tcp, [::]:9443->9443/tcp portainer
media.labb.home | FAILED | rc=1 >>
permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock: Get "http://%2Fvar%2Frun%2Fdocker.sock/v1.47/containers/json": dial unix /var/run/docker.sock: connect: permission deniednon-zero return code
dev.lab.home | FAILED | rc=1 >>
permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock: Get "http://%2Fvar%2Frun%2Fdocker.sock/v1.51/containers/json": dial unix /var/run/docker.sock: connect: permission deniednon-zero return code
automate.prod.home | FAILED | rc=1 >>
permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock: Get "http://%2Fvar%2Frun%2Fdocker.sock/v1.45/containers/json": dial unix /var/run/docker.sock: connect: permission deniednon-zero return code
orangepi.pc.home | FAILED | rc=1 >>
permission denied while trying to connect to the docker API at unix:///var/run/docker.socknon-zero return code
raspi.4gb.home | FAILED | rc=1 >>
permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock: Get "http://%2Fvar%2Frun%2Fdocker.sock/v1.24/containers/json": dial unix /var/run/docker.sock: connect: permission deniednon-zero return code
raspi.8gb.home | FAILED | rc=1 >>
permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock: Get "http://%2Fvar%2Frun%2Fdocker.sock/v1.50/containers/json": dial unix /var/run/docker.sock: connect: permission deniednon-zero return code
```
---
*Généré automatiquement par Homelab Automation Dashboard*
*Date: 2025-12-26T02:23:12.082368+00:00*

View File

@ -0,0 +1,97 @@
# ✅ Ad-hoc: docker ps
## Informations
| Propriété | Valeur |
|-----------|--------|
| **ID** | `adhoc_3669dd171f2f` |
| **Nom** | Ad-hoc: docker ps |
| **Cible** | `role_docker` |
| **Statut** | completed |
| **Type** | Ad-hoc |
| **Progression** | 100% |
| **Début** | 2025-12-26T02:23:40.465585+00:00 |
| **Fin** | 2025-12-26T02:23:44.237494+00:00 |
| **Durée** | 3.77s |
## Sortie
```
jump.point.home | CHANGED | rc=0 >>
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
1e24b6d26de6 portainer/portainer-ce:latest "/portainer" 2 days ago Up 10 hours 0.0.0.0:8000->8000/tcp, [::]:8000->8000/tcp, 0.0.0.0:9000->9000/tcp, [::]:9000->9000/tcp, 0.0.0.0:9443->9443/tcp, [::]:9443->9443/tcp portainer
dev.lab.home | CHANGED | rc=0 >>
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
3d30c6d6ffe2 docker-registry.dev.home:5000/homelab-automation-api:latest "python -m uvicorn m…" 33 hours ago Up 10 hours (unhealthy) 0.0.0.0:7680-7699->7680-7699/tcp, [::]:7680-7699->7680-7699/tcp, 0.0.0.0:8008->8008/tcp, [::]:8008->8008/tcp homelab-dashboard
6c4698d1bbdb dbeaver/cloudbeaver:latest "./run-server.sh" 3 days ago Up 10 hours 0.0.0.0:8888->8978/tcp, [::]:8888->8978/tcp cloudbeaver
748bcea89196 docker-registry.dev.home:5000/obsiviewer-angular:latest "docker-entrypoint.s…" 7 weeks ago Up 10 hours (healthy) 0.0.0.0:4441->4000/tcp, [::]:4441->4000/tcp obsiviewer-it
da9ebdd76b91 getmeili/meilisearch:v1.11 "tini -- /bin/sh -c …" 7 weeks ago Up 10 hours (healthy) 0.0.0.0:7700->7700/tcp, [::]:7700->7700/tcp obsiviewer-meilisearch
398e895a866f docker-registry.dev.home:5000/obsiviewer-angular:latest "docker-entrypoint.s…" 7 weeks ago Up 10 hours (healthy) 0.0.0.0:4444->4000/tcp, [::]:4444->4000/tcp obsiviewer-sessionsmanger
f9965e311370 docker-registry.dev.home:5000/obsiviewer-angular:latest "docker-entrypoint.s…" 7 weeks ago Up 10 hours (healthy) 0.0.0.0:4442->4000/tcp, [::]:4442->4000/tcp obsiviewer-recettes
74eb8f7d431f docker-registry.dev.home:5000/obsiviewer-angular:latest "docker-entrypoint.s…" 7 weeks ago Up 10 hours (healthy) 0.0.0.0:4443->4000/tcp, [::]:4443->4000/tcp obsiviewer-workout
0660683cac93 docker-registry.dev.home:5000/obsiviewer-angular:latest "docker-entrypoint.s…" 7 weeks ago Up 10 hours (healthy) 0.0.0.0:4440->4000/tcp, [::]:4440->4000/tcp obsiviewer-main
6eae7a426c7a gitea/gitea:latest "/usr/bin/entrypoint…" 3 months ago Up 10 hours 0.0.0.0:3000->3000/tcp, [::]:3000->3000/tcp, 0.0.0.0:222->22/tcp, [::]:222->22/tcp gitea_server
ac1af89d9f48 mysql:8 "docker-entrypoint.s…" 3 months ago Up 10 hours 3306/tcp, 33060/tcp gitea_db
0afc07c37ee5 joxit/docker-registry-ui:main "/docker-entrypoint.…" 3 months ago Up 10 hours 0.0.0.0:5001->80/tcp, [::]:5001->80/tcp docker-registry-ui
03b3940d5eb1 joxit/docker-registry-ui:main "/docker-entrypoint.…" 3 months ago Up 10 hours 0.0.0.0:5002->80/tcp, [::]:5002->80/tcp docker-registry-ui-public
19a02aeaed18 registry:2 "/entrypoint.sh regi…" 3 months ago Up 10 hours 0.0.0.0:5000->5000/tcp, [::]:5000->5000/tcp docker-registry
ea77aad893ed sonatype/nexus3:latest "/opt/sonatype/nexus…" 5 months ago Up 10 hours 0.0.0.0:8081->8081/tcp, [::]:8081->8081/tcp nexus
9f1382cc1af7 lscr.io/linuxserver/obsidian:latest "/init" 5 months ago Up 10 hours 0.0.0.0:3001->3001/tcp, [::]:3001->3001/tcp, 0.0.0.0:3002->3000/tcp, [::]:3002->3000/tcp obsidian
91123592269f syncthing/syncthing:latest "/bin/entrypoint.sh …" 5 months ago Up 10 hours (healthy) 0.0.0.0:8384->8384/tcp, 0.0.0.0:21027->21027/udp, [::]:8384->8384/tcp, [::]:21027->21027/udp, 0.0.0.0:22000->22000/tcp, [::]:22000->22000/tcp, 0.0.0.0:22000->22000/udp, [::]:22000->22000/udp syncthing
7df784a5e4d2 mcp/obsidian:latest "mcp-obsidian" 6 months ago Restarting (0) 41 seconds ago obsidian-mcp
7b2bdb22c7b0 cloudflare/cloudflared:latest "cloudflared --no-au…" 8 months ago Up 10 hours tunnel-dev-lab-home
345b3776f2fb lscr.io/linuxserver/openvscode-server:latest "/init" 13 months ago Up 10 hours 0.0.0.0:3005->3000/tcp, [::]:3005->3000/tcp openvscode-server
93a402e3dae5 caddy:latest "caddy run --config …" 18 months ago Up 10 hours 0.0.0.0:80->80/tcp, [::]:80->80/tcp, 0.0.0.0:443->443/tcp, [::]:443->443/tcp, 0.0.0.0:443->443/udp, [::]:443->443/udp, 2019/tcp caddy-caddy-1
fa9151665243 budibase/budibase:latest "tini -- /docker-ent…" 20 months ago Up 10 hours (healthy) 443/tcp, 2222/tcp, 4369/tcp, 5984/tcp, 9100/tcp, 0.0.0.0:8080->80/tcp, [::]:8080->80/tcp budibase-budibase-1
6a5e3f941382 phpmyadmin/phpmyadmin "/docker-entrypoint.…" 20 months ago Up 10 hours 0.0.0.0:8887->80/tcp, [::]:8887->80/tcp DEV_phpmyadmin
e60a8f71b005 mysql:latest "docker-entrypoint.s…" 20 months ago Up 10 hours 0.0.0.0:3306->3306/tcp, [::]:3306->3306/tcp, 33060/tcp DEV_HomeLabManager_MySQL
cc30639a219d hurlenko/filebrowser "/filebrowser --root…" 20 months ago Up 10 hours 0.0.0.0:32221->8080/tcp, [::]:32221->8080/tcp filebrowser-os
3f9f6875ec1b amir20/dozzle:latest "/dozzle" 20 months ago Up 10 hours 0.0.0.0:9999->8080/tcp, [::]:9999->8080/tcp dozzle
c633b84dcc5d portainer/portainer-ce:latest "/portainer" 20 months ago Up 10 hours 0.0.0.0:8000->8000/tcp, [::]:8000->8000/tcp, 0.0.0.0:9000->9000/tcp, [::]:9000->9000/tcp, 0.0.0.0:9443->9443/tcp, [::]:9443->9443/tcp portainer-portainer-1
dev.prod.home | CHANGED | rc=0 >>
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
5a083d25eb73 gitea/gitea:latest "/usr/bin/entrypoint…" 3 months ago Up 10 hours 0.0.0.0:3000->3000/tcp, :::3000->3000/tcp, 0.0.0.0:222->22/tcp, [::]:222->22/tcp gitea_server
91b8d838cba5 mysql:8 "docker-entrypoint.s…" 3 months ago Up 10 hours 3306/tcp, 33060/tcp gitea_db
172d5f1540da sonatype/nexus3:latest "/opt/sonatype/nexus…" 5 months ago Up 10 hours 0.0.0.0:8081->8081/tcp, :::8081->8081/tcp nexus
658d7740c366 shaarli/shaarli:latest "/bin/s6-svscan /etc…" 8 months ago Up 10 hours 0.0.0.0:7777->80/tcp, [::]:7777->80/tcp shaarli_bookmarks
a1f24f9f6eff cloudflare/cloudflared:latest "cloudflared --no-au…" 8 months ago Up 10 hours tunnel-dev-prod-home
8e5e80c28fc3 phpmyadmin/phpmyadmin "/docker-entrypoint.…" 20 months ago Up 10 hours 0.0.0.0:8887->80/tcp, [::]:8887->80/tcp PROD_phpmyadmin
a8b43b35a433 mysql:latest "docker-entrypoint.s…" 20 months ago Up 10 hours 0.0.0.0:3306->3306/tcp, :::3306->3306/tcp, 33060/tcp PROD_HomeLabManager_MySQL
b8bf48d662e6 dbeaver/cloudbeaver:latest "./run-server.sh" 20 months ago Up 10 hours 0.0.0.0:8888->8978/tcp, [::]:8888->8978/tcp cloudbeaver
43afd7852917 hurlenko/filebrowser "/filebrowser --root…" 20 months ago Up 10 hours 0.0.0.0:32221->8080/tcp, [::]:32221->8080/tcp filebrowser-os
4f5d4125eb65 budibase/budibase:latest "tini -- /docker-ent…" 20 months ago Up 10 hours (healthy) 443/tcp, 2222/tcp, 4369/tcp, 5984/tcp, 9100/tcp, 0.0.0.0:8080->80/tcp, [::]:8080->80/tcp budibase-prod
33e40a2c0a24 portainer/portainer-ce:latest "/portainer" 20 months ago Up 10 hours 0.0.0.0:8000->8000/tcp, :::8000->8000/tcp, 0.0.0.0:9000->9000/tcp, :::9000->9000/tcp, 0.0.0.0:9443->9443/tcp, :::9443->9443/tcp portainer-portainer-1
automate.prod.home | CHANGED | rc=0 >>
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
bec7cc69e4eb n8nio/n8n:latest "tini -- /docker-ent…" 6 months ago Up 15 seconds 0.0.0.0:5678->5678/tcp, :::5678->5678/tcp n8n
9aa5f46dc9d3 postgres:15 "docker-entrypoint.s…" 6 months ago Up 10 hours (healthy) 5432/tcp postgres-n8n
d8ce5c13dcf4 kestra/kestra:latest-full "/bin/bash -c '/app/…" 8 months ago Up 10 hours 0.0.0.0:8280->8080/tcp, :::8280->8080/tcp, 0.0.0.0:8281->8081/tcp, :::8281->8081/tcp kestra
7c86c2f111bb postgres "docker-entrypoint.s…" 8 months ago Up 10 hours (healthy) 5432/tcp postgres-kestra
bfbe010d17a1 portainer/portainer-ce:latest "/portainer" 13 months ago Up 10 hours 0.0.0.0:8000->8000/tcp, :::8000->8000/tcp, 0.0.0.0:9000->9000/tcp, :::9000->9000/tcp, 0.0.0.0:9443->9443/tcp, :::9443->9443/tcp portainer
media.labb.home | CHANGED | rc=0 >>
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
e51db26ee805 docker-registry.dev.home:5000/newtube-angular:latest "docker-entrypoint.s…" 3 months ago Up 10 hours (healthy) 0.0.0.0:4000->4000/tcp, :::4000->4000/tcp newtube
d304e7a9ea94 searxng/searxng:latest "/usr/local/searxng/…" 3 months ago Up 10 hours 0.0.0.0:8888->8080/tcp, [::]:8888->8080/tcp searxng
650e400ec284 registry.gitlab.com/bockiii/deemix-docker "/init" 4 months ago Up 10 hours 0.0.0.0:6595->6595/tcp, :::6595->6595/tcp Deemix
76deebd2c039 lscr.io/linuxserver/jackett:latest "/init" 4 months ago Up 10 hours 0.0.0.0:9117->9117/tcp, :::9117->9117/tcp jackett
3bbf49447f31 lscr.io/linuxserver/qbittorrent:latest "/init" 4 months ago Up 10 hours 0.0.0.0:6881->6881/tcp, :::6881->6881/tcp, 0.0.0.0:8080->8080/tcp, 0.0.0.0:6881->6881/udp, :::8080->8080/tcp, :::6881->6881/udp qbittorrent
4dcd78c55cd8 linuxserver/emby:latest "/init" 5 months ago Up 10 hours 0.0.0.0:8097->8096/tcp, [::]:8097->8096/tcp, 0.0.0.0:8921->8920/tcp, [::]:8921->8920/tcp emby
d32e0bd5ef27 jellyfin/jellyfin "/jellyfin/jellyfin" 6 months ago Up 10 hours (healthy) 0.0.0.0:8096->8096/tcp, :::8096->8096/tcp, 0.0.0.0:8920->8920/tcp, :::8920->8920/tcp jellyfin
c818527c4095 portainer/portainer-ce:latest "/portainer" 7 months ago Up 10 hours 0.0.0.0:8000->8000/tcp, :::8000->8000/tcp, 0.0.0.0:9000->9000/tcp, :::9000->9000/tcp, 0.0.0.0:9443->9443/tcp, :::9443->9443/tcp portainer
raspi.4gb.home | CHANGED | rc=0 >>
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
c54b087047f4 hurlenko/filebrowser "/filebrowser --root…" 9 months ago Up 10 hours 0.0.0.0:32221->8080/tcp, :::32221->8080/tcp filebrowser-os
75b798021a41 louislam/uptime-kuma:latest "/usr/bin/dumb-init …" 9 months ago Up 10 hours (healthy) 0.0.0.0:3001->3001/tcp, :::3001->3001/tcp uptime-kuma
385b70685c61 portainer/portainer-ce:latest "/portainer" 13 months ago Up 10 hours 0.0.0.0:8000->8000/tcp, :::8000->8000/tcp, 0.0.0.0:9000->9000/tcp, :::9000->9000/tcp, 0.0.0.0:9443->9443/tcp, :::9443->9443/tcp portainer
raspi.8gb.home | CHANGED | rc=0 >>
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
a400c79ee967 binwiederhier/ntfy "ntfy serve" 2 weeks ago Up 10 hours (healthy) 0.0.0.0:8150->80/tcp, [::]:8150->80/tcp ntfy
orangepi.pc.home | CHANGED | rc=0 >>
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
f25e63aabf37 portainer/portainer-ce:latest "/portainer" 10 months ago Up 10 hours 0.0.0.0:8000->8000/tcp, [::]:8000->8000/tcp, 0.0.0.0:9000->9000/tcp, [::]:9000->9000/tcp, 0.0.0.0:9443->9443/tcp, [::]:9443->9443/tcp portainer
```
---
*Généré automatiquement par Homelab Automation Dashboard*
*Date: 2025-12-26T02:23:44.244877+00:00*

View File

@ -1,2 +0,0 @@
# Ce fichier garde le répertoire tasks_logs dans git
# Les logs de tâches (*.md) seront créés ici dans le format YYYY/MM/JJ/

File diff suppressed because one or more lines are too long

View File

@ -686,4 +686,42 @@ describe('main.js - Ad-Hoc widget regression', () => {
// Since only 2 are displayed but 3 are loaded, load-more must be visible. // Since only 2 are displayed but 3 are loaded, load-more must be visible.
expect(document.getElementById('adhoc-widget-load-more').classList.contains('hidden')).toBe(false); expect(document.getElementById('adhoc-widget-load-more').classList.contains('hidden')).toBe(false);
}); });
it('does not crash structured playbook viewer when parsedOutput.msg is an object', async () => {
await import('../../app/main.js');
const dash = new window.DashboardManager();
dash.escapeHtml = (s) => String(s ?? '');
const parsedOutput = {
plays: [
{
name: 'Test Play',
tasks: [
{
name: 'Task 1',
hostResults: [
{
hostname: 'host1',
status: 'ok',
parsedOutput: { msg: { hello: 'world', code: 200 } }
}
]
}
]
}
],
recap: {
host1: { ok: 1, changed: 0, failed: 0, skipped: 0, unreachable: 0 }
},
metadata: { playbookName: 'dummy.yml' },
stats: { totalHosts: 1, totalTasks: 1 }
};
expect(() => dash.renderTaskHierarchy(parsedOutput)).not.toThrow();
const html = dash.renderTaskHierarchy(parsedOutput);
expect(html).toContain('host1');
expect(html).toContain('"hello"');
});
}); });