From 21bee026ffe4e1bd1506df95329890844e048986 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Thu, 4 Dec 2025 09:04:42 -0500 Subject: [PATCH] first commit --- .dockerignore | 65 + .env.example | 22 + .gitignore | 46 + Dockerfile | 55 + OKComputer_Optimiser UI_UX.zip | Bin 0 -> 19697 bytes README.md | 426 ++ ansible/ansible.cfg | 9 + ansible/inventory/group_vars/env_homelab.yml | 4 + ansible/inventory/group_vars/env_lab.yml | 4 + ansible/inventory/group_vars/env_prod.yml | 4 + ansible/inventory/group_vars/role_proxmox.yml | 4 + ansible/inventory/group_vars/role_sbc.yml | 4 + ansible/inventory/group_vars/role_truenas.yml | 1 + ansible/inventory/hosts.yml | 52 + ansible/inventory/hosts.yml.bak | 55 + ansible/playbooks/backup-config.yml | 47 + ansible/playbooks/bootstrap-host.yml | 229 + ansible/playbooks/health-check.yml | 83 + ansible/playbooks/mon-playbook.yml | 15 + ansible/playbooks/vm-install-jq.yml | 44 + ansible/playbooks/vm-reboot.yml | 23 + ansible/playbooks/vm-upgrade.yml | 34 + app/app_optimized.py | 3843 ++++++++++ app/index.html | 2540 +++++++ app/main.js | 6412 +++++++++++++++++ app/requirements.txt | 10 + docker-compose.yml | 58 + tasks_logs/.bootstrap_status.json | 79 + tasks_logs/.gitkeep | 2 + 29 files changed, 14170 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 OKComputer_Optimiser UI_UX.zip create mode 100644 README.md create mode 100644 ansible/ansible.cfg create mode 100644 ansible/inventory/group_vars/env_homelab.yml create mode 100644 ansible/inventory/group_vars/env_lab.yml create mode 100644 ansible/inventory/group_vars/env_prod.yml create mode 100644 ansible/inventory/group_vars/role_proxmox.yml create mode 100644 ansible/inventory/group_vars/role_sbc.yml create mode 100644 ansible/inventory/group_vars/role_truenas.yml create mode 100644 ansible/inventory/hosts.yml create mode 100644 ansible/inventory/hosts.yml.bak create mode 100644 ansible/playbooks/backup-config.yml create mode 100644 ansible/playbooks/bootstrap-host.yml create mode 100644 ansible/playbooks/health-check.yml create mode 100644 ansible/playbooks/mon-playbook.yml create mode 100644 ansible/playbooks/vm-install-jq.yml create mode 100644 ansible/playbooks/vm-reboot.yml create mode 100644 ansible/playbooks/vm-upgrade.yml create mode 100644 app/app_optimized.py create mode 100644 app/index.html create mode 100644 app/main.js create mode 100644 app/requirements.txt create mode 100644 docker-compose.yml create mode 100644 tasks_logs/.bootstrap_status.json create mode 100644 tasks_logs/.gitkeep diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2e2bd56 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,65 @@ +# Git +.git +.gitignore + +# Python +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +*.so +.eggs +*.egg-info +.pytest_cache +.mypy_cache +.tox +.nox +.coverage +htmlcov +.hypothesis + +# Virtual environments +venv +.venv +env +ENV + +# IDE +.idea +.vscode +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Docker +Dockerfile +docker-compose.yml +.dockerignore + +# Documentation +*.md +!README.md + +# Logs +*.log +logs/ + +# Environment files (contient des secrets) +.env +.env.* +!.env.example + +# Tests +tests/ +test_*.py +*_test.py + +# Temporary files +tmp/ +temp/ +*.tmp diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ec66856 --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +# Configuration du Homelab Automation Dashboard +# Copier ce fichier en .env et adapter les valeurs + +# Clé API pour l'authentification (changer en production!) +API_KEY=dev-key-12345 + +# Utilisateur SSH pour Ansible (doit exister sur les hôtes cibles) +SSH_USER=automation + +# Répertoire contenant les clés SSH sur la machine hôte +# Sera monté en lecture seule dans le container +SSH_KEY_DIR=~/.ssh + +# Répertoire des logs de tâches (fichiers markdown classés par YYYY/MM/JJ) +# Ce répertoire sera monté dans le container et contiendra l'historique +# des exécutions de tâches au format markdown +# Exemple Windows: C:\Obsidian_doc\SessionsManager\60-TACHES\LOGS +# Exemple Linux: /home/user/tasks_logs +DIR_LOGS_TASKS=./tasks_logs + +# Optionnel: Chemin spécifique de la clé privée SSH +# SSH_KEY_PATH=/path/to/id_rsa diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..64d03c3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +.env +.venv +env/ +venv/ +ENV/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Logs de tâches (gardent la structure mais pas les fichiers md) +tasks_logs/**/*.md +tasks_logs/**/.adhoc_history.json +!tasks_logs/.gitkeep + +# Docker +*.log + +# OS +.DS_Store +Thumbs.db diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..93461dc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,55 @@ +# Dockerfile pour Homelab Automation Dashboard avec Ansible +FROM python:3.11-slim + +# Métadonnées +LABEL maintainer="Homelab Automation" +LABEL description="Dashboard d'automatisation Homelab avec FastAPI et Ansible" +LABEL version="1.0" + +# Variables d'environnement +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV ANSIBLE_HOST_KEY_CHECKING=False +ENV ANSIBLE_RETRY_FILES_ENABLED=False + +# Répertoire de travail +WORKDIR /app + +# Installation des dépendances système pour Ansible et SSH +RUN apt-get update && apt-get install -y --no-install-recommends \ + openssh-client \ + sshpass \ + ansible \ + git \ + curl \ + && rm -rf /var/lib/apt/lists/* \ + && apt-get clean + +# Création du répertoire SSH et configuration +RUN mkdir -p /root/.ssh && chmod 700 /root/.ssh + +# Copie des requirements et installation des dépendances Python +COPY app/requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +# Copie du code de l'application +COPY app/ ./ + +# Copie de la configuration Ansible +COPY ansible/ /ansible/ + +# Création du répertoire pour les clés SSH (sera monté en volume) +RUN mkdir -p /app/ssh_keys + +# Configuration Ansible pour utiliser le bon répertoire +ENV ANSIBLE_CONFIG=/ansible/ansible.cfg + +# Exposition du port +EXPOSE 8000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8000/api/health || exit 1 + +# Commande de démarrage +CMD ["python", "-m", "uvicorn", "app_optimized:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/OKComputer_Optimiser UI_UX.zip b/OKComputer_Optimiser UI_UX.zip new file mode 100644 index 0000000000000000000000000000000000000000..d6dbc99edd8b9cfcc936152b70008ae42661a23a GIT binary patch literal 19697 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 + cd homelab-automation-api-v2/app + ``` + +2. **Installer les dépendances Python** + ```bash + pip install -r requirements.txt + ``` + +3. **Lancer le serveur backend (recommandé)** + ```bash + python -m uvicorn app_optimized:app --host 0.0.0.0 --port 8000 --reload + ``` + + Ou directement via le script Python : + ```bash + python app_optimized.py + ``` + +4. **Ouvrir le dashboard frontend complet** (interface de l'image 1) + + Naviguez vers : + - `http://localhost:8000/ui` → Dashboard Homelab (frontend HTML/JS) + +5. **Accéder à la page API** (interface de l'image 2) + + Naviguez vers : + - `http://localhost:8000` → Page d'accueil API avec liens vers la documentation + +## 📖 Utilisation + +### Interface Web + +1. **Tableau de Bord** : Vue d'ensemble des métriques système +2. **Gestion des Hôtes** : + - Ajouter de nouveaux hôtes avec le bouton "Ajouter Host" + - Surveiller l'état des hôtes en temps réel + - Exécuter des actions (connexion, mise à jour, redémarrage) +3. **Gestion des Tâches** : + - Créer des tâches d'automatisation + - Suivre la progression en temps réel + - Voir les détails et les logs +4. **Logs Système** : Consulter l'historique des événements + +### API REST + +#### Authentification +Toutes les requêtes API nécessitent une clé API dans le header `X-API-Key`: +```bash +curl -H "X-API-Key: dev-key-12345" http://localhost:8000/api/hosts +``` + +#### Endpoints Principaux + +**Hôtes** +- `GET /api/hosts` - Liste tous les hôtes +- `POST /api/hosts` - Crée un nouvel hôte +- `GET /api/hosts/{id}` - Récupère un hôte spécifique +- `DELETE /api/hosts/{id}` - Supprime un hôte + +**Tâches** +- `GET /api/tasks` - Liste toutes les tâches +- `POST /api/tasks` - Crée une nouvelle tâche +- `GET /api/tasks/{id}` - Récupère une tâche spécifique +- `DELETE /api/tasks/{id}` - Supprime une tâche + +**Logs** +- `GET /api/logs` - Récupère les logs récents +- `POST /api/logs` - Ajoute un nouveau log +- `DELETE /api/logs` - Efface tous les logs + +**Métriques** +- `GET /api/metrics` - Métriques système +- `GET /api/health/{host}` - Health check d'un hôte + +**WebSocket** +- `WS /ws` - Connexion WebSocket pour les mises à jour temps réel + +**Ansible** (nouveaux endpoints) +- `GET /api/ansible/playbooks` - Liste les playbooks disponibles +- `GET /api/ansible/inventory` - Récupère l'inventaire Ansible (hôtes et groupes) +- `GET /api/ansible/groups` - Liste les groupes Ansible +- `POST /api/ansible/execute` - Exécute un playbook directement +- `POST /api/ansible/adhoc` - Exécute une commande ad-hoc sur les hôtes +- `POST /api/ansible/bootstrap` - Bootstrap un hôte pour Ansible (crée user, SSH, sudo, Python) +- `GET /api/ansible/ssh-config` - Diagnostic de la configuration SSH + +#### Exemples d'utilisation Ansible + +**Lister les playbooks disponibles :** +```bash +curl -H "X-API-Key: dev-key-12345" http://localhost:8000/api/ansible/playbooks +``` + +**Voir l'inventaire Ansible :** +```bash +curl -H "X-API-Key: dev-key-12345" http://localhost:8000/api/ansible/inventory +``` + +**Exécuter un playbook (ex: mise à jour sur le groupe "lab") :** +```bash +curl -X POST -H "X-API-Key: dev-key-12345" -H "Content-Type: application/json" \ + -d '{"playbook": "vm-upgrade.yml", "target": "lab"}' \ + http://localhost:8000/api/ansible/execute +``` + +**Créer une tâche Ansible via l'API tasks :** +```bash +curl -X POST -H "X-API-Key: dev-key-12345" -H "Content-Type: application/json" \ + -d '{"action": "upgrade", "group": "proxmox"}' \ + http://localhost:8000/api/tasks +``` + +**Exécuter une commande ad-hoc :** +```bash +# Vérifier l'espace disque sur tous les hôtes +curl -X POST -H "X-API-Key: dev-key-12345" -H "Content-Type: application/json" \ + -d '{"target": "all", "command": "df -h /", "module": "shell"}' \ + http://localhost:8000/api/ansible/adhoc + +# Redémarrer un service avec sudo +curl -X POST -H "X-API-Key: dev-key-12345" -H "Content-Type: application/json" \ + -d '{"target": "web-servers", "command": "systemctl restart nginx", "become": true}' \ + http://localhost:8000/api/ansible/adhoc +``` + +### Documentation API + +- **Swagger UI** : `http://localhost:8000/api/docs` +- **ReDoc** : `http://localhost:8000/api/redoc` + +## 🎨 Personnalisation + +### Thèmes +L'application supporte les thèmes clair et sombre. Utilisez le bouton en haut à droite pour basculer. + +### Couleurs +Les couleurs principales peuvent être modifiées dans les variables CSS : +```css +:root { + --primary-bg: #0a0a0a; + --accent-color: #7c3aed; + --success-color: #10b981; + --error-color: #ef4444; +} +``` + +### Animations +Les animations sont gérées par Anime.js dans `main.js`. Vous pouvez ajuster : +- La durée des animations +- Les effets de transition +- Les comportements au scroll + +## 🔧 Configuration + +### Variables d'Environnement + +Créez un fichier `.env` pour configurer l'application : + +```env +API_KEY=votre-cle-api-secrete +SSH_REMOTE_USER=automation +LOGS_DIR=/var/log/homelab +ANSIBLE_DIR=/etc/ansible +``` + +### Base de Données + +Par défaut, l'application utilise une base de données en mémoire. Pour une utilisation en production, configurez PostgreSQL ou SQLite en modifiant la classe `InMemoryDB`. + +## 🚀 Déploiement + +### Production + +1. **Configuration de la base de données** + ```python + # Remplacer InMemoryDB par une vraie base de données + ``` + +2. **Sécurité** + - Utilisez une clé API forte + - Activez HTTPS + - Configurez les pare-feu + - Limitez les origines CORS + +3. **Performance** + - Utilisez un serveur de production (Gunicorn) + - Configurez Redis pour le cache + - Activez la compression + +### Docker + +Le projet inclut un Dockerfile et docker-compose.yml prêts à l'emploi avec Ansible intégré. + +#### Démarrage rapide avec Docker Compose + +```bash +# 1. Copier le fichier d'exemple d'environnement +cp .env.example .env + +# 2. Éditer .env pour configurer vos clés SSH +nano .env + +# 3. Lancer le container +docker-compose up -d + +# 4. Accéder au dashboard +# http://localhost:8000/ui +``` + +#### Configuration des clés SSH + +Par défaut, docker-compose monte votre répertoire `~/.ssh` en lecture seule. Assurez-vous que : + +1. Votre clé privée SSH existe : `~/.ssh/id_rsa` +2. Votre clé publique existe : `~/.ssh/id_rsa.pub` + +Ou spécifiez un répertoire différent dans `.env` : +```bash +SSH_KEY_DIR=/chemin/vers/vos/cles/ssh +``` + +#### Variables d'environnement + +| Variable | Description | Défaut | +|----------|-------------|--------| +| `API_KEY` | Clé API pour l'authentification | `dev-key-12345` | +| `SSH_USER` | Utilisateur SSH pour Ansible | `automation` | +| `SSH_KEY_DIR` | Répertoire des clés SSH sur l'hôte | `~/.ssh` | +| `SSH_KEY_PATH` | Chemin de la clé privée dans le container | `/app/ssh_keys/id_rsa` | + +#### Construction manuelle de l'image + +```bash +# Construire l'image +docker build -t homelab-dashboard . +``` + +```bash +# Exécuter le container +docker run -d \ + -p 8000:8000 \ + -v ~/.ssh:/app/ssh_keys:ro \ + -v ./ansible/inventory:/ansible/inventory:ro \ + -e API_KEY=votre-cle-api-secrete \ + --name homelab-dashboard \ + homelab-dashboard +``` + +```bash +# se connecter au container +docker exec -it homelab-dashboard /bin/bash +``` + +## 🔧 Bootstrap SSH + +Le dashboard inclut une fonctionnalité de **Bootstrap** pour préparer automatiquement un hôte à recevoir des commandes Ansible. + +### Ce que fait le Bootstrap + +1. **Crée l'utilisateur d'automatisation** (par défaut: `automation`) +2. **Configure l'authentification SSH par clé** (copie votre clé publique) +3. **Installe et configure sudo** sans mot de passe pour cet utilisateur +4. **Installe Python3** (requis par Ansible) +5. **Vérifie la connexion SSH** par clé après configuration + +### Systèmes supportés + +- **Debian/Ubuntu** (apt) +- **Alpine Linux** (apk) +- **FreeBSD** (pkg) + +### Utilisation via l'interface + +1. Cliquez sur le bouton **Bootstrap** (jaune) sur la carte d'un hôte +2. Entrez le **mot de passe root** de l'hôte distant +3. Optionnel : modifiez le nom de l'utilisateur d'automatisation +4. Cliquez sur **Lancer le Bootstrap** + +### Utilisation via l'API + +```bash +curl -X POST http://localhost:8000/api/ansible/bootstrap \ + -H "X-API-Key: dev-key-12345" \ + -H "Content-Type: application/json" \ + -d '{ + "host": "192.168.1.100", + "root_password": "votre-mot-de-passe-root", + "automation_user": "automation" + }' +``` + +### Prérequis + +- **sshpass** doit être installé sur la machine qui exécute le dashboard +- L'hôte cible doit accepter les connexions SSH avec mot de passe (pour la configuration initiale) +- Le compte root doit être accessible via SSH + +```bash +# Installation de sshpass +# Debian/Ubuntu +apt install sshpass + +# Alpine +apk add sshpass + +# macOS +brew install hudochenkov/sshpass/sshpass +``` + +## 🤝 Contribution + +Les contributions sont les bienvenues ! Veuillez : + +1. Fork le projet +2. Créer une branche pour votre fonctionnalité +3. Commit vos changements +4. Push vers la branche +5. Ouvrir une Pull Request + +## 📄 Licence + +Ce projet est sous licence MIT. Voir le fichier LICENSE pour plus de détails. + +## 🆘 Support + +Pour toute question ou problème : + +1. Consultez la documentation +2. Vérifiez les logs du serveur +3. Ouvrez une issue sur GitHub +4. Contactez l'équipe de développement + +--- + +**Développé avec ❤️ pour la communauté homelab** \ No newline at end of file diff --git a/ansible/ansible.cfg b/ansible/ansible.cfg new file mode 100644 index 0000000..0b1ba50 --- /dev/null +++ b/ansible/ansible.cfg @@ -0,0 +1,9 @@ +[defaults] +inventory = ./inventory/hosts.yml +host_key_checking = False +retry_files_enabled = False +stdout_callback = default +bin_ansible_callbacks = True + +[callback_default] +result_format = yaml diff --git a/ansible/inventory/group_vars/env_homelab.yml b/ansible/inventory/group_vars/env_homelab.yml new file mode 100644 index 0000000..ec660c6 --- /dev/null +++ b/ansible/inventory/group_vars/env_homelab.yml @@ -0,0 +1,4 @@ +ansible_port: 22 +ansible_user: automation +ansible_ssh_private_key_file: /app/ssh_keys/id_automation_ansible + diff --git a/ansible/inventory/group_vars/env_lab.yml b/ansible/inventory/group_vars/env_lab.yml new file mode 100644 index 0000000..4eb3868 --- /dev/null +++ b/ansible/inventory/group_vars/env_lab.yml @@ -0,0 +1,4 @@ +ansible_port: 22 +ansible_user: automation +ansible_ssh_private_key_file: /app/ssh_keys/id_automation_ansible +ansible_python_interpreter: /usr/bin/python3 diff --git a/ansible/inventory/group_vars/env_prod.yml b/ansible/inventory/group_vars/env_prod.yml new file mode 100644 index 0000000..4eb3868 --- /dev/null +++ b/ansible/inventory/group_vars/env_prod.yml @@ -0,0 +1,4 @@ +ansible_port: 22 +ansible_user: automation +ansible_ssh_private_key_file: /app/ssh_keys/id_automation_ansible +ansible_python_interpreter: /usr/bin/python3 diff --git a/ansible/inventory/group_vars/role_proxmox.yml b/ansible/inventory/group_vars/role_proxmox.yml new file mode 100644 index 0000000..93bdaa9 --- /dev/null +++ b/ansible/inventory/group_vars/role_proxmox.yml @@ -0,0 +1,4 @@ +ansible_port: 22 +ansible_user: automation +ansible_ssh_private_key_file: /app/ssh_keys/id_automation_ansible +ansible_python_interpreter: /usr/bin/python3 \ No newline at end of file diff --git a/ansible/inventory/group_vars/role_sbc.yml b/ansible/inventory/group_vars/role_sbc.yml new file mode 100644 index 0000000..4eb3868 --- /dev/null +++ b/ansible/inventory/group_vars/role_sbc.yml @@ -0,0 +1,4 @@ +ansible_port: 22 +ansible_user: automation +ansible_ssh_private_key_file: /app/ssh_keys/id_automation_ansible +ansible_python_interpreter: /usr/bin/python3 diff --git a/ansible/inventory/group_vars/role_truenas.yml b/ansible/inventory/group_vars/role_truenas.yml new file mode 100644 index 0000000..4388ad1 --- /dev/null +++ b/ansible/inventory/group_vars/role_truenas.yml @@ -0,0 +1 @@ +ansible_python_interpreter: /usr/local/bin/python3 \ No newline at end of file diff --git a/ansible/inventory/hosts.yml b/ansible/inventory/hosts.yml new file mode 100644 index 0000000..92bf1c5 --- /dev/null +++ b/ansible/inventory/hosts.yml @@ -0,0 +1,52 @@ +all: + children: + env_homelab: + hosts: + ali2v.xeon.home: null + hp.nas.home: null + hp2.i7.home: null + hp3.i5.home: null + mimi.pc.home: null + orangepi.pc.home: null + raspi.4gb.home: null + raspi.8gb.home: null + env_lab: + hosts: + media.labb.home: null + dev.lab.home: null + env_prod: + hosts: + hp.truenas.home: null + ali2v.truenas.home: null + jump.point.home: null + automate.prod.home: null + dev.prod.home: null + role_proxmox: + hosts: + ali2v.xeon.home: null + hp.nas.home: null + hp2.i7.home: null + hp3.i5.home: null + mimi.pc.home: null + role_lab_servers: + hosts: + media.labb.home: null + dev.lab.home: null + role_truenas: + hosts: + hp.truenas.home: null + ali2v.truenas.home: + ansible_python_interpreter: /usr/bin/python3 + role_prod_servers: + hosts: + jump.point.home: null + automate.prod.home: null + dev.prod.home: null + role_sbc: + hosts: + orangepi.pc.home: null + raspi.4gb.home: null + raspi.8gb.home: null + role_docker: + hosts: + dev.lab.home: null diff --git a/ansible/inventory/hosts.yml.bak b/ansible/inventory/hosts.yml.bak new file mode 100644 index 0000000..ecad769 --- /dev/null +++ b/ansible/inventory/hosts.yml.bak @@ -0,0 +1,55 @@ +all: + children: + env_homelab: + hosts: + ali2v.xeon.home: null + hp.nas.home: null + hp2.i7.home: null + hp3.i5.home: null + mimi.pc.home: null + orangepi.pc.home: null + raspi.4gb.home: null + raspi.8gb.home: null + env_lab: + hosts: + media.labb.home: null + toto: + ansible_host: toto.home + dev.lab.home: null + env_prod: + hosts: + hp.truenas.home: null + ali2v.truenas.home: null + jump.point.home: null + automate.prod.home: null + dev.prod.home: null + role_proxmox: + hosts: + ali2v.xeon.home: null + hp.nas.home: null + hp2.i7.home: null + hp3.i5.home: null + mimi.pc.home: null + role_lab_servers: + hosts: + media.labb.home: null + toto: null + dev.lab.home: null + role_truenas: + hosts: + hp.truenas.home: null + ali2v.truenas.home: + ansible_python_interpreter: /usr/bin/python3 + role_prod_servers: + hosts: + jump.point.home: null + automate.prod.home: null + dev.prod.home: null + role_sbc: + hosts: + orangepi.pc.home: null + raspi.4gb.home: null + raspi.8gb.home: null + role_docker: + hosts: + dev.lab.home: null diff --git a/ansible/playbooks/backup-config.yml b/ansible/playbooks/backup-config.yml new file mode 100644 index 0000000..dc1a843 --- /dev/null +++ b/ansible/playbooks/backup-config.yml @@ -0,0 +1,47 @@ +--- +- name: Backup configuration files + hosts: all + become: true + gather_facts: true + vars: + category: backup + subcategory: configuration + backup_dir: /tmp/config_backup + timestamp: "{{ ansible_date_time.iso8601_basic_short }}" + tasks: + - name: Create backup directory + ansible.builtin.file: + path: "{{ backup_dir }}" + state: directory + mode: '0755' + + - name: Backup /etc directory (essential configs) + ansible.builtin.archive: + path: + - /etc/hostname + - /etc/hosts + - /etc/passwd + - /etc/group + - /etc/shadow + - /etc/sudoers + - /etc/ssh/sshd_config + dest: "{{ backup_dir }}/etc_backup_{{ timestamp }}.tar.gz" + format: gz + ignore_errors: true + + - name: Backup crontabs + ansible.builtin.shell: | + crontab -l > {{ backup_dir }}/crontab_{{ timestamp }}.txt 2>/dev/null || echo "No crontab" + changed_when: false + + - name: List backup files + ansible.builtin.find: + paths: "{{ backup_dir }}" + patterns: "*{{ timestamp }}*" + register: backup_files + + - name: Display backup summary + ansible.builtin.debug: + msg: | + Backup completed for {{ inventory_hostname }} + Files created: {{ backup_files.files | map(attribute='path') | list }} diff --git a/ansible/playbooks/bootstrap-host.yml b/ansible/playbooks/bootstrap-host.yml new file mode 100644 index 0000000..5067c1e --- /dev/null +++ b/ansible/playbooks/bootstrap-host.yml @@ -0,0 +1,229 @@ +--- +- name: Bootstrap host for Ansible automation + hosts: all + become: true + gather_facts: false + vars: + category: maintenance + subcategory: bootstrap + automation_user: "{{ lookup('env', 'SSH_USER') | default('automation', true) }}" + ssh_public_key_path: "/app/ssh_keys/id_rsa.pub" + + tasks: + - name: Detect OS type + raw: | + if command -v apk >/dev/null 2>&1; then + echo "alpine" + elif command -v pkg >/dev/null 2>&1 && [ -f /etc/freebsd-update.conf ]; then + echo "freebsd" + elif [ -f /etc/debian_version ]; then + echo "debian" + elif [ -f /etc/redhat-release ]; then + echo "redhat" + else + echo "unknown" + fi + register: os_type_raw + changed_when: false + + - name: Detect OS variant (Armbian, Raspbian, etc.) + raw: | + if [ -f /etc/armbian-release ]; then + echo "armbian" + elif [ -f /etc/rpi-issue ] || grep -qi "raspberry" /proc/cpuinfo 2>/dev/null; then + echo "raspbian" + else + echo "standard" + fi + register: os_variant_raw + changed_when: false + + - name: Set OS type fact + set_fact: + os_type: "{{ os_type_raw.stdout | trim }}" + os_variant: "{{ os_variant_raw.stdout | trim }}" + + - name: Display detected OS + debug: + msg: "[1/7] OS détecté: {{ os_type }} ({{ os_variant }})" + + - name: Check if automation user exists + raw: id {{ automation_user }} 2>/dev/null && echo "exists" || echo "not_exists" + register: user_check + changed_when: false + + - name: Display user status + debug: + msg: "[2/7] Utilisateur {{ automation_user }}: {{ 'existant' if 'exists' in user_check.stdout else 'à créer' }}" + + - name: Create automation user (Debian/Ubuntu) + raw: useradd -m -s /bin/bash {{ automation_user }} || useradd -m -s /bin/sh {{ automation_user }} + when: + - "'not_exists' in user_check.stdout" + - os_type == "debian" + + - name: Create automation user (Alpine) + raw: adduser -D {{ automation_user }} + when: + - "'not_exists' in user_check.stdout" + - os_type == "alpine" + + - name: Create automation user (FreeBSD) + raw: pw useradd {{ automation_user }} -m -s /bin/sh + when: + - "'not_exists' in user_check.stdout" + - os_type == "freebsd" + + - name: Display user creation result + debug: + msg: "[3/7] Utilisateur {{ automation_user }} configuré" + + - name: Unlock user account + raw: | + if command -v passwd >/dev/null 2>&1; then + passwd -u {{ automation_user }} 2>/dev/null || true + fi + if command -v usermod >/dev/null 2>&1; then + usermod -U {{ automation_user }} 2>/dev/null || true + fi + changed_when: false + + - name: Get home directory + raw: "getent passwd {{ automation_user }} | cut -d: -f6 || echo /home/{{ automation_user }}" + register: home_dir_raw + changed_when: false + + - name: Set home directory fact + set_fact: + home_dir: "{{ home_dir_raw.stdout | trim }}" + + - name: Create .ssh directory + raw: | + mkdir -p {{ home_dir }}/.ssh + chown {{ automation_user }}:{{ automation_user }} {{ home_dir }}/.ssh + chmod 700 {{ home_dir }}/.ssh + changed_when: false + + - name: Read local SSH public key + delegate_to: localhost + become: false + slurp: + src: "{{ ssh_public_key_path }}" + register: ssh_pub_key + ignore_errors: true + + - name: Try alternate SSH key paths + delegate_to: localhost + become: false + slurp: + src: "{{ item }}" + register: ssh_pub_key_alt + loop: + - "/app/ssh_keys/id_ed25519.pub" + - "/app/ssh_keys/id_automation_ansible.pub" + - "{{ lookup('env', 'HOME') }}/.ssh/id_rsa.pub" + when: ssh_pub_key.failed | default(false) + ignore_errors: true + + - name: Set SSH public key content + set_fact: + ssh_key_content: "{{ (ssh_pub_key.content | b64decode | trim) if not (ssh_pub_key.failed | default(false)) else (ssh_pub_key_alt.results | selectattr('content', 'defined') | map(attribute='content') | first | b64decode | trim) }}" + when: not (ssh_pub_key.failed | default(false)) or (ssh_pub_key_alt.results | selectattr('content', 'defined') | list | length > 0) + + - name: Install SSH public key + raw: | + cat > {{ home_dir }}/.ssh/authorized_keys << 'SSHKEY_EOF' + {{ ssh_key_content }} + SSHKEY_EOF + chown {{ automation_user }}:{{ automation_user }} {{ home_dir }}/.ssh/authorized_keys + chmod 600 {{ home_dir }}/.ssh/authorized_keys + when: ssh_key_content is defined + + - name: Verify authorized_keys + raw: test -s {{ home_dir }}/.ssh/authorized_keys && wc -l < {{ home_dir }}/.ssh/authorized_keys || echo "0" + register: auth_keys_count + changed_when: false + + - name: Display SSH key status + debug: + msg: "[4/7] Clé SSH installée ({{ auth_keys_count.stdout | trim }} clé(s))" + + - name: Install sudo (Debian/Ubuntu) + raw: | + if ! command -v sudo >/dev/null 2>&1; then + apt-get update -qq && apt-get install -y sudo + fi + when: os_type == "debian" + + - name: Install sudo (Alpine) + raw: | + if ! command -v sudo >/dev/null 2>&1; then + apk add --no-cache sudo + fi + when: os_type == "alpine" + + - name: Install sudo (FreeBSD) + raw: | + if ! command -v sudo >/dev/null 2>&1; then + pkg install -y sudo + fi + when: os_type == "freebsd" + + - name: Display sudo status + debug: + msg: "[5/7] sudo installé/vérifié" + + - name: Configure sudoers + raw: | + mkdir -p /etc/sudoers.d + echo "{{ automation_user }} ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/automation + chmod 440 /etc/sudoers.d/automation + changed_when: false + + - name: Display sudoers status + debug: + msg: "[6/7] Sudoers configuré: /etc/sudoers.d/automation" + + - name: Install Python3 (Debian/Ubuntu) + raw: | + if ! command -v python3 >/dev/null 2>&1; then + apt-get update -qq && apt-get install -y python3 + fi + when: os_type == "debian" + + - name: Install Python3 (Alpine) + raw: | + if ! command -v python3 >/dev/null 2>&1; then + apk add --no-cache python3 + fi + when: os_type == "alpine" + + - name: Install Python3 (FreeBSD) + raw: | + if ! command -v python3 >/dev/null 2>&1; then + pkg install -y python3 + fi + when: os_type == "freebsd" + + - name: Get Python version + raw: python3 --version 2>&1 || echo "N/A" + register: python_version + changed_when: false + + - name: Display Python status + debug: + msg: "[7/7] Python3 installé: {{ python_version.stdout | trim }}" + + - name: Display bootstrap summary + debug: + msg: | + ═══════════════════════════════════════ + Bootstrap terminé avec succès! + ═══════════════════════════════════════ + Host: {{ inventory_hostname }} + OS Type: {{ os_type }} ({{ os_variant }}) + User: {{ automation_user }} + Home: {{ home_dir }} + SSH Keys: {{ auth_keys_count.stdout | trim }} + Python: {{ python_version.stdout | trim }} + ═══════════════════════════════════════ diff --git a/ansible/playbooks/health-check.yml b/ansible/playbooks/health-check.yml new file mode 100644 index 0000000..8e17a79 --- /dev/null +++ b/ansible/playbooks/health-check.yml @@ -0,0 +1,83 @@ +--- +- name: Health check on target host + hosts: all + become: false + gather_facts: false + vars: + category: monitoring + subcategory: health + tasks: + - name: Check if host is reachable (ping) + ansible.builtin.ping: + register: ping_result + + - name: Gather minimal facts + ansible.builtin.setup: + gather_subset: + - '!all' + - '!min' + - distribution + ignore_errors: true + register: facts_result + + - name: Get system uptime + ansible.builtin.command: uptime + register: uptime_result + changed_when: false + ignore_errors: true + + - name: Get disk usage + ansible.builtin.shell: df -h / | tail -1 | awk '{print $5}' + register: disk_usage + changed_when: false + ignore_errors: true + + - name: Get memory usage (Linux) + ansible.builtin.shell: | + if command -v free >/dev/null 2>&1; then + free -m | grep Mem | awk '{printf "%.1f%%", $3/$2 * 100}' + else + # Fallback for systems without free command + cat /proc/meminfo | awk '/MemTotal/{total=$2} /MemAvailable/{avail=$2} END{printf "%.1f%%", (total-avail)/total*100}' + fi + register: memory_usage + changed_when: false + ignore_errors: true + + - name: Get CPU temperature (ARM/SBC) + ansible.builtin.shell: | + if [ -f /sys/class/thermal/thermal_zone0/temp ]; then + temp=$(cat /sys/class/thermal/thermal_zone0/temp) + # Use awk instead of bc for better compatibility + echo "${temp}" | awk '{printf "%.1f°C", $1/1000}' + else + echo "N/A" + fi + register: cpu_temp + changed_when: false + ignore_errors: true + + - name: Get CPU load + ansible.builtin.shell: | + if [ -f /proc/loadavg ]; then + cat /proc/loadavg | awk '{print $1}' + else + uptime | awk -F'load average:' '{print $2}' | awk -F',' '{print $1}' | tr -d ' ' + fi + register: cpu_load + changed_when: false + ignore_errors: true + + - name: Display health status + ansible.builtin.debug: + msg: | + ═══════════════════════════════════════ + Host: {{ inventory_hostname }} + Status: {{ 'OK' if ping_result is success else 'UNREACHABLE' }} + ═══════════════════════════════════════ + Uptime: {{ uptime_result.stdout | default('N/A') }} + Disk Usage: {{ disk_usage.stdout | default('N/A') }} + Memory Usage: {{ memory_usage.stdout | default('N/A') }} + CPU Load: {{ cpu_load.stdout | default('N/A') }} + CPU Temp: {{ cpu_temp.stdout | default('N/A') }} + ═══════════════════════════════════════ diff --git a/ansible/playbooks/mon-playbook.yml b/ansible/playbooks/mon-playbook.yml new file mode 100644 index 0000000..5c52d24 --- /dev/null +++ b/ansible/playbooks/mon-playbook.yml @@ -0,0 +1,15 @@ +--- +# mon-playbook.yml +# Créé le 03/12/2025 + +- name: mon playbook + hosts: all + become: yes + vars: + category: Test + subcategory: other + + tasks: + - name: Exemple de tâche + ansible.builtin.debug: + msg: "Playbook mon-playbook exécuté avec succès!" diff --git a/ansible/playbooks/vm-install-jq.yml b/ansible/playbooks/vm-install-jq.yml new file mode 100644 index 0000000..b66bf52 --- /dev/null +++ b/ansible/playbooks/vm-install-jq.yml @@ -0,0 +1,44 @@ +--- +- name: Install jq on target host + hosts: all + become: true + gather_facts: true + vars: + category: maintenance + subcategory: system + tasks: + - name: Install jq + ansible.builtin.package: + name: jq + state: present + when: ansible_facts['os_family'] == 'Debian' + - name: Install jq on RedHat family + ansible.builtin.dnf: + name: jq + state: present + when: ansible_facts['os_family'] == 'RedHat' + - name: Install jq on Alpine + ansible.builtin.apk: + name: jq + state: present + when: ansible_facts['os_family'] == 'Alpine' + - name: Install jq on FreeBSD + ansible.builtin.pkg: + name: jq + state: present + when: ansible_facts['os_family'] == 'FreeBSD' + - name: Install jq on OpenBSD + ansible.builtin.pkg_add: + name: jq + state: present + when: ansible_facts['os_family'] == 'OpenBSD' + - name: Install jq on macOS + ansible.builtin.homebrew: + name: jq + state: present + when: ansible_facts['os_family'] == 'Darwin' + - name: Install jq on Windows + ansible.builtin.win_chocolatey: + name: jq + state: present + when: ansible_facts['os_family'] == 'Windows' \ No newline at end of file diff --git a/ansible/playbooks/vm-reboot.yml b/ansible/playbooks/vm-reboot.yml new file mode 100644 index 0000000..890c5b9 --- /dev/null +++ b/ansible/playbooks/vm-reboot.yml @@ -0,0 +1,23 @@ +--- +- name: Reboot target host + hosts: all + become: true + vars: + category: maintenance + subcategory: system + tasks: + - name: Detect distribution + ansible.builtin.setup: + gather_subset: + - os_family + + - name: Reboot the machine (Linux) + ansible.builtin.reboot: + reboot_timeout: 600 + when: ansible_facts['os_family'] != 'FreeBSD' + + - name: Reboot the machine (FreeBSD) + ansible.builtin.reboot: + reboot_timeout: 600 + boot_time_command: sysctl kern.boottime + when: ansible_facts['os_family'] == 'FreeBSD' diff --git a/ansible/playbooks/vm-upgrade.yml b/ansible/playbooks/vm-upgrade.yml new file mode 100644 index 0000000..d0658b9 --- /dev/null +++ b/ansible/playbooks/vm-upgrade.yml @@ -0,0 +1,34 @@ +--- +- name: Upgrade packages on target host + hosts: all + become: true + vars: + category: maintenance + subcategory: system + tasks: + - name: Detect distribution + ansible.builtin.setup: + gather_subset: + - os_family + + - name: Upgrade on Debian/Ubuntu + ansible.builtin.apt: + update_cache: yes + upgrade: dist + when: ansible_facts['os_family'] == 'Debian' + + - name: Upgrade on Alpine + ansible.builtin.shell: | + apk update && apk upgrade + when: ansible_facts['os_family'] == 'Alpine' + + - name: Upgrade on RedHat family + ansible.builtin.dnf: + name: "*" + state: latest + when: ansible_facts['os_family'] == 'RedHat' + + - name: Upgrade on FreeBSD + ansible.builtin.shell: | + pkg update && pkg upgrade -y + when: ansible_facts['os_family'] == 'FreeBSD' diff --git a/app/app_optimized.py b/app/app_optimized.py new file mode 100644 index 0000000..b0c026f --- /dev/null +++ b/app/app_optimized.py @@ -0,0 +1,3843 @@ +""" +Homelab Automation Dashboard - Backend Optimisé +API REST moderne avec FastAPI pour la gestion d'homelab +""" + +from datetime import datetime, timezone +from pathlib import Path +from time import perf_counter, time +import os +import re +import shutil +import subprocess +import sqlite3 +import yaml +from abc import ABC, abstractmethod +from typing import Literal, Any, List, Dict, Optional +from threading import Lock +import asyncio +import json + +from fastapi import FastAPI, HTTPException, Depends, Request, Form, WebSocket, WebSocketDisconnect +from fastapi.responses import HTMLResponse, JSONResponse, FileResponse +from fastapi.security import APIKeyHeader +from fastapi.templating import Jinja2Templates +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel, Field, field_validator +import uvicorn + +BASE_DIR = Path(__file__).resolve().parent + +# Configuration avancée de l'application +app = FastAPI( + title="Homelab Automation Dashboard API", + version="1.0.0", + description="API REST moderne pour la gestion automatique d'homelab", + docs_url="/api/docs", + redoc_url="/api/redoc" +) + +# Middleware CORS pour le développement +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # À restreindre en production + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.mount("/static", StaticFiles(directory=BASE_DIR, html=False), name="static") + +# Configuration des chemins et variables d'environnement +LOGS_DIR = Path(os.environ.get("LOGS_DIR", "/logs")) +ANSIBLE_DIR = BASE_DIR.parent / "ansible" +SSH_KEY_PATH = os.environ.get("SSH_KEY_PATH", str(Path.home() / ".ssh" / "id_rsa")) +SSH_USER = os.environ.get("SSH_USER", "automation") +SSH_REMOTE_USER = os.environ.get("SSH_REMOTE_USER", "root") +DB_PATH = LOGS_DIR / "homelab.db" +API_KEY = os.environ.get("API_KEY", "dev-key-12345") +# Répertoire pour les logs de tâches en markdown (format YYYY/MM/JJ) +DIR_LOGS_TASKS = Path(os.environ.get("DIR_LOGS_TASKS", str(BASE_DIR.parent / "tasks_logs"))) +# Fichier JSON pour l'historique des commandes ad-hoc +ADHOC_HISTORY_FILE = DIR_LOGS_TASKS / ".adhoc_history.json" +# Fichier JSON pour les statuts persistés +BOOTSTRAP_STATUS_FILE = DIR_LOGS_TASKS / ".bootstrap_status.json" +HOST_STATUS_FILE = ANSIBLE_DIR / ".host_status.json" + +# Mapping des actions vers les playbooks +ACTION_PLAYBOOK_MAP = { + 'upgrade': 'vm-upgrade.yml', + 'reboot': 'vm-reboot.yml', + 'health-check': 'health-check.yml', + 'backup': 'backup-config.yml', + 'bootstrap': 'bootstrap-host.yml', +} + +# Gestionnaire de clés API +api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) + +# Modèles Pydantic améliorés +class CommandResult(BaseModel): + status: str + return_code: int + stdout: str + stderr: Optional[str] = None + execution_time: Optional[float] = None + timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + +class Host(BaseModel): + id: int + name: str + ip: str + status: Literal["online", "offline", "warning"] + os: str + last_seen: Optional[datetime] = None + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + groups: List[str] = [] # Groupes Ansible auxquels appartient l'hôte + bootstrap_ok: bool = False # Indique si le bootstrap a été effectué avec succès + bootstrap_date: Optional[datetime] = None # Date du dernier bootstrap réussi + + class Config: + json_encoders = { + datetime: lambda v: v.isoformat() + } + +class Task(BaseModel): + id: int + name: str + host: str + status: Literal["pending", "running", "completed", "failed", "cancelled"] + progress: int = Field(ge=0, le=100, default=0) + start_time: Optional[datetime] = None + end_time: Optional[datetime] = None + duration: Optional[str] = None + output: Optional[str] = None + error: Optional[str] = None + + class Config: + json_encoders = { + datetime: lambda v: v.isoformat() if v else None + } + +class LogEntry(BaseModel): + id: int + timestamp: datetime + level: Literal["DEBUG", "INFO", "WARN", "ERROR"] + message: str + source: Optional[str] = None + host: Optional[str] = None + + class Config: + json_encoders = { + datetime: lambda v: v.isoformat() + } + +class SystemMetrics(BaseModel): + online_hosts: int + total_tasks: int + success_rate: float + uptime: float + cpu_usage: float + memory_usage: float + disk_usage: float + +class HealthCheck(BaseModel): + host: str + ssh_ok: bool = False + ansible_ok: bool = False + sudo_ok: bool = False + reachable: bool = False + error_message: Optional[str] = None + response_time: Optional[float] = None + cached: bool = False + cache_age: int = 0 + +class AnsibleExecutionRequest(BaseModel): + playbook: str = Field(..., description="Nom du playbook à exécuter") + target: str = Field(default="all", description="Hôte ou groupe cible") + extra_vars: Optional[Dict[str, Any]] = Field(default=None, description="Variables supplémentaires") + check_mode: bool = Field(default=False, description="Mode dry-run (--check)") + verbose: bool = Field(default=False, description="Mode verbeux") + +class AnsibleInventoryHost(BaseModel): + name: str + ansible_host: str + group: str + groups: List[str] = [] # All groups this host belongs to + vars: Dict[str, Any] = {} + +class TaskRequest(BaseModel): + host: Optional[str] = Field(default=None, description="Hôte cible") + group: Optional[str] = Field(default=None, description="Groupe cible") + action: str = Field(..., description="Action à exécuter") + cmd: Optional[str] = Field(default=None, description="Commande personnalisée") + extra_vars: Optional[Dict[str, Any]] = Field(default=None, description="Variables Ansible") + tags: Optional[List[str]] = Field(default=None, description="Tags Ansible") + dry_run: bool = Field(default=False, description="Mode simulation") + ssh_user: Optional[str] = Field(default=None, description="Utilisateur SSH") + ssh_password: Optional[str] = Field(default=None, description="Mot de passe SSH") + + @field_validator('action') + @classmethod + def validate_action(cls, v: str) -> str: + valid_actions = ['upgrade', 'reboot', 'health-check', 'backup', 'deploy', 'rollback', 'maintenance', 'bootstrap'] + if v not in valid_actions: + raise ValueError(f'Action doit être l\'une de: {", ".join(valid_actions)}') + return v + +class HostRequest(BaseModel): + name: str = Field(..., min_length=3, max_length=100, description="Hostname (ex: server.domain.home)") + # ansible_host peut être soit une IPv4, soit un hostname résolvable → on enlève la contrainte de pattern + ip: Optional[str] = Field(default=None, description="Adresse IP ou hostname (optionnel si hostname résolvable)") + os: str = Field(default="Linux", min_length=3, max_length=50) + ssh_user: Optional[str] = Field(default="root", min_length=1, max_length=50) + ssh_port: int = Field(default=22, ge=1, le=65535) + description: Optional[str] = Field(default=None, max_length=200) + env_group: str = Field(..., description="Groupe d'environnement (ex: env_homelab, env_prod)") + role_groups: List[str] = Field(default=[], description="Groupes de rôles (ex: role_proxmox, role_sbc)") + + +class HostUpdateRequest(BaseModel): + """Requête de mise à jour d'un hôte""" + env_group: Optional[str] = Field(default=None, description="Nouveau groupe d'environnement") + role_groups: Optional[List[str]] = Field(default=None, description="Nouveaux groupes de rôles") + ansible_host: Optional[str] = Field(default=None, description="Nouvelle adresse ansible_host") + + +class GroupRequest(BaseModel): + """Requête pour créer un groupe""" + name: str = Field(..., min_length=3, max_length=50, description="Nom du groupe (ex: env_prod, role_web)") + type: str = Field(..., description="Type de groupe: 'env' ou 'role'") + + @field_validator('name') + @classmethod + def validate_name(cls, v: str) -> str: + import re + if not re.match(r'^[a-zA-Z0-9_-]+$', v): + raise ValueError('Le nom du groupe ne peut contenir que des lettres, chiffres, tirets et underscores') + return v + + @field_validator('type') + @classmethod + def validate_type(cls, v: str) -> str: + if v not in ['env', 'role']: + raise ValueError("Le type doit être 'env' ou 'role'") + return v + + +class GroupUpdateRequest(BaseModel): + """Requête pour modifier un groupe""" + new_name: str = Field(..., min_length=3, max_length=50, description="Nouveau nom du groupe") + + @field_validator('new_name') + @classmethod + def validate_new_name(cls, v: str) -> str: + import re + if not re.match(r'^[a-zA-Z0-9_-]+$', v): + raise ValueError('Le nom du groupe ne peut contenir que des lettres, chiffres, tirets et underscores') + return v + + +class GroupDeleteRequest(BaseModel): + """Requête pour supprimer un groupe""" + move_hosts_to: Optional[str] = Field(default=None, description="Groupe vers lequel déplacer les hôtes") + + +class AdHocCommandRequest(BaseModel): + """Requête pour exécuter une commande ad-hoc Ansible""" + target: str = Field(..., description="Hôte ou groupe cible") + command: str = Field(..., description="Commande shell à exécuter") + module: str = Field(default="shell", description="Module Ansible (shell, command, raw)") + become: bool = Field(default=False, description="Exécuter avec sudo") + timeout: int = Field(default=60, ge=5, le=600, description="Timeout en secondes") + + +class AdHocCommandResult(BaseModel): + """Résultat d'une commande ad-hoc""" + target: str + command: str + success: bool + return_code: int + stdout: str + stderr: Optional[str] = None + duration: float + hosts_results: Optional[Dict[str, Any]] = None + + +class AdHocHistoryEntry(BaseModel): + """Entrée dans l'historique des commandes ad-hoc""" + id: str + command: str + target: str + module: str + become: bool + category: str = "default" + description: Optional[str] = None + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + last_used: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + use_count: int = 1 + + +class AdHocHistoryCategory(BaseModel): + """Catégorie pour organiser les commandes ad-hoc""" + name: str + description: Optional[str] = None + color: str = "#7c3aed" + icon: str = "fa-folder" + + +class TaskLogFile(BaseModel): + """Représentation d'un fichier de log de tâche""" + id: str + filename: str + path: str + task_name: str + target: str + status: str + date: str # Format YYYY-MM-DD + year: str + month: str + day: str + created_at: datetime + size_bytes: int + # Nouveaux champs pour affichage enrichi + start_time: Optional[str] = None # Format ISO ou HH:MM:SS + end_time: Optional[str] = None # Format ISO ou HH:MM:SS + duration: Optional[str] = None # Durée formatée + duration_seconds: Optional[int] = None # Durée en secondes + hosts: List[str] = [] # Liste des hôtes impliqués + category: Optional[str] = None # Catégorie (Playbook, Ad-hoc, etc.) + subcategory: Optional[str] = None # Sous-catégorie + target_type: Optional[str] = None # Type de cible: 'host', 'group', 'role' + + +class TasksFilterParams(BaseModel): + """Paramètres de filtrage des tâches""" + status: Optional[str] = None # pending, running, completed, failed, all + year: Optional[str] = None + month: Optional[str] = None + day: Optional[str] = None + target: Optional[str] = None + search: Optional[str] = None + + +# ===== SERVICE DE LOGGING MARKDOWN ===== + +class TaskLogService: + """Service pour gérer les logs de tâches en fichiers markdown""" + + def __init__(self, base_dir: Path): + self.base_dir = base_dir + self._ensure_base_dir() + + def _ensure_base_dir(self): + """Crée le répertoire de base s'il n'existe pas""" + self.base_dir.mkdir(parents=True, exist_ok=True) + + def _get_date_path(self, dt: datetime = None) -> Path: + """Retourne le chemin du répertoire pour une date donnée (YYYY/MM/JJ)""" + if dt is None: + dt = datetime.now(timezone.utc) + year = dt.strftime("%Y") + month = dt.strftime("%m") + day = dt.strftime("%d") + return self.base_dir / year / month / day + + def _generate_task_id(self) -> str: + """Génère un ID unique pour une tâche""" + import uuid + return f"task_{datetime.now(timezone.utc).strftime('%H%M%S')}_{uuid.uuid4().hex[:6]}" + + def save_task_log(self, task: 'Task', output: str = "", error: str = "") -> str: + """Sauvegarde un log de tâche en markdown et retourne le chemin""" + dt = task.start_time or datetime.now(timezone.utc) + date_path = self._get_date_path(dt) + date_path.mkdir(parents=True, exist_ok=True) + + # Générer le nom du fichier + task_id = self._generate_task_id() + status_emoji = { + "completed": "✅", + "failed": "❌", + "running": "🔄", + "pending": "⏳", + "cancelled": "🚫" + }.get(task.status, "❓") + + # Sanitize task name and host for filename + safe_name = task.name.replace(' ', '_').replace(':', '').replace('/', '-')[:50] + safe_host = task.host.replace(' ', '_').replace(':', '').replace('/', '-')[:30] if task.host else 'unknown' + filename = f"{task_id}_{safe_host}_{safe_name}_{task.status}.md" + filepath = date_path / filename + + # Créer le contenu markdown + md_content = f"""# {status_emoji} {task.name} + +## Informations + +| Propriété | Valeur | +|-----------|--------| +| **ID** | `{task.id}` | +| **Nom** | {task.name} | +| **Cible** | `{task.host}` | +| **Statut** | {task.status} | +| **Progression** | {task.progress}% | +| **Début** | {task.start_time.isoformat() if task.start_time else 'N/A'} | +| **Fin** | {task.end_time.isoformat() if task.end_time else 'N/A'} | +| **Durée** | {task.duration or 'N/A'} | + +## Sortie + +``` +{output or task.output or '(Aucune sortie)'} +``` + +""" + if error or task.error: + md_content += f"""## Erreurs + +``` +{error or task.error} +``` + +""" + + md_content += f"""--- +*Généré automatiquement par Homelab Automation Dashboard* +*Date: {datetime.now(timezone.utc).isoformat()}* +""" + + # Écrire le fichier + filepath.write_text(md_content, encoding='utf-8') + + return str(filepath) + + def _parse_markdown_metadata(self, content: str) -> Dict[str, Any]: + """Parse le contenu markdown pour extraire les métadonnées enrichies""" + metadata = { + 'start_time': None, + 'end_time': None, + 'duration': None, + 'duration_seconds': None, + 'hosts': [], + 'category': None, + 'subcategory': None, + 'target_type': None + } + + # Extraire les heures de début et fin + start_match = re.search(r'\|\s*\*\*Début\*\*\s*\|\s*([^|]+)', content) + if start_match: + start_val = start_match.group(1).strip() + if start_val and start_val != 'N/A': + metadata['start_time'] = start_val + + end_match = re.search(r'\|\s*\*\*Fin\*\*\s*\|\s*([^|]+)', content) + if end_match: + end_val = end_match.group(1).strip() + if end_val and end_val != 'N/A': + metadata['end_time'] = end_val + + duration_match = re.search(r'\|\s*\*\*Durée\*\*\s*\|\s*([^|]+)', content) + if duration_match: + dur_val = duration_match.group(1).strip() + if dur_val and dur_val != 'N/A': + metadata['duration'] = dur_val + # Convertir en secondes si possible + metadata['duration_seconds'] = self._parse_duration_to_seconds(dur_val) + + # Extraire les hôtes depuis la sortie Ansible + # Pattern pour les hôtes dans PLAY RECAP ou les résultats de tâches + host_patterns = [ + r'^([a-zA-Z0-9][a-zA-Z0-9._-]+)\s*:\s*ok=', # PLAY RECAP format + r'^\s*([a-zA-Z0-9][a-zA-Z0-9._-]+)\s*\|\s*(SUCCESS|CHANGED|FAILED|UNREACHABLE)', # Ad-hoc format + ] + hosts_found = set() + for pattern in host_patterns: + for match in re.finditer(pattern, content, re.MULTILINE): + host = match.group(1).strip() + if host and len(host) > 2 and '.' in host or len(host) > 5: + hosts_found.add(host) + metadata['hosts'] = sorted(list(hosts_found)) + + # Détecter la catégorie + task_name_match = re.search(r'^#\s*[✅❌🔄⏳🚫❓]?\s*(.+)$', content, re.MULTILINE) + if task_name_match: + task_name = task_name_match.group(1).strip().lower() + if 'playbook' in task_name: + metadata['category'] = 'Playbook' + # Extraire sous-catégorie du nom + if 'health' in task_name: + metadata['subcategory'] = 'Health Check' + elif 'backup' in task_name: + metadata['subcategory'] = 'Backup' + elif 'upgrade' in task_name or 'update' in task_name: + metadata['subcategory'] = 'Upgrade' + elif 'bootstrap' in task_name: + metadata['subcategory'] = 'Bootstrap' + elif 'reboot' in task_name: + metadata['subcategory'] = 'Reboot' + elif 'ad-hoc' in task_name or 'adhoc' in task_name: + metadata['category'] = 'Ad-hoc' + else: + metadata['category'] = 'Autre' + + # Détecter le type de cible + target_match = re.search(r'\|\s*\*\*Cible\*\*\s*\|\s*`([^`]+)`', content) + if target_match: + target_val = target_match.group(1).strip() + if target_val == 'all': + metadata['target_type'] = 'group' + elif target_val.startswith('env_') or target_val.startswith('role_'): + metadata['target_type'] = 'group' + elif '.' in target_val: + metadata['target_type'] = 'host' + else: + metadata['target_type'] = 'group' + + return metadata + + def _parse_duration_to_seconds(self, duration_str: str) -> Optional[int]: + """Convertit une chaîne de durée en secondes""" + if not duration_str: + return None + + total_seconds = 0 + # Pattern: Xh Xm Xs ou X:XX:XX ou Xs + + # Format HH:MM:SS + hms_match = re.match(r'(\d+):(\d+):(\d+)', duration_str) + if hms_match: + h, m, s = map(int, hms_match.groups()) + return h * 3600 + m * 60 + s + + # Format avec h, m, s + hours = re.search(r'(\d+)\s*h', duration_str) + minutes = re.search(r'(\d+)\s*m', duration_str) + seconds = re.search(r'(\d+)\s*s', duration_str) + + if hours: + total_seconds += int(hours.group(1)) * 3600 + if minutes: + total_seconds += int(minutes.group(1)) * 60 + if seconds: + total_seconds += int(seconds.group(1)) + + return total_seconds if total_seconds > 0 else None + + def get_task_logs(self, + year: str = None, + month: str = None, + day: str = None, + status: str = None, + target: str = None, + category: str = None) -> List[TaskLogFile]: + """Récupère la liste des logs de tâches avec filtrage""" + logs = [] + + # Déterminer le chemin de recherche + if year and month and day: + search_paths = [self.base_dir / year / month / day] + elif year and month: + month_path = self.base_dir / year / month + search_paths = list(month_path.glob("*")) if month_path.exists() else [] + elif year: + year_path = self.base_dir / year + search_paths = [] + if year_path.exists(): + for m in year_path.iterdir(): + if m.is_dir(): + search_paths.extend(m.glob("*")) + else: + search_paths = [] + if self.base_dir.exists(): + for y in self.base_dir.iterdir(): + if y.is_dir() and y.name.isdigit(): + for m in y.iterdir(): + if m.is_dir(): + search_paths.extend(m.glob("*")) + + # Parcourir les répertoires + for path in search_paths: + if not path.is_dir(): + continue + + for md_file in path.glob("*.md"): + try: + # Extraire les infos du nom de fichier + # Format: task_HHMMSS_XXXXXX_TARGET_TASKNAME_STATUS.md + parts = md_file.stem.split("_") + if len(parts) >= 4: + file_status = parts[-1] + # Format nouveau: task_HHMMSS_XXXXXX_target_taskname_status + # parts[0] = task, parts[1] = HHMMSS, parts[2] = XXXXXX (id) + # parts[3] = target, parts[4:-1] = task_name, parts[-1] = status + if len(parts) >= 5: + file_target = parts[3] + task_name_from_file = "_".join(parts[4:-1]) if len(parts) > 5 else parts[4] if len(parts) > 4 else "unknown" + else: + file_target = "" + task_name_from_file = "_".join(parts[3:-1]) if len(parts) > 4 else parts[3] if len(parts) > 3 else "unknown" + + # Filtrer par statut si spécifié + if status and status != "all" and file_status != status: + continue + + # Extraire la date du chemin + rel_path = md_file.relative_to(self.base_dir) + path_parts = rel_path.parts + if len(path_parts) >= 3: + log_year, log_month, log_day = path_parts[0], path_parts[1], path_parts[2] + else: + continue + + stat = md_file.stat() + + # Lire le contenu pour extraire les métadonnées enrichies + try: + content = md_file.read_text(encoding='utf-8') + metadata = self._parse_markdown_metadata(content) + # Extraire le nom de tâche et la cible depuis le contenu markdown + task_name_match = re.search(r'^#\s*[✅❌🔄⏳🚫❓]?\s*(.+)$', content, re.MULTILINE) + if task_name_match: + task_name = task_name_match.group(1).strip() + else: + task_name = task_name_from_file.replace("_", " ") + + # Extraire la cible depuis le contenu + target_match = re.search(r'\|\s*\*\*Cible\*\*\s*\|\s*`([^`]+)`', content) + if target_match: + file_target = target_match.group(1).strip() + except Exception: + metadata = {} + task_name = task_name_from_file.replace("_", " ") + + # Filtrer par target si spécifié + if target and target != "all" and file_target: + if target.lower() not in file_target.lower(): + continue + + # Filtrer par catégorie si spécifié + if category and category != "all": + file_category = metadata.get('category', '') + if file_category and category.lower() not in file_category.lower(): + continue + + logs.append(TaskLogFile( + id=parts[0] + "_" + parts[1] + "_" + parts[2] if len(parts) > 2 else parts[0], + filename=md_file.name, + path=str(md_file), + task_name=task_name, + target=file_target, + status=file_status, + date=f"{log_year}-{log_month}-{log_day}", + year=log_year, + month=log_month, + day=log_day, + created_at=datetime.fromtimestamp(stat.st_ctime, tz=timezone.utc), + size_bytes=stat.st_size, + start_time=metadata.get('start_time'), + end_time=metadata.get('end_time'), + duration=metadata.get('duration'), + duration_seconds=metadata.get('duration_seconds'), + hosts=metadata.get('hosts', []), + category=metadata.get('category'), + subcategory=metadata.get('subcategory'), + target_type=metadata.get('target_type') + )) + except Exception: + continue + + # Trier par date décroissante + logs.sort(key=lambda x: x.created_at, reverse=True) + return logs + + def get_available_dates(self) -> Dict[str, Any]: + """Retourne la structure des dates disponibles pour le filtrage""" + dates = {"years": {}} + + if not self.base_dir.exists(): + return dates + + for year_dir in sorted(self.base_dir.iterdir(), reverse=True): + if year_dir.is_dir() and year_dir.name.isdigit(): + year = year_dir.name + dates["years"][year] = {"months": {}} + + for month_dir in sorted(year_dir.iterdir(), reverse=True): + if month_dir.is_dir() and month_dir.name.isdigit(): + month = month_dir.name + dates["years"][year]["months"][month] = {"days": []} + + for day_dir in sorted(month_dir.iterdir(), reverse=True): + if day_dir.is_dir() and day_dir.name.isdigit(): + day = day_dir.name + count = len(list(day_dir.glob("*.md"))) + dates["years"][year]["months"][month]["days"].append({ + "day": day, + "count": count + }) + + return dates + + def get_stats(self) -> Dict[str, int]: + """Retourne les statistiques des tâches""" + stats = {"total": 0, "completed": 0, "failed": 0, "running": 0, "pending": 0} + + for log in self.get_task_logs(): + stats["total"] += 1 + if log.status in stats: + stats[log.status] += 1 + + return stats + + +# ===== SERVICE HISTORIQUE COMMANDES AD-HOC ===== + +class AdHocHistoryService: + """Service pour gérer l'historique des commandes ad-hoc avec catégories""" + + def __init__(self, history_file: Path): + self.history_file = history_file + self._ensure_file() + + def _ensure_file(self): + """Crée le fichier d'historique s'il n'existe pas""" + self.history_file.parent.mkdir(parents=True, exist_ok=True) + if not self.history_file.exists(): + self._save_data({"commands": [], "categories": [ + {"name": "default", "description": "Commandes générales", "color": "#7c3aed", "icon": "fa-terminal"}, + {"name": "diagnostic", "description": "Commandes de diagnostic", "color": "#10b981", "icon": "fa-stethoscope"}, + {"name": "maintenance", "description": "Commandes de maintenance", "color": "#f59e0b", "icon": "fa-wrench"}, + {"name": "deployment", "description": "Commandes de déploiement", "color": "#3b82f6", "icon": "fa-rocket"}, + ]}) + + def _load_data(self) -> Dict: + """Charge les données depuis le fichier""" + try: + with open(self.history_file, 'r', encoding='utf-8') as f: + return json.load(f) + except: + return {"commands": [], "categories": []} + + def _save_data(self, data: Dict): + """Sauvegarde les données dans le fichier""" + with open(self.history_file, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, default=str, ensure_ascii=False) + + def add_command(self, command: str, target: str, module: str, become: bool, + category: str = "default", description: str = None) -> AdHocHistoryEntry: + """Ajoute ou met à jour une commande dans l'historique""" + data = self._load_data() + + # Chercher si la commande existe déjà + existing = None + for cmd in data["commands"]: + if cmd["command"] == command and cmd["target"] == target: + existing = cmd + break + + if existing: + existing["last_used"] = datetime.now(timezone.utc).isoformat() + existing["use_count"] = existing.get("use_count", 1) + 1 + if category != "default": + existing["category"] = category + if description: + existing["description"] = description + entry = AdHocHistoryEntry(**existing) + else: + import uuid + entry = AdHocHistoryEntry( + id=f"adhoc_{uuid.uuid4().hex[:8]}", + command=command, + target=target, + module=module, + become=become, + category=category, + description=description + ) + data["commands"].append(entry.dict()) + + self._save_data(data) + return entry + + def get_commands(self, category: str = None, search: str = None, limit: int = 50) -> List[AdHocHistoryEntry]: + """Récupère les commandes de l'historique""" + data = self._load_data() + commands = [] + + for cmd in data.get("commands", []): + if category and cmd.get("category") != category: + continue + if search and search.lower() not in cmd.get("command", "").lower(): + continue + + try: + # Convertir les dates string en datetime si nécessaire + if isinstance(cmd.get("created_at"), str): + cmd["created_at"] = datetime.fromisoformat(cmd["created_at"].replace("Z", "+00:00")) + if isinstance(cmd.get("last_used"), str): + cmd["last_used"] = datetime.fromisoformat(cmd["last_used"].replace("Z", "+00:00")) + commands.append(AdHocHistoryEntry(**cmd)) + except Exception: + continue + + # Trier par dernière utilisation + commands.sort(key=lambda x: x.last_used, reverse=True) + return commands[:limit] + + def get_categories(self) -> List[AdHocHistoryCategory]: + """Récupère la liste des catégories""" + data = self._load_data() + return [AdHocHistoryCategory(**cat) for cat in data.get("categories", [])] + + def add_category(self, name: str, description: str = None, color: str = "#7c3aed", icon: str = "fa-folder") -> AdHocHistoryCategory: + """Ajoute une nouvelle catégorie""" + data = self._load_data() + + # Vérifier si la catégorie existe déjà + for cat in data["categories"]: + if cat["name"] == name: + return AdHocHistoryCategory(**cat) + + new_cat = AdHocHistoryCategory(name=name, description=description, color=color, icon=icon) + data["categories"].append(new_cat.dict()) + self._save_data(data) + return new_cat + + def delete_command(self, command_id: str) -> bool: + """Supprime une commande de l'historique""" + data = self._load_data() + original_len = len(data["commands"]) + data["commands"] = [c for c in data["commands"] if c.get("id") != command_id] + + if len(data["commands"]) < original_len: + self._save_data(data) + return True + return False + + def update_command_category(self, command_id: str, category: str, description: str = None) -> bool: + """Met à jour la catégorie d'une commande""" + data = self._load_data() + + for cmd in data["commands"]: + if cmd.get("id") == command_id: + cmd["category"] = category + if description: + cmd["description"] = description + self._save_data(data) + return True + return False + + def update_category(self, category_name: str, new_name: str, description: str, color: str, icon: str) -> bool: + """Met à jour une catégorie existante""" + data = self._load_data() + + for cat in data["categories"]: + if cat["name"] == category_name: + # Mettre à jour les commandes si le nom change + if new_name != category_name: + for cmd in data["commands"]: + if cmd.get("category") == category_name: + cmd["category"] = new_name + + cat["name"] = new_name + cat["description"] = description + cat["color"] = color + cat["icon"] = icon + self._save_data(data) + return True + return False + + def delete_category(self, category_name: str) -> bool: + """Supprime une catégorie et déplace ses commandes vers 'default'""" + if category_name == "default": + return False + + data = self._load_data() + + # Vérifier si la catégorie existe + cat_exists = any(cat["name"] == category_name for cat in data["categories"]) + if not cat_exists: + return False + + # Déplacer les commandes vers 'default' + for cmd in data["commands"]: + if cmd.get("category") == category_name: + cmd["category"] = "default" + + # Supprimer la catégorie + data["categories"] = [cat for cat in data["categories"] if cat["name"] != category_name] + + self._save_data(data) + return True + + +# ===== SERVICE BOOTSTRAP STATUS ===== + +class BootstrapStatusService: + """Service pour gérer le statut de bootstrap des hôtes""" + + def __init__(self, status_file: Path): + self.status_file = status_file + self._ensure_file() + + def _ensure_file(self): + """Crée le fichier de statut s'il n'existe pas""" + self.status_file.parent.mkdir(parents=True, exist_ok=True) + if not self.status_file.exists(): + self._save_data({"hosts": {}}) + + def _load_data(self) -> Dict: + """Charge les données depuis le fichier""" + try: + with open(self.status_file, 'r', encoding='utf-8') as f: + return json.load(f) + except: + return {"hosts": {}} + + def _save_data(self, data: Dict): + """Sauvegarde les données dans le fichier""" + with open(self.status_file, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, default=str, ensure_ascii=False) + + def set_bootstrap_status(self, host_name: str, success: bool, details: str = None) -> Dict: + """Enregistre le statut de bootstrap d'un hôte""" + data = self._load_data() + + data["hosts"][host_name] = { + "bootstrap_ok": success, + "bootstrap_date": datetime.now(timezone.utc).isoformat(), + "details": details + } + + self._save_data(data) + return data["hosts"][host_name] + + def get_bootstrap_status(self, host_name: str) -> Dict: + """Récupère le statut de bootstrap d'un hôte""" + data = self._load_data() + return data.get("hosts", {}).get(host_name, { + "bootstrap_ok": False, + "bootstrap_date": None, + "details": None + }) + + def get_all_status(self) -> Dict[str, Dict]: + """Récupère le statut de tous les hôtes""" + data = self._load_data() + return data.get("hosts", {}) + + def remove_host(self, host_name: str) -> bool: + """Supprime le statut d'un hôte""" + data = self._load_data() + if host_name in data.get("hosts", {}): + del data["hosts"][host_name] + self._save_data(data) + return True + return False + + +# ===== SERVICE HOST STATUS ===== + +class HostStatusService: + def __init__(self, status_file: Path): + self.status_file = status_file + self._ensure_file() + + def _ensure_file(self): + self.status_file.parent.mkdir(parents=True, exist_ok=True) + if not self.status_file.exists(): + self._save_data({"hosts": {}}) + + def _load_data(self) -> Dict: + try: + with open(self.status_file, 'r', encoding='utf-8') as f: + return json.load(f) + except: + return {"hosts": {}} + + def _save_data(self, data: Dict): + with open(self.status_file, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, default=str, ensure_ascii=False) + + def set_status(self, host_name: str, status: str, last_seen: Optional[datetime], os_info: Optional[str]) -> Dict: + data = self._load_data() + data.setdefault("hosts", {}) + data["hosts"][host_name] = { + "status": status, + "last_seen": last_seen.isoformat() if isinstance(last_seen, datetime) else last_seen, + "os": os_info, + } + self._save_data(data) + return data["hosts"][host_name] + + def get_status(self, host_name: str) -> Dict: + data = self._load_data() + hosts = data.get("hosts", {}) + return hosts.get(host_name, {"status": "online", "last_seen": None, "os": None}) + + def get_all_status(self) -> Dict[str, Dict]: + data = self._load_data() + return data.get("hosts", {}) + + def remove_host(self, host_name: str) -> bool: + data = self._load_data() + hosts = data.get("hosts", {}) + if host_name in hosts: + del hosts[host_name] + data["hosts"] = hosts + self._save_data(data) + return True + return False + + +# Instances globales des services +task_log_service = TaskLogService(DIR_LOGS_TASKS) +adhoc_history_service = AdHocHistoryService(ADHOC_HISTORY_FILE) +bootstrap_status_service = BootstrapStatusService(BOOTSTRAP_STATUS_FILE) +host_status_service = HostStatusService(HOST_STATUS_FILE) + + +class WebSocketManager: + def __init__(self): + self.active_connections: List[WebSocket] = [] + self.lock = Lock() + + async def connect(self, websocket: WebSocket): + await websocket.accept() + with self.lock: + self.active_connections.append(websocket) + + def disconnect(self, websocket: WebSocket): + with self.lock: + if websocket in self.active_connections: + self.active_connections.remove(websocket) + + async def broadcast(self, message: dict): + with self.lock: + disconnected = [] + for connection in self.active_connections: + try: + await connection.send_json(message) + except: + disconnected.append(connection) + + # Nettoyer les connexions déconnectées + for conn in disconnected: + self.active_connections.remove(conn) + +# Instance globale du gestionnaire WebSocket +ws_manager = WebSocketManager() + + +# Service Ansible +class AnsibleService: + """Service pour exécuter les playbooks Ansible""" + + def __init__(self, ansible_dir: Path): + self.ansible_dir = ansible_dir + self.playbooks_dir = ansible_dir / "playbooks" + self.inventory_path = ansible_dir / "inventory" / "hosts.yml" + self._inventory_cache: Optional[Dict] = None + + def get_playbooks(self) -> List[Dict[str, Any]]: + """Liste les playbooks disponibles avec leurs métadonnées (category/subcategory). + + Les métadonnées sont lues en priorité dans play['vars'] pour être compatibles + avec la syntaxe Ansible (category/subcategory ne sont pas des clés de Play). + """ + playbooks = [] + if self.playbooks_dir.exists(): + for pb in self.playbooks_dir.glob("*.yml"): + # Récupérer les infos du fichier + stat = pb.stat() + playbook_info = { + "name": pb.stem, + "filename": pb.name, + "path": str(pb), + "category": "general", + "subcategory": "other", + "size": stat.st_size, + "modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat() + } + # Extract category/subcategory from playbook + try: + with open(pb, 'r', encoding='utf-8') as f: + content = yaml.safe_load(f) + if content and isinstance(content, list) and len(content) > 0: + play = content[0] + vars_ = play.get('vars', {}) or {} + + # Lecture de category avec fallback: play puis vars + if 'category' in play: + playbook_info['category'] = play['category'] + elif 'category' in vars_: + playbook_info['category'] = vars_['category'] + + # Lecture de subcategory avec fallback + if 'subcategory' in play: + playbook_info['subcategory'] = play['subcategory'] + elif 'subcategory' in vars_: + playbook_info['subcategory'] = vars_['subcategory'] + + if 'name' in play: + playbook_info['description'] = play['name'] + except Exception: + # On ignore les erreurs de parsing individuelles pour ne pas + # casser l'ensemble de la liste de playbooks. + pass + playbooks.append(playbook_info) + return playbooks + + def get_playbook_categories(self) -> Dict[str, List[str]]: + """Retourne les catégories et sous-catégories des playbooks""" + categories = {} + for pb in self.get_playbooks(): + cat = pb.get('category', 'general') + subcat = pb.get('subcategory', 'other') + if cat not in categories: + categories[cat] = [] + if subcat not in categories[cat]: + categories[cat].append(subcat) + return categories + + def load_inventory(self) -> Dict: + """Charge l'inventaire Ansible depuis le fichier YAML""" + if self._inventory_cache: + return self._inventory_cache + + if not self.inventory_path.exists(): + return {} + + with open(self.inventory_path, 'r') as f: + self._inventory_cache = yaml.safe_load(f) + return self._inventory_cache + + def get_hosts_from_inventory(self, group_filter: str = None) -> List[AnsibleInventoryHost]: + """Extrait la liste des hôtes de l'inventaire sans doublons. + + Args: + group_filter: Si spécifié, filtre les hôtes par ce groupe + """ + inventory = self.load_inventory() + # Use dict to track unique hosts and accumulate their groups + hosts_dict: Dict[str, AnsibleInventoryHost] = {} + + def extract_hosts(data: Dict, current_group: str = ""): + if not isinstance(data, dict): + return + + # Extraire les hôtes directs + if 'hosts' in data: + for host_name, host_data in data['hosts'].items(): + host_data = host_data or {} + + if host_name in hosts_dict: + # Host already exists, add group to its groups list + if current_group and current_group not in hosts_dict[host_name].groups: + hosts_dict[host_name].groups.append(current_group) + else: + # New host + hosts_dict[host_name] = AnsibleInventoryHost( + name=host_name, + ansible_host=host_data.get('ansible_host', host_name), + group=current_group, + groups=[current_group] if current_group else [], + vars=host_data + ) + + # Parcourir les enfants (sous-groupes) + if 'children' in data: + for child_name, child_data in data['children'].items(): + extract_hosts(child_data, child_name) + + extract_hosts(inventory.get('all', {})) + + # Convert to list + hosts = list(hosts_dict.values()) + + # Apply group filter if specified + if group_filter and group_filter != 'all': + hosts = [h for h in hosts if group_filter in h.groups] + + return hosts + + def invalidate_cache(self): + """Invalide le cache de l'inventaire pour forcer un rechargement""" + self._inventory_cache = None + + def get_groups(self) -> List[str]: + """Extrait la liste des groupes de l'inventaire""" + inventory = self.load_inventory() + groups = set() + + def extract_groups(data: Dict, parent: str = ""): + if not isinstance(data, dict): + return + if 'children' in data: + for child_name in data['children'].keys(): + groups.add(child_name) + extract_groups(data['children'][child_name], child_name) + + extract_groups(inventory.get('all', {})) + return sorted(list(groups)) + + def get_env_groups(self) -> List[str]: + """Retourne uniquement les groupes d'environnement (préfixés par env_)""" + return [g for g in self.get_groups() if g.startswith('env_')] + + def get_role_groups(self) -> List[str]: + """Retourne uniquement les groupes de rôles (préfixés par role_)""" + return [g for g in self.get_groups() if g.startswith('role_')] + + def _save_inventory(self, inventory: Dict): + """Sauvegarde l'inventaire dans le fichier YAML""" + # Créer une sauvegarde avant modification + backup_path = self.inventory_path.with_suffix('.yml.bak') + if self.inventory_path.exists(): + import shutil + shutil.copy2(self.inventory_path, backup_path) + + with open(self.inventory_path, 'w', encoding='utf-8') as f: + yaml.dump(inventory, f, default_flow_style=False, allow_unicode=True, sort_keys=False) + + # Invalider le cache + self.invalidate_cache() + + def add_host_to_inventory(self, hostname: str, env_group: str, role_groups: List[str], ansible_host: str = None) -> bool: + """Ajoute un hôte à l'inventaire Ansible + + Args: + hostname: Nom de l'hôte (ex: server.domain.home) + env_group: Groupe d'environnement (ex: env_homelab) + role_groups: Liste des groupes de rôles (ex: ['role_proxmox', 'role_sbc']) + ansible_host: Adresse IP ou hostname pour ansible_host (optionnel) + + Returns: + True si l'ajout a réussi + """ + inventory = self.load_inventory() + + # S'assurer que la structure existe + if 'all' not in inventory: + inventory['all'] = {} + if 'children' not in inventory['all']: + inventory['all']['children'] = {} + + children = inventory['all']['children'] + + # Ajouter au groupe d'environnement + if env_group not in children: + children[env_group] = {'hosts': {}} + if 'hosts' not in children[env_group]: + children[env_group]['hosts'] = {} + + # Définir les variables de l'hôte + host_vars = None + if ansible_host and ansible_host != hostname: + host_vars = {'ansible_host': ansible_host} + + children[env_group]['hosts'][hostname] = host_vars + + # Ajouter aux groupes de rôles + for role_group in role_groups: + if role_group not in children: + children[role_group] = {'hosts': {}} + if 'hosts' not in children[role_group]: + children[role_group]['hosts'] = {} + children[role_group]['hosts'][hostname] = None + + self._save_inventory(inventory) + return True + + def remove_host_from_inventory(self, hostname: str) -> bool: + """Supprime un hôte de tous les groupes de l'inventaire + + Args: + hostname: Nom de l'hôte à supprimer + + Returns: + True si la suppression a réussi + """ + inventory = self.load_inventory() + + if 'all' not in inventory or 'children' not in inventory['all']: + return False + + children = inventory['all']['children'] + removed = False + + # Parcourir tous les groupes et supprimer l'hôte + for group_name, group_data in children.items(): + if isinstance(group_data, dict) and 'hosts' in group_data: + if hostname in group_data['hosts']: + del group_data['hosts'][hostname] + removed = True + + if removed: + self._save_inventory(inventory) + + # Supprimer aussi les statuts persistés (bootstrap + health) + bootstrap_status_service.remove_host(hostname) + try: + host_status_service.remove_host(hostname) + except Exception: + pass + + return removed + + def update_host_groups(self, hostname: str, env_group: str = None, role_groups: List[str] = None, ansible_host: str = None) -> bool: + """Met à jour les groupes d'un hôte existant + + Args: + hostname: Nom de l'hôte à modifier + env_group: Nouveau groupe d'environnement (None = pas de changement) + role_groups: Nouvelle liste de groupes de rôles (None = pas de changement) + ansible_host: Nouvelle adresse ansible_host (None = pas de changement) + + Returns: + True si la mise à jour a réussi + """ + inventory = self.load_inventory() + + if 'all' not in inventory or 'children' not in inventory['all']: + return False + + children = inventory['all']['children'] + + # Trouver le groupe d'environnement actuel + current_env_group = None + current_role_groups = [] + current_ansible_host = None + + for group_name, group_data in children.items(): + if isinstance(group_data, dict) and 'hosts' in group_data: + if hostname in group_data['hosts']: + if group_name.startswith('env_'): + current_env_group = group_name + # Récupérer ansible_host si défini + host_vars = group_data['hosts'][hostname] + if isinstance(host_vars, dict) and 'ansible_host' in host_vars: + current_ansible_host = host_vars['ansible_host'] + elif group_name.startswith('role_'): + current_role_groups.append(group_name) + + if not current_env_group: + return False # Hôte non trouvé + + # Appliquer les changements + new_env_group = env_group if env_group else current_env_group + new_role_groups = role_groups if role_groups is not None else current_role_groups + new_ansible_host = ansible_host if ansible_host else current_ansible_host + + # Supprimer l'hôte de tous les groupes actuels + for group_name, group_data in children.items(): + if isinstance(group_data, dict) and 'hosts' in group_data: + if hostname in group_data['hosts']: + del group_data['hosts'][hostname] + + # Ajouter au nouveau groupe d'environnement + if new_env_group not in children: + children[new_env_group] = {'hosts': {}} + if 'hosts' not in children[new_env_group]: + children[new_env_group]['hosts'] = {} + + host_vars = None + if new_ansible_host and new_ansible_host != hostname: + host_vars = {'ansible_host': new_ansible_host} + children[new_env_group]['hosts'][hostname] = host_vars + + # Ajouter aux nouveaux groupes de rôles + for role_group in new_role_groups: + if role_group not in children: + children[role_group] = {'hosts': {}} + if 'hosts' not in children[role_group]: + children[role_group]['hosts'] = {} + children[role_group]['hosts'][hostname] = None + + self._save_inventory(inventory) + return True + + def host_exists(self, hostname: str) -> bool: + """Vérifie si un hôte existe dans l'inventaire""" + hosts = self.get_hosts_from_inventory() + return any(h.name == hostname for h in hosts) + + def group_exists(self, group_name: str) -> bool: + """Vérifie si un groupe existe dans l'inventaire""" + return group_name in self.get_groups() + + def add_group(self, group_name: str) -> bool: + """Ajoute un nouveau groupe à l'inventaire + + Args: + group_name: Nom du groupe (doit commencer par env_ ou role_) + + Returns: + True si l'ajout a réussi + """ + if self.group_exists(group_name): + return False # Groupe existe déjà + + inventory = self.load_inventory() + + # S'assurer que la structure existe + if 'all' not in inventory: + inventory['all'] = {} + if 'children' not in inventory['all']: + inventory['all']['children'] = {} + + # Ajouter le groupe vide + inventory['all']['children'][group_name] = {'hosts': {}} + + self._save_inventory(inventory) + return True + + def rename_group(self, old_name: str, new_name: str) -> bool: + """Renomme un groupe dans l'inventaire + + Args: + old_name: Nom actuel du groupe + new_name: Nouveau nom du groupe + + Returns: + True si le renommage a réussi + """ + if not self.group_exists(old_name): + return False # Groupe source n'existe pas + + if self.group_exists(new_name): + return False # Groupe cible existe déjà + + inventory = self.load_inventory() + children = inventory.get('all', {}).get('children', {}) + + if old_name not in children: + return False + + # Copier les données du groupe vers le nouveau nom + children[new_name] = children[old_name] + del children[old_name] + + self._save_inventory(inventory) + return True + + def delete_group(self, group_name: str, move_hosts_to: str = None) -> Dict[str, Any]: + """Supprime un groupe de l'inventaire + + Args: + group_name: Nom du groupe à supprimer + move_hosts_to: Groupe vers lequel déplacer les hôtes (optionnel) + + Returns: + Dict avec le résultat de l'opération + """ + if not self.group_exists(group_name): + return {"success": False, "error": "Groupe non trouvé"} + + inventory = self.load_inventory() + children = inventory.get('all', {}).get('children', {}) + + if group_name not in children: + return {"success": False, "error": "Groupe non trouvé dans children"} + + group_data = children[group_name] + hosts_in_group = list(group_data.get('hosts', {}).keys()) if group_data else [] + + # Si des hôtes sont dans le groupe et qu'on veut les déplacer + if hosts_in_group and move_hosts_to: + if not self.group_exists(move_hosts_to) and move_hosts_to != group_name: + # Créer le groupe cible s'il n'existe pas + children[move_hosts_to] = {'hosts': {}} + + if move_hosts_to in children: + if 'hosts' not in children[move_hosts_to]: + children[move_hosts_to]['hosts'] = {} + + # Déplacer les hôtes + for hostname in hosts_in_group: + host_vars = group_data['hosts'].get(hostname) + children[move_hosts_to]['hosts'][hostname] = host_vars + + # Supprimer le groupe + del children[group_name] + + self._save_inventory(inventory) + return { + "success": True, + "hosts_affected": hosts_in_group, + "hosts_moved_to": move_hosts_to if hosts_in_group and move_hosts_to else None + } + + def get_group_hosts(self, group_name: str) -> List[str]: + """Retourne la liste des hôtes dans un groupe + + Args: + group_name: Nom du groupe + + Returns: + Liste des noms d'hôtes + """ + inventory = self.load_inventory() + children = inventory.get('all', {}).get('children', {}) + + if group_name not in children: + return [] + + group_data = children[group_name] + if not group_data or 'hosts' not in group_data: + return [] + + return list(group_data['hosts'].keys()) + + async def execute_playbook( + self, + playbook: str, + target: str = "all", + extra_vars: Optional[Dict[str, Any]] = None, + check_mode: bool = False, + verbose: bool = False + ) -> Dict[str, Any]: + """Exécute un playbook Ansible""" + # Résoudre le chemin du playbook + # On accepte soit un nom avec extension, soit un nom sans extension (ex: "health-check") + playbook_path = self.playbooks_dir / playbook + + # Si le fichier n'existe pas tel quel, essayer avec des extensions courantes + if not playbook_path.exists(): + from pathlib import Path + + pb_name = Path(playbook).name # enlever d'éventuels chemins + # Si aucune extension n'est fournie, tester .yml puis .yaml + if not Path(pb_name).suffix: + for ext in (".yml", ".yaml"): + candidate = self.playbooks_dir / f"{pb_name}{ext}" + if candidate.exists(): + playbook_path = candidate + break + + if not playbook_path.exists(): + # À ce stade, on n'a trouvé aucun fichier correspondant + raise FileNotFoundError(f"Playbook introuvable: {playbook}") + + # Construire la commande ansible-playbook + cmd = [ + "ansible-playbook", + str(playbook_path), + "-i", str(self.inventory_path), + "--limit", target + ] + + if check_mode: + cmd.append("--check") + + if verbose: + cmd.append("-v") + + if extra_vars: + cmd.extend(["--extra-vars", json.dumps(extra_vars)]) + + private_key = find_ssh_private_key() + if private_key: + cmd.extend(["--private-key", private_key]) + + if SSH_USER: + cmd.extend(["-u", SSH_USER]) + + start_time = perf_counter() + + try: + # Exécuter la commande + process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=str(self.ansible_dir) + ) + + stdout, stderr = await process.communicate() + execution_time = perf_counter() - start_time + + return { + "success": process.returncode == 0, + "return_code": process.returncode, + "stdout": stdout.decode('utf-8', errors='replace'), + "stderr": stderr.decode('utf-8', errors='replace'), + "execution_time": round(execution_time, 2), + "command": " ".join(cmd) + } + except FileNotFoundError: + return { + "success": False, + "return_code": -1, + "stdout": "", + "stderr": "ansible-playbook non trouvé. Vérifiez que Ansible est installé.", + "execution_time": 0, + "command": " ".join(cmd) + } + except Exception as e: + return { + "success": False, + "return_code": -1, + "stdout": "", + "stderr": str(e), + "execution_time": perf_counter() - start_time, + "command": " ".join(cmd) + } + + +# Instance globale du service Ansible +ansible_service = AnsibleService(ANSIBLE_DIR) + + +# ===== SERVICE BOOTSTRAP SSH ===== + +class BootstrapRequest(BaseModel): + """Requête de bootstrap pour un hôte""" + host: str = Field(..., description="Adresse IP ou hostname de l'hôte") + root_password: str = Field(..., description="Mot de passe root pour la connexion initiale") + automation_user: str = Field(default="automation", description="Nom de l'utilisateur d'automatisation à créer") + + +class CommandResult(BaseModel): + """Résultat d'une commande SSH""" + status: str + return_code: int + stdout: str + stderr: Optional[str] = None + + +def find_ssh_private_key() -> Optional[str]: + """Trouve une clé privée SSH disponible en inspectant plusieurs répertoires.""" + candidate_dirs = [] + env_path = Path(SSH_KEY_PATH) + candidate_dirs.append(env_path.parent) + candidate_dirs.append(Path("/app/ssh_keys")) + candidate_dirs.append(Path.home() / ".ssh") + + seen = set() + key_paths: List[str] = [] + + for directory in candidate_dirs: + if not directory or not directory.exists(): + continue + for name in [ + env_path.name, + "id_automation_ansible", + "id_rsa", + "id_ed25519", + "id_ecdsa", + ]: + path = directory / name + if str(path) not in seen: + seen.add(str(path)) + key_paths.append(str(path)) + # Ajouter dynamiquement toutes les clés sans extension .pub + for file in directory.iterdir(): + if file.is_file() and not file.suffix and not file.name.startswith("known_hosts"): + if str(file) not in seen: + seen.add(str(file)) + key_paths.append(str(file)) + + for key_path in key_paths: + if key_path and Path(key_path).exists(): + return key_path + + return None + + +def run_ssh_command( + host: str, + command: str, + ssh_user: str = "root", + ssh_password: Optional[str] = None, + timeout: int = 60 +) -> tuple: + """Exécute une commande SSH sur un hôte distant. + + Returns: + tuple: (return_code, stdout, stderr) + """ + ssh_cmd = ["ssh"] + + # Options SSH communes + ssh_opts = [ + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "ConnectTimeout=10", + "-o", "BatchMode=no" if ssh_password else "BatchMode=yes", + ] + + # Si pas de mot de passe, utiliser la clé SSH + if not ssh_password: + private_key = find_ssh_private_key() + if private_key: + ssh_opts.extend(["-i", private_key]) + + ssh_cmd.extend(ssh_opts) + ssh_cmd.append(f"{ssh_user}@{host}") + ssh_cmd.append(command) + + try: + if ssh_password: + # Utiliser sshpass pour l'authentification par mot de passe + full_cmd = ["sshpass", "-p", ssh_password] + ssh_cmd + else: + full_cmd = ssh_cmd + + result = subprocess.run( + full_cmd, + capture_output=True, + text=True, + timeout=timeout + ) + return result.returncode, result.stdout, result.stderr + except subprocess.TimeoutExpired: + return -1, "", f"Timeout après {timeout} secondes" + except FileNotFoundError as e: + if "sshpass" in str(e): + return -1, "", "sshpass n'est pas installé. Installez-le avec: apt install sshpass" + return -1, "", str(e) + except Exception as e: + return -1, "", str(e) + + +def bootstrap_host(host: str, root_password: str, automation_user: str = "automation") -> CommandResult: + """Prépare un hôte pour Ansible (création user, clé SSH, sudo, python3) pour Debian/Alpine/FreeBSD. + + Utilise un script shell complet uploadé via heredoc pour éviter les problèmes de quoting. + """ + import logging + logger = logging.getLogger("bootstrap") + + # Chercher la clé publique dans plusieurs emplacements possibles + primary_dirs = [ + Path(SSH_KEY_PATH).parent, + Path("/app/ssh_keys"), + Path.home() / ".ssh", + ] + ssh_dir = primary_dirs[0] + pub_paths = [ + SSH_KEY_PATH + ".pub", + "/app/ssh_keys/id_rsa.pub", + "/app/ssh_keys/id_ed25519.pub", + "/app/ssh_keys/id_ecdsa.pub", + "/app/ssh_keys/id_automation_ansible.pub", + ] + + # Ajouter dynamiquement toutes les clés .pub trouvées dans le répertoire SSH + for directory in primary_dirs: + if not directory.exists(): + continue + for f in directory.iterdir(): + if f.is_file() and f.suffix == ".pub" and str(f) not in pub_paths: + pub_paths.append(str(f)) + + logger.info(f"SSH_KEY_PATH = {SSH_KEY_PATH}") + logger.info(f"Recherche de clé publique dans: {pub_paths}") + + pub_key = None + pub_path_used = None + + for pub_path in pub_paths: + try: + if Path(pub_path).exists(): + pub_key = Path(pub_path).read_text(encoding="utf-8").strip() + if pub_key: + pub_path_used = pub_path + logger.info(f"Clé publique trouvée: {pub_path}") + break + except Exception as e: + logger.warning(f"Erreur lecture {pub_path}: {e}") + continue + + if not pub_key: + # Lister les fichiers disponibles pour le debug + ssh_dir = Path(SSH_KEY_PATH).parent + available_files = [] + if ssh_dir.exists(): + available_files = [f.name for f in ssh_dir.iterdir()] + + raise HTTPException( + status_code=500, + detail=f"Clé publique SSH non trouvée. Chemins testés: {pub_paths}. Fichiers disponibles dans {ssh_dir}: {available_files}", + ) + + # Script shell complet, robuste, avec logs détaillés + bootstrap_script = f"""#!/bin/sh +set -e + +AUT_USER="{automation_user}" + +echo "=== Bootstrap Ansible Host ===" +echo "User: $AUT_USER" +echo "" + +# 1) Détection OS +if command -v apk >/dev/null 2>&1; then + OS_TYPE="alpine" + echo "[1/7] OS détecté: Alpine Linux" +elif [ "$(uname -s 2>/dev/null)" = "FreeBSD" ] || \ + command -v pkg >/dev/null 2>&1 || \ + ( [ -f /etc/os-release ] && grep -qi 'ID=freebsd' /etc/os-release ); then + OS_TYPE="freebsd" + echo "[1/7] OS détecté: FreeBSD" +else + OS_TYPE="debian" + echo "[1/7] OS détecté: Debian-like" +fi + +# 2) Vérification / préparation utilisateur +echo "[2/7] Vérification utilisateur/groupe..." +if id "$AUT_USER" >/dev/null 2>&1; then + echo " - Utilisateur déjà existant: $AUT_USER (aucune suppression)" +else + echo " - Utilisateur inexistant, il sera créé" +fi + +# 3) Création utilisateur (idempotent) +echo "[3/7] Création utilisateur $AUT_USER..." +if id "$AUT_USER" >/dev/null 2>&1; then + echo " - Utilisateur déjà présent, réutilisation" +elif [ "$OS_TYPE" = "alpine" ]; then + adduser -D "$AUT_USER" + echo " - Utilisateur créé (Alpine: adduser -D)" +elif [ "$OS_TYPE" = "freebsd" ]; then + pw useradd "$AUT_USER" -m -s /bin/sh + echo " - Utilisateur créé (FreeBSD: pw useradd)" +else + useradd -m -s /bin/bash "$AUT_USER" || useradd -m -s /bin/sh "$AUT_USER" + echo " - Utilisateur créé (Debian: useradd -m)" +fi + +# 3b) S'assurer que le compte n'est pas verrouillé +echo " - Vérification du verrouillage du compte..." +if command -v passwd >/dev/null 2>&1; then + passwd -u "$AUT_USER" 2>/dev/null || true +fi +if command -v usermod >/dev/null 2>&1; then + usermod -U "$AUT_USER" 2>/dev/null || true +fi + +# 4) Configuration clé SSH +echo "[4/7] Configuration clé SSH..." +HOME_DIR=$(getent passwd "$AUT_USER" | cut -d: -f6) +if [ -z "$HOME_DIR" ]; then + HOME_DIR="/home/$AUT_USER" +fi +echo " - HOME_DIR: $HOME_DIR" + +mkdir -p "$HOME_DIR/.ssh" +chown "$AUT_USER":"$AUT_USER" "$HOME_DIR/.ssh" +chmod 700 "$HOME_DIR/.ssh" +echo " - Répertoire .ssh créé et configuré" + +cat > "$HOME_DIR/.ssh/authorized_keys" << 'SSHKEY_EOF' +{pub_key} +SSHKEY_EOF + +chown "$AUT_USER":"$AUT_USER" "$HOME_DIR/.ssh/authorized_keys" +chmod 600 "$HOME_DIR/.ssh/authorized_keys" +echo " - Clé publique installée dans authorized_keys" + +if [ -s "$HOME_DIR/.ssh/authorized_keys" ]; then + KEY_COUNT=$(wc -l < "$HOME_DIR/.ssh/authorized_keys") + echo " - Vérification: $KEY_COUNT clé(s) dans authorized_keys" +else + echo " - ERREUR: authorized_keys vide ou absent!" + exit 1 +fi + +# 5) Installation sudo +echo "[5/7] Installation sudo..." +if command -v sudo >/dev/null 2>&1; then + echo " - sudo déjà installé" +else + if [ "$OS_TYPE" = "alpine" ]; then + apk add --no-cache sudo + echo " - sudo installé (apk)" + elif [ "$OS_TYPE" = "freebsd" ]; then + pkg install -y sudo + echo " - sudo installé (pkg)" + else + apt-get update -qq && apt-get install -y sudo + echo " - sudo installé (apt)" + fi +fi + +# 6) Configuration sudoers +echo "[6/7] Configuration sudoers..." +if [ ! -d /etc/sudoers.d ]; then + mkdir -p /etc/sudoers.d + chmod 750 /etc/sudoers.d 2>/dev/null || true + echo " - Répertoire /etc/sudoers.d créé" +fi +echo "$AUT_USER ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/automation +chmod 440 /etc/sudoers.d/automation +echo " - Sudoers configuré: /etc/sudoers.d/automation" + +# 7) Installation Python3 +echo "[7/7] Installation Python3..." +if command -v python3 >/dev/null 2>&1; then + PYTHON_VERSION=$(python3 --version 2>&1) + echo " - Python3 déjà installé: $PYTHON_VERSION" +else + if [ "$OS_TYPE" = "alpine" ]; then + apk add --no-cache python3 + echo " - Python3 installé (apk)" + elif [ "$OS_TYPE" = "freebsd" ]; then + pkg install -y python3 + echo " - Python3 installé (pkg)" + else + apt-get update -qq && apt-get install -y python3 + echo " - Python3 installé (apt)" + fi +fi + +echo "" +echo "=== Bootstrap terminé avec succès ===" +echo "Utilisateur: $AUT_USER" +echo "HOME: $HOME_DIR" +echo "SSH: $HOME_DIR/.ssh/authorized_keys" +echo "Sudo: /etc/sudoers.d/automation" +""" + + # Envoyer le script de manière compatible avec tous les shells + lines = bootstrap_script.splitlines() + + def _sh_single_quote(s: str) -> str: + """Protège une chaîne pour un shell POSIX en simple quotes.""" + return "'" + s.replace("'", "'\"'\"'") + "'" + + quoted_lines = " ".join(_sh_single_quote(line) for line in lines) + remote_cmd = f"printf '%s\\n' {quoted_lines} | sh" + + rc, out, err = run_ssh_command( + host, + remote_cmd, + ssh_user="root", + ssh_password=root_password, + ) + + if rc != 0: + raise HTTPException( + status_code=500, + detail={ + "status": "error", + "return_code": rc, + "stdout": out, + "stderr": err, + }, + ) + + # Vérification: tester la connexion SSH par clé avec l'utilisateur d'automatisation + verify_rc, verify_out, verify_err = run_ssh_command( + host, + "echo 'ssh_key_ok'", + ssh_user=automation_user, + ssh_password=None, + ) + + if verify_rc != 0: + combined_stdout = (out or "") + f"\n\n[SSH VERIFY] Échec de la connexion par clé pour {automation_user}@{host}\n" + (verify_out or "") + combined_stderr = (err or "") + f"\n\n[SSH VERIFY] " + (verify_err or "Aucune erreur détaillée") + raise HTTPException( + status_code=500, + detail={ + "status": "error", + "return_code": verify_rc, + "stdout": combined_stdout, + "stderr": combined_stderr, + }, + ) + + # Succès complet + final_stdout = (out or "") + f"\n\n[SSH VERIFY] Connexion par clé OK pour {automation_user}@{host}" + return CommandResult( + status="ok", + return_code=0, + stdout=final_stdout, + stderr=err, + ) + + +# Base de données hybride : hôtes depuis Ansible, tâches/logs en mémoire +class HybridDB: + """Base de données qui charge les hôtes depuis l'inventaire Ansible""" + + def __init__(self, ansible_svc: AnsibleService): + self.ansible_service = ansible_svc + self._hosts_cache: Optional[List[Host]] = None + self._hosts_cache_time: float = 0 + self._cache_ttl: float = 60 # Cache de 60 secondes + # Statuts runtime des hôtes (en mémoire) rechargés depuis le fichier JSON persistant + self._host_runtime_status: Dict[str, Dict[str, Any]] = {} + try: + persisted_hosts = host_status_service.get_all_status() + for host_name, info in persisted_hosts.items(): + last_seen_raw = info.get("last_seen") + last_seen_dt: Optional[datetime] = None + if isinstance(last_seen_raw, str): + try: + last_seen_dt = datetime.fromisoformat(last_seen_raw.replace("Z", "+00:00")) + except Exception: + last_seen_dt = None + elif isinstance(last_seen_raw, datetime): + last_seen_dt = last_seen_raw + + self._host_runtime_status[host_name] = { + "status": info.get("status", "online"), + "last_seen": last_seen_dt, + "os": info.get("os"), + } + except Exception: + # En cas de problème de lecture, on repartira d'un état en mémoire vierge + self._host_runtime_status = {} + + # Tâches et logs en mémoire (persistés pendant l'exécution) + self.tasks: List[Task] = [] + + self.logs: List[LogEntry] = [ + LogEntry(id=1, timestamp=datetime.now(timezone.utc), level="INFO", + message="Dashboard démarré - Inventaire Ansible chargé") + ] + + self._id_counters = {"hosts": 100, "tasks": 1, "logs": 2} + + @property + def hosts(self) -> List[Host]: + """Charge les hôtes depuis l'inventaire Ansible avec cache""" + current_time = time() + + # Retourner le cache si valide + if self._hosts_cache and (current_time - self._hosts_cache_time) < self._cache_ttl: + return self._hosts_cache + + # Recharger depuis Ansible + self._hosts_cache = self._load_hosts_from_ansible() + self._hosts_cache_time = current_time + return self._hosts_cache + + def _load_hosts_from_ansible(self) -> List[Host]: + """Convertit l'inventaire Ansible en liste d'hôtes (sans doublons)""" + hosts = [] + ansible_hosts = self.ansible_service.get_hosts_from_inventory() + + # Charger tous les statuts de bootstrap + all_bootstrap_status = bootstrap_status_service.get_all_status() + + for idx, ah in enumerate(ansible_hosts, start=1): + # Extraire le groupe principal depuis les groupes + primary_group = ah.groups[0] if ah.groups else "unknown" + + # Récupérer le statut bootstrap pour cet hôte + bootstrap_info = all_bootstrap_status.get(ah.name, {}) + bootstrap_ok = bootstrap_info.get("bootstrap_ok", False) + bootstrap_date_str = bootstrap_info.get("bootstrap_date") + bootstrap_date = None + if bootstrap_date_str: + try: + bootstrap_date = datetime.fromisoformat(bootstrap_date_str.replace("Z", "+00:00")) + except: + pass + + runtime_status = self._host_runtime_status.get(ah.name, {}) + status = runtime_status.get("status", "online") + last_seen = runtime_status.get("last_seen") + os_label = runtime_status.get("os", f"Linux ({primary_group})") + + host = Host( + id=idx, + name=ah.name, + ip=ah.ansible_host, + status=status, + os=os_label, + last_seen=last_seen, + groups=ah.groups, # Tous les groupes de l'hôte + bootstrap_ok=bootstrap_ok, + bootstrap_date=bootstrap_date + ) + hosts.append(host) + + return hosts + + def refresh_hosts(self): + """Force le rechargement des hôtes depuis Ansible""" + self._hosts_cache = None + return self.hosts + + def update_host_status(self, host_name: str, status: str, os_info: str = None): + """Met à jour le statut d'un hôte après un health-check""" + for host in self.hosts: + if host.name == host_name: + host.status = status + host.last_seen = datetime.now(timezone.utc) + if os_info: + host.os = os_info + self._host_runtime_status[host_name] = { + "status": host.status, + "last_seen": host.last_seen, + "os": host.os, + } + # Persister dans le fichier JSON partagé avec Ansible + try: + host_status_service.set_status(host_name, host.status, host.last_seen, host.os) + except Exception: + # Ne pas casser l'exécution si la persistance échoue + pass + break + + @property + def metrics(self) -> SystemMetrics: + """Calcule les métriques en temps réel basées sur les logs de tâches""" + hosts = self.hosts + + # Utiliser les statistiques des fichiers de logs de tâches + task_stats = task_log_service.get_stats() + total_tasks = task_stats.get("total", 0) + completed_tasks = task_stats.get("completed", 0) + failed_tasks = task_stats.get("failed", 0) + total_finished = completed_tasks + failed_tasks + + return SystemMetrics( + online_hosts=len([h for h in hosts if h.status == "online"]), + total_tasks=total_tasks, + success_rate=round((completed_tasks / total_finished * 100) if total_finished > 0 else 100, 1), + uptime=99.9, + cpu_usage=0, + memory_usage=0, + disk_usage=0 + ) + + def get_next_id(self, collection: str) -> int: + self._id_counters[collection] += 1 + return self._id_counters[collection] - 1 + + +# Instance globale de la base de données hybride +db = HybridDB(ansible_service) + +# Dépendances FastAPI +async def verify_api_key(api_key: str = Depends(api_key_header)) -> bool: + """Vérifie la clé API fournie""" + if not api_key or api_key != API_KEY: + raise HTTPException(status_code=401, detail="Clé API invalide ou manquante") + return True + +# Routes API +@app.get("/", response_class=HTMLResponse) +async def root(request: Request): + """Page principale du dashboard""" + return FileResponse(BASE_DIR / "index.html") + + +@app.get("/api", response_class=HTMLResponse) +async def api_home(request: Request): + """Page d'accueil de l'API Homelab Dashboard""" + return """ + + + + + + Homelab Dashboard API + + + +
+

Homelab Dashboard API

+

+ API REST moderne pour la gestion automatique d'homelab +

+ +
+

Documentation API

+

Explorez les endpoints disponibles et testez les fonctionnalités

+ +
+ +
+

Endpoints Principaux

+
+
+ GET + /api/hosts + - Liste des hôtes +
+
+ POST + /api/tasks + - Créer une tâche +
+
+ GET + /api/metrics + - Métriques système +
+
+ WS + /ws + - WebSocket temps réel +
+
+
+ +
+

Version 1.0.0 | Développé avec FastAPI et technologies modernes

+
+
+ + + """ + +# ===== ENDPOINTS HOSTS - Routes statiques d'abord ===== + +@app.get("/api/hosts/groups") +async def get_host_groups(api_key_valid: bool = Depends(verify_api_key)): + """Récupère les groupes disponibles pour les hôtes (environnements et rôles)""" + return { + "env_groups": ansible_service.get_env_groups(), + "role_groups": ansible_service.get_role_groups(), + "all_groups": ansible_service.get_groups() + } + + +# ===== ENDPOINTS GROUPS - Gestion des groupes d'environnement et de rôles ===== + +@app.get("/api/groups") +async def get_all_groups(api_key_valid: bool = Depends(verify_api_key)): + """Récupère tous les groupes avec leurs détails""" + env_groups = ansible_service.get_env_groups() + role_groups = ansible_service.get_role_groups() + + groups = [] + for g in env_groups: + hosts = ansible_service.get_group_hosts(g) + groups.append({ + "name": g, + "type": "env", + "display_name": g.replace('env_', ''), + "hosts_count": len(hosts), + "hosts": hosts + }) + + for g in role_groups: + hosts = ansible_service.get_group_hosts(g) + groups.append({ + "name": g, + "type": "role", + "display_name": g.replace('role_', ''), + "hosts_count": len(hosts), + "hosts": hosts + }) + + return { + "groups": groups, + "env_count": len(env_groups), + "role_count": len(role_groups) + } + + +@app.get("/api/groups/{group_name}") +async def get_group_details(group_name: str, api_key_valid: bool = Depends(verify_api_key)): + """Récupère les détails d'un groupe spécifique""" + if not ansible_service.group_exists(group_name): + raise HTTPException(status_code=404, detail=f"Groupe '{group_name}' non trouvé") + + hosts = ansible_service.get_group_hosts(group_name) + group_type = "env" if group_name.startswith("env_") else "role" if group_name.startswith("role_") else "other" + + return { + "name": group_name, + "type": group_type, + "display_name": group_name.replace('env_', '').replace('role_', ''), + "hosts_count": len(hosts), + "hosts": hosts + } + + +@app.post("/api/groups") +async def create_group(group_request: GroupRequest, api_key_valid: bool = Depends(verify_api_key)): + """Crée un nouveau groupe d'environnement ou de rôle""" + # Construire le nom complet du groupe + prefix = "env_" if group_request.type == "env" else "role_" + + # Si le nom ne commence pas déjà par le préfixe, l'ajouter + if group_request.name.startswith(prefix): + full_name = group_request.name + else: + full_name = f"{prefix}{group_request.name}" + + # Vérifier si le groupe existe déjà + if ansible_service.group_exists(full_name): + raise HTTPException(status_code=400, detail=f"Le groupe '{full_name}' existe déjà") + + # Créer le groupe + success = ansible_service.add_group(full_name) + + if not success: + raise HTTPException(status_code=500, detail="Erreur lors de la création du groupe") + + return { + "success": True, + "message": f"Groupe '{full_name}' créé avec succès", + "group": { + "name": full_name, + "type": group_request.type, + "display_name": full_name.replace('env_', '').replace('role_', ''), + "hosts_count": 0, + "hosts": [] + } + } + + +@app.put("/api/groups/{group_name}") +async def update_group(group_name: str, group_update: GroupUpdateRequest, api_key_valid: bool = Depends(verify_api_key)): + """Renomme un groupe existant""" + if not ansible_service.group_exists(group_name): + raise HTTPException(status_code=404, detail=f"Groupe '{group_name}' non trouvé") + + # Déterminer le type du groupe + if group_name.startswith("env_"): + prefix = "env_" + group_type = "env" + elif group_name.startswith("role_"): + prefix = "role_" + group_type = "role" + else: + raise HTTPException(status_code=400, detail="Seuls les groupes env_ et role_ peuvent être modifiés") + + # Construire le nouveau nom + if group_update.new_name.startswith(prefix): + new_full_name = group_update.new_name + else: + new_full_name = f"{prefix}{group_update.new_name}" + + # Vérifier si le nouveau nom existe déjà + if ansible_service.group_exists(new_full_name): + raise HTTPException(status_code=400, detail=f"Le groupe '{new_full_name}' existe déjà") + + # Renommer le groupe + success = ansible_service.rename_group(group_name, new_full_name) + + if not success: + raise HTTPException(status_code=500, detail="Erreur lors du renommage du groupe") + + hosts = ansible_service.get_group_hosts(new_full_name) + + return { + "success": True, + "message": f"Groupe renommé de '{group_name}' vers '{new_full_name}'", + "group": { + "name": new_full_name, + "type": group_type, + "display_name": new_full_name.replace('env_', '').replace('role_', ''), + "hosts_count": len(hosts), + "hosts": hosts + } + } + + +@app.delete("/api/groups/{group_name}") +async def delete_group( + group_name: str, + move_hosts_to: Optional[str] = None, + api_key_valid: bool = Depends(verify_api_key) +): + """Supprime un groupe existant + + Args: + group_name: Nom du groupe à supprimer + move_hosts_to: Groupe vers lequel déplacer les hôtes (optionnel, query param) + """ + if not ansible_service.group_exists(group_name): + raise HTTPException(status_code=404, detail=f"Groupe '{group_name}' non trouvé") + + # Vérifier si le groupe contient des hôtes + hosts_in_group = ansible_service.get_group_hosts(group_name) + + # Si le groupe contient des hôtes et qu'on ne spécifie pas où les déplacer + if hosts_in_group and not move_hosts_to: + # Pour les groupes d'environnement, c'est critique car les hôtes doivent avoir un env + if group_name.startswith("env_"): + raise HTTPException( + status_code=400, + detail=f"Le groupe contient {len(hosts_in_group)} hôte(s). Spécifiez 'move_hosts_to' pour les déplacer." + ) + + # Si on veut déplacer les hôtes, vérifier que le groupe cible est valide + if move_hosts_to: + # Vérifier que le groupe cible est du même type + if group_name.startswith("env_") and not move_hosts_to.startswith("env_"): + raise HTTPException(status_code=400, detail="Les hôtes doivent être déplacés vers un groupe d'environnement") + if group_name.startswith("role_") and not move_hosts_to.startswith("role_"): + raise HTTPException(status_code=400, detail="Les hôtes doivent être déplacés vers un groupe de rôle") + + # Supprimer le groupe + result = ansible_service.delete_group(group_name, move_hosts_to) + + if not result.get("success"): + raise HTTPException(status_code=500, detail=result.get("error", "Erreur lors de la suppression")) + + return { + "success": True, + "message": f"Groupe '{group_name}' supprimé avec succès", + "hosts_affected": result.get("hosts_affected", []), + "hosts_moved_to": result.get("hosts_moved_to") + } + + +@app.get("/api/hosts/by-name/{host_name}") +async def get_host_by_name(host_name: str, api_key_valid: bool = Depends(verify_api_key)): + """Récupère un hôte spécifique par son nom""" + host = next((h for h in db.hosts if h.name == host_name), None) + if not host: + raise HTTPException(status_code=404, detail="Hôte non trouvé") + return host + +@app.get("/api/hosts", response_model=List[Host]) +async def get_hosts( + bootstrap_status: Optional[str] = None, + api_key_valid: bool = Depends(verify_api_key) +): + """Récupère la liste de tous les hôtes + + Args: + bootstrap_status: Filtrer par statut bootstrap ('ready', 'not_configured', ou None pour tous) + """ + hosts = db.hosts + + # Filtrer par statut bootstrap si spécifié + if bootstrap_status == 'ready': + hosts = [h for h in hosts if h.bootstrap_ok] + elif bootstrap_status == 'not_configured': + hosts = [h for h in hosts if not h.bootstrap_ok] + + return hosts + +@app.get("/api/hosts/{host_id}", response_model=Host) +async def get_host(host_id: int, api_key_valid: bool = Depends(verify_api_key)): + """Récupère un hôte spécifique par ID""" + host = next((h for h in db.hosts if h.id == host_id), None) + if not host: + raise HTTPException(status_code=404, detail="Hôte non trouvé") + return host + +@app.post("/api/hosts") +async def create_host(host_request: HostRequest, api_key_valid: bool = Depends(verify_api_key)): + """Crée un nouvel hôte dans l'inventaire Ansible (hosts.yml) + + L'hôte sera ajouté au groupe d'environnement spécifié et aux groupes de rôles. + """ + # Vérifier si l'hôte existe déjà + if ansible_service.host_exists(host_request.name): + raise HTTPException(status_code=400, detail=f"L'hôte '{host_request.name}' existe déjà dans l'inventaire") + + # Valider le groupe d'environnement + env_groups = ansible_service.get_env_groups() + if host_request.env_group not in env_groups: + # Créer le groupe s'il n'existe pas mais commence par env_ + if not host_request.env_group.startswith('env_'): + raise HTTPException(status_code=400, detail=f"Le groupe d'environnement doit commencer par 'env_'. Groupes existants: {env_groups}") + + # Valider les groupes de rôles + role_groups = ansible_service.get_role_groups() + for role in host_request.role_groups: + if role not in role_groups and not role.startswith('role_'): + raise HTTPException(status_code=400, detail=f"Le groupe de rôle '{role}' doit commencer par 'role_'. Groupes existants: {role_groups}") + + try: + # Ajouter l'hôte à l'inventaire + ansible_service.add_host_to_inventory( + hostname=host_request.name, + env_group=host_request.env_group, + role_groups=host_request.role_groups, + ansible_host=host_request.ip + ) + + # Invalider le cache pour recharger les hôtes + db._hosts_cache = None + + # Récupérer le nouvel hôte + new_host = next((h for h in db.hosts if h.name == host_request.name), None) + + # Ajouter un log + log_entry = LogEntry( + id=db.get_next_id("logs"), + timestamp=datetime.now(timezone.utc), + level="INFO", + message=f"Hôte '{host_request.name}' ajouté à l'inventaire (env: {host_request.env_group}, roles: {host_request.role_groups})", + source="inventory", + host=host_request.name + ) + db.logs.insert(0, log_entry) + + # Notifier les clients WebSocket + await ws_manager.broadcast({ + "type": "host_created", + "data": new_host.dict() if new_host else {"name": host_request.name} + }) + + return { + "message": f"Hôte '{host_request.name}' ajouté avec succès", + "host": new_host.dict() if new_host else None, + "inventory_updated": True + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Erreur lors de l'ajout de l'hôte: {str(e)}") + +@app.put("/api/hosts/{host_name}") +async def update_host( + host_name: str, + update_request: HostUpdateRequest, + api_key_valid: bool = Depends(verify_api_key) +): + """Met à jour les groupes d'un hôte existant dans l'inventaire Ansible""" + # Vérifier que l'hôte existe + if not ansible_service.host_exists(host_name): + raise HTTPException(status_code=404, detail=f"Hôte '{host_name}' non trouvé dans l'inventaire") + + # Valider le groupe d'environnement si fourni + if update_request.env_group: + env_groups = ansible_service.get_env_groups() + if update_request.env_group not in env_groups and not update_request.env_group.startswith('env_'): + raise HTTPException(status_code=400, detail=f"Le groupe d'environnement doit commencer par 'env_'") + + # Valider les groupes de rôles si fournis + if update_request.role_groups: + for role in update_request.role_groups: + if not role.startswith('role_'): + raise HTTPException(status_code=400, detail=f"Le groupe de rôle '{role}' doit commencer par 'role_'") + + try: + success = ansible_service.update_host_groups( + hostname=host_name, + env_group=update_request.env_group, + role_groups=update_request.role_groups, + ansible_host=update_request.ansible_host + ) + + if not success: + raise HTTPException(status_code=500, detail="Échec de la mise à jour de l'hôte") + + # Invalider le cache + db._hosts_cache = None + + # Récupérer l'hôte mis à jour + updated_host = next((h for h in db.hosts if h.name == host_name), None) + + # Ajouter un log + log_entry = LogEntry( + id=db.get_next_id("logs"), + timestamp=datetime.now(timezone.utc), + level="INFO", + message=f"Hôte '{host_name}' mis à jour (env: {update_request.env_group}, roles: {update_request.role_groups})", + source="inventory", + host=host_name + ) + db.logs.insert(0, log_entry) + + # Notifier les clients WebSocket + await ws_manager.broadcast({ + "type": "host_updated", + "data": updated_host.dict() if updated_host else {"name": host_name} + }) + + return { + "message": f"Hôte '{host_name}' mis à jour avec succès", + "host": updated_host.dict() if updated_host else None, + "inventory_updated": True + } + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Erreur lors de la mise à jour: {str(e)}") + +@app.delete("/api/hosts/by-name/{host_name}") +async def delete_host_by_name(host_name: str, api_key_valid: bool = Depends(verify_api_key)): + """Supprime un hôte de l'inventaire Ansible par son nom""" + # Vérifier que l'hôte existe + if not ansible_service.host_exists(host_name): + raise HTTPException(status_code=404, detail=f"Hôte '{host_name}' non trouvé dans l'inventaire") + + try: + success = ansible_service.remove_host_from_inventory(host_name) + + if not success: + raise HTTPException(status_code=500, detail="Échec de la suppression de l'hôte") + + # Invalider le cache + db._hosts_cache = None + + # Ajouter un log + log_entry = LogEntry( + id=db.get_next_id("logs"), + timestamp=datetime.now(timezone.utc), + level="WARN", + message=f"Hôte '{host_name}' supprimé de l'inventaire", + source="inventory", + host=host_name + ) + db.logs.insert(0, log_entry) + + # Notifier les clients WebSocket + await ws_manager.broadcast({ + "type": "host_deleted", + "data": {"name": host_name} + }) + + return { + "message": f"Hôte '{host_name}' supprimé avec succès", + "inventory_updated": True + } + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Erreur lors de la suppression: {str(e)}") + +@app.delete("/api/hosts/{host_id}") +async def delete_host(host_id: int, api_key_valid: bool = Depends(verify_api_key)): + """Supprime un hôte par ID""" + host = next((h for h in db.hosts if h.id == host_id), None) + if not host: + raise HTTPException(status_code=404, detail="Hôte non trouvé") + + return await delete_host_by_name(host.name, api_key_valid) + +@app.get("/api/tasks", response_model=List[Task]) +async def get_tasks(api_key_valid: bool = Depends(verify_api_key)): + """Récupère la liste de toutes les tâches""" + return db.tasks + +@app.post("/api/tasks", response_model=Task) +async def create_task(task_request: TaskRequest, api_key_valid: bool = Depends(verify_api_key)): + """Crée une nouvelle tâche et exécute le playbook Ansible correspondant""" + task_names = { + 'upgrade': 'Mise à jour système', + 'reboot': 'Redémarrage système', + 'health-check': 'Vérification de santé', + 'backup': 'Sauvegarde', + 'deploy': 'Déploiement', + 'rollback': 'Rollback', + 'maintenance': 'Maintenance', + 'bootstrap': 'Bootstrap Ansible' + } + + new_task = Task( + id=db.get_next_id("tasks"), + name=task_names.get(task_request.action, f"Tâche {task_request.action}"), + host=task_request.host or task_request.group or "all", + status="running", + progress=0, + start_time=datetime.now(timezone.utc) + ) + + db.tasks.append(new_task) + + # Notifier les clients WebSocket + await ws_manager.broadcast({ + "type": "task_created", + "data": new_task.dict() + }) + + # Exécuter le playbook Ansible en arrière-plan + playbook = ACTION_PLAYBOOK_MAP.get(task_request.action) + if playbook: + asyncio.create_task(execute_ansible_task( + task_id=new_task.id, + playbook=playbook, + target=new_task.host, + extra_vars=task_request.extra_vars, + check_mode=task_request.dry_run + )) + else: + # Pas de playbook correspondant, simuler + asyncio.create_task(simulate_task_execution(new_task.id)) + + return new_task + + +# ===== ENDPOINTS LOGS DE TÂCHES (MARKDOWN) ===== +# IMPORTANT: Ces routes doivent être AVANT /api/tasks/{task_id} pour éviter les conflits + +@app.get("/api/tasks/logs") +async def get_task_logs( + status: Optional[str] = None, + year: Optional[str] = None, + month: Optional[str] = None, + day: Optional[str] = None, + target: Optional[str] = None, + category: Optional[str] = None, + api_key_valid: bool = Depends(verify_api_key) +): + """Récupère les logs de tâches depuis les fichiers markdown avec filtrage""" + logs = task_log_service.get_task_logs( + year=year, + month=month, + day=day, + status=status, + target=target, + category=category + ) + return { + "logs": [log.dict() for log in logs], + "count": len(logs), + "filters": { + "status": status, + "year": year, + "month": month, + "day": day, + "target": target + } + } + + +@app.get("/api/tasks/logs/dates") +async def get_task_logs_dates(api_key_valid: bool = Depends(verify_api_key)): + """Récupère la structure des dates disponibles pour le filtrage""" + return task_log_service.get_available_dates() + + +@app.get("/api/tasks/logs/stats") +async def get_task_logs_stats(api_key_valid: bool = Depends(verify_api_key)): + """Récupère les statistiques des logs de tâches""" + return task_log_service.get_stats() + + +@app.get("/api/tasks/logs/{log_id}") +async def get_task_log_content(log_id: str, api_key_valid: bool = Depends(verify_api_key)): + """Récupère le contenu d'un log de tâche spécifique""" + logs = task_log_service.get_task_logs() + log = next((l for l in logs if l.id == log_id), None) + + if not log: + raise HTTPException(status_code=404, detail="Log non trouvé") + + try: + content = Path(log.path).read_text(encoding='utf-8') + return { + "log": log.dict(), + "content": content + } + except Exception as e: + raise HTTPException(status_code=500, detail=f"Erreur lecture du fichier: {str(e)}") + + +@app.delete("/api/tasks/logs/{log_id}") +async def delete_task_log(log_id: str, api_key_valid: bool = Depends(verify_api_key)): + """Supprime un fichier markdown de log de tâche.""" + logs = task_log_service.get_task_logs() + log = next((l for l in logs if l.id == log_id), None) + + if not log: + raise HTTPException(status_code=404, detail="Log non trouvé") + + try: + log_path = Path(log.path) + if log_path.exists(): + log_path.unlink() + return {"message": "Log supprimé", "id": log_id} + except Exception as e: + raise HTTPException(status_code=500, detail=f"Erreur suppression du fichier: {str(e)}") + + +@app.get("/api/tasks/running") +async def get_running_tasks(api_key_valid: bool = Depends(verify_api_key)): + """Récupère uniquement les tâches en cours d'exécution (running ou pending)""" + running_tasks = [t for t in db.tasks if t.status in ("running", "pending")] + return { + "tasks": [t.dict() for t in running_tasks], + "count": len(running_tasks) + } + + +@app.get("/api/tasks/{task_id}", response_model=Task) +async def get_task(task_id: int, api_key_valid: bool = Depends(verify_api_key)): + """Récupère une tâche spécifique""" + task = next((t for t in db.tasks if t.id == task_id), None) + if not task: + raise HTTPException(status_code=404, detail="Tâche non trouvée") + return task + +@app.delete("/api/tasks/{task_id}") +async def delete_task(task_id: int, api_key_valid: bool = Depends(verify_api_key)): + """Supprime une tâche""" + task = next((t for t in db.tasks if t.id == task_id), None) + if not task: + raise HTTPException(status_code=404, detail="Tâche non trouvée") + + db.tasks = [t for t in db.tasks if t.id != task_id] + + # Notifier les clients WebSocket + await ws_manager.broadcast({ + "type": "task_deleted", + "data": {"id": task_id} + }) + + return {"message": "Tâche supprimée avec succès"} + +@app.get("/api/logs", response_model=List[LogEntry]) +async def get_logs(limit: int = 50, api_key_valid: bool = Depends(verify_api_key)): + """Récupère les logs récents""" + return db.logs[:limit] + +@app.post("/api/logs") +async def create_log(log_entry: LogEntry, api_key_valid: bool = Depends(verify_api_key)): + """Ajoute un nouvel entrée de log""" + log_entry.id = db.get_next_id("logs") + db.logs.insert(0, log_entry) + + # Garder seulement les 100 derniers logs + if len(db.logs) > 100: + db.logs = db.logs[:100] + + # Notifier les clients WebSocket + await ws_manager.broadcast({ + "type": "new_log", + "data": log_entry.dict() + }) + + return log_entry + +@app.delete("/api/logs") +async def clear_logs(api_key_valid: bool = Depends(verify_api_key)): + """Efface tous les logs""" + db.logs = [] + + # Notifier les clients WebSocket + await ws_manager.broadcast({ + "type": "logs_cleared", + "data": {} + }) + + return {"message": "Logs effacés avec succès"} + +@app.get("/api/metrics", response_model=SystemMetrics) +async def get_metrics(api_key_valid: bool = Depends(verify_api_key)): + """Récupère les métriques système calculées dynamiquement""" + return db.metrics + + +@app.post("/api/hosts/refresh") +async def refresh_hosts(api_key_valid: bool = Depends(verify_api_key)): + """Force le rechargement des hôtes depuis l'inventaire Ansible""" + ansible_service.invalidate_cache() # Clear ansible inventory cache first + hosts = db.refresh_hosts() + + # Notifier les clients WebSocket + await ws_manager.broadcast({ + "type": "hosts_refreshed", + "data": {"count": len(hosts)} + }) + + return {"message": f"{len(hosts)} hôtes rechargés depuis l'inventaire Ansible"} + + +# ===== ENDPOINTS ANSIBLE ===== + +@app.get("/api/ansible/playbooks") +async def get_ansible_playbooks(api_key_valid: bool = Depends(verify_api_key)): + """Liste les playbooks Ansible disponibles avec leurs catégories""" + return { + "playbooks": ansible_service.get_playbooks(), + "categories": ansible_service.get_playbook_categories(), + "ansible_dir": str(ANSIBLE_DIR) + } + +@app.get("/api/ansible/inventory") +async def get_ansible_inventory( + group: Optional[str] = None, + api_key_valid: bool = Depends(verify_api_key) +): + """Récupère l'inventaire Ansible avec les hôtes et groupes. + + Args: + group: Filtrer les hôtes par groupe (optionnel) + """ + return { + "hosts": [h.dict() for h in ansible_service.get_hosts_from_inventory(group_filter=group)], + "groups": ansible_service.get_groups(), + "inventory_path": str(ansible_service.inventory_path), + "filter": group + } + +@app.post("/api/ansible/execute") +async def execute_ansible_playbook( + request: AnsibleExecutionRequest, + api_key_valid: bool = Depends(verify_api_key) +): + """Exécute un playbook Ansible directement""" + start_time_dt = datetime.now(timezone.utc) + + # Créer une tâche pour l'historique + task_id = db.get_next_id("tasks") + playbook_name = request.playbook.replace('.yml', '').replace('-', ' ').title() + task = Task( + id=task_id, + name=f"Playbook: {playbook_name}", + host=request.target, + status="running", + progress=0, + start_time=start_time_dt + ) + db.tasks.insert(0, task) + + try: + result = await ansible_service.execute_playbook( + playbook=request.playbook, + target=request.target, + extra_vars=request.extra_vars, + check_mode=request.check_mode, + verbose=request.verbose + ) + + # Mettre à jour la tâche + task.status = "completed" if result["success"] else "failed" + task.progress = 100 + task.end_time = datetime.now(timezone.utc) + task.duration = f"{result.get('execution_time', 0):.1f}s" + task.output = result.get("stdout", "") + task.error = result.get("stderr", "") if not result["success"] else None + + # Ajouter un log + log_entry = LogEntry( + id=db.get_next_id("logs"), + timestamp=datetime.now(timezone.utc), + level="INFO" if result["success"] else "ERROR", + message=f"Playbook {request.playbook} exécuté sur {request.target}: {'succès' if result['success'] else 'échec'}", + source="ansible", + host=request.target + ) + db.logs.insert(0, log_entry) + + # Sauvegarder le log markdown + try: + task_log_service.save_task_log( + task=task, + output=result.get("stdout", ""), + error=result.get("stderr", "") + ) + except Exception as log_error: + print(f"Erreur sauvegarde log markdown: {log_error}") + + await ws_manager.broadcast({ + "type": "ansible_execution", + "data": result + }) + + # Ajouter task_id au résultat + result["task_id"] = task_id + + return result + except FileNotFoundError as e: + task.status = "failed" + task.end_time = datetime.now(timezone.utc) + task.error = str(e) + task_log_service.save_task_log(task=task, error=str(e)) + raise HTTPException(status_code=404, detail=str(e)) + except Exception as e: + task.status = "failed" + task.end_time = datetime.now(timezone.utc) + task.error = str(e) + task_log_service.save_task_log(task=task, error=str(e)) + raise HTTPException(status_code=500, detail=str(e)) + +@app.get("/api/ansible/groups") +async def get_ansible_groups(api_key_valid: bool = Depends(verify_api_key)): + """Récupère la liste des groupes Ansible""" + return {"groups": ansible_service.get_groups()} + + +# ===== ENDPOINTS PLAYBOOKS CRUD ===== + +class PlaybookContentRequest(BaseModel): + """Requête pour sauvegarder le contenu d'un playbook""" + content: str = Field(..., description="Contenu YAML du playbook") + + +@app.get("/api/playbooks/{filename}/content") +async def get_playbook_content( + filename: str, + api_key_valid: bool = Depends(verify_api_key) +): + """Récupère le contenu d'un playbook""" + playbook_path = ansible_service.playbooks_dir / filename + + # Vérifier les extensions valides + if not filename.endswith(('.yml', '.yaml')): + raise HTTPException(status_code=400, detail="Extension de fichier invalide. Utilisez .yml ou .yaml") + + if not playbook_path.exists(): + raise HTTPException(status_code=404, detail=f"Playbook non trouvé: {filename}") + + # Vérifier que le fichier est bien dans le répertoire playbooks (sécurité) + try: + playbook_path.resolve().relative_to(ansible_service.playbooks_dir.resolve()) + except ValueError: + raise HTTPException(status_code=403, detail="Accès non autorisé") + + try: + content = playbook_path.read_text(encoding='utf-8') + stat = playbook_path.stat() + return { + "filename": filename, + "content": content, + "size": stat.st_size, + "modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat() + } + except Exception as e: + raise HTTPException(status_code=500, detail=f"Erreur lecture fichier: {str(e)}") + + +@app.put("/api/playbooks/{filename}/content") +async def save_playbook_content( + filename: str, + request: PlaybookContentRequest, + api_key_valid: bool = Depends(verify_api_key) +): + """Sauvegarde le contenu d'un playbook (création ou modification)""" + # Vérifier les extensions valides + if not filename.endswith(('.yml', '.yaml')): + raise HTTPException(status_code=400, detail="Extension de fichier invalide. Utilisez .yml ou .yaml") + + # Valider le nom de fichier (sécurité) + import re + if not re.match(r'^[a-zA-Z0-9_-]+\.(yml|yaml)$', filename): + raise HTTPException(status_code=400, detail="Nom de fichier invalide") + + playbook_path = ansible_service.playbooks_dir / filename + + # S'assurer que le répertoire existe + ansible_service.playbooks_dir.mkdir(parents=True, exist_ok=True) + + # Valider le contenu YAML + try: + parsed = yaml.safe_load(request.content) + if parsed is None: + raise HTTPException(status_code=400, detail="Contenu YAML vide ou invalide") + except yaml.YAMLError as e: + raise HTTPException(status_code=400, detail=f"Erreur de syntaxe YAML: {str(e)}") + + is_new = not playbook_path.exists() + + try: + playbook_path.write_text(request.content, encoding='utf-8') + stat = playbook_path.stat() + + # Log l'action + action = "créé" if is_new else "modifié" + log_entry = LogEntry( + id=db.get_next_id("logs"), + timestamp=datetime.now(timezone.utc), + level="INFO", + message=f"Playbook {filename} {action}", + source="playbook_editor" + ) + db.logs.insert(0, log_entry) + + return { + "success": True, + "message": f"Playbook {filename} {'créé' if is_new else 'sauvegardé'} avec succès", + "filename": filename, + "size": stat.st_size, + "modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(), + "is_new": is_new + } + except Exception as e: + raise HTTPException(status_code=500, detail=f"Erreur sauvegarde fichier: {str(e)}") + + +@app.delete("/api/playbooks/{filename}") +async def delete_playbook( + filename: str, + api_key_valid: bool = Depends(verify_api_key) +): + """Supprime un playbook""" + # Vérifier les extensions valides + if not filename.endswith(('.yml', '.yaml')): + raise HTTPException(status_code=400, detail="Extension de fichier invalide") + + playbook_path = ansible_service.playbooks_dir / filename + + if not playbook_path.exists(): + raise HTTPException(status_code=404, detail=f"Playbook non trouvé: {filename}") + + # Vérifier que le fichier est bien dans le répertoire playbooks (sécurité) + try: + playbook_path.resolve().relative_to(ansible_service.playbooks_dir.resolve()) + except ValueError: + raise HTTPException(status_code=403, detail="Accès non autorisé") + + try: + playbook_path.unlink() + + # Log l'action + log_entry = LogEntry( + id=db.get_next_id("logs"), + timestamp=datetime.now(timezone.utc), + level="WARN", + message=f"Playbook {filename} supprimé", + source="playbook_editor" + ) + db.logs.insert(0, log_entry) + + return { + "success": True, + "message": f"Playbook {filename} supprimé avec succès" + } + except Exception as e: + raise HTTPException(status_code=500, detail=f"Erreur suppression fichier: {str(e)}") + + +@app.get("/api/ansible/ssh-config") +async def get_ssh_config(api_key_valid: bool = Depends(verify_api_key)): + """Diagnostic de la configuration SSH pour le bootstrap""" + ssh_key_path = Path(SSH_KEY_PATH) + ssh_dir = ssh_key_path.parent + + # Lister les fichiers dans le répertoire SSH + available_files = [] + if ssh_dir.exists(): + available_files = [f.name for f in ssh_dir.iterdir()] + + # Vérifier les clés + private_key_exists = ssh_key_path.exists() + public_key_exists = Path(SSH_KEY_PATH + ".pub").exists() + + # Chercher d'autres clés publiques + pub_keys_found = [] + for ext in [".pub"]: + for key_type in ["id_rsa", "id_ed25519", "id_ecdsa", "id_dsa"]: + key_path = ssh_dir / f"{key_type}{ext}" + if key_path.exists(): + pub_keys_found.append(str(key_path)) + + # Trouver la clé privée qui sera utilisée + active_private_key = find_ssh_private_key() + + return { + "ssh_key_path": SSH_KEY_PATH, + "ssh_dir": str(ssh_dir), + "ssh_dir_exists": ssh_dir.exists(), + "private_key_exists": private_key_exists, + "public_key_exists": public_key_exists, + "available_files": available_files, + "public_keys_found": pub_keys_found, + "active_private_key": active_private_key, + "ssh_user": SSH_USER, + "sshpass_available": shutil.which("sshpass") is not None, + } + + +@app.post("/api/ansible/adhoc", response_model=AdHocCommandResult) +async def execute_adhoc_command( + request: AdHocCommandRequest, + api_key_valid: bool = Depends(verify_api_key) +): + """Exécute une commande ad-hoc Ansible sur un ou plusieurs hôtes. + + Exemples: + - Lister les fichiers: {"target": "all", "command": "ls -la /tmp"} + - Vérifier l'espace disque: {"target": "proxmox", "command": "df -h", "become": true} + - Redémarrer un service: {"target": "web-servers", "command": "systemctl restart nginx", "become": true} + """ + start_time_perf = perf_counter() + start_time_dt = datetime.now(timezone.utc) + + # Créer une tâche pour l'historique + task_id = db.get_next_id("tasks") + task_name = f"Ad-hoc: {request.command[:40]}{'...' if len(request.command) > 40 else ''}" + task = Task( + id=task_id, + name=task_name, + host=request.target, + status="running", + progress=0, + start_time=start_time_dt + ) + db.tasks.insert(0, task) + + # Construire la commande ansible + ansible_cmd = [ + "ansible", + request.target, + "-i", str(ANSIBLE_DIR / "inventory" / "hosts.yml"), + "-m", request.module, + "-a", request.command, + "--timeout", str(request.timeout), + ] + + # Ajouter les options + if request.become: + ansible_cmd.append("--become") + + private_key = find_ssh_private_key() + if private_key: + ansible_cmd.extend(["--private-key", private_key]) + + if SSH_USER: + ansible_cmd.extend(["-u", SSH_USER]) + + try: + result = subprocess.run( + ansible_cmd, + capture_output=True, + text=True, + timeout=request.timeout + 10, + cwd=str(ANSIBLE_DIR) + ) + + duration = perf_counter() - start_time_perf + success = result.returncode == 0 + + # Mettre à jour la tâche + task.status = "completed" if success else "failed" + task.progress = 100 + task.end_time = datetime.now(timezone.utc) + task.duration = f"{round(duration, 2)}s" + task.output = result.stdout + task.error = result.stderr if result.stderr else None + + # Sauvegarder le log de tâche en markdown + task_log_service.save_task_log(task, output=result.stdout, error=result.stderr or "") + + # Log de l'exécution + log_entry = LogEntry( + id=db.get_next_id("logs"), + timestamp=datetime.now(timezone.utc), + level="INFO" if success else "WARN", + message=f"Ad-hoc [{request.module}] sur {request.target}: {request.command[:50]}{'...' if len(request.command) > 50 else ''}", + source="ansible-adhoc", + host=request.target + ) + db.logs.insert(0, log_entry) + + # Notifier via WebSocket + await ws_manager.broadcast({ + "type": "adhoc_executed", + "data": { + "target": request.target, + "command": request.command, + "success": success, + "task_id": task_id + } + }) + + # Sauvegarder dans l'historique des commandes ad-hoc (pour réutilisation) + adhoc_history_service.add_command( + command=request.command, + target=request.target, + module=request.module, + become=request.become + ) + + return AdHocCommandResult( + target=request.target, + command=request.command, + success=success, + return_code=result.returncode, + stdout=result.stdout, + stderr=result.stderr if result.stderr else None, + duration=round(duration, 2) + ) + + except subprocess.TimeoutExpired: + duration = perf_counter() - start_time_perf + # Mettre à jour la tâche en échec + task.status = "failed" + task.progress = 100 + task.end_time = datetime.now(timezone.utc) + task.duration = f"{round(duration, 2)}s" + task.error = f"Timeout après {request.timeout} secondes" + + # Sauvegarder le log de tâche + task_log_service.save_task_log(task, error=task.error) + + return AdHocCommandResult( + target=request.target, + command=request.command, + success=False, + return_code=-1, + stdout="", + stderr=f"Timeout après {request.timeout} secondes", + duration=round(duration, 2) + ) + except FileNotFoundError: + duration = perf_counter() - start_time_perf + error_msg = "ansible non trouvé. Vérifiez que Ansible est installé et accessible." + # Mettre à jour la tâche en échec + task.status = "failed" + task.progress = 100 + task.end_time = datetime.now(timezone.utc) + task.duration = f"{round(duration, 2)}s" + task.error = error_msg + + # Sauvegarder le log de tâche + task_log_service.save_task_log(task, error=error_msg) + + return AdHocCommandResult( + target=request.target, + command=request.command, + success=False, + return_code=-1, + stdout="", + stderr=error_msg, + duration=round(duration, 2) + ) + except Exception as e: + duration = perf_counter() - start_time_perf + error_msg = f"Erreur interne: {str(e)}" + # Mettre à jour la tâche en échec + task.status = "failed" + task.progress = 100 + task.end_time = datetime.now(timezone.utc) + task.duration = f"{round(duration, 2)}s" + task.error = error_msg + + # Sauvegarder le log de tâche + task_log_service.save_task_log(task, error=error_msg) + + # Return a proper result instead of raising HTTP 500 + return AdHocCommandResult( + target=request.target, + command=request.command, + success=False, + return_code=-1, + stdout="", + stderr=error_msg, + duration=round(duration, 2) + ) + + +@app.post("/api/ansible/bootstrap", response_model=CommandResult) +async def bootstrap_ansible_host( + request: BootstrapRequest, + api_key_valid: bool = Depends(verify_api_key) +): + """Bootstrap un hôte pour Ansible. + + Cette opération: + 1. Se connecte à l'hôte via SSH avec le mot de passe root + 2. Crée l'utilisateur d'automatisation (par défaut: automation) + 3. Configure la clé SSH publique pour l'authentification sans mot de passe + 4. Installe et configure sudo pour cet utilisateur + 5. Installe Python3 (requis par Ansible) + 6. Vérifie la connexion SSH par clé + + Supporte: Debian/Ubuntu, Alpine Linux, FreeBSD + """ + import logging + import traceback + logger = logging.getLogger("bootstrap_endpoint") + + try: + logger.info(f"Bootstrap request for host={request.host}, user={request.automation_user}") + result = bootstrap_host( + host=request.host, + root_password=request.root_password, + automation_user=request.automation_user + ) + logger.info(f"Bootstrap result: status={result.status}, return_code={result.return_code}") + + # Si le bootstrap a échoué (return_code != 0), lever une exception avec les détails + if result.return_code != 0: + raise HTTPException( + status_code=500, + detail={ + "status": result.status, + "return_code": result.return_code, + "stdout": result.stdout, + "stderr": result.stderr + } + ) + + # Trouver le nom de l'hôte (peut être IP ou hostname) + host_name = request.host + for h in db.hosts: + if h.ip == request.host or h.name == request.host: + host_name = h.name + break + + # Enregistrer le statut de bootstrap réussi + bootstrap_status_service.set_bootstrap_status( + host_name=host_name, + success=True, + details=f"Bootstrap réussi via API (user: {request.automation_user})" + ) + + # Invalider le cache des hôtes pour recharger avec le nouveau statut + db._hosts_cache = None + + # Ajouter un log de succès + log_entry = LogEntry( + id=db.get_next_id("logs"), + timestamp=datetime.now(timezone.utc), + level="INFO", + message=f"Bootstrap réussi pour {host_name} (user: {request.automation_user})", + source="bootstrap", + host=host_name + ) + db.logs.insert(0, log_entry) + + # Notifier via WebSocket + await ws_manager.broadcast({ + "type": "bootstrap_success", + "data": { + "host": host_name, + "user": request.automation_user, + "status": "ok", + "bootstrap_ok": True + } + }) + + return result + + except HTTPException: + raise + except Exception as e: + logger.error(f"Bootstrap exception: {e}") + logger.error(traceback.format_exc()) + # Ajouter un log d'erreur + log_entry = LogEntry( + id=db.get_next_id("logs"), + timestamp=datetime.now(timezone.utc), + level="ERROR", + message=f"Échec bootstrap pour {request.host}: {str(e)}", + source="bootstrap", + host=request.host + ) + db.logs.insert(0, log_entry) + + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/health") +async def global_health_check(): + """Endpoint de healthcheck global utilisé par Docker. + + Ne nécessite pas de clé API pour permettre aux orchestrateurs + de vérifier l'état du service facilement. + """ + return { + "status": "ok", + "service": "homelab-automation-api", + "timestamp": datetime.now(timezone.utc).isoformat() + } + + +# ===== ENDPOINTS BOOTSTRAP STATUS ===== + +@app.get("/api/bootstrap/status") +async def get_all_bootstrap_status(api_key_valid: bool = Depends(verify_api_key)): + """Récupère le statut de bootstrap de tous les hôtes""" + return { + "hosts": bootstrap_status_service.get_all_status() + } + + +@app.get("/api/bootstrap/status/{host_name}") +async def get_host_bootstrap_status( + host_name: str, + api_key_valid: bool = Depends(verify_api_key) +): + """Récupère le statut de bootstrap d'un hôte spécifique""" + status = bootstrap_status_service.get_bootstrap_status(host_name) + return { + "host": host_name, + **status + } + + +@app.post("/api/bootstrap/status/{host_name}") +async def set_host_bootstrap_status( + host_name: str, + success: bool = True, + details: Optional[str] = None, + api_key_valid: bool = Depends(verify_api_key) +): + """Définit manuellement le statut de bootstrap d'un hôte""" + result = bootstrap_status_service.set_bootstrap_status( + host_name=host_name, + success=success, + details=details or f"Status défini manuellement" + ) + + # Invalider le cache des hôtes + db._hosts_cache = None + + # Notifier via WebSocket + await ws_manager.broadcast({ + "type": "bootstrap_status_updated", + "data": { + "host": host_name, + "bootstrap_ok": success + } + }) + + return { + "host": host_name, + "status": "updated", + **result + } + + +# ===== ENDPOINTS HISTORIQUE AD-HOC ===== + +@app.get("/api/adhoc/history") +async def get_adhoc_history( + category: Optional[str] = None, + search: Optional[str] = None, + limit: int = 50, + api_key_valid: bool = Depends(verify_api_key) +): + """Récupère l'historique des commandes ad-hoc""" + commands = adhoc_history_service.get_commands( + category=category, + search=search, + limit=limit + ) + return { + "commands": [cmd.dict() for cmd in commands], + "count": len(commands) + } + + +@app.get("/api/adhoc/categories") +async def get_adhoc_categories(api_key_valid: bool = Depends(verify_api_key)): + """Récupère la liste des catégories de commandes ad-hoc""" + categories = adhoc_history_service.get_categories() + return {"categories": [cat.dict() for cat in categories]} + + +@app.post("/api/adhoc/categories") +async def create_adhoc_category( + name: str, + description: Optional[str] = None, + color: str = "#7c3aed", + icon: str = "fa-folder", + api_key_valid: bool = Depends(verify_api_key) +): + """Crée une nouvelle catégorie de commandes ad-hoc""" + category = adhoc_history_service.add_category(name, description, color, icon) + return {"category": category.dict(), "message": "Catégorie créée"} + + +@app.put("/api/adhoc/categories/{category_name}") +async def update_adhoc_category( + category_name: str, + request: Request, + api_key_valid: bool = Depends(verify_api_key) +): + """Met à jour une catégorie existante""" + try: + data = await request.json() + new_name = data.get("name", category_name) + description = data.get("description", "") + color = data.get("color", "#7c3aed") + icon = data.get("icon", "fa-folder") + + success = adhoc_history_service.update_category(category_name, new_name, description, color, icon) + if not success: + raise HTTPException(status_code=404, detail="Catégorie non trouvée") + return {"message": "Catégorie mise à jour", "category": new_name} + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@app.delete("/api/adhoc/categories/{category_name}") +async def delete_adhoc_category( + category_name: str, + api_key_valid: bool = Depends(verify_api_key) +): + """Supprime une catégorie et déplace ses commandes vers 'default'""" + if category_name == "default": + raise HTTPException(status_code=400, detail="La catégorie 'default' ne peut pas être supprimée") + + success = adhoc_history_service.delete_category(category_name) + if not success: + raise HTTPException(status_code=404, detail="Catégorie non trouvée") + return {"message": "Catégorie supprimée", "category": category_name} + + +@app.put("/api/adhoc/history/{command_id}/category") +async def update_adhoc_command_category( + command_id: str, + category: str, + description: Optional[str] = None, + api_key_valid: bool = Depends(verify_api_key) +): + """Met à jour la catégorie d'une commande dans l'historique""" + success = adhoc_history_service.update_command_category(command_id, category, description) + if not success: + raise HTTPException(status_code=404, detail="Commande non trouvée") + return {"message": "Catégorie mise à jour", "command_id": command_id, "category": category} + + +@app.delete("/api/adhoc/history/{command_id}") +async def delete_adhoc_command(command_id: str, api_key_valid: bool = Depends(verify_api_key)): + """Supprime une commande de l'historique""" + success = adhoc_history_service.delete_command(command_id) + if not success: + raise HTTPException(status_code=404, detail="Commande non trouvée") + return {"message": "Commande supprimée", "command_id": command_id} + + +@app.get("/api/health/{host_name}", response_model=HealthCheck) +async def check_host_health(host_name: str, api_key_valid: bool = Depends(verify_api_key)): + """Effectue un health check sur un hôte spécifique et met à jour son last_seen""" + host = next((h for h in db.hosts if h.name == host_name), None) + if not host: + raise HTTPException(status_code=404, detail="Hôte non trouvé") + + # Simuler un health check à partir du statut actuel + health_check = HealthCheck( + host=host_name, + ssh_ok=host.status == "online", + ansible_ok=host.status == "online", + sudo_ok=host.status == "online", + reachable=host.status != "offline", + response_time=0.123 if host.status == "online" else None, + error_message=None if host.status != "offline" else "Hôte injoignable" + ) + + # Mettre à jour le statut runtime + persistant + new_status = "online" if health_check.reachable else "offline" + db.update_host_status(host_name, new_status, host.os) + + # Ajouter un log pour le health check + log_entry = LogEntry( + timestamp=datetime.now(timezone.utc), + level="INFO" if health_check.reachable else "ERROR", + message=f"Health check {'réussi' if health_check.reachable else 'échoué'} pour {host_name}", + source="health_check", + host=host_name + ) + + db.logs.insert(0, log_entry) + + # Notifier les clients WebSocket + await ws_manager.broadcast({ + "type": "health_check", + "data": health_check.dict() + }) + + return health_check + +# WebSocket pour les mises à jour en temps réel +@app.websocket("/ws") +async def websocket_endpoint(websocket: WebSocket): + await ws_manager.connect(websocket) + try: + while True: + # Garder la connexion ouverte + data = await websocket.receive_text() + # Traiter les messages entrants si nécessaire + except WebSocketDisconnect: + ws_manager.disconnect(websocket) + +# Fonctions utilitaires +async def simulate_task_execution(task_id: int): + """Simule l'exécution d'une tâche en arrière-plan""" + task = next((t for t in db.tasks if t.id == task_id), None) + if not task: + return + + # Simuler la progression + for progress in range(0, 101, 10): + task.progress = progress + + # Notifier les clients WebSocket + await ws_manager.broadcast({ + "type": "task_progress", + "data": { + "id": task_id, + "progress": progress + } + }) + + await asyncio.sleep(0.5) # Attendre 500ms entre chaque mise à jour + + # Marquer la tâche comme terminée + task.status = "completed" + task.end_time = datetime.now(timezone.utc) + task.duration = "2m 30s" + + # Ajouter un log + log_entry = LogEntry( + timestamp=datetime.now(timezone.utc), + level="INFO", + message=f"Tâche '{task.name}' terminée avec succès sur {task.host}", + source="task_manager", + host=task.host + ) + db.logs.insert(0, log_entry) + + # Notifier les clients WebSocket + await ws_manager.broadcast({ + "type": "task_completed", + "data": { + "id": task_id, + "status": "completed", + "progress": 100 + } + }) + + # Sauvegarder le log markdown + try: + task_log_service.save_task_log(task=task, output="Tâche simulée terminée avec succès") + except Exception as log_error: + print(f"Erreur sauvegarde log markdown: {log_error}") + + +async def execute_ansible_task( + task_id: int, + playbook: str, + target: str, + extra_vars: Optional[Dict[str, Any]] = None, + check_mode: bool = False +): + """Exécute un playbook Ansible pour une tâche""" + task = next((t for t in db.tasks if t.id == task_id), None) + if not task: + return + + # Notifier le début + task.progress = 10 + await ws_manager.broadcast({ + "type": "task_progress", + "data": {"id": task_id, "progress": 10, "message": "Démarrage du playbook Ansible..."} + }) + + start_time = perf_counter() + + try: + # Exécuter le playbook + result = await ansible_service.execute_playbook( + playbook=playbook, + target=target, + extra_vars=extra_vars, + check_mode=check_mode, + verbose=True + ) + + execution_time = perf_counter() - start_time + + # Mettre à jour la tâche + task.progress = 100 + task.status = "completed" if result["success"] else "failed" + task.end_time = datetime.now(timezone.utc) + task.duration = f"{execution_time:.1f}s" + task.output = result.get("stdout", "") + task.error = result.get("stderr", "") if not result["success"] else None + + # Si c'est un health-check ciblé, mettre à jour le statut/last_seen de l'hôte + if "health-check" in playbook and target and target != "all": + try: + new_status = "online" if result["success"] else "offline" + db.update_host_status(target, new_status) + except Exception: + # Ne pas interrompre la gestion de la tâche si la MAJ de statut échoue + pass + + # Ajouter un log + log_entry = LogEntry( + id=db.get_next_id("logs"), + timestamp=datetime.now(timezone.utc), + level="INFO" if result["success"] else "ERROR", + message=f"Tâche '{task.name}' {'terminée avec succès' if result['success'] else 'échouée'} sur {target}", + source="ansible", + host=target + ) + db.logs.insert(0, log_entry) + + # Notifier les clients WebSocket + await ws_manager.broadcast({ + "type": "task_completed", + "data": { + "id": task_id, + "status": task.status, + "progress": 100, + "duration": task.duration, + "success": result["success"], + "output": result.get("stdout", "")[:500] # Limiter la taille + } + }) + + # Sauvegarder le log markdown + try: + log_path = task_log_service.save_task_log( + task=task, + output=result.get("stdout", ""), + error=result.get("stderr", "") + ) + log_entry = LogEntry( + id=db.get_next_id("logs"), + timestamp=datetime.now(timezone.utc), + level="DEBUG", + message=f"Log de tâche sauvegardé: {log_path}", + source="task_log", + host=target + ) + db.logs.insert(0, log_entry) + except Exception as log_error: + print(f"Erreur sauvegarde log markdown: {log_error}") + + except Exception as e: + task.status = "failed" + task.end_time = datetime.now(timezone.utc) + task.error = str(e) + + log_entry = LogEntry( + id=db.get_next_id("logs"), + timestamp=datetime.now(timezone.utc), + level="ERROR", + message=f"Erreur lors de l'exécution de '{task.name}': {str(e)}", + source="ansible", + host=target + ) + db.logs.insert(0, log_entry) + + # Sauvegarder le log markdown même en cas d'échec + try: + task_log_service.save_task_log(task=task, error=str(e)) + except Exception: + pass + + await ws_manager.broadcast({ + "type": "task_failed", + "data": { + "id": task_id, + "status": "failed", + "error": str(e) + } + }) + + +# Démarrer l'application +if __name__ == "__main__": + uvicorn.run( + "app_optimized:app", + host="0.0.0.0", + port=8008, + reload=True, + log_level="info" + ) \ No newline at end of file diff --git a/app/index.html b/app/index.html new file mode 100644 index 0000000..377cbd2 --- /dev/null +++ b/app/index.html @@ -0,0 +1,2540 @@ + + + + + + Homelab Automation Dashboard + + + + + + + + + + + + + + +
+ +
+
+
+

+ Automation Dashboard +

+

+ Gérez votre homelab avec puissance et élégance. Surveillance, automatisation et contrôle en temps réel. +

+
+ + +
+
+ + +
+
+
12
+
Hosts En Ligne
+
+
+
48
+
Tâches Exécutées
+
+
+
98.5%
+
Taux de Succès
+
+
+
99.9%
+
Disponibilité
+
+
+
+
+ + +
+
+

Tableau de Bord

+ +
+ +
+
+
+

Gestion des Hosts

+
+ +
+ + +
+
+
+ +
+ +
+
+
+ + +
+

Actions Rapides

+
+ + + + +
+
+
+
+
+
+ + + +
+
+
+
+

+ Gestion des Hosts +

+

Gérez et surveillez tous vos serveurs depuis un seul endroit

+
+ +
+
+

Inventaire des Hosts

+
+ +
+ + +
+
+
+ +
+ +
+
+
+
+
+ + + +
+
+
+ +
+

+ Gestion des Playbooks +

+

Gérez vos scripts d'automatisation Ansible depuis un seul endroit

+
+ +
+ +
+ +
+
+ + +
+ + 0 playbooks + +
+ + +
+ + +
+
+ + +
+ Catégorie: + + + + + + +
+ + +
+ +
+ +

Chargement des playbooks...

+
+
+
+
+
+
+ + + +
+
+
+

Gestion des Tâches

+ +
+ +
+
+

Tâches

+ 0 +
+ + +
+ + + + +
+
+ + +
+ + + Filtrer par date: + + + +
+ + + + +
+ + + +
+ + +
+ +
+ + + +
+
+
+
+ + + +
+
+
+
+

+ Logs Système +

+

Consultez l'historique des opérations et événements système

+
+ +
+
+

Logs Récentes

+
+ + +
+
+ +
+ +
+
+
+
+
+ + + +
+
+
+ +
+

+ Centre d'Aide +

+

+ Bienvenue dans le guide d'utilisation du Homelab Automation Dashboard. + Découvrez comment gérer efficacement votre infrastructure. +

+
+ + +
+ + + + +
+ + +
+

+ + Démarrage Rapide +

+
+
+
+

Ajouter vos Hosts

+

+ Commencez par ajouter vos serveurs dans la section Hosts. + Chaque host nécessite un nom, une adresse IP et un système d'exploitation. +

+
+
+
+

Bootstrap Ansible

+

+ Exécutez le Bootstrap sur chaque host pour configurer + l'accès SSH et les prérequis Ansible. +

+
+
+
+

Automatiser

+

+ Utilisez les Actions Rapides ou exécutez des playbooks + personnalisés pour automatiser vos tâches. +

+
+
+
+ + +
+

+ + Indicateurs de Santé des Hosts +

+

+ Chaque host affiche un indicateur visuel de santé représenté par des barres colorées. + Cet indicateur combine plusieurs facteurs pour évaluer l'état global de votre serveur. +

+ +
+ +
+

Comprendre l'Indicateur

+
+ +
+
+
+
+
+
+
+
+
+ Excellent + (5 barres vertes) +

Host en ligne, bootstrap OK, vérifié récemment

+
+
+ + +
+
+
+
+
+
+
+
+
+ Bon + (3-4 barres jaunes) +

Host fonctionnel mais certains aspects à améliorer

+
+
+ + +
+
+
+
+
+
+
+
+
+ Moyen + (2 barres oranges) +

Attention requise - vérification recommandée

+
+
+ + +
+
+
+
+
+
+
+
+
+ Faible + (1 barre rouge) +

Host hors ligne ou non configuré

+
+
+
+
+ + +
+

Facteurs de Calcul du Score

+
+
+
+ + Statut en ligne + +2 points +
+

Le host répond aux requêtes réseau

+
+ +
+
+ + Bootstrap Ansible OK + +1 point +
+

SSH et prérequis Ansible configurés

+
+ +
+
+ + Vérifié récemment (<1h) + +2 points +
+

Dernière vérification il y a moins d'une heure

+
+ +
+
+ + Vérifié aujourd'hui + +1 point +
+

Dernière vérification dans les 24 dernières heures

+
+
+ +
+

+ + Astuce: Exécutez régulièrement un Health Check + pour maintenir un score de santé élevé. +

+
+
+
+ + +
+

Statuts Bootstrap Ansible

+
+
+
+ + Ansible Ready + +
+

+ Le host est entièrement configuré pour Ansible. L'utilisateur automation existe, + la clé SSH est déployée et sudo est configuré sans mot de passe. +

+
+ +
+
+ + Non configuré + +
+

+ Le bootstrap n'a pas encore été exécuté sur ce host. Cliquez sur le bouton + Bootstrap pour configurer l'accès Ansible. +

+
+
+
+ + +
+

+ + Que signifie "Jamais vérifié" ? +

+

+ Ce message apparaît lorsqu'aucun Health Check n'a été exécuté sur le host depuis son ajout. + Le système ne peut pas déterminer l'état réel du serveur. Lancez un Health Check pour + mettre à jour le statut et obtenir un score de santé précis. +

+
+
+ + +
+

+ + Architecture de la Solution +

+
+
+

Stack Technologique

+
    +
  • + + Backend: FastAPI (Python) - API REST haute performance +
  • +
  • + + Automation: Ansible - Gestion de configuration +
  • +
  • + + Frontend: HTML/CSS/JS avec TailwindCSS +
  • +
  • + + Déploiement: Docker & Docker Compose +
  • +
  • + + Temps réel: WebSocket pour les mises à jour live +
  • +
+
+
+

Structure des Fichiers

+
+
+homelab-automation/
+├── app/
+│   ├── app_optimized.py    # API FastAPI
+│   ├── index.html          # Interface web
+│   └── main.js             # Logique frontend
+├── ansible/
+│   ├── inventory/
+│   │   ├── hosts.yml       # Inventaire des hosts
+│   │   └── group_vars/     # Variables par groupe
+│   └── playbooks/          # Playbooks Ansible
+├── tasks_logs/             # Logs des tâches
+├── docker-compose.yml
+└── Dockerfile
+
+
+
+
+ + +
+

+ + Fonctionnalités par Section +

+ + +
+ +
+
+ + + Dashboard + + +
+
+
+

Vue d'ensemble de votre infrastructure avec métriques en temps réel.

+
    +
  • Métriques: Nombre d'hosts en ligne, tâches exécutées, taux de succès
  • +
  • Actions Rapides: Mise à jour, redémarrage, health check, backup
  • +
  • Aperçu Hosts: Liste condensée avec statut de chaque serveur
  • +
+
+
+
+ + +
+
+ + + Hosts + + +
+
+
+

Gestion complète de vos serveurs et machines.

+
    +
  • Ajouter un Host: Nom, IP, OS, groupes d'environnement et de rôle
  • +
  • Bootstrap: Configure SSH et les prérequis Ansible sur le host
  • +
  • Filtres: Par groupe, par statut Ansible Ready/Non configuré
  • +
  • Actions: Health Check, Upgrade, Reboot, Backup par host
  • +
  • Playbooks: Exécuter des playbooks sur un groupe de hosts
  • +
+
+
+
+ + +
+
+ + + Tasks + + +
+
+
+

Historique et suivi des tâches d'automatisation.

+
    +
  • Statuts: En cours (bleu), Terminées (vert), Échouées (rouge)
  • +
  • Filtres: Par statut, par date (année/mois/jour)
  • +
  • Détails: Cliquez sur une tâche pour voir les logs complets
  • +
  • Polling: Mise à jour automatique des tâches en cours
  • +
+
+
+
+ + +
+
+ + + Logs + + +
+
+
+

Journal des événements système en temps réel.

+
    +
  • Types: Info, Warning, Error avec codes couleur
  • +
  • WebSocket: Logs en temps réel sans rafraîchissement
  • +
  • Export: Téléchargez les logs au format texte
  • +
  • Effacer: Nettoyez l'historique des logs
  • +
+
+
+
+
+
+ + +
+

+ + Playbooks Ansible Disponibles +

+
+
+

+ + bootstrap-host.yml +

+

+ Configure un nouveau host pour Ansible: création utilisateur, clé SSH, sudo sans mot de passe. +

+ Requis avant toute autre opération +
+
+

+ + health-check.yml +

+

+ Vérifie l'état de santé: CPU, RAM, disque, services critiques. +

+ Exécution rapide, non destructif +
+
+

+ + system-upgrade.yml +

+

+ Met à jour tous les paquets système (apt/yum/dnf selon l'OS). +

+ Peut nécessiter un redémarrage +
+
+

+ + backup-config.yml +

+

+ Sauvegarde les fichiers de configuration importants (/etc, configs apps). +

+ Stockage local ou distant +
+
+
+ + +
+

+ + Référence API +

+

+ L'API REST est accessible sur le port configuré. Authentification via header X-API-Key. +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
EndpointMéthodeDescription
/api/hostsGETListe tous les hosts
/api/hostsPOSTAjoute un nouveau host
/api/tasks/logsGETRécupère les logs de tâches
/api/ansible/playbooksGETListe les playbooks disponibles
/api/ansible/executePOSTExécute un playbook
/api/metricsGETMétriques du dashboard
+
+
+ + +
+

+ + Dépannage +

+
+
+
+ Le bootstrap échoue avec "Permission denied" + +
+
+
+

Cause: Les identifiants SSH fournis sont incorrects ou l'utilisateur n'a pas les droits sudo.

+

Solution: Vérifiez le nom d'utilisateur et mot de passe. Assurez-vous que l'utilisateur peut exécuter sudo sur le host cible.

+
+
+
+
+
+ Les hosts apparaissent "offline" alors qu'ils sont accessibles + +
+
+
+

Cause: Le health check n'a pas été exécuté ou la clé SSH n'est pas configurée.

+

Solution: Exécutez le bootstrap si ce n'est pas fait, puis lancez un Health Check.

+
+
+
+
+
+ Les tâches restent bloquées "En cours" + +
+
+
+

Cause: Le processus Ansible peut être bloqué ou le host ne répond plus.

+

Solution: Vérifiez la connectivité réseau. Consultez les logs système pour plus de détails. Redémarrez le conteneur Docker si nécessaire.

+
+
+
+
+
+ L'interface ne se met pas à jour en temps réel + +
+
+
+

Cause: La connexion WebSocket est interrompue.

+

Solution: Rafraîchissez la page. Vérifiez que le port WebSocket n'est pas bloqué par un firewall ou proxy.

+
+
+
+
+
+ + +
+

+ + Raccourcis & Astuces +

+
+
+

Navigation

+
    +
  • Cliquez sur le logo pour revenir au Dashboard
  • +
  • Utilisez les onglets du menu pour naviguer
  • +
  • Le thème clair/sombre est persistant
  • +
+
+
+

Productivité

+
    +
  • Filtrez les hosts par groupe pour des actions groupées
  • +
  • Utilisez les filtres de date pour retrouver des tâches
  • +
  • Exportez les logs avant de les effacer
  • +
+
+
+
+ +
+
+
+
+
+ + + +
+
+

+ © 2025 Homelab Automation Dashboard. Propulsé par + FastAPI, + Ansible et + Technologies Modernes +

+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/main.js b/app/main.js new file mode 100644 index 0000000..ec121f5 --- /dev/null +++ b/app/main.js @@ -0,0 +1,6412 @@ +// Homelab Dashboard JavaScript - Intégration API +class DashboardManager { + constructor() { + // Configuration API + this.apiKey = 'dev-key-12345'; + this.apiBase = window.location.origin; + + // Données locales (seront remplies par l'API) + this.hosts = []; + this.tasks = []; + this.logs = []; + this.ansibleHosts = []; + this.ansibleGroups = []; + this.playbooks = []; + + // Logs de tâches depuis les fichiers markdown + this.taskLogs = []; + this.taskLogsStats = { total: 0, completed: 0, failed: 0, running: 0, pending: 0 }; + this.taskLogsDates = { years: {} }; + + // Filtres actifs + this.currentStatusFilter = 'all'; + this.currentDateFilter = { year: '', month: '', day: '' }; + // Sélection de dates via le calendrier (liste de chaînes YYYY-MM-DD) + this.selectedTaskDates = []; + this.taskCalendarMonth = new Date(); + this.currentGroupFilter = 'all'; + this.currentBootstrapFilter = 'all'; + this.currentCategoryFilter = 'all'; + this.currentSubcategoryFilter = 'all'; + this.currentTargetFilter = 'all'; + + // Groupes pour la gestion des hôtes + this.envGroups = []; + this.roleGroups = []; + + // Catégories de playbooks + this.playbookCategories = {}; + + // Filtres playbooks + this.currentPlaybookCategoryFilter = 'all'; + this.currentPlaybookSearch = ''; + + // Historique des commandes ad-hoc + this.adhocHistory = []; + this.adhocCategories = []; + + // WebSocket + this.ws = null; + + // Polling des tâches en cours + this.runningTasksPollingInterval = null; + this.pollingIntervalMs = 2000; // Polling toutes les 2 secondes + + // Pagination des tâches + this.tasksDisplayedCount = 20; + this.tasksPerPage = 20; + + this.init(); + } + + async init() { + this.setupEventListeners(); + this.setupScrollAnimations(); + this.startAnimations(); + this.loadThemePreference(); + + // Charger les données depuis l'API + await this.loadAllData(); + + // Connecter WebSocket pour les mises à jour temps réel + this.connectWebSocket(); + + // Rafraîchir périodiquement les métriques + setInterval(() => this.loadMetrics(), 30000); + + // Démarrer le polling des tâches en cours + this.startRunningTasksPolling(); + } + + // ===== API CALLS ===== + + async apiCall(endpoint, options = {}) { + const url = `${this.apiBase}${endpoint}`; + const defaultOptions = { + headers: { + 'X-API-Key': this.apiKey, + 'Content-Type': 'application/json' + } + }; + + try { + const response = await fetch(url, { ...defaultOptions, ...options }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + return await response.json(); + } catch (error) { + console.error(`API Error (${endpoint}):`, error); + throw error; + } + } + + async loadAllData() { + try { + // Charger en parallèle + const [hostsData, tasksData, logsData, metricsData, inventoryData, playbooksData, taskLogsData, taskStatsData, taskDatesData, adhocHistoryData, adhocCategoriesData] = await Promise.all([ + this.apiCall('/api/hosts').catch(() => []), + this.apiCall('/api/tasks').catch(() => []), + this.apiCall('/api/logs').catch(() => []), + this.apiCall('/api/metrics').catch(() => ({})), + this.apiCall('/api/ansible/inventory').catch(() => ({ hosts: [], groups: [] })), + this.apiCall('/api/ansible/playbooks').catch(() => ({ playbooks: [] })), + this.apiCall('/api/tasks/logs').catch(() => ({ logs: [], count: 0 })), + this.apiCall('/api/tasks/logs/stats').catch(() => ({ total: 0, completed: 0, failed: 0, running: 0, pending: 0 })), + this.apiCall('/api/tasks/logs/dates').catch(() => ({ years: {} })), + this.apiCall('/api/adhoc/history').catch(() => ({ commands: [], count: 0 })), + this.apiCall('/api/adhoc/categories').catch(() => ({ categories: [] })) + ]); + + this.hosts = hostsData; + this.tasks = tasksData; + this.logs = logsData; + this.ansibleHosts = inventoryData.hosts || []; + this.ansibleGroups = inventoryData.groups || []; + this.playbooks = playbooksData.playbooks || []; + this.playbookCategories = playbooksData.categories || {}; + + // Logs de tâches markdown + this.taskLogs = taskLogsData.logs || []; + this.taskLogsStats = taskStatsData; + this.taskLogsDates = taskDatesData; + + // Historique ad-hoc + this.adhocHistory = adhocHistoryData.commands || []; + this.adhocCategories = adhocCategoriesData.categories || []; + + console.log('Data loaded:', { + taskLogs: this.taskLogs.length, + taskLogsStats: this.taskLogsStats, + adhocHistory: this.adhocHistory.length, + adhocCategories: this.adhocCategories.length + }); + + // Mettre à jour l'affichage + this.renderHosts(); + this.renderTasks(); + this.renderLogs(); + this.renderPlaybooks(); + this.updateMetricsDisplay(metricsData); + this.updateDateFilters(); + this.updateTaskCounts(); + + } catch (error) { + console.error('Erreur chargement données:', error); + this.showNotification('Erreur de connexion à l\'API', 'error'); + } + } + + async loadMetrics() { + try { + const metrics = await this.apiCall('/api/metrics'); + this.updateMetricsDisplay(metrics); + } catch (error) { + console.error('Erreur chargement métriques:', error); + } + } + + updateMetricsDisplay(metrics) { + if (!metrics) return; + + const elements = { + 'online-hosts': metrics.online_hosts, + 'total-tasks': metrics.total_tasks, + 'success-rate': `${metrics.success_rate}%`, + 'uptime': `${metrics.uptime}%` + }; + + Object.entries(elements).forEach(([id, value]) => { + const el = document.getElementById(id); + if (el && value !== undefined) { + el.textContent = value; + } + }); + } + + // ===== WEBSOCKET ===== + + connectWebSocket() { + const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${wsProtocol}//${window.location.host}/ws`; + + try { + this.ws = new WebSocket(wsUrl); + + this.ws.onopen = () => { + console.log('WebSocket connecté'); + }; + + this.ws.onmessage = (event) => { + const data = JSON.parse(event.data); + this.handleWebSocketMessage(data); + }; + + this.ws.onclose = () => { + console.log('WebSocket déconnecté, reconnexion dans 5s...'); + setTimeout(() => this.connectWebSocket(), 5000); + }; + + this.ws.onerror = (error) => { + console.error('WebSocket erreur:', error); + }; + } catch (error) { + console.error('Erreur WebSocket:', error); + } + } + + handleWebSocketMessage(data) { + switch (data.type) { + case 'task_created': + // Nouvelle tâche créée - mettre à jour immédiatement + this.handleTaskCreated(data.data); + break; + case 'task_completed': + case 'task_failed': + // Tâche terminée - mettre à jour et rafraîchir les logs + this.handleTaskCompleted(data.data); + break; + case 'task_progress': + // Mise à jour de progression - mettre à jour l'UI dynamiquement + this.handleTaskProgress(data.data); + break; + case 'host_created': + case 'host_deleted': + this.loadAllData(); + break; + case 'new_log': + case 'logs_cleared': + this.loadLogs(); + break; + case 'ansible_execution': + this.showNotification( + data.data.success ? 'Playbook exécuté avec succès' : 'Échec du playbook', + data.data.success ? 'success' : 'error' + ); + break; + case 'bootstrap_success': + this.showNotification( + `Bootstrap réussi pour ${data.data.host}`, + 'success' + ); + this.loadAllData(); + break; + case 'bootstrap_status_updated': + this.loadAllData(); + break; + } + } + + // ===== POLLING DES TÂCHES EN COURS ===== + + startRunningTasksPolling() { + // Arrêter le polling existant si présent + this.stopRunningTasksPolling(); + + // Démarrer le polling + this.runningTasksPollingInterval = setInterval(() => { + this.pollRunningTasks(); + }, this.pollingIntervalMs); + + // Exécuter immédiatement une première fois + this.pollRunningTasks(); + + console.log('Polling des tâches en cours démarré'); + } + + stopRunningTasksPolling() { + if (this.runningTasksPollingInterval) { + clearInterval(this.runningTasksPollingInterval); + this.runningTasksPollingInterval = null; + console.log('Polling des tâches en cours arrêté'); + } + } + + async pollRunningTasks() { + try { + const result = await this.apiCall('/api/tasks/running'); + const runningTasks = result.tasks || []; + + // Vérifier si des tâches ont changé de statut + const previousRunningIds = this.tasks + .filter(t => t.status === 'running' || t.status === 'pending') + .map(t => t.id); + const currentRunningIds = runningTasks.map(t => t.id); + + // Détecter les tâches terminées + const completedTaskIds = previousRunningIds.filter(id => !currentRunningIds.includes(id)); + + if (completedTaskIds.length > 0) { + // Des tâches ont été terminées - rafraîchir les logs + console.log('Tâches terminées détectées:', completedTaskIds); + await this.refreshTaskLogs(); + } + + // Mettre à jour les tâches en cours + this.updateRunningTasks(runningTasks); + + } catch (error) { + console.error('Erreur polling tâches:', error); + } + } + + updateRunningTasks(runningTasks) { + // Mettre à jour la liste des tâches en mémoire + const nonRunningTasks = this.tasks.filter(t => t.status !== 'running' && t.status !== 'pending'); + this.tasks = [...runningTasks, ...nonRunningTasks]; + + // Mettre à jour l'affichage dynamiquement + this.updateRunningTasksUI(runningTasks); + this.updateTaskCounts(); + } + + updateRunningTasksUI(runningTasks) { + const container = document.getElementById('tasks-list'); + if (!container) return; + + // Trouver ou créer la section des tâches en cours + let runningSection = container.querySelector('.running-tasks-section'); + + if (runningTasks.length === 0) { + // Supprimer la section si plus de tâches en cours + if (runningSection) { + runningSection.remove(); + } + return; + } + + // Créer la section si elle n'existe pas + if (!runningSection) { + runningSection = document.createElement('div'); + runningSection.className = 'running-tasks-section mb-4'; + runningSection.innerHTML = '

En cours

'; + + // Insérer au début du container (après le header) + const header = container.querySelector('.flex.flex-col'); + if (header && header.nextSibling) { + container.insertBefore(runningSection, header.nextSibling); + } else { + container.prepend(runningSection); + } + } + + // Mettre à jour le contenu des tâches en cours + const tasksContainer = runningSection.querySelector('.running-tasks-list') || document.createElement('div'); + tasksContainer.className = 'running-tasks-list space-y-2'; + + tasksContainer.innerHTML = runningTasks.map(task => this.createRunningTaskHTML(task)).join(''); + + if (!runningSection.querySelector('.running-tasks-list')) { + runningSection.appendChild(tasksContainer); + } + + // Mettre à jour le badge "en cours" dans le header + const runningBadge = container.querySelector('.running-badge'); + if (runningBadge) { + runningBadge.textContent = `${runningTasks.length} en cours`; + } + } + + createRunningTaskHTML(task) { + const startTime = task.start_time + ? new Date(task.start_time).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) + : '--'; + + const duration = task.duration || this.calculateDuration(task.start_time); + const progress = task.progress || 0; + + return ` +
+
+
+
+ +

${this.escapeHtml(task.name)}

+ En cours +
+

Cible: ${this.escapeHtml(task.host)}

+

Début: ${startTime} • Durée: ${duration}

+
+
+
+

${progress}% complété

+
+
+ +
+
+
+ `; + } + + calculateDuration(startTime) { + if (!startTime) return '--'; + const start = new Date(startTime); + const now = new Date(); + const diffMs = now - start; + const diffSec = Math.floor(diffMs / 1000); + + if (diffSec < 60) return `${diffSec}s`; + const diffMin = Math.floor(diffSec / 60); + const remainingSec = diffSec % 60; + if (diffMin < 60) return `${diffMin}m ${remainingSec}s`; + const diffHour = Math.floor(diffMin / 60); + const remainingMin = diffMin % 60; + return `${diffHour}h ${remainingMin}m`; + } + + // ===== HANDLERS WEBSOCKET POUR LES TÂCHES ===== + + handleTaskCreated(taskData) { + console.log('Nouvelle tâche créée:', taskData); + + // Ajouter la tâche à la liste + const existingIndex = this.tasks.findIndex(t => t.id === taskData.id); + if (existingIndex === -1) { + this.tasks.push(taskData); + } else { + this.tasks[existingIndex] = taskData; + } + + // Mettre à jour l'UI immédiatement + this.updateRunningTasksUI(this.tasks.filter(t => t.status === 'running' || t.status === 'pending')); + this.updateTaskCounts(); + + // Notification + this.showNotification(`Tâche "${taskData.name}" démarrée`, 'info'); + } + + handleTaskProgress(progressData) { + console.log('Progression tâche:', progressData); + + // Mettre à jour la tâche dans la liste + const task = this.tasks.find(t => t.id === progressData.task_id); + if (task) { + task.progress = progressData.progress; + + // Mettre à jour l'UI de cette tâche spécifique + const taskCard = document.querySelector(`.task-card-${progressData.task_id}`); + if (taskCard) { + const progressBar = taskCard.querySelector('.bg-blue-500'); + const progressText = taskCard.querySelector('.text-gray-500.mt-1'); + if (progressBar) { + progressBar.style.width = `${progressData.progress}%`; + } + if (progressText) { + progressText.textContent = `${progressData.progress}% complété`; + } + } + } + } + + handleTaskCompleted(taskData) { + console.log('Tâche terminée:', taskData); + + // Retirer la tâche de la liste des tâches en cours + this.tasks = this.tasks.filter(t => t.id !== taskData.task_id); + + // Mettre à jour l'UI + this.updateRunningTasksUI(this.tasks.filter(t => t.status === 'running' || t.status === 'pending')); + + // Rafraîchir les logs de tâches pour voir la tâche terminée + this.refreshTaskLogs(); + + // Notification + const status = taskData.status || 'completed'; + const isSuccess = status === 'completed'; + this.showNotification( + `Tâche terminée: ${isSuccess ? 'Succès' : 'Échec'}`, + isSuccess ? 'success' : 'error' + ); + } + + async loadLogs() { + try { + const logsData = await this.apiCall('/api/logs'); + this.logs = logsData; + this.renderLogs(); + } catch (error) { + console.error('Erreur chargement logs:', error); + } + } + + setupEventListeners() { + // Theme toggle + const themeToggle = document.getElementById('theme-toggle'); + if (themeToggle) { + themeToggle.addEventListener('click', () => { + this.toggleTheme(); + }); + } + + // Initialiser le calendrier de filtrage des tâches + this.setupTaskDateCalendar(); + + // Navigation est gérée par le script de navigation des pages dans index.html + } + + // ===== CALENDRIER DE FILTRAGE DES TÂCHES ===== + setupTaskDateCalendar() { + const wrapper = document.getElementById('task-date-filter-wrapper'); + const button = document.getElementById('task-date-filter-button'); + const calendar = document.getElementById('task-date-calendar'); + const prevBtn = document.getElementById('task-cal-prev-month'); + const nextBtn = document.getElementById('task-cal-next-month'); + const clearBtn = document.getElementById('task-cal-clear'); + const applyBtn = document.getElementById('task-cal-apply'); + + if (!wrapper || !button || !calendar) { + return; // Section tâches pas présente + } + + // État initial + this.taskCalendarMonth = new Date(); + this.selectedTaskDates = this.selectedTaskDates || []; + + const toggleCalendar = (open) => { + const shouldOpen = typeof open === 'boolean' ? open : calendar.classList.contains('hidden'); + if (shouldOpen) { + calendar.classList.remove('hidden'); + this.renderTaskCalendar(); + } else { + calendar.classList.add('hidden'); + } + }; + + button.addEventListener('click', (event) => { + event.stopPropagation(); + toggleCalendar(); + }); + + document.addEventListener('click', (event) => { + if (!wrapper.contains(event.target)) { + calendar.classList.add('hidden'); + } + }); + + prevBtn?.addEventListener('click', (event) => { + event.stopPropagation(); + this.changeTaskCalendarMonth(-1); + }); + + nextBtn?.addEventListener('click', (event) => { + event.stopPropagation(); + this.changeTaskCalendarMonth(1); + }); + + clearBtn?.addEventListener('click', (event) => { + event.stopPropagation(); + this.selectedTaskDates = []; + this.updateDateFilters(); + this.renderTaskCalendar(); + }); + + applyBtn?.addEventListener('click', (event) => { + event.stopPropagation(); + this.applyDateFilter(); + calendar.classList.add('hidden'); + }); + + // Premier rendu + this.updateDateFilters(); + this.renderTaskCalendar(); + } + + changeTaskCalendarMonth(delta) { + const base = this.taskCalendarMonth instanceof Date ? this.taskCalendarMonth : new Date(); + const d = new Date(base); + d.setMonth(d.getMonth() + delta); + this.taskCalendarMonth = d; + this.renderTaskCalendar(); + } + + renderTaskCalendar() { + const grid = document.getElementById('task-cal-grid'); + const monthLabel = document.getElementById('task-cal-current-month'); + if (!grid || !monthLabel) return; + + const base = this.taskCalendarMonth instanceof Date ? this.taskCalendarMonth : new Date(); + const year = base.getFullYear(); + const month = base.getMonth(); + + const monthNames = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre']; + monthLabel.textContent = `${monthNames[month]} ${year}`; + + grid.innerHTML = ''; + + const firstDayOfMonth = new Date(year, month, 1); + const firstDayOfWeek = firstDayOfMonth.getDay(); // 0 (dimanche) - 6 (samedi) + const daysInMonth = new Date(year, month + 1, 0).getDate(); + const prevMonthLastDay = new Date(year, month, 0).getDate(); + + const totalCells = 42; // 6 lignes * 7 colonnes + for (let i = 0; i < totalCells; i++) { + const cell = document.createElement('div'); + cell.className = 'flex justify-center items-center py-0.5'; + + let date; + if (i < firstDayOfWeek) { + // Jours du mois précédent + const day = prevMonthLastDay - (firstDayOfWeek - 1 - i); + date = new Date(year, month - 1, day); + } else if (i < firstDayOfWeek + daysInMonth) { + // Jours du mois courant + const day = i - firstDayOfWeek + 1; + date = new Date(year, month, day); + } else { + // Jours du mois suivant + const day = i - (firstDayOfWeek + daysInMonth) + 1; + date = new Date(year, month + 1, day); + } + + const btn = document.createElement('button'); + btn.type = 'button'; + + const key = this.getDateKey(date); + const isCurrentMonth = date.getMonth() === month; + const isSelected = this.selectedTaskDates.includes(key); + const today = new Date(); + today.setHours(0, 0, 0, 0); + const isToday = date.getFullYear() === today.getFullYear() && + date.getMonth() === today.getMonth() && + date.getDate() === today.getDate(); + + let classes = 'w-9 h-9 flex items-center justify-center rounded-full text-xs transition-colors duration-150 '; + + if (!isCurrentMonth) { + classes += 'text-gray-600'; + btn.disabled = true; + } else { + btn.dataset.date = key; + if (isSelected) { + classes += 'bg-purple-600 text-white hover:bg-purple-500 cursor-pointer'; + } else if (isToday) { + classes += 'border border-purple-400 text-purple-200 hover:bg-gray-800 cursor-pointer'; + } else { + classes += 'text-gray-200 hover:bg-gray-800 cursor-pointer'; + } + + btn.addEventListener('click', (event) => { + event.stopPropagation(); + this.toggleTaskDateSelection(key); + }); + } + + btn.className = classes; + btn.textContent = String(date.getDate()); + cell.appendChild(btn); + grid.appendChild(cell); + } + } + + toggleTaskDateSelection(key) { + const index = this.selectedTaskDates.indexOf(key); + if (index > -1) { + this.selectedTaskDates.splice(index, 1); + } else { + this.selectedTaskDates.push(key); + } + // Garder les dates triées + this.selectedTaskDates.sort(); + this.updateDateFilters(); + this.renderTaskCalendar(); + } + + getDateKey(date) { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; + } + + parseDateKey(key) { + const [y, m, d] = key.split('-').map(v => parseInt(v, 10)); + return new Date(y, (m || 1) - 1, d || 1); + } + + toggleTheme() { + const body = document.body; + const currentTheme = body.classList.contains('light-theme') ? 'light' : 'dark'; + const newTheme = currentTheme === 'dark' ? 'light' : 'dark'; + + if (newTheme === 'light') { + body.classList.add('light-theme'); + document.getElementById('theme-toggle').innerHTML = ''; + } else { + body.classList.remove('light-theme'); + document.getElementById('theme-toggle').innerHTML = ''; + } + + // Persist theme preference + localStorage.setItem('theme', newTheme); + } + + loadThemePreference() { + const savedTheme = localStorage.getItem('theme'); + if (savedTheme === 'light') { + document.body.classList.add('light-theme'); + document.getElementById('theme-toggle').innerHTML = ''; + } + } + + renderHosts() { + const container = document.getElementById('hosts-list'); + const hostsPageContainer = document.getElementById('hosts-page-list'); + if (!container && !hostsPageContainer) return; + + // Filtrer les hôtes par groupe si un filtre est actif + let filteredHosts = this.hosts; + if (this.currentGroupFilter && this.currentGroupFilter !== 'all') { + filteredHosts = this.hosts.filter(h => + h.groups && h.groups.includes(this.currentGroupFilter) + ); + } + + // Filtrer par statut bootstrap si un filtre est actif + if (this.currentBootstrapFilter && this.currentBootstrapFilter !== 'all') { + if (this.currentBootstrapFilter === 'ready') { + filteredHosts = filteredHosts.filter(h => h.bootstrap_ok); + } else if (this.currentBootstrapFilter === 'not_configured') { + filteredHosts = filteredHosts.filter(h => !h.bootstrap_ok); + } + } + + // Compter les hôtes par statut bootstrap + const readyCount = this.hosts.filter(h => h.bootstrap_ok).length; + const notConfiguredCount = this.hosts.filter(h => !h.bootstrap_ok).length; + + // Options des groupes pour le filtre + const groupOptions = this.ansibleGroups.map(g => + `` + ).join(''); + + // Header avec filtres et boutons - Design professionnel + const headerHtml = ` +
+ +
+
+ + ${filteredHosts.length}/${this.hosts.length} hôtes + + + ${readyCount} Ready + + + ${notConfiguredCount} Non configuré + +
+
+ + +
+
+ + +
+ Filtres: + + +
+ + + +
+ + + +
+
+ `; + + // Apply to both containers + const containers = [container, hostsPageContainer].filter(c => c); + containers.forEach(c => c.innerHTML = headerHtml); + + if (filteredHosts.length === 0) { + const emptyHtml = ` +
+ +

Aucun hôte trouvé ${this.currentGroupFilter !== 'all' ? `dans le groupe "${this.currentGroupFilter}"` : ''} ${this.currentBootstrapFilter && this.currentBootstrapFilter !== 'all' ? `avec le statut "${this.currentBootstrapFilter === 'ready' ? 'Ansible Ready' : 'Non configuré'}"` : ''}

+

+ +

+
+ `; + containers.forEach(c => c.innerHTML += emptyHtml); + return; + } + + filteredHosts.forEach(host => { + const statusClass = `status-${host.status}`; + + // Formater last_seen + const lastSeen = host.last_seen + ? new Date(host.last_seen).toLocaleString('fr-FR') + : 'Jamais vérifié'; + + // Indicateur de bootstrap + const bootstrapOk = host.bootstrap_ok || false; + const bootstrapDate = host.bootstrap_date + ? new Date(host.bootstrap_date).toLocaleDateString('fr-FR') + : null; + const bootstrapIndicator = bootstrapOk + ? ` + Ansible Ready + ` + : ` + Non configuré + `; + + // Indicateur de qualité de communication + const commQuality = this.getHostCommunicationQuality(host); + const commIndicator = ` +
+
+ ${[1,2,3,4,5].map(i => ` +
+ `).join('')} +
+ ${commQuality.label} +
+ `; + + const hostCard = document.createElement('div'); + hostCard.className = 'host-card group'; + + // Séparer les groupes env et role + const hostGroups = host.groups || []; + const envGroup = hostGroups.find(g => g.startsWith('env_')); + const roleGroups = hostGroups.filter(g => g.startsWith('role_')); + + const envBadge = envGroup + ? `${envGroup.replace('env_', '')}` + : ''; + const roleBadges = roleGroups.map(g => + `${g.replace('role_', '')}` + ).join(''); + + hostCard.innerHTML = ` +
+
+
+
+
+
+
+

${host.name}

+ ${bootstrapIndicator} +
+

${host.ip} • ${host.os}${envGroup ? ` (${envGroup})` : ''}

+
+ ${roleBadges} +
+
+
+ +
+ ${commIndicator} + +
+ + + +
+
+
+ +
+ + + + + + +
+ `; + // Append to all containers + containers.forEach(c => { + const clonedCard = hostCard.cloneNode(true); + c.appendChild(clonedCard); + }); + }); + } + + filterHostsByBootstrap(status) { + this.currentBootstrapFilter = status; + this.renderHosts(); + } + + // Calcul de la qualité de communication d'un hôte + getHostCommunicationQuality(host) { + // Facteurs de qualité: + // - Statut actuel (online/offline) + // - Dernière vérification (last_seen) + // - Bootstrap configuré + // - Historique des tâches récentes (si disponible) + + let score = 0; + let factors = []; + + // Statut online = +2 points + if (host.status === 'online') { + score += 2; + factors.push('En ligne'); + } else if (host.status === 'offline') { + factors.push('Hors ligne'); + } + + // Bootstrap OK = +1 point + if (host.bootstrap_ok) { + score += 1; + factors.push('Ansible configuré'); + } + + // Last seen récent = +2 points (moins de 1h), +1 point (moins de 24h) + if (host.last_seen) { + const lastSeenDate = new Date(host.last_seen); + const now = new Date(); + const hoursDiff = (now - lastSeenDate) / (1000 * 60 * 60); + + if (hoursDiff < 1) { + score += 2; + factors.push('Vérifié récemment'); + } else if (hoursDiff < 24) { + score += 1; + factors.push('Vérifié aujourd\'hui'); + } else { + factors.push('Non vérifié récemment'); + } + } else { + factors.push('Jamais vérifié'); + } + + // Convertir le score en niveau (1-5) + const level = Math.min(5, Math.max(1, Math.round(score))); + + // Déterminer couleur et label selon le niveau + let colorClass, textClass, label; + if (level >= 4) { + colorClass = 'bg-green-500'; + textClass = 'text-green-400'; + label = 'Excellent'; + } else if (level >= 3) { + colorClass = 'bg-yellow-500'; + textClass = 'text-yellow-400'; + label = 'Bon'; + } else if (level >= 2) { + colorClass = 'bg-orange-500'; + textClass = 'text-orange-400'; + label = 'Moyen'; + } else { + colorClass = 'bg-red-500'; + textClass = 'text-red-400'; + label = 'Faible'; + } + + return { + level, + colorClass, + textClass, + label, + tooltip: factors.join(' • ') + }; + } + + // Modal pour exécuter un playbook sur un hôte spécifique + async showPlaybookModalForHost(hostName) { + // Récupérer la liste des playbooks disponibles + try { + const pbResult = await this.apiCall('/api/ansible/playbooks'); + const playbooks = (pbResult && pbResult.playbooks) ? pbResult.playbooks : []; + + const playbookOptions = playbooks.map(p => ` + + `).join(''); + + const modalContent = ` +
+
+ +
+

Hôte cible

+

${this.escapeHtml(hostName)}

+
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ `; + + this.showModal('Exécuter un Playbook', modalContent); + } catch (error) { + this.showNotification(`Erreur chargement playbooks: ${error.message}`, 'error'); + } + } + + async executePlaybookOnHost(hostName) { + const playbookSelect = document.getElementById('playbook-select'); + const extraVarsInput = document.getElementById('playbook-extra-vars'); + const checkModeInput = document.getElementById('playbook-check-mode'); + + const playbook = playbookSelect?.value; + if (!playbook) { + this.showNotification('Veuillez sélectionner un playbook', 'warning'); + return; + } + + let extraVars = {}; + if (extraVarsInput?.value.trim()) { + try { + extraVars = JSON.parse(extraVarsInput.value); + } catch (e) { + this.showNotification('Variables JSON invalides', 'error'); + return; + } + } + + const checkMode = checkModeInput?.checked || false; + + this.closeModal(); + this.showLoading(); + + try { + const result = await this.apiCall('/api/ansible/execute', { + method: 'POST', + body: JSON.stringify({ + playbook: playbook, + target: hostName, + extra_vars: extraVars, + check_mode: checkMode + }) + }); + + this.hideLoading(); + this.showNotification(`Playbook "${playbook}" lancé sur ${hostName}`, 'success'); + + // Rafraîchir les tâches + await this.loadTaskLogsWithFilters(); + + } catch (error) { + this.hideLoading(); + this.showNotification(`Erreur: ${error.message}`, 'error'); + } + } + + async refreshHosts() { + this.showLoading(); + try { + await this.apiCall('/api/hosts/refresh', { method: 'POST' }); + await this.loadAllData(); + this.hideLoading(); + this.showNotification('Hôtes rechargés depuis l\'inventaire Ansible', 'success'); + } catch (error) { + this.hideLoading(); + this.showNotification(`Erreur: ${error.message}`, 'error'); + } + } + + filterHostsByGroup(group) { + this.currentGroupFilter = group; + this.renderHosts(); + } + + // ===== GESTION DES HÔTES (CRUD) ===== + + async loadHostGroups() { + try { + const result = await this.apiCall('/api/hosts/groups'); + this.envGroups = result.env_groups || []; + this.roleGroups = result.role_groups || []; + return result; + } catch (error) { + console.error('Erreur chargement groupes:', error); + return { env_groups: [], role_groups: [] }; + } + } + + async showAddHostModal() { + // Charger les groupes disponibles + await this.loadHostGroups(); + + const envOptions = this.envGroups.map(g => + `` + ).join(''); + + const roleCheckboxes = this.roleGroups.map(g => ` + + `).join(''); + + this.showModal('Ajouter un Host', ` +
+
+ + L'hôte sera ajouté au fichier hosts.yml +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ ${roleCheckboxes || '

Aucun groupe de rôle disponible

'} +
+
+ +
+ + +
+
+ `); + } + + async createHost(event) { + event.preventDefault(); + const formData = new FormData(event.target); + + const envGroup = formData.get('env_group'); + if (!envGroup) { + this.showNotification('Veuillez sélectionner un groupe d\'environnement', 'error'); + return; + } + + // Récupérer les rôles sélectionnés + const roleGroups = []; + document.querySelectorAll('input[name="role_groups"]:checked').forEach(cb => { + roleGroups.push(cb.value); + }); + + const payload = { + name: formData.get('name'), + ip: formData.get('ip') || null, + env_group: envGroup, + role_groups: roleGroups + }; + + this.closeModal(); + this.showLoading(); + + try { + const result = await this.apiCall('/api/hosts', { + method: 'POST', + body: JSON.stringify(payload) + }); + + this.hideLoading(); + this.showNotification(`Hôte "${payload.name}" ajouté avec succès dans hosts.yml`, 'success'); + + // Recharger les données + await this.loadAllData(); + + } catch (error) { + this.hideLoading(); + this.showNotification(`Erreur: ${error.message}`, 'error'); + } + } + + async showEditHostModal(hostName) { + // Trouver l'hôte + const host = this.hosts.find(h => h.name === hostName); + if (!host) { + this.showNotification('Hôte non trouvé', 'error'); + return; + } + + // Charger les groupes disponibles + await this.loadHostGroups(); + + // Identifier le groupe d'environnement actuel + const currentEnvGroup = (host.groups || []).find(g => g.startsWith('env_')) || ''; + const currentRoleGroups = (host.groups || []).filter(g => g.startsWith('role_')); + + const envOptions = this.envGroups.map(g => + `` + ).join(''); + + const roleCheckboxes = this.roleGroups.map(g => ` + + `).join(''); + + this.showModal(`Modifier: ${hostName}`, ` +
+
+ + Les modifications seront appliquées au fichier hosts.yml +
+ +
+
+
+ +
+
+

${hostName}

+

${host.ip}

+
+
+
+ +
+ + +
+ +
+ + +
+ +
+ +
+ ${roleCheckboxes || '

Aucun groupe de rôle disponible

'} +
+
+ +
+ + +
+
+ `); + } + + async updateHost(event, hostName) { + event.preventDefault(); + const formData = new FormData(event.target); + + // Récupérer les rôles sélectionnés + const roleGroups = []; + document.querySelectorAll('input[name="role_groups"]:checked').forEach(cb => { + roleGroups.push(cb.value); + }); + + const payload = { + env_group: formData.get('env_group') || null, + role_groups: roleGroups, + ansible_host: formData.get('ansible_host') || null + }; + + this.closeModal(); + this.showLoading(); + + try { + const result = await this.apiCall(`/api/hosts/${encodeURIComponent(hostName)}`, { + method: 'PUT', + body: JSON.stringify(payload) + }); + + this.hideLoading(); + this.showNotification(`Hôte "${hostName}" mis à jour avec succès`, 'success'); + + // Recharger les données + await this.loadAllData(); + + } catch (error) { + this.hideLoading(); + this.showNotification(`Erreur: ${error.message}`, 'error'); + } + } + + confirmDeleteHost(hostName) { + this.showModal('Confirmer la suppression', ` +
+
+
+ +
+

Attention !

+

+ Vous êtes sur le point de supprimer l'hôte ${hostName} de l'inventaire Ansible. +

+

+ Cette action supprimera l'hôte de tous les groupes et ne peut pas être annulée. +

+
+
+
+ +
+ + +
+
+ `); + } + + async deleteHost(hostName) { + this.closeModal(); + this.showLoading(); + + try { + await this.apiCall(`/api/hosts/by-name/${encodeURIComponent(hostName)}`, { + method: 'DELETE' + }); + + this.hideLoading(); + this.showNotification(`Hôte "${hostName}" supprimé avec succès`, 'success'); + + // Recharger les données + await this.loadAllData(); + + } catch (error) { + this.hideLoading(); + this.showNotification(`Erreur: ${error.message}`, 'error'); + } + } + + // ===== GESTION DES GROUPES (CRUD) ===== + + async loadGroups() { + try { + const result = await this.apiCall('/api/groups'); + return result; + } catch (error) { + console.error('Erreur chargement groupes:', error); + return { groups: [], env_count: 0, role_count: 0 }; + } + } + + showAddGroupModal(type) { + const typeLabel = type === 'env' ? 'environnement' : 'rôle'; + const prefix = type === 'env' ? 'env_' : 'role_'; + const icon = type === 'env' ? 'fa-globe' : 'fa-tags'; + const color = type === 'env' ? 'green' : 'blue'; + + this.showModal(`Ajouter un groupe d'${typeLabel}`, ` +
+
+ + Le groupe sera ajouté à l'inventaire Ansible avec le préfixe ${prefix} +
+ +
+ +
+ ${prefix} + +
+
+ +
+ + +
+
+ `); + } + + async createGroup(event, type) { + event.preventDefault(); + const formData = new FormData(event.target); + + const payload = { + name: formData.get('name'), + type: type + }; + + this.closeModal(); + this.showLoading(); + + try { + const result = await this.apiCall('/api/groups', { + method: 'POST', + body: JSON.stringify(payload) + }); + + this.hideLoading(); + this.showNotification(result.message || `Groupe créé avec succès`, 'success'); + + // Recharger les groupes + await this.loadHostGroups(); + await this.loadAllData(); + + } catch (error) { + this.hideLoading(); + this.showNotification(`Erreur: ${error.message}`, 'error'); + } + } + + async showManageGroupsModal(type) { + const typeLabel = type === 'env' ? 'environnement' : 'rôle'; + const typeLabelPlural = type === 'env' ? 'environnements' : 'rôles'; + const icon = type === 'env' ? 'fa-globe' : 'fa-tags'; + const color = type === 'env' ? 'green' : 'blue'; + + // Charger les groupes + const groupsData = await this.loadGroups(); + const groups = groupsData.groups.filter(g => g.type === type); + + let groupsHtml = ''; + if (groups.length === 0) { + groupsHtml = ` +
+ +

Aucun groupe d'${typeLabel} trouvé

+ +
+ `; + } else { + groupsHtml = ` +
+ ${groups.map(g => ` +
+
+
+ +
+
+

${g.display_name}

+

+ ${g.name} + ${g.hosts_count} hôte(s) +

+
+
+
+ + +
+
+ `).join('')} +
+ `; + } + + this.showModal(`Gérer les ${typeLabelPlural}`, ` +
+
+

${groups.length} groupe(s) d'${typeLabel}

+ +
+ + ${groupsHtml} + +
+ +
+
+ `); + } + + async showEditGroupModal(groupName, type) { + const typeLabel = type === 'env' ? 'environnement' : 'rôle'; + const prefix = type === 'env' ? 'env_' : 'role_'; + const icon = type === 'env' ? 'fa-globe' : 'fa-tags'; + const color = type === 'env' ? 'green' : 'blue'; + const displayName = groupName.replace(prefix, ''); + + this.showModal(`Modifier le groupe: ${displayName}`, ` +
+
+ + Le renommage affectera tous les hôtes associés à ce groupe. +
+ +
+ +
+ ${prefix} + +
+
+ +
+ + +
+
+ `); + } + + async updateGroup(event, groupName, type) { + event.preventDefault(); + const formData = new FormData(event.target); + + const payload = { + new_name: formData.get('new_name') + }; + + this.closeModal(); + this.showLoading(); + + try { + const result = await this.apiCall(`/api/groups/${encodeURIComponent(groupName)}`, { + method: 'PUT', + body: JSON.stringify(payload) + }); + + this.hideLoading(); + this.showNotification(result.message || `Groupe modifié avec succès`, 'success'); + + // Recharger les groupes et afficher la liste + await this.loadHostGroups(); + await this.loadAllData(); + this.showManageGroupsModal(type); + + } catch (error) { + this.hideLoading(); + this.showNotification(`Erreur: ${error.message}`, 'error'); + } + } + + async confirmDeleteGroup(groupName, type, hostsCount) { + const typeLabel = type === 'env' ? 'environnement' : 'rôle'; + const prefix = type === 'env' ? 'env_' : 'role_'; + const displayName = groupName.replace(prefix, ''); + + // Charger les autres groupes du même type pour le déplacement + const groupsData = await this.loadGroups(); + const otherGroups = groupsData.groups.filter(g => g.type === type && g.name !== groupName); + + let moveOptions = ''; + if (hostsCount > 0 && type === 'env') { + // Pour les groupes d'environnement avec des hôtes, on doit proposer un déplacement + moveOptions = ` +
+ + +

Les ${hostsCount} hôte(s) seront déplacés vers ce groupe.

+
+ `; + } + + this.showModal('Confirmer la suppression', ` +
+
+
+ +
+

Attention !

+

+ Vous êtes sur le point de supprimer le groupe d'${typeLabel} ${displayName}. +

+ ${hostsCount > 0 ? ` +

+ Ce groupe contient ${hostsCount} hôte(s). +

+ ` : ''} +
+
+
+ + ${moveOptions} + +
+ + +
+
+ `); + } + + async deleteGroup(groupName, type) { + // Récupérer le groupe de destination si spécifié + const moveSelect = document.getElementById('move-hosts-to'); + const moveHostsTo = moveSelect ? moveSelect.value : null; + + this.closeModal(); + this.showLoading(); + + try { + let url = `/api/groups/${encodeURIComponent(groupName)}`; + if (moveHostsTo) { + url += `?move_hosts_to=${encodeURIComponent(moveHostsTo)}`; + } + + const result = await this.apiCall(url, { + method: 'DELETE' + }); + + this.hideLoading(); + this.showNotification(result.message || `Groupe supprimé avec succès`, 'success'); + + // Recharger les groupes et afficher la liste + await this.loadHostGroups(); + await this.loadAllData(); + this.showManageGroupsModal(type); + + } catch (error) { + this.hideLoading(); + this.showNotification(`Erreur: ${error.message}`, 'error'); + } + } + + executePlaybookOnGroup() { + const currentGroup = this.currentGroupFilter; + + // Générer la liste des playbooks groupés par catégorie + const categoryColors = { + 'maintenance': 'text-orange-400', + 'monitoring': 'text-green-400', + 'backup': 'text-blue-400', + 'general': 'text-gray-400' + }; + + let playbooksByCategory = {}; + this.playbooks.forEach(pb => { + const cat = pb.category || 'general'; + if (!playbooksByCategory[cat]) playbooksByCategory[cat] = []; + playbooksByCategory[cat].push(pb); + }); + + let playbooksHtml = ''; + Object.entries(playbooksByCategory).forEach(([category, playbooks]) => { + const colorClass = categoryColors[category] || 'text-gray-400'; + playbooksHtml += ` +
+
+ ${category} +
+
+ ${playbooks.map(pb => ` + + `).join('')} +
+
+ `; + }); + + this.showModal(`Exécuter un Playbook sur "${currentGroup === 'all' ? 'Tous les hôtes' : currentGroup}"`, ` +
+
+ + Sélectionnez un playbook à exécuter sur ${currentGroup === 'all' ? 'tous les hôtes' : 'le groupe ' + currentGroup} +
+
+ ${playbooksHtml || '

Aucun playbook disponible

'} +
+ +
+ `); + } + + async runPlaybookOnTarget(playbook, target) { + this.closeModal(); + this.showLoading(); + + try { + const result = await this.apiCall('/api/ansible/execute', { + method: 'POST', + body: JSON.stringify({ + playbook: playbook, + target: target === 'all' ? 'all' : target, + check_mode: false, + verbose: true + }) + }); + + this.hideLoading(); + + const statusColor = result.success ? 'bg-green-900/30 border-green-600' : 'bg-red-900/30 border-red-600'; + const statusIcon = result.success ? 'fa-check-circle text-green-500' : 'fa-times-circle text-red-500'; + + this.showModal(`Résultat: ${playbook}`, ` +
+
+
+ +
+

${result.success ? 'Exécution réussie' : 'Échec de l\'exécution'}

+

Durée: ${result.execution_time}s

+
+
+
+
+
${this.escapeHtml(result.stdout || '(pas de sortie)')}
+
+ ${result.stderr ? ` +
+

Erreurs:

+
${this.escapeHtml(result.stderr)}
+
+ ` : ''} + +
+ `); + + this.showNotification( + result.success ? `Playbook ${playbook} exécuté avec succès` : `Échec du playbook ${playbook}`, + result.success ? 'success' : 'error' + ); + + await this.loadAllData(); + + } catch (error) { + this.hideLoading(); + this.showNotification(`Erreur: ${error.message}`, 'error'); + } + } + + renderTasks() { + const container = document.getElementById('tasks-list'); + if (!container) return; + + // Combiner les tâches en mémoire avec les logs markdown + const runningTasks = this.tasks.filter(t => t.status === 'running' || t.status === 'pending'); + + // Filtrer les logs selon les filtres actifs + let filteredLogs = this.taskLogs; + if (this.currentStatusFilter && this.currentStatusFilter !== 'all') { + filteredLogs = filteredLogs.filter(log => log.status === this.currentStatusFilter); + } + + // Générer les options de catégories + const categoryOptions = Object.keys(this.playbookCategories).map(cat => + `` + ).join(''); + + // Générer les options de sous-catégories selon la catégorie sélectionnée + let subcategoryOptions = ''; + if (this.currentCategoryFilter !== 'all' && this.playbookCategories[this.currentCategoryFilter]) { + subcategoryOptions = this.playbookCategories[this.currentCategoryFilter].map(sub => + `` + ).join(''); + } + + // Générer les options de target (groupes + hôtes) + const groupOptions = this.ansibleGroups.map(g => + `` + ).join(''); + const hostOptions = this.hosts.map(h => + `` + ).join(''); + + // Catégories dynamiques pour le filtre + const taskCategories = ['Playbook', 'Ad-hoc', 'Autre']; + const taskCategoryOptions = taskCategories.map(cat => + `` + ).join(''); + + // Vérifier si des filtres sont actifs + const hasActiveFilters = (this.currentTargetFilter && this.currentTargetFilter !== 'all') || + (this.currentCategoryFilter && this.currentCategoryFilter !== 'all'); + + // Générer les badges de filtres actifs + const activeFiltersHtml = hasActiveFilters ? ` +
+ Filtres actifs: + ${this.currentTargetFilter && this.currentTargetFilter !== 'all' ? ` + + ${this.escapeHtml(this.currentTargetFilter)} + + + ` : ''} + ${this.currentCategoryFilter && this.currentCategoryFilter !== 'all' ? ` + + ${this.escapeHtml(this.currentCategoryFilter)} + + + ` : ''} + +
+ ` : ''; + + // Header avec filtres de catégorie, target et bouton console + const headerHtml = ` +
+
+
+ + ${filteredLogs.length} log(s) + + ${runningTasks.length > 0 ? ` + + ${runningTasks.length} en cours + + ` : ''} +
+
+ + + +
+
+ ${activeFiltersHtml} +
+ `; + + container.innerHTML = headerHtml; + + // Afficher d'abord les tâches en cours (section dynamique) + if (runningTasks.length > 0) { + const runningSection = document.createElement('div'); + runningSection.className = 'running-tasks-section mb-4'; + runningSection.innerHTML = '

En cours

'; + + const tasksContainer = document.createElement('div'); + tasksContainer.className = 'running-tasks-list space-y-2'; + tasksContainer.innerHTML = runningTasks.map(task => this.createRunningTaskHTML(task)).join(''); + runningSection.appendChild(tasksContainer); + + container.appendChild(runningSection); + } + + // Afficher les logs markdown + if (filteredLogs.length > 0) { + const logsSection = document.createElement('div'); + logsSection.id = 'task-logs-section'; + logsSection.innerHTML = '

Historique des tâches

'; + + // Utiliser le compteur de pagination + const displayCount = Math.min(this.tasksDisplayedCount, filteredLogs.length); + filteredLogs.slice(0, displayCount).forEach(log => { + logsSection.appendChild(this.createTaskLogCard(log)); + }); + container.appendChild(logsSection); + + // Afficher la pagination si nécessaire + const paginationEl = document.getElementById('tasks-pagination'); + if (paginationEl) { + if (filteredLogs.length > this.tasksDisplayedCount) { + paginationEl.classList.remove('hidden'); + // Mettre à jour le texte du bouton avec le nombre restant + const remaining = filteredLogs.length - this.tasksDisplayedCount; + paginationEl.innerHTML = ` + + `; + } else { + paginationEl.classList.add('hidden'); + } + } + } else if (runningTasks.length === 0) { + container.innerHTML += ` +
+ +

Aucune tâche trouvée

+

Utilisez "Actions Rapides" ou la Console pour lancer une commande

+
+ `; + } + } + + loadMoreTasks() { + // Augmenter le compteur de tâches affichées + this.tasksDisplayedCount += this.tasksPerPage; + + // Filtrer les logs selon les filtres actifs + let filteredLogs = this.taskLogs; + if (this.currentStatusFilter && this.currentStatusFilter !== 'all') { + filteredLogs = filteredLogs.filter(log => log.status === this.currentStatusFilter); + } + + // Récupérer la section des logs + const logsSection = document.getElementById('task-logs-section'); + if (!logsSection) { + // Si la section n'existe pas, re-render tout + this.renderTasks(); + return; + } + + // Ajouter les nouvelles tâches + const startIndex = this.tasksDisplayedCount - this.tasksPerPage; + const endIndex = Math.min(this.tasksDisplayedCount, filteredLogs.length); + + for (let i = startIndex; i < endIndex; i++) { + logsSection.appendChild(this.createTaskLogCard(filteredLogs[i])); + } + + // Mettre à jour le bouton de pagination + const paginationEl = document.getElementById('tasks-pagination'); + if (paginationEl) { + if (filteredLogs.length > this.tasksDisplayedCount) { + const remaining = filteredLogs.length - this.tasksDisplayedCount; + paginationEl.innerHTML = ` + + `; + } else { + paginationEl.classList.add('hidden'); + } + } + } + + createTaskLogCard(log) { + const statusColors = { + 'completed': 'border-green-500 bg-green-500/10', + 'failed': 'border-red-500 bg-red-500/10', + 'running': 'border-blue-500 bg-blue-500/10', + 'pending': 'border-yellow-500 bg-yellow-500/10' + }; + + const statusIcons = { + 'completed': '', + 'failed': '', + 'running': '', + 'pending': '' + }; + + // Formater les heures de début et fin + const formatTime = (isoString) => { + if (!isoString || isoString === 'N/A') return null; + try { + const date = new Date(isoString); + if (isNaN(date.getTime())) return null; + return date.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + } catch { + return null; + } + }; + + // Formater la durée + const formatDuration = (seconds) => { + if (!seconds || seconds <= 0) return null; + if (seconds < 60) return `${seconds} seconde${seconds > 1 ? 's' : ''}`; + if (seconds < 3600) { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return secs > 0 ? `${mins} minute${mins > 1 ? 's' : ''} ${secs} seconde${secs > 1 ? 's' : ''}` : `${mins} minute${mins > 1 ? 's' : ''}`; + } + const hours = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + let result = `${hours} heure${hours > 1 ? 's' : ''}`; + if (mins > 0) result += ` ${mins} minute${mins > 1 ? 's' : ''}`; + if (secs > 0) result += ` ${secs} seconde${secs > 1 ? 's' : ''}`; + return result; + }; + + const startTime = formatTime(log.start_time); + const endTime = formatTime(log.end_time); + const duration = log.duration_seconds ? formatDuration(log.duration_seconds) : (log.duration && log.duration !== 'N/A' ? log.duration : null); + + // Générer les badges d'hôtes + const hostsHtml = log.hosts && log.hosts.length > 0 + ? `
+ ${log.hosts.slice(0, 8).map(host => ` + + + ${this.escapeHtml(host)} + + `).join('')} + ${log.hosts.length > 8 ? `+${log.hosts.length - 8} autres` : ''} +
` + : ''; + + // Badge de catégorie + const categoryBadge = log.category + ? ` + ${this.escapeHtml(log.category)}${log.subcategory ? ` / ${this.escapeHtml(log.subcategory)}` : ''} + ` + : ''; + + // Cible cliquable + const targetHtml = log.target + ? ` + ${this.escapeHtml(log.target)} + ` + : ''; + + const card = document.createElement('div'); + card.className = `host-card border-l-4 ${statusColors[log.status] || 'border-gray-500'} cursor-pointer hover:bg-opacity-20 transition-all`; + card.onclick = () => this.viewTaskLogContent(log.id); + card.innerHTML = ` +
+
+ +
+ ${statusIcons[log.status] || ''} +

${this.escapeHtml(log.task_name)}

+ ${this.getStatusBadge(log.status)} + ${categoryBadge} +
+ +
+ ${log.date} + ${log.target ? `${targetHtml}` : ''} + ${startTime ? `${startTime}` : ''} + ${endTime ? `${endTime}` : ''} + ${duration ? `${duration}` : ''} +
+ + ${hostsHtml} +
+ +
+ + + +
+
+ `; + return card; + } + + // Nouvelles fonctions de filtrage par clic + filterByHost(host) { + this.currentTargetFilter = host; + this.tasksDisplayedCount = this.tasksPerPage; + this.loadTaskLogsWithFilters(); + this.showNotification(`Filtre appliqué: ${host}`, 'info'); + } + + filterByTarget(target) { + this.currentTargetFilter = target; + this.tasksDisplayedCount = this.tasksPerPage; + this.loadTaskLogsWithFilters(); + this.showNotification(`Filtre appliqué: ${target}`, 'info'); + } + + filterByCategory(category) { + this.currentCategoryFilter = category; + this.currentSubcategoryFilter = 'all'; + this.tasksDisplayedCount = this.tasksPerPage; + this.loadTaskLogsWithFilters(); + this.showNotification(`Filtre catégorie: ${category}`, 'info'); + } + + async viewTaskLogContent(logId) { + try { + const result = await this.apiCall(`/api/tasks/logs/${logId}`); + const parsed = this.parseTaskLogMarkdown(result.content); + + // Détecter si c'est une sortie de playbook structurée + const isPlaybookOutput = parsed.output && ( + parsed.output.includes('PLAY [') || + parsed.output.includes('TASK [') || + parsed.output.includes('PLAY RECAP') + ); + + // Si c'est un playbook, utiliser la vue structurée + if (isPlaybookOutput) { + const parsedPlaybook = this.parseAnsiblePlaybookOutput(parsed.output); + this.currentParsedOutput = parsedPlaybook; + this.currentTaskLogRawOutput = parsed.output; + + // Mémoriser les métadonnées et le titre pour pouvoir revenir au résumé + this.currentStructuredPlaybookMetadata = { + duration: parsed.duration || 'N/A', + date: result.log.date || '', + target: result.log.target || parsed.target || 'N/A' + }; + this.currentStructuredPlaybookTitle = `Log: ${result.log.task_name}`; + + this.showStructuredPlaybookViewModal(); + return; + } + + // Sinon, utiliser la vue structurée ad-hoc (similaire aux playbooks) + const hostOutputs = this.parseOutputByHost(parsed.output); + this.currentTaskLogHostOutputs = hostOutputs; + + // Compter les succès/échecs + const successCount = hostOutputs.filter(h => h.status === 'changed' || h.status === 'success').length; + const failedCount = hostOutputs.filter(h => h.status === 'failed' || h.status === 'unreachable').length; + const totalHosts = hostOutputs.length; + + // Utiliser la vue structurée ad-hoc si plusieurs hôtes + if (totalHosts > 0) { + const isSuccess = result.log.status === 'completed'; + const adHocView = this.renderAdHocStructuredView(hostOutputs, { + taskName: result.log.task_name, + target: result.log.target || parsed.target || 'N/A', + duration: parsed.duration || 'N/A', + returnCode: parsed.returnCode, + date: result.log.date || '', + isSuccess: isSuccess, + error: parsed.error + }); + + this.currentAdHocMetadata = { + taskName: result.log.task_name, + target: result.log.target || parsed.target || 'N/A', + duration: parsed.duration || 'N/A', + returnCode: parsed.returnCode, + date: result.log.date || '', + isSuccess: isSuccess, + error: parsed.error + }; + this.currentAdHocTitle = `Log: ${result.log.task_name}`; + + this.showModal(this.currentAdHocTitle, ` +
+ ${adHocView} +
+ +
+
+ `); + return; + } + + // Déterminer le statut global + const isSuccess = result.log.status === 'completed'; + const statusConfig = { + completed: { icon: 'fa-check-circle', color: 'green', text: 'Succès' }, + failed: { icon: 'fa-times-circle', color: 'red', text: 'Échoué' }, + running: { icon: 'fa-spinner fa-spin', color: 'blue', text: 'En cours' }, + pending: { icon: 'fa-clock', color: 'yellow', text: 'En attente' } + }; + const status = statusConfig[result.log.status] || statusConfig.failed; + + // Générer les onglets des hôtes + let hostTabsHtml = ''; + if (hostOutputs.length > 1 || (hostOutputs.length === 1 && hostOutputs[0].hostname !== 'output')) { + hostTabsHtml = ` +
+
+ + Sortie par hôte + + + (${totalHosts} hôtes: ${successCount} OK${failedCount > 0 ? `, ${failedCount} échec` : ''}) + + +
+
+ ${hostOutputs.map((host, index) => { + const hostStatusColor = (host.status === 'changed' || host.status === 'success') + ? 'bg-green-600/80 hover:bg-green-500 border-green-500' + : (host.status === 'failed' || host.status === 'unreachable') + ? 'bg-red-600/80 hover:bg-red-500 border-red-500' + : 'bg-gray-600/80 hover:bg-gray-500 border-gray-500'; + const hostStatusIcon = (host.status === 'changed' || host.status === 'success') + ? 'fa-check' + : (host.status === 'failed' || host.status === 'unreachable') + ? 'fa-times' + : 'fa-minus'; + return ` + + `; + }).join('')} +
+
+ `; + } + + // Contenu du modal amélioré + const modalContent = ` +
+ +
+
+
+ +
+
+

Résultat d'exécution

+

+ ${status.text} • Cible: ${this.escapeHtml(result.log.target || parsed.target || 'N/A')} +

+
+
+
+
+ + ${parsed.duration || 'N/A'} +
+ ${parsed.returnCode !== undefined ? ` +
+ + Code: ${parsed.returnCode} +
+ ` : ''} +
+
+ + +
+
+ + ${hostTabsHtml} + + +
+
+ + Sortie + + +
+
${this.formatAnsibleOutput(hostOutputs.length > 0 ? hostOutputs[0].output : parsed.output, isSuccess)}
+
+ + + ${parsed.error ? ` +
+
+ + + Erreurs + + + +
${this.escapeHtml(parsed.error)}
+
+
+ ` : ''} +
+
+ + +
+ +
+
+ `; + + this.showModal(`Log: ${result.log.task_name}`, modalContent); + + // Stocker la sortie brute pour la copie + this.currentTaskLogRawOutput = parsed.output; + + } catch (error) { + this.showNotification(`Erreur: ${error.message}`, 'error'); + } + } + + showStructuredPlaybookViewModal() { + if (!this.currentParsedOutput) return; + + const metadata = this.currentStructuredPlaybookMetadata || {}; + const structuredView = this.renderStructuredPlaybookView(this.currentParsedOutput, metadata); + const title = this.currentStructuredPlaybookTitle || 'Résultat Playbook'; + const rawOutput = this.currentTaskLogRawOutput || ''; + + this.showModal(title, ` +
+ ${structuredView} +
+ +
+
+ `); + + // Remplir la sortie brute formatée + setTimeout(() => { + const rawEl = document.getElementById('ansible-raw-output'); + if (rawEl) { + rawEl.innerHTML = this.formatAnsibleOutput(rawOutput, true); + } + }, 100); + } + + returnToStructuredPlaybookView() { + this.showStructuredPlaybookViewModal(); + } + + renderAdHocStructuredView(hostOutputs, metadata) { + /** + * Génère une vue structurée pour les commandes ad-hoc (similaire aux playbooks) + */ + const isSuccess = metadata.isSuccess; + const totalHosts = hostOutputs.length; + const successCount = hostOutputs.filter(h => h.status === 'changed' || h.status === 'success').length; + const failedCount = hostOutputs.filter(h => h.status === 'failed' || h.status === 'unreachable').length; + const successRate = totalHosts > 0 ? Math.round((successCount / totalHosts) * 100) : 0; + + // Générer les cartes d'hôtes + const hostCardsHtml = hostOutputs.map(host => { + const isFailed = host.status === 'failed' || host.status === 'unreachable'; + const hasChanges = host.status === 'changed'; + + let statusClass, statusIcon; + if (isFailed) { + statusClass = 'border-red-500/50 bg-red-900/20'; + statusIcon = ''; + } else if (hasChanges) { + statusClass = 'border-yellow-500/50 bg-yellow-900/20'; + statusIcon = ''; + } else { + statusClass = 'border-green-500/50 bg-green-900/20'; + statusIcon = ''; + } + + const hostStatus = isFailed ? 'failed' : (hasChanges ? 'changed' : 'ok'); + + return ` +
+
+
+ ${statusIcon} + + ${this.escapeHtml(host.hostname)} + +
+ + ${host.status.toUpperCase()} + +
+
+ ${this.escapeHtml(host.output.substring(0, 60))}${host.output.length > 60 ? '...' : ''} +
+
+ `; + }).join(''); + + return ` +
+ +
+
+
+
+ +
+
+
+ + Commande Ad-Hoc + +
+

${this.escapeHtml(metadata.taskName || 'Commande Ansible')}

+

+ ${totalHosts} hôte(s) + + Cible: ${this.escapeHtml(metadata.target)} + + ${metadata.duration} +

+
+
+
+
+ + ${isSuccess ? '✓' : '✗'} + + + ${isSuccess ? 'SUCCESS' : 'FAILED'} + +
+
${metadata.date || ''}
+ ${metadata.returnCode !== undefined ? ` +
+ + Code: ${metadata.returnCode} +
+ ` : ''} +
+
+
+ + +
+
+
+ + OK +
+
${successCount}
+
+
+
+ + Changed +
+
${hostOutputs.filter(h => h.status === 'changed').length}
+
+
+
+ + Failed +
+
${failedCount}
+
+
+
+ + Success Rate +
+
${successRate}%
+
+
+ + +
+
+

+ État des Hôtes +

+
+ + + + +
+
+
+ ${hostCardsHtml} +
+
+ + ${metadata.error ? ` + +
+
+ + + Erreurs détectées + + +
${this.escapeHtml(metadata.error)}
+
+
+ ` : ''} +
+ `; + } + + showAdHocHostDetails(hostname) { + const hostOutputs = this.currentTaskLogHostOutputs || []; + const host = hostOutputs.find(h => h.hostname === hostname); + if (!host) { + this.showNotification('Hôte non trouvé', 'error'); + return; + } + + const isFailed = host.status === 'failed' || host.status === 'unreachable'; + const hasChanges = host.status === 'changed'; + + let statusClass, statusIcon, statusText; + if (isFailed) { + statusClass = 'bg-red-900/30 border-red-700/50'; + statusIcon = ''; + statusText = 'FAILED'; + } else if (hasChanges) { + statusClass = 'bg-yellow-900/30 border-yellow-700/50'; + statusIcon = ''; + statusText = 'CHANGED'; + } else { + statusClass = 'bg-green-900/30 border-green-700/50'; + statusIcon = ''; + statusText = 'OK'; + } + + const content = ` +
+
+ + +
+
+
+ +
+
+

${this.escapeHtml(hostname)}

+
+ Statut: ${statusText} +
+
+ ${statusIcon} +
+ +
+
+ + Sortie + + +
+
${this.formatAnsibleOutput(host.output, !isFailed)}
+
+ + +
+ `; + + this.showModal(`Détails: ${hostname}`, content); + } + + returnToAdHocView() { + if (!this.currentTaskLogHostOutputs || !this.currentAdHocMetadata) return; + + const adHocView = this.renderAdHocStructuredView(this.currentTaskLogHostOutputs, this.currentAdHocMetadata); + const title = this.currentAdHocTitle || 'Résultat Ad-Hoc'; + + this.showModal(title, ` +
+ ${adHocView} +
+ +
+
+ `); + } + + filterAdHocViewByStatus(status) { + const cards = document.querySelectorAll('.adhoc-host-cards-grid .host-card-item'); + const buttons = document.querySelectorAll('.host-status-section .av-filter-btn'); + + buttons.forEach(btn => { + btn.classList.remove('active', 'bg-purple-600/50', 'text-white'); + btn.classList.add('bg-gray-700/50', 'text-gray-400'); + }); + + const activeBtn = document.querySelector(`.host-status-section .av-filter-btn[data-filter="${status}"]`); + if (activeBtn) { + activeBtn.classList.remove('bg-gray-700/50', 'text-gray-400'); + activeBtn.classList.add('active', 'bg-purple-600/50', 'text-white'); + } + + cards.forEach(card => { + const cardStatus = card.dataset.status; + if (status === 'all' || cardStatus === status) { + card.style.display = ''; + } else { + card.style.display = 'none'; + } + }); + } + + parseTaskLogMarkdown(content) { + // Parser le contenu markdown pour extraire les métadonnées + const result = { + taskName: '', + id: '', + target: '', + status: '', + progress: 0, + startTime: '', + endTime: '', + duration: '', + output: '', + error: '', + returnCode: undefined + }; + + // Extraire le nom de la tâche du titre + const titleMatch = content.match(/^#\s*[✅❌🔄⏳🚫❓]?\s*(.+)$/m); + if (titleMatch) result.taskName = titleMatch[1].trim(); + + // Extraire les valeurs de la table d'informations + const tablePatterns = { + id: /\|\s*\*\*ID\*\*\s*\|\s*`([^`]+)`/, + target: /\|\s*\*\*Cible\*\*\s*\|\s*`([^`]+)`/, + status: /\|\s*\*\*Statut\*\*\s*\|\s*(\w+)/, + progress: /\|\s*\*\*Progression\*\*\s*\|\s*(\d+)%/, + startTime: /\|\s*\*\*Début\*\*\s*\|\s*([^|]+)/, + endTime: /\|\s*\*\*Fin\*\*\s*\|\s*([^|]+)/, + duration: /\|\s*\*\*Durée\*\*\s*\|\s*([^|]+)/ + }; + + for (const [key, pattern] of Object.entries(tablePatterns)) { + const match = content.match(pattern); + if (match) { + if (key === 'progress') { + result[key] = parseInt(match[1]); + } else { + result[key] = match[1].trim(); + } + } + } + + // Extraire la sortie + const outputMatch = content.match(/## Sortie\s*```([\s\S]*?)```/m); + if (outputMatch) { + result.output = outputMatch[1].trim(); + + // Essayer d'extraire le return code de la sortie + const rcMatch = result.output.match(/rc=(\d+)/); + if (rcMatch) { + result.returnCode = parseInt(rcMatch[1]); + } + } + + // Extraire les erreurs + const errorMatch = content.match(/## Erreurs\s*```([\s\S]*?)```/m); + if (errorMatch) { + result.error = errorMatch[1].trim(); + } + + return result; + } + + // ===== PARSER ANSIBLE INTELLIGENT ===== + + parseAnsiblePlaybookOutput(output) { + /** + * Parser intelligent pour extraire la structure complète d'une exécution Ansible + * Retourne: { plays: [], hosts: {}, recap: {}, metadata: {} } + */ + const result = { + metadata: { + configFile: '', + playbookName: '', + executionTime: null, + totalDuration: 0 + }, + plays: [], + hosts: {}, + recap: {}, + stats: { + totalTasks: 0, + totalHosts: 0, + successRate: 0, + changedRate: 0 + } + }; + + const lines = output.split('\n'); + let currentPlay = null; + let currentTask = null; + let inRecap = false; + + // Patterns de détection + const patterns = { + config: /^Using\s+(.+)\s+as config file$/, + play: /^PLAY\s+\[([^\]]+)\]\s*\*+$/, + task: /^TASK\s+\[([^\]]+)\]\s*\*+$/, + hostResult: /^(ok|changed|failed|unreachable|skipping|fatal):\s*\[([^\]]+)\]\s*(?:=>\s*)?(.*)$/i, + hostResultAlt: /^([\w\.\-]+)\s*\|\s*(SUCCESS|CHANGED|FAILED|UNREACHABLE)\s*(?:\|\s*rc=(\d+))?\s*>>?\s*$/i, + recap: /^PLAY RECAP\s*\*+$/, + recapLine: /^([\w\.\-]+)\s*:\s*ok=(\d+)\s+changed=(\d+)\s+unreachable=(\d+)\s+failed=(\d+)(?:\s+skipped=(\d+))?(?:\s+rescued=(\d+))?(?:\s+ignored=(\d+))?/ + }; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line) continue; + + // Détecter le fichier de configuration + let match = line.match(patterns.config); + if (match) { + result.metadata.configFile = match[1]; + continue; + } + + // Détecter un PLAY + match = line.match(patterns.play); + if (match) { + currentPlay = { + name: match[1], + tasks: [], + startIndex: i + }; + result.plays.push(currentPlay); + if (!result.metadata.playbookName) { + result.metadata.playbookName = match[1]; + } + continue; + } + + // Détecter une TASK + match = line.match(patterns.task); + if (match) { + currentTask = { + name: match[1], + hostResults: [], + startIndex: i + }; + if (currentPlay) { + currentPlay.tasks.push(currentTask); + } + result.stats.totalTasks++; + continue; + } + + // Détecter PLAY RECAP + if (patterns.recap.test(line)) { + inRecap = true; + continue; + } + + // Parser les lignes de RECAP + if (inRecap) { + match = line.match(patterns.recapLine); + if (match) { + const hostname = match[1]; + result.recap[hostname] = { + ok: parseInt(match[2]) || 0, + changed: parseInt(match[3]) || 0, + unreachable: parseInt(match[4]) || 0, + failed: parseInt(match[5]) || 0, + skipped: parseInt(match[6]) || 0, + rescued: parseInt(match[7]) || 0, + ignored: parseInt(match[8]) || 0 + }; + + // Déterminer le statut global de l'hôte + const stats = result.recap[hostname]; + if (stats.failed > 0 || stats.unreachable > 0) { + result.hosts[hostname] = { ...result.hosts[hostname], globalStatus: 'failed' }; + } else if (stats.changed > 0) { + result.hosts[hostname] = { ...result.hosts[hostname], globalStatus: 'changed' }; + } else { + result.hosts[hostname] = { ...result.hosts[hostname], globalStatus: 'ok' }; + } + } + continue; + } + + // Détecter les résultats par hôte (format standard) + match = line.match(patterns.hostResult); + if (match && currentTask) { + const status = match[1].toLowerCase(); + const hostname = match[2]; + let outputData = match[3] || ''; + + // Collecter les lignes suivantes si c'est un JSON multi-lignes + if (outputData.includes('{') && !outputData.includes('}')) { + let braceCount = (outputData.match(/{/g) || []).length - (outputData.match(/}/g) || []).length; + while (braceCount > 0 && i + 1 < lines.length) { + i++; + outputData += '\n' + lines[i]; + braceCount += (lines[i].match(/{/g) || []).length - (lines[i].match(/}/g) || []).length; + } + } + + const hostResult = { + hostname, + status, + output: outputData, + taskName: currentTask.name + }; + + // Parser le JSON si présent + try { + const jsonMatch = outputData.match(/=>\s*({[\s\S]*})/m) || outputData.match(/^({[\s\S]*})$/m); + if (jsonMatch) { + hostResult.parsedOutput = JSON.parse(jsonMatch[1]); + } + } catch (e) { + // Ignorer les erreurs de parsing JSON + } + + currentTask.hostResults.push(hostResult); + + // Enregistrer l'hôte s'il n'existe pas + if (!result.hosts[hostname]) { + result.hosts[hostname] = { taskResults: [], globalStatus: 'unknown' }; + } + result.hosts[hostname].taskResults.push(hostResult); + continue; + } + + // Format alternatif (hostname | STATUS | rc=X >>) + match = line.match(patterns.hostResultAlt); + if (match && currentTask) { + const hostname = match[1]; + const status = match[2].toLowerCase(); + const rc = match[3] ? parseInt(match[3]) : 0; + + // Collecter la sortie sur les lignes suivantes + let outputLines = []; + while (i + 1 < lines.length) { + const nextLine = lines[i + 1]; + if (nextLine.match(patterns.hostResultAlt) || + nextLine.match(patterns.task) || + nextLine.match(patterns.play) || + nextLine.match(patterns.recap)) { + break; + } + i++; + outputLines.push(lines[i]); + } + + const hostResult = { + hostname, + status, + returnCode: rc, + output: outputLines.join('\n'), + taskName: currentTask.name + }; + + currentTask.hostResults.push(hostResult); + + if (!result.hosts[hostname]) { + result.hosts[hostname] = { taskResults: [], globalStatus: 'unknown' }; + } + result.hosts[hostname].taskResults.push(hostResult); + } + } + + // Calculer les statistiques + result.stats.totalHosts = Object.keys(result.hosts).length; + + if (Object.keys(result.recap).length > 0) { + let totalOk = 0, totalChanged = 0, totalFailed = 0, totalTasks = 0; + for (const stats of Object.values(result.recap)) { + totalOk += stats.ok; + totalChanged += stats.changed; + totalFailed += stats.failed + stats.unreachable; + totalTasks += stats.ok + stats.changed + stats.failed + stats.unreachable + stats.skipped; + } + result.stats.successRate = totalTasks > 0 ? Math.round(((totalOk + totalChanged) / totalTasks) * 100) : 0; + result.stats.changedRate = totalTasks > 0 ? Math.round((totalChanged / totalTasks) * 100) : 0; + } + + return result; + } + + detectPlaybookType(parsedOutput) { + /** + * Détecte automatiquement le type de playbook basé sur le contenu + */ + const taskNames = parsedOutput.plays.flatMap(p => p.tasks.map(t => t.name.toLowerCase())); + const playName = (parsedOutput.metadata.playbookName || '').toLowerCase(); + + // Patterns de détection + const patterns = { + healthCheck: ['health', 'check', 'ping', 'uptime', 'status', 'monitor', 'disk', 'memory', 'cpu'], + deployment: ['deploy', 'release', 'version', 'rollout', 'install', 'upgrade'], + configuration: ['config', 'configure', 'setup', 'settings', 'template'], + backup: ['backup', 'restore', 'snapshot', 'archive'], + security: ['security', 'firewall', 'ssl', 'certificate', 'password', 'key'], + maintenance: ['clean', 'prune', 'update', 'patch', 'restart', 'reboot'] + }; + + for (const [type, keywords] of Object.entries(patterns)) { + const matchScore = keywords.filter(kw => + playName.includes(kw) || taskNames.some(t => t.includes(kw)) + ).length; + if (matchScore >= 2 || playName.includes(type.toLowerCase())) { + return type; + } + } + + return 'general'; + } + + renderStructuredPlaybookView(parsedOutput, metadata = {}) { + /** + * Génère le HTML structuré pour l'affichage du playbook + */ + const playbookType = this.detectPlaybookType(parsedOutput); + const isSuccess = !Object.values(parsedOutput.recap).some(r => r.failed > 0 || r.unreachable > 0); + const hasChanges = Object.values(parsedOutput.recap).some(r => r.changed > 0); + + // Icônes par type de playbook + const typeIcons = { + healthCheck: 'fa-heartbeat', + deployment: 'fa-rocket', + configuration: 'fa-cogs', + backup: 'fa-database', + security: 'fa-shield-alt', + maintenance: 'fa-tools', + general: 'fa-play-circle' + }; + + const typeLabels = { + healthCheck: 'Health Check', + deployment: 'Déploiement', + configuration: 'Configuration', + backup: 'Backup', + security: 'Sécurité', + maintenance: 'Maintenance', + general: 'Playbook' + }; + + // Générer les cartes d'hôtes + const hostCardsHtml = this.renderHostStatusCards(parsedOutput); + + // Générer l'arborescence des tâches + const taskTreeHtml = this.renderTaskHierarchy(parsedOutput); + + // Générer les statistiques + const statsHtml = this.renderExecutionStats(parsedOutput); + + return ` +
+ +
+
+
+
+ +
+
+
+ + ${typeLabels[playbookType]} + + ${hasChanges ? 'Changes Applied' : ''} +
+

${this.escapeHtml(parsedOutput.metadata.playbookName || 'Ansible Playbook')}

+

+ ${parsedOutput.stats.totalHosts} hôte(s) + + ${parsedOutput.stats.totalTasks} tâche(s) + + ${metadata.duration || 'N/A'} +

+
+
+
+
+ + ${isSuccess ? '✓' : '✗'} + + + ${isSuccess ? 'SUCCESS' : 'FAILED'} + +
+
${metadata.date || ''}
+
+
+
+ + + ${statsHtml} + + +
+
+

+ État des Hôtes +

+
+ + + + +
+
+
+ ${hostCardsHtml} +
+
+ + +
+
+

+ Hiérarchie des Tâches +

+
+ + +
+
+
+ ${taskTreeHtml} +
+
+ + +
+ + + Afficher la sortie brute + +

+                
+
+ `; + } + + renderHostStatusCards(parsedOutput) { + const hosts = Object.entries(parsedOutput.recap); + if (hosts.length === 0) { + return '
Aucun hôte détecté
'; + } + + return hosts.map(([hostname, stats]) => { + const total = stats.ok + stats.changed + stats.failed + stats.unreachable + stats.skipped; + const successPercent = total > 0 ? Math.round(((stats.ok + stats.changed) / total) * 100) : 0; + const isFailed = stats.failed > 0 || stats.unreachable > 0; + const hasChanges = stats.changed > 0; + + let statusClass, statusIcon, statusBg; + if (isFailed) { + statusClass = 'border-red-500/50 bg-red-900/20'; + statusIcon = ''; + statusBg = 'bg-red-500'; + } else if (hasChanges) { + statusClass = 'border-yellow-500/50 bg-yellow-900/20'; + statusIcon = ''; + statusBg = 'bg-yellow-500'; + } else { + statusClass = 'border-green-500/50 bg-green-900/20'; + statusIcon = ''; + statusBg = 'bg-green-500'; + } + + const hostStatus = isFailed ? 'failed' : (hasChanges ? 'changed' : 'ok'); + + return ` +
+
+
+ ${statusIcon} + + ${this.escapeHtml(hostname)} + +
+ + ${successPercent}% + +
+
+ ${stats.ok > 0 ? `
` : ''} + ${stats.changed > 0 ? `
` : ''} + ${stats.skipped > 0 ? `
` : ''} + ${stats.failed > 0 ? `
` : ''} + ${stats.unreachable > 0 ? `
` : ''} +
+
+ ${stats.ok} ok + ${stats.changed} chg + ${stats.failed} fail +
+
+ `; + }).join(''); + } + + renderTaskHierarchy(parsedOutput) { + if (parsedOutput.plays.length === 0) { + return '
Aucune tâche détectée
'; + } + + return parsedOutput.plays.map((play, playIndex) => { + const playTasks = play.tasks; + const allTasksSuccess = playTasks.every(t => + t.hostResults.every(r => r.status === 'ok' || r.status === 'changed' || r.status === 'skipping') + ); + const hasFailedTasks = playTasks.some(t => + t.hostResults.some(r => r.status === 'failed' || r.status === 'fatal' || r.status === 'unreachable') + ); + + const playStatusIcon = hasFailedTasks + ? '' + : ''; + + const tasksHtml = playTasks.map((task, taskIndex) => { + const hasFailures = task.hostResults.some(r => r.status === 'failed' || r.status === 'fatal' || r.status === 'unreachable'); + const hasChanges = task.hostResults.some(r => r.status === 'changed'); + const allSkipped = task.hostResults.every(r => r.status === 'skipping' || r.status === 'skipped'); + + let taskIcon, taskColor; + if (hasFailures) { + taskIcon = 'fa-times-circle'; + taskColor = 'text-red-400'; + } else if (hasChanges) { + taskIcon = 'fa-exchange-alt'; + taskColor = 'text-yellow-400'; + } else if (allSkipped) { + taskIcon = 'fa-forward'; + taskColor = 'text-gray-500'; + } else { + taskIcon = 'fa-check-circle'; + taskColor = 'text-green-400'; + } + + const hostResultsHtml = task.hostResults.map(result => { + let resultIcon, resultColor, resultBg; + switch(result.status) { + case 'ok': + resultIcon = 'fa-check'; resultColor = 'text-green-400'; resultBg = 'bg-green-900/30'; + break; + case 'changed': + resultIcon = 'fa-exchange-alt'; resultColor = 'text-yellow-400'; resultBg = 'bg-yellow-900/30'; + break; + case 'failed': + case 'fatal': + resultIcon = 'fa-times'; resultColor = 'text-red-400'; resultBg = 'bg-red-900/30'; + break; + case 'unreachable': + resultIcon = 'fa-unlink'; resultColor = 'text-orange-400'; resultBg = 'bg-orange-900/30'; + break; + case 'skipping': + case 'skipped': + resultIcon = 'fa-forward'; resultColor = 'text-gray-500'; resultBg = 'bg-gray-800/50'; + break; + default: + resultIcon = 'fa-question'; resultColor = 'text-gray-400'; resultBg = 'bg-gray-800/50'; + } + + // Extraire les données importantes de l'output + let outputPreview = ''; + if (result.parsedOutput) { + const po = result.parsedOutput; + if (po.msg) outputPreview = po.msg; + else if (po.stdout) outputPreview = po.stdout.substring(0, 100); + else if (po.cmd) outputPreview = Array.isArray(po.cmd) ? po.cmd.join(' ') : po.cmd; + } + + return ` +
+
+ + ${this.escapeHtml(result.hostname)} +
+
+ ${outputPreview ? `${this.escapeHtml(outputPreview.substring(0, 50))}${outputPreview.length > 50 ? '...' : ''}` : ''} + + ${result.status} + +
+
+ `; + }).join(''); + + return ` +
+ +
+ + + ${this.escapeHtml(task.name)} +
+
+ ${task.hostResults.length} hôte(s) +
+ ${task.hostResults.slice(0, 5).map(r => { + const dotColor = r.status === 'ok' ? 'bg-green-500' : + r.status === 'changed' ? 'bg-yellow-500' : + r.status === 'failed' || r.status === 'fatal' ? 'bg-red-500' : 'bg-gray-500'; + return `
`; + }).join('')} + ${task.hostResults.length > 5 ? `+${task.hostResults.length - 5}` : ''} +
+
+
+
+ ${hostResultsHtml} +
+
+ `; + }).join(''); + + return ` +
+
+ ${playStatusIcon} + PLAY [${this.escapeHtml(play.name)}] + ${playTasks.length} tâche(s) +
+
+ ${tasksHtml} +
+
+ `; + }).join(''); + } + + renderExecutionStats(parsedOutput) { + const recap = parsedOutput.recap; + const hosts = Object.keys(recap); + + if (hosts.length === 0) return ''; + + let totalOk = 0, totalChanged = 0, totalFailed = 0, totalSkipped = 0, totalUnreachable = 0; + for (const stats of Object.values(recap)) { + totalOk += stats.ok; + totalChanged += stats.changed; + totalFailed += stats.failed; + totalSkipped += stats.skipped; + totalUnreachable += stats.unreachable; + } + + const total = totalOk + totalChanged + totalFailed + totalSkipped + totalUnreachable; + const successRate = total > 0 ? Math.round(((totalOk + totalChanged) / total) * 100) : 0; + + return ` +
+
+
+ + OK +
+
${totalOk}
+
+
+
+ + Changed +
+
${totalChanged}
+
+
+
+ + Failed +
+
${totalFailed}
+
+
+
+ + Success Rate +
+
${successRate}%
+
+
+ `; + } + + // Méthodes d'interaction pour la vue structurée + filterAnsibleViewByStatus(status) { + document.querySelectorAll('.av-filter-btn').forEach(btn => { + btn.classList.remove('active', 'bg-gray-700', 'text-gray-300'); + btn.classList.add('bg-gray-700/50', 'text-gray-400'); + }); + document.querySelector(`.av-filter-btn[data-filter="${status}"]`)?.classList.add('active', 'bg-gray-700', 'text-gray-300'); + document.querySelector(`.av-filter-btn[data-filter="${status}"]`)?.classList.remove('bg-gray-700/50', 'text-gray-400'); + + document.querySelectorAll('.host-card-item').forEach(card => { + if (status === 'all' || card.dataset.status === status) { + card.style.display = ''; + } else { + card.style.display = 'none'; + } + }); + } + + expandAllTasks() { + document.querySelectorAll('.task-item').forEach(item => { + item.setAttribute('open', 'open'); + }); + } + + collapseAllTasks() { + document.querySelectorAll('.task-item').forEach(item => { + item.removeAttribute('open'); + }); + } + + showHostDetails(hostname) { + if (!this.currentParsedOutput || !this.currentParsedOutput.hosts[hostname]) return; + + const hostData = this.currentParsedOutput.hosts[hostname]; + const recapData = this.currentParsedOutput.recap[hostname] || {}; + + const tasksHtml = hostData.taskResults.map(result => { + let statusIcon, statusColor; + switch(result.status) { + case 'ok': statusIcon = 'fa-check'; statusColor = 'text-green-400'; break; + case 'changed': statusIcon = 'fa-exchange-alt'; statusColor = 'text-yellow-400'; break; + case 'failed': case 'fatal': statusIcon = 'fa-times'; statusColor = 'text-red-400'; break; + default: statusIcon = 'fa-minus'; statusColor = 'text-gray-400'; + } + + return ` +
+
+
+ + ${this.escapeHtml(result.taskName)} +
+ + ${result.status.toUpperCase()} + +
+ ${result.output ? ` +
${this.escapeHtml(result.output)}
+ ` : ''} +
+ `; + }).join(''); + + const content = ` +
+
+ + +
+
+
+ +
+
+

${this.escapeHtml(hostname)}

+
+ ${recapData.ok || 0} ok • + ${recapData.changed || 0} changed • + ${recapData.failed || 0} failed +
+
+
+
+ ${tasksHtml} +
+ +
+ `; + + this.showModal(`Détails: ${hostname}`, content); + } + + switchTaskLogHostTab(index) { + if (!this.currentTaskLogHostOutputs || !this.currentTaskLogHostOutputs[index]) return; + + const hostOutput = this.currentTaskLogHostOutputs[index]; + const outputPre = document.getElementById('tasklog-output'); + const tabs = document.querySelectorAll('.tasklog-host-tab'); + + // Mettre à jour l'onglet actif + tabs.forEach((tab, i) => { + if (i === index) { + const host = this.currentTaskLogHostOutputs[i]; + const statusColor = (host.status === 'changed' || host.status === 'success') + ? 'bg-green-600/80 border-green-500' + : (host.status === 'failed' || host.status === 'unreachable') + ? 'bg-red-600/80 border-red-500' + : 'bg-gray-600/80 border-gray-500'; + tab.className = `tasklog-host-tab flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all border ${statusColor} text-white`; + } else { + tab.className = 'tasklog-host-tab flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all border bg-gray-800 text-gray-400 hover:text-white border-gray-700'; + } + }); + + // Afficher le contenu + if (outputPre) { + outputPre.innerHTML = this.formatAnsibleOutput(hostOutput.output, hostOutput.status === 'changed' || hostOutput.status === 'success'); + } + } + + showAllTaskLogHostsOutput() { + if (!this.currentTaskLogHostOutputs) return; + + const outputPre = document.getElementById('tasklog-output'); + if (outputPre) { + const allOutput = this.currentTaskLogHostOutputs.map(h => h.output).join('\n\n'); + outputPre.innerHTML = this.formatAnsibleOutput(allOutput, true); + } + + // Désélectionner tous les onglets + document.querySelectorAll('.tasklog-host-tab').forEach(tab => { + tab.className = 'tasklog-host-tab flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all border bg-gray-800 text-gray-400 hover:text-white border-gray-700'; + }); + } + + copyTaskLogOutput() { + const text = this.currentTaskLogRawOutput || ''; + navigator.clipboard.writeText(text).then(() => { + this.showNotification('Sortie copiée dans le presse-papiers', 'success'); + }).catch(() => { + this.showNotification('Erreur lors de la copie', 'error'); + }); + } + + downloadTaskLog(path) { + // Créer un lien pour télécharger le fichier + this.showNotification('Fonctionnalité de téléchargement à implémenter', 'info'); + } + + async deleteTaskLog(logId, filename) { + if (!confirm(`Supprimer le log "${filename}" ? Cette action est définitive.`)) { + return; + } + + try { + await this.apiCall(`/api/tasks/logs/${logId}`, { + method: 'DELETE' + }); + this.showNotification('Log supprimé', 'success'); + + // Recharger la liste des logs avec les filtres courants + await this.loadTaskLogsWithFilters(); + } catch (error) { + this.showNotification(`Erreur: ${error.message}`, 'error'); + } + } + + // ===== FILTRAGE DES TÂCHES ===== + + filterTasksByStatus(status) { + this.currentStatusFilter = status; + this.tasksDisplayedCount = this.tasksPerPage; // Reset pagination + + // Mettre à jour l'apparence des boutons + document.querySelectorAll('.task-filter-btn').forEach(btn => { + btn.classList.remove('active', 'bg-purple-600', 'bg-blue-600', 'bg-green-600', 'bg-red-600'); + btn.classList.add('bg-gray-700'); + }); + + const activeBtn = document.querySelector(`.task-filter-btn[data-status="${status}"]`); + if (activeBtn) { + activeBtn.classList.remove('bg-gray-700'); + const colorMap = { + 'all': 'bg-purple-600', + 'running': 'bg-blue-600', + 'completed': 'bg-green-600', + 'failed': 'bg-red-600' + }; + activeBtn.classList.add('active', colorMap[status] || 'bg-purple-600'); + } + + // Recharger les logs avec le filtre + this.loadTaskLogsWithFilters(); + } + + filterTasksByCategory(category) { + this.currentCategoryFilter = category; + this.currentSubcategoryFilter = 'all'; // Reset subcategory when category changes + this.tasksDisplayedCount = this.tasksPerPage; // Reset pagination + this.loadTaskLogsWithFilters(); + } + + filterTasksBySubcategory(subcategory) { + this.currentSubcategoryFilter = subcategory; + this.tasksDisplayedCount = this.tasksPerPage; // Reset pagination + this.loadTaskLogsWithFilters(); + } + + filterTasksByTarget(target) { + this.currentTargetFilter = target; + this.tasksDisplayedCount = this.tasksPerPage; // Reset pagination + this.loadTaskLogsWithFilters(); + } + + // Fonctions pour effacer les filtres individuellement + clearTargetFilter() { + this.currentTargetFilter = 'all'; + this.tasksDisplayedCount = this.tasksPerPage; + this.loadTaskLogsWithFilters(); + this.showNotification('Filtre cible effacé', 'info'); + } + + clearCategoryFilter() { + this.currentCategoryFilter = 'all'; + this.currentSubcategoryFilter = 'all'; + this.tasksDisplayedCount = this.tasksPerPage; + this.loadTaskLogsWithFilters(); + this.showNotification('Filtre catégorie effacé', 'info'); + } + + clearAllTaskFilters() { + this.currentTargetFilter = 'all'; + this.currentCategoryFilter = 'all'; + this.currentSubcategoryFilter = 'all'; + this.currentStatusFilter = 'all'; + this.tasksDisplayedCount = this.tasksPerPage; + this.loadTaskLogsWithFilters(); + this.showNotification('Tous les filtres effacés', 'info'); + } + + async loadTaskLogsWithFilters() { + const params = new URLSearchParams(); + if (this.currentStatusFilter && this.currentStatusFilter !== 'all') { + params.append('status', this.currentStatusFilter); + } + // Si plusieurs dates sont sélectionnées, utiliser le premier jour comme filtre principal (compat API) + if (this.selectedTaskDates && this.selectedTaskDates.length > 0) { + const firstDate = this.parseDateKey(this.selectedTaskDates[0]); + const year = String(firstDate.getFullYear()); + const month = String(firstDate.getMonth() + 1).padStart(2, '0'); + const day = String(firstDate.getDate()).padStart(2, '0'); + params.append('year', year); + params.append('month', month); + params.append('day', day); + } else { + if (this.currentDateFilter.year) params.append('year', this.currentDateFilter.year); + if (this.currentDateFilter.month) params.append('month', this.currentDateFilter.month); + if (this.currentDateFilter.day) params.append('day', this.currentDateFilter.day); + } + if (this.currentTargetFilter && this.currentTargetFilter !== 'all') { + params.append('target', this.currentTargetFilter); + } + if (this.currentCategoryFilter && this.currentCategoryFilter !== 'all') { + params.append('category', this.currentCategoryFilter); + } + + try { + const result = await this.apiCall(`/api/tasks/logs?${params.toString()}`); + this.taskLogs = result.logs || []; + this.renderTasks(); + this.updateTaskCounts(); + } catch (error) { + console.error('Erreur chargement logs:', error); + } + } + + updateTaskCounts() { + // Mettre à jour les compteurs dans les boutons + const stats = this.taskLogsStats || { total: 0, completed: 0, failed: 0, running: 0, pending: 0 }; + const running = this.tasks.filter(t => t.status === 'running').length; + + const countAll = document.getElementById('count-all'); + const countRunning = document.getElementById('count-running'); + const countCompleted = document.getElementById('count-completed'); + const countFailed = document.getElementById('count-failed'); + + if (countAll) countAll.textContent = (stats.total || 0) + running; + if (countRunning) countRunning.textContent = running + (stats.running || 0); + if (countCompleted) countCompleted.textContent = stats.completed || 0; + if (countFailed) countFailed.textContent = stats.failed || 0; + + // Mettre à jour le badge principal + const badge = document.getElementById('tasks-count-badge'); + if (badge) badge.textContent = (stats.total || 0) + running; + + console.log('Task counts updated:', { stats, running }); + } + + updateDateFilters() { + // Mettre à jour le libellé du bouton et le résumé sous le calendrier + const label = document.getElementById('task-date-filter-label'); + const summary = document.getElementById('task-cal-summary'); + + let text = 'Toutes les dates'; + if (this.selectedTaskDates && this.selectedTaskDates.length > 0) { + if (this.selectedTaskDates.length === 1) { + const d = this.parseDateKey(this.selectedTaskDates[0]); + text = d.toLocaleDateString('fr-FR', { year: 'numeric', month: 'short', day: '2-digit' }); + } else { + text = `${this.selectedTaskDates.length} jours sélectionnés`; + } + } + + if (label) label.textContent = text; + if (summary) summary.textContent = text; + } + + applyDateFilter() { + // Lorsque plusieurs dates sont sélectionnées, on garde uniquement la première pour l’API + if (this.selectedTaskDates && this.selectedTaskDates.length > 0) { + const firstDate = this.parseDateKey(this.selectedTaskDates[0]); + this.currentDateFilter.year = String(firstDate.getFullYear()); + this.currentDateFilter.month = String(firstDate.getMonth() + 1).padStart(2, '0'); + this.currentDateFilter.day = String(firstDate.getDate()).padStart(2, '0'); + } else { + this.currentDateFilter = { year: '', month: '', day: '' }; + } + + this.updateDateFilters(); + this.loadTaskLogsWithFilters(); + } + + clearDateFilters() { + this.currentDateFilter = { year: '', month: '', day: '' }; + this.selectedTaskDates = []; + this.updateDateFilters(); + this.renderTaskCalendar(); + this.loadTaskLogsWithFilters(); + } + + async refreshTaskLogs() { + this.showLoading(); + try { + const [taskLogsData, taskStatsData, taskDatesData] = await Promise.all([ + this.apiCall('/api/tasks/logs'), + this.apiCall('/api/tasks/logs/stats'), + this.apiCall('/api/tasks/logs/dates') + ]); + + this.taskLogs = taskLogsData.logs || []; + this.taskLogsStats = taskStatsData; + this.taskLogsDates = taskDatesData; + + this.renderTasks(); + this.updateDateFilters(); + this.updateTaskCounts(); + this.hideLoading(); + this.showNotification('Logs de tâches rafraîchis', 'success'); + } catch (error) { + this.hideLoading(); + this.showNotification(`Erreur: ${error.message}`, 'error'); + } + } + + createTaskCard(task, isRunning) { + const statusBadge = this.getStatusBadge(task.status); + const progressBar = isRunning ? ` +
+
+
+ ` : ''; + + const startTime = task.start_time + ? new Date(task.start_time).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) + : '--'; + const duration = task.duration || '--'; + + // Icône selon le statut + const statusIcon = { + 'completed': '', + 'failed': '', + 'running': '', + 'pending': '' + }[task.status] || ''; + + const taskCard = document.createElement('div'); + taskCard.className = `host-card ${isRunning ? 'border-l-4 border-blue-500' : ''} ${task.status === 'failed' ? 'border-l-4 border-red-500' : ''}`; + taskCard.innerHTML = ` +
+
+
+ ${statusIcon} +

${task.name}

+ ${statusBadge} +
+

Cible: ${task.host}

+

Début: ${startTime} • Durée: ${duration}

+ ${progressBar} + ${task.output ? ` +
+
${this.escapeHtml(task.output.substring(0, 150))}${task.output.length > 150 ? '...' : ''}
+
+ ` : ''} + ${task.error ? ` +
+
${this.escapeHtml(task.error.substring(0, 150))}${task.error.length > 150 ? '...' : ''}
+
+ ` : ''} +
+
+ + ${task.status === 'failed' ? ` + + ` : ''} +
+
+ `; + return taskCard; + } + + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + viewTaskDetails(taskId) { + const task = this.tasks.find(t => t.id === taskId); + if (!task) { + this.showNotification('Tâche non trouvée', 'error'); + return; + } + + const statusBadge = this.getStatusBadge(task.status); + const startTime = task.start_time + ? new Date(task.start_time).toLocaleString('fr-FR') + : '--'; + + this.showModal(`Détails de la tâche #${task.id}`, ` +
+
+

${task.name}

+ ${statusBadge} +
+ +
+
+ Cible: + ${task.host} +
+
+ Durée: + ${task.duration || '--'} +
+
+ Début: + ${startTime} +
+
+ Progression: + ${task.progress}% +
+
+ + ${task.output ? ` +
+
+

+ Sortie +

+ +
+
${this.escapeHtml(task.output)}
+
+ ` : ''} + + ${task.error ? ` +
+

+ Erreur +

+
${this.escapeHtml(task.error)}
+
+ ` : ''} + +
+ ${task.status === 'failed' ? ` + + ` : ''} + +
+
+ `); + } + + copyToClipboard(text) { + navigator.clipboard.writeText(text).then(() => { + this.showNotification('Copié dans le presse-papiers', 'success'); + }).catch(() => { + this.showNotification('Erreur lors de la copie', 'error'); + }); + } + + async clearCompletedTasks() { + const completedTasks = this.tasks.filter(t => t.status === 'completed' || t.status === 'failed'); + if (completedTasks.length === 0) { + this.showNotification('Aucune tâche à nettoyer', 'info'); + return; + } + + // Supprimer localement les tâches terminées + this.tasks = this.tasks.filter(t => t.status === 'running' || t.status === 'pending'); + this.renderTasks(); + this.showNotification(`${completedTasks.length} tâche(s) nettoyée(s)`, 'success'); + } + + async retryTask(taskId) { + const task = this.tasks.find(t => t.id === taskId); + if (!task) return; + + // Extraire l'action du nom de la tâche + const actionMap = { + 'Mise à jour système': 'upgrade', + 'Redémarrage système': 'reboot', + 'Vérification de santé': 'health-check', + 'Sauvegarde': 'backup' + }; + + const action = actionMap[task.name] || 'health-check'; + await this.executeTask(action, task.host); + } + + showAdHocConsole() { + console.log('Opening Ad-Hoc Console with:', { + adhocHistory: this.adhocHistory, + adhocCategories: this.adhocCategories + }); + + const hostOptions = this.hosts.map(h => + `` + ).join(''); + + const groupOptions = this.ansibleGroups.map(g => + `` + ).join(''); + + // Catégories par défaut si aucune n'est chargée + const categories = this.adhocCategories.length > 0 ? this.adhocCategories : [ + { name: 'default', description: 'Commandes générales', color: '#7c3aed', icon: 'fa-terminal' }, + { name: 'diagnostic', description: 'Diagnostic', color: '#10b981', icon: 'fa-stethoscope' }, + { name: 'maintenance', description: 'Maintenance', color: '#f59e0b', icon: 'fa-wrench' }, + { name: 'deployment', description: 'Déploiement', color: '#3b82f6', icon: 'fa-rocket' } + ]; + + const categoryOptions = categories.map(c => + `` + ).join(''); + + // Stocker le filtre actuel (utiliser la propriété de classe) + this.currentHistoryCategoryFilter = this.currentHistoryCategoryFilter || 'all'; + + // Générer la liste de l'historique groupé par catégorie + const historyByCategory = {}; + (this.adhocHistory || []).forEach(cmd => { + const cat = cmd.category || 'default'; + if (!historyByCategory[cat]) historyByCategory[cat] = []; + historyByCategory[cat].push(cmd); + }); + + let historyHtml = ''; + if (Object.keys(historyByCategory).length > 0) { + Object.entries(historyByCategory).forEach(([category, commands]) => { + // Filtrer par catégorie si un filtre est actif + if (this.currentHistoryCategoryFilter !== 'all' && category !== this.currentHistoryCategoryFilter) { + return; // Skip cette catégorie + } + + const catInfo = categories.find(c => c.name === category) || { color: '#7c3aed', icon: 'fa-folder' }; + historyHtml += ` +
+
+ ${category.toUpperCase()} + (${commands.length}) +
+
+ ${commands.map(cmd => ` +
+
+ ${this.escapeHtml(cmd.command)} + ${cmd.target} +
+
+ ${cmd.use_count || 1}x + + +
+
+ `).join('')} +
+
+ `; + }); + } + + // Afficher les catégories disponibles avec filtrage et actions + // Ajouter "toutes" comme option de filtrage + let categoriesListHtml = ` + + `; + categories.forEach(cat => { + const isDefault = cat.name === 'default'; + categoriesListHtml += ` +
+ +
+ + ${!isDefault ? ` + + ` : ``} +
+
+ `; + }); + + this.showModal('Console Ad-Hoc Ansible', ` +
+ +
+
+
+ + Exécutez des commandes shell directement sur vos hôtes via Ansible +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + Hôtes ciblés + + +
+
+ +
+
+ +
+ + +
+ +
+ +
+ + + sec +
+
+ + +
+
+ + + +
+ + +
+
+
+ + +
+
+
+

+ Historique +

+ +
+ + +
+

Catégories:

+
+ ${categoriesListHtml} +
+
+ + +
+ ${historyHtml || '

Aucune commande dans l\'historique
Exécutez une commande pour la sauvegarder

'} +
+
+
+
+ `); + + // Initialiser l'aperçu des hôtes ciblés + this.updateTargetHostsPreview('all'); + + // Ajouter l'event listener pour mettre à jour l'aperçu quand la cible change + const targetSelect = document.getElementById('adhoc-target'); + if (targetSelect) { + targetSelect.addEventListener('change', (e) => { + this.updateTargetHostsPreview(e.target.value); + }); + } + } + + /** + * Récupère la liste des hôtes pour une cible donnée (groupe, hôte individuel ou "all") + */ + getHostsForTarget(target) { + if (!target || target === 'all') { + // Tous les hôtes + return this.hosts; + } + + // Vérifier si c'est un groupe + if (this.ansibleGroups.includes(target)) { + return this.hosts.filter(h => h.groups && h.groups.includes(target)); + } + + // Sinon c'est un hôte individuel + const host = this.hosts.find(h => h.name === target); + return host ? [host] : []; + } + + /** + * Met à jour l'aperçu des hôtes ciblés dans la console Ad-Hoc + */ + updateTargetHostsPreview(target) { + const listContainer = document.getElementById('adhoc-target-hosts-list'); + const countSpan = document.getElementById('adhoc-target-hosts-count'); + const previewContainer = document.getElementById('adhoc-target-hosts-preview'); + + if (!listContainer || !countSpan || !previewContainer) return; + + const hosts = this.getHostsForTarget(target); + + // Mettre à jour le compteur + countSpan.textContent = `${hosts.length} hôte${hosts.length > 1 ? 's' : ''}`; + + // Générer les badges des hôtes + if (hosts.length === 0) { + listContainer.innerHTML = 'Aucun hôte trouvé'; + previewContainer.classList.add('border-amber-700/50'); + previewContainer.classList.remove('border-gray-700'); + } else { + previewContainer.classList.remove('border-amber-700/50'); + previewContainer.classList.add('border-gray-700'); + + listContainer.innerHTML = hosts.map(h => { + const statusColor = h.bootstrap_ok ? 'bg-green-900/40 text-green-400 border-green-700/50' : 'bg-gray-700/50 text-gray-400 border-gray-600/50'; + const statusIcon = h.bootstrap_ok ? 'fa-check-circle' : 'fa-circle'; + return ` + + + ${this.escapeHtml(h.name)} + ${h.ip ? `${h.ip}` : ''} + + `; + }).join(''); + } + } + + loadHistoryCommand(command, target, module, become) { + document.getElementById('adhoc-command').value = command; + document.getElementById('adhoc-target').value = target; + document.getElementById('adhoc-module').value = module; + document.getElementById('adhoc-become').checked = become; + // Mettre à jour l'aperçu des hôtes ciblés + this.updateTargetHostsPreview(target); + this.showNotification('Commande chargée depuis l\'historique', 'info'); + } + + /** + * Rafraîchit dynamiquement la section historique des commandes Ad-Hoc + * sans recharger toute la modale + */ + async refreshAdHocHistory() { + try { + // Récupérer l'historique mis à jour depuis l'API + const historyData = await this.apiCall('/api/adhoc/history'); + this.adhocHistory = historyData.commands || []; + + const historyContainer = document.getElementById('adhoc-history-container'); + if (!historyContainer) return; + + // Catégories pour le rendu + const categories = this.adhocCategories.length > 0 ? this.adhocCategories : [ + { name: 'default', description: 'Commandes générales', color: '#7c3aed', icon: 'fa-terminal' }, + { name: 'diagnostic', description: 'Diagnostic', color: '#10b981', icon: 'fa-stethoscope' }, + { name: 'maintenance', description: 'Maintenance', color: '#f59e0b', icon: 'fa-wrench' }, + { name: 'deployment', description: 'Déploiement', color: '#3b82f6', icon: 'fa-rocket' } + ]; + + // Générer la liste de l'historique groupé par catégorie + const historyByCategory = {}; + (this.adhocHistory || []).forEach(cmd => { + const cat = cmd.category || 'default'; + if (!historyByCategory[cat]) historyByCategory[cat] = []; + historyByCategory[cat].push(cmd); + }); + + let historyHtml = ''; + if (Object.keys(historyByCategory).length > 0) { + Object.entries(historyByCategory).forEach(([category, commands]) => { + // Filtrer par catégorie si un filtre est actif + if (this.currentHistoryCategoryFilter !== 'all' && category !== this.currentHistoryCategoryFilter) { + return; + } + + const catInfo = categories.find(c => c.name === category) || { color: '#7c3aed', icon: 'fa-folder' }; + historyHtml += ` +
+
+ ${category.toUpperCase()} + (${commands.length}) +
+
+ ${commands.map(cmd => ` +
+
+ ${this.escapeHtml(cmd.command)} + ${cmd.target} +
+
+ ${cmd.use_count || 1}x + + +
+
+ `).join('')} +
+
+ `; + }); + } + + // Mettre à jour le contenu avec animation + historyContainer.style.opacity = '0.5'; + historyContainer.innerHTML = historyHtml || '

Aucune commande dans l\'historique
Exécutez une commande pour la sauvegarder

'; + + // Animation de mise à jour + setTimeout(() => { + historyContainer.style.opacity = '1'; + historyContainer.style.transition = 'opacity 0.3s ease'; + }, 50); + + } catch (error) { + console.error('Erreur lors du rafraîchissement de l\'historique:', error); + } + } + + async deleteHistoryCommand(commandId) { + if (!confirm('Supprimer cette commande de l\'historique ?')) return; + + try { + await this.apiCall(`/api/adhoc/history/${commandId}`, { method: 'DELETE' }); + this.showNotification('Commande supprimée', 'success'); + // Recharger l'historique et réafficher la console + const historyData = await this.apiCall('/api/adhoc/history'); + this.adhocHistory = historyData.commands || []; + this.showAdHocConsole(); + } catch (error) { + this.showNotification(`Erreur: ${error.message}`, 'error'); + } + } + + async editHistoryCommand(commandId) { + const categoryOptions = this.adhocCategories.map(c => + `` + ).join(''); + + this.showModal('Modifier la catégorie', ` +
+
+ + +
+
+ + +
+
+ + +
+
+ `); + } + + async updateCommandCategory(event, commandId) { + event.preventDefault(); + const formData = new FormData(event.target); + + try { + await this.apiCall(`/api/adhoc/history/${commandId}/category?category=${encodeURIComponent(formData.get('category'))}&description=${encodeURIComponent(formData.get('description') || '')}`, { + method: 'PUT' + }); + this.showNotification('Catégorie mise à jour', 'success'); + const historyData = await this.apiCall('/api/adhoc/history'); + this.adhocHistory = historyData.commands || []; + this.showAdHocConsole(); + } catch (error) { + this.showNotification(`Erreur: ${error.message}`, 'error'); + } + } + + showAddCategoryModal() { + this.showModal('Ajouter une catégorie', ` +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ `); + } + + async createCategory(event) { + event.preventDefault(); + const formData = new FormData(event.target); + + try { + await this.apiCall(`/api/adhoc/categories?name=${encodeURIComponent(formData.get('name'))}&description=${encodeURIComponent(formData.get('description') || '')}&color=${encodeURIComponent(formData.get('color'))}&icon=${encodeURIComponent(formData.get('icon'))}`, { + method: 'POST' + }); + this.showNotification('Catégorie créée', 'success'); + const categoriesData = await this.apiCall('/api/adhoc/categories'); + this.adhocCategories = categoriesData.categories || []; + this.showAdHocConsole(); + } catch (error) { + this.showNotification(`Erreur: ${error.message}`, 'error'); + } + } + + filterHistoryByCategory(category) { + this.currentHistoryCategoryFilter = category; + + // Mettre à jour les boutons de filtre visuellement + document.querySelectorAll('.category-filter-btn').forEach(btn => { + const btnCategory = btn.getAttribute('data-category-filter'); + if (btnCategory === category) { + if (category === 'all') { + btn.className = 'category-filter-btn active inline-flex items-center px-2 py-1 rounded text-xs transition-all bg-purple-600 text-white hover:bg-purple-500'; + } else { + btn.classList.add('active', 'ring-2', 'ring-white/50'); + } + } else { + btn.classList.remove('active', 'ring-2', 'ring-white/50'); + if (btnCategory === 'all') { + btn.className = 'category-filter-btn inline-flex items-center px-2 py-1 rounded text-xs transition-all bg-gray-700 text-gray-400 hover:bg-gray-600'; + } + } + }); + + // Filtrer les sections de l'historique + document.querySelectorAll('.history-category-section').forEach(section => { + const sectionCategory = section.getAttribute('data-category'); + if (category === 'all' || sectionCategory === category) { + section.classList.remove('hidden'); + } else { + section.classList.add('hidden'); + } + }); + + // Si aucun résultat visible, afficher un message + const visibleSections = document.querySelectorAll('.history-category-section:not(.hidden)'); + const historyContainer = document.querySelector('.overflow-y-auto[style*="max-height: 400px"]'); + if (historyContainer && visibleSections.length === 0) { + // Pas de commandes dans cette catégorie + const emptyMsg = historyContainer.querySelector('.empty-filter-msg'); + if (!emptyMsg) { + const msg = document.createElement('p'); + msg.className = 'empty-filter-msg text-xs text-gray-500 text-center py-4'; + msg.innerHTML = 'Aucune commande dans cette catégorie'; + historyContainer.appendChild(msg); + } + } else { + const emptyMsg = historyContainer?.querySelector('.empty-filter-msg'); + if (emptyMsg) emptyMsg.remove(); + } + } + + editCategory(categoryName) { + const category = this.adhocCategories.find(c => c.name === categoryName); + if (!category) { + this.showNotification('Catégorie non trouvée', 'error'); + return; + } + + this.showModal(`Modifier la catégorie: ${categoryName}`, ` +
+
+ + + ${categoryName === 'default' ? '

La catégorie par défaut ne peut pas être renommée

' : ''} +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+

Aperçu:

+ + ${category.name} + +
+ +
+ + +
+
+ `); + } + + async updateCategory(event, originalName) { + event.preventDefault(); + const formData = new FormData(event.target); + const newName = formData.get('name') || originalName; + + try { + await this.apiCall(`/api/adhoc/categories/${encodeURIComponent(originalName)}`, { + method: 'PUT', + body: JSON.stringify({ + name: newName, + description: formData.get('description') || '', + color: formData.get('color'), + icon: formData.get('icon') + }) + }); + this.showNotification('Catégorie mise à jour', 'success'); + const categoriesData = await this.apiCall('/api/adhoc/categories'); + this.adhocCategories = categoriesData.categories || []; + this.showAdHocConsole(); + } catch (error) { + this.showNotification(`Erreur: ${error.message}`, 'error'); + } + } + + async deleteCategory(categoryName) { + if (categoryName === 'default') { + this.showNotification('La catégorie par défaut ne peut pas être supprimée', 'error'); + return; + } + + // Compter les commandes dans cette catégorie + const commandsInCategory = (this.adhocHistory || []).filter(cmd => cmd.category === categoryName).length; + + const confirmMsg = commandsInCategory > 0 + ? `Supprimer la catégorie "${categoryName}" ?\n\n${commandsInCategory} commande(s) seront déplacées vers "default".` + : `Supprimer la catégorie "${categoryName}" ?`; + + if (!confirm(confirmMsg)) return; + + try { + await this.apiCall(`/api/adhoc/categories/${encodeURIComponent(categoryName)}`, { + method: 'DELETE' + }); + this.showNotification('Catégorie supprimée', 'success'); + + // Recharger les données + const [categoriesData, historyData] = await Promise.all([ + this.apiCall('/api/adhoc/categories'), + this.apiCall('/api/adhoc/history') + ]); + this.adhocCategories = categoriesData.categories || []; + this.adhocHistory = historyData.commands || []; + + // Réinitialiser le filtre si on filtrait sur cette catégorie + if (this.currentHistoryCategoryFilter === categoryName) { + this.currentHistoryCategoryFilter = 'all'; + } + + this.showAdHocConsole(); + } catch (error) { + this.showNotification(`Erreur: ${error.message}`, 'error'); + } + } + + async executeAdHocCommand(event) { + event.preventDefault(); + const formData = new FormData(event.target); + + const payload = { + target: formData.get('target'), + command: formData.get('command'), + module: formData.get('module'), + become: formData.get('become') === 'on', + timeout: parseInt(formData.get('timeout')) || 60 + }; + + const resultDiv = document.getElementById('adhoc-result'); + const stdoutPre = document.getElementById('adhoc-stdout'); + const stderrPre = document.getElementById('adhoc-stderr'); + const stderrSection = document.getElementById('adhoc-stderr-section'); + const statusIcon = document.getElementById('adhoc-status-icon'); + const resultMeta = document.getElementById('adhoc-result-meta'); + const resultStats = document.getElementById('adhoc-result-stats'); + const resultHeader = document.getElementById('adhoc-result-header'); + + // Reset et afficher + resultDiv.classList.remove('hidden'); + stderrSection.classList.add('hidden'); + resultHeader.className = 'flex items-center justify-between px-4 py-3 bg-gray-800/80 border-b border-gray-700'; + statusIcon.innerHTML = ''; + statusIcon.className = 'w-8 h-8 rounded-lg flex items-center justify-center bg-blue-900/50'; + resultMeta.textContent = 'Exécution en cours...'; + resultStats.innerHTML = ''; + stdoutPre.innerHTML = '⏳ Exécution de la commande...'; + + try { + const result = await this.apiCall('/api/ansible/adhoc', { + method: 'POST', + body: JSON.stringify(payload) + }); + + // Mise à jour du header avec le statut + if (result.success) { + resultHeader.className = 'flex items-center justify-between px-4 py-3 bg-green-900/30 border-b border-green-800/50'; + statusIcon.innerHTML = ''; + statusIcon.className = 'w-8 h-8 rounded-lg flex items-center justify-center bg-green-900/50'; + resultMeta.innerHTML = `Succès • Cible: ${this.escapeHtml(result.target)}`; + } else { + resultHeader.className = 'flex items-center justify-between px-4 py-3 bg-red-900/30 border-b border-red-800/50'; + statusIcon.innerHTML = ''; + statusIcon.className = 'w-8 h-8 rounded-lg flex items-center justify-center bg-red-900/50'; + resultMeta.innerHTML = `Échec • Cible: ${this.escapeHtml(result.target)}`; + } + + // Stats dans le header + resultStats.innerHTML = ` +
+ + ${result.duration}s +
+
+ + Code: ${result.return_code} +
+ `; + + // Parser et afficher le résultat avec onglets par hôte + const stdoutContent = result.stdout || '(pas de sortie)'; + const hostOutputs = this.parseOutputByHost(stdoutContent); + + // Si plusieurs hôtes, afficher avec onglets + if (hostOutputs.length > 1) { + this.renderHostTabs(hostOutputs, result.success); + } else { + // Un seul hôte ou output non parsable + stdoutPre.innerHTML = this.formatAnsibleOutput(stdoutContent, result.success); + } + + // Afficher STDERR si présent + if (result.stderr && result.stderr.trim()) { + stderrSection.classList.remove('hidden'); + stderrPre.innerHTML = this.formatAnsibleWarnings(result.stderr); + } + + this.showNotification( + result.success ? 'Commande exécutée avec succès' : 'Commande échouée', + result.success ? 'success' : 'error' + ); + + // Mettre à jour dynamiquement l'historique des commandes + await this.refreshAdHocHistory(); + + } catch (error) { + resultHeader.className = 'flex items-center justify-between px-4 py-3 bg-red-900/30 border-b border-red-800/50'; + statusIcon.innerHTML = ''; + statusIcon.className = 'w-8 h-8 rounded-lg flex items-center justify-center bg-red-900/50'; + resultMeta.innerHTML = 'Erreur de connexion'; + resultStats.innerHTML = ''; + stdoutPre.innerHTML = `❌ ${this.escapeHtml(error.message)}`; + this.showNotification('Erreur lors de l\'exécution', 'error'); + } + } + + formatAnsibleOutput(output, isSuccess) { + // Formater la sortie Ansible pour une meilleure lisibilité + let formatted = this.escapeHtml(output); + + // Colorer les hosts avec statut + formatted = formatted.replace(/^(\S+)\s*\|\s*(CHANGED|SUCCESS)\s*=>/gm, + '$1 $2 =>'); + formatted = formatted.replace(/^(\S+)\s*\|\s*(FAILED|UNREACHABLE)\s*(!)?\s*=>/gm, + '$1 $2 =>'); + + // Colorer les clés JSON + formatted = formatted.replace(/"(\w+)"\s*:/g, '"$1":'); + + // Colorer les valeurs importantes + formatted = formatted.replace(/: "([^"]+)"/g, ': "$1"'); + formatted = formatted.replace(/: (true|false)/g, ': $1'); + formatted = formatted.replace(/: (\d+)/g, ': $1'); + + // Mettre en évidence les lignes de résumé + formatted = formatted.replace(/^(PLAY RECAP \*+)$/gm, '$1'); + formatted = formatted.replace(/(ok=\d+)/g, '$1'); + formatted = formatted.replace(/(changed=\d+)/g, '$1'); + formatted = formatted.replace(/(unreachable=\d+)/g, '$1'); + formatted = formatted.replace(/(failed=\d+)/g, '$1'); + + return formatted; + } + + formatAnsibleWarnings(stderr) { + let formatted = this.escapeHtml(stderr); + + // Mettre en évidence les warnings + formatted = formatted.replace(/\[WARNING\]:/g, '[WARNING]:'); + formatted = formatted.replace(/\[DEPRECATION WARNING\]:/g, '[DEPRECATION WARNING]:'); + + // Colorer les URLs + formatted = formatted.replace(/(https?:\/\/[^\s<]+)/g, '$1'); + + // Mettre en évidence les chemins de fichiers + formatted = formatted.replace(/(\/[\w\-\.\/]+)/g, '$1'); + + return formatted; + } + + parseOutputByHost(output) { + // Parser la sortie Ansible pour séparer par hôte + // Format typique: "hostname | STATUS | rc=X >>" + const hostOutputs = []; + const lines = output.split('\n'); + let currentHost = null; + let currentOutput = []; + let currentStatus = 'unknown'; + + const hostPattern = /^(\S+)\s*\|\s*(CHANGED|SUCCESS|FAILED|UNREACHABLE)\s*\|?\s*rc=(\d+)\s*>>?/; + + for (const line of lines) { + const match = line.match(hostPattern); + if (match) { + // Sauvegarder l'hôte précédent si existant + if (currentHost) { + hostOutputs.push({ + hostname: currentHost, + status: currentStatus, + output: currentOutput.join('\n').trim() + }); + } + // Commencer un nouvel hôte + currentHost = match[1]; + currentStatus = match[2].toLowerCase(); + currentOutput = [line]; + } else if (currentHost) { + currentOutput.push(line); + } else { + // Lignes avant le premier hôte (header, etc.) + if (!hostOutputs.length && line.trim()) { + currentOutput.push(line); + } + } + } + + // Ajouter le dernier hôte + if (currentHost) { + hostOutputs.push({ + hostname: currentHost, + status: currentStatus, + output: currentOutput.join('\n').trim() + }); + } + + // Si aucun hôte trouvé, retourner l'output brut + if (hostOutputs.length === 0) { + return [{ + hostname: 'output', + status: 'unknown', + output: output + }]; + } + + return hostOutputs; + } + + renderHostTabs(hostOutputs, isSuccess) { + const stdoutSection = document.getElementById('adhoc-stdout-section'); + if (!stdoutSection) return; + + // Stocker les outputs pour référence + this.currentHostOutputs = hostOutputs; + + // Générer les onglets + const tabsHtml = hostOutputs.map((host, index) => { + const statusColor = host.status === 'changed' || host.status === 'success' + ? 'bg-green-600 hover:bg-green-500' + : host.status === 'failed' || host.status === 'unreachable' + ? 'bg-red-600 hover:bg-red-500' + : 'bg-gray-600 hover:bg-gray-500'; + const statusIcon = host.status === 'changed' || host.status === 'success' + ? 'fa-check' + : host.status === 'failed' || host.status === 'unreachable' + ? 'fa-times' + : 'fa-question'; + + return ` + + `; + }).join(''); + + // Générer le compteur d'hôtes + const successCount = hostOutputs.filter(h => h.status === 'changed' || h.status === 'success').length; + const failedCount = hostOutputs.filter(h => h.status === 'failed' || h.status === 'unreachable').length; + + stdoutSection.innerHTML = ` +
+
+ + Sortie par hôte + + + (${hostOutputs.length} hôtes: + ${successCount} OK + ${failedCount > 0 ? `, ${failedCount} échec` : ''}) + +
+ +
+ + +
+ ${tabsHtml} +
+ + +
${this.formatAnsibleOutput(hostOutputs[0].output, isSuccess)}
+ `; + } + + switchHostTab(index) { + if (!this.currentHostOutputs || !this.currentHostOutputs[index]) return; + + const hostOutput = this.currentHostOutputs[index]; + const stdoutPre = document.getElementById('adhoc-stdout'); + const tabs = document.querySelectorAll('.host-tab'); + + // Mettre à jour l'onglet actif + tabs.forEach((tab, i) => { + if (i === index) { + const host = this.currentHostOutputs[i]; + const statusColor = host.status === 'changed' || host.status === 'success' + ? 'bg-green-600' + : host.status === 'failed' || host.status === 'unreachable' + ? 'bg-red-600' + : 'bg-gray-600'; + tab.className = `host-tab flex items-center gap-2 px-3 py-1.5 rounded-t-lg text-xs font-medium transition-all ${statusColor} text-white`; + } else { + tab.className = 'host-tab flex items-center gap-2 px-3 py-1.5 rounded-t-lg text-xs font-medium transition-all bg-gray-700/50 text-gray-400 hover:text-white'; + } + }); + + // Afficher le contenu + if (stdoutPre) { + stdoutPre.innerHTML = this.formatAnsibleOutput(hostOutput.output, hostOutput.status === 'changed' || hostOutput.status === 'success'); + } + } + + showAllHostsOutput() { + if (!this.currentHostOutputs) return; + + const stdoutPre = document.getElementById('adhoc-stdout'); + if (stdoutPre) { + const allOutput = this.currentHostOutputs.map(h => h.output).join('\n\n'); + stdoutPre.innerHTML = this.formatAnsibleOutput(allOutput, true); + } + + // Désélectionner tous les onglets + document.querySelectorAll('.host-tab').forEach(tab => { + tab.className = 'host-tab flex items-center gap-2 px-3 py-1.5 rounded-t-lg text-xs font-medium transition-all bg-gray-700/50 text-gray-400 hover:text-white'; + }); + } + + renderLogs() { + const container = document.getElementById('logs-container'); + if (!container) return; + container.innerHTML = ''; + + if (this.logs.length === 0) { + container.innerHTML = ` +
+

Aucun log disponible

+
+ `; + return; + } + + this.logs.forEach(log => { + const levelColor = this.getLogLevelColor(log.level); + // Formater le timestamp depuis l'API (format ISO) + const timestamp = log.timestamp + ? new Date(log.timestamp).toLocaleString('fr-FR') + : '--'; + + const logEntry = document.createElement('div'); + logEntry.className = 'log-entry'; + logEntry.innerHTML = ` +
+ ${timestamp} + ${log.level} + ${log.message} + ${log.host ? `[${log.host}]` : ''} +
+ `; + container.appendChild(logEntry); + }); + + // Auto-scroll to bottom + container.scrollTop = container.scrollHeight; + } + + getStatusBadge(status) { + const badges = { + 'completed': 'Terminé', + 'running': 'En cours', + 'pending': 'En attente', + 'failed': 'Échoué' + }; + return badges[status] || badges['pending']; + } + + getLogLevelColor(level) { + const colors = { + 'INFO': 'bg-blue-600 text-white', + 'WARN': 'bg-yellow-600 text-white', + 'ERROR': 'bg-red-600 text-white', + 'DEBUG': 'bg-gray-600 text-white' + }; + return colors[level] || colors['INFO']; + } + + startAnimations() { + // Animate metrics on load + anime({ + targets: '.metric-card', + translateY: [50, 0], + opacity: [0, 1], + delay: anime.stagger(200), + duration: 800, + easing: 'easeOutExpo' + }); + + // Animate host cards + anime({ + targets: '.host-card', + translateX: [-30, 0], + opacity: [0, 1], + delay: anime.stagger(100), + duration: 600, + easing: 'easeOutExpo' + }); + + // Floating animation for hero elements + anime({ + targets: '.animate-float', + translateY: [-10, 10], + duration: 3000, + direction: 'alternate', + loop: true, + easing: 'easeInOutSine' + }); + } + + setupScrollAnimations() { + const observerOptions = { + threshold: 0.1, + rootMargin: '0px 0px -50px 0px' + }; + + const observer = new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + entry.target.classList.add('visible'); + } + }); + }, observerOptions); + + document.querySelectorAll('.fade-in').forEach(el => { + observer.observe(el); + }); + } + + // Les mises à jour en temps réel sont gérées par WebSocket maintenant + + // Public methods for UI interactions + showQuickActions() { + // Construire la liste des groupes Ansible pour le sélecteur + const groupOptions = this.ansibleGroups.map(g => + `` + ).join(''); + + this.showModal('Actions Rapides - Ansible', ` +
+
+ + +
+ + + + +
+ `); + } + + async executeAnsibleTask(action) { + const targetSelect = document.getElementById('ansible-target'); + const target = targetSelect ? targetSelect.value : 'all'; + + this.closeModal(); + this.showLoading(); + + try { + // Appeler l'API pour créer une tâche Ansible + const result = await this.apiCall('/api/tasks', { + method: 'POST', + body: JSON.stringify({ + action: action, + group: target, + dry_run: false + }) + }); + + this.hideLoading(); + this.showNotification(`Tâche '${result.name}' lancée sur ${target}`, 'success'); + + // Recharger les données + await this.loadAllData(); + + } catch (error) { + this.hideLoading(); + this.showNotification(`Erreur: ${error.message}`, 'error'); + } + } + + // Legacy function for backward compatibility + executeTask(taskType) { + // Mapper les anciens types vers les nouvelles actions + const actionMap = { + 'upgrade-all': 'upgrade', + 'reboot-all': 'reboot', + 'health-check': 'health-check', + 'backup': 'backup' + }; + const action = actionMap[taskType] || taskType; + this.executeAnsibleTask(action); + } + + async executeHostAction(action, hostName) { + // hostName peut être un ID (ancien système) ou un nom d'hôte (nouveau système Ansible) + let targetHost = hostName; + + // Si c'est un nombre, chercher par ID (compatibilité) + if (typeof hostName === 'number') { + const host = this.hosts.find(h => h.id === hostName); + if (!host) { + this.showNotification('Hôte non trouvé', 'error'); + return; + } + targetHost = host.name; + } + + this.showLoading(); + + try { + // Mapper les actions vers les playbooks Ansible + const actionMap = { + 'update': 'upgrade', + 'upgrade': 'upgrade', + 'reboot': 'reboot', + 'connect': 'health-check', + 'health-check': 'health-check', + 'backup': 'backup' + }; + + const ansibleAction = actionMap[action]; + if (ansibleAction) { + const result = await this.apiCall('/api/tasks', { + method: 'POST', + body: JSON.stringify({ + action: ansibleAction, + host: targetHost, + dry_run: false + }) + }); + + this.hideLoading(); + this.showNotification(`Tâche '${result.name}' lancée sur ${targetHost}`, 'success'); + await this.loadAllData(); + } else { + this.hideLoading(); + this.showNotification(`Action '${action}' non supportée`, 'warning'); + } + } catch (error) { + this.hideLoading(); + this.showNotification(`Erreur: ${error.message}`, 'error'); + } + } + + // Redirige vers la modale avancée d'ajout d'hôte (avec groupes env/role et écriture dans hosts.yml) + addHost() { + this.showAddHostModal(); + } + + showBootstrapModal(hostName, hostIp) { + this.showModal(`Bootstrap SSH - ${hostName}`, ` +
+
+
+ +
+

Cette opération va :

+
    +
  • Créer l'utilisateur d'automatisation
  • +
  • Configurer l'authentification SSH par clé
  • +
  • Installer et configurer sudo
  • +
  • Installer Python3 (requis par Ansible)
  • +
+
+
+
+ + + +
+ + +
+ +
+ + +

Utilisé uniquement pour la configuration initiale

+
+ +
+ + +
+ +
+ + +
+
+ `); + } + + async executeBootstrap(event) { + event.preventDefault(); + const formData = new FormData(event.target); + + const host = formData.get('host'); + const rootPassword = formData.get('root_password'); + const automationUser = formData.get('automation_user') || 'automation'; + + this.closeModal(); + this.showLoading(); + + try { + const result = await this.apiCall('/api/ansible/bootstrap', { + method: 'POST', + body: JSON.stringify({ + host: host, + root_password: rootPassword, + automation_user: automationUser + }) + }); + + this.hideLoading(); + + // Afficher le résultat dans un modal + this.showModal('Bootstrap Réussi', ` +
+
+
+ +
+

Configuration terminée!

+

L'hôte ${host} est prêt pour Ansible

+
+
+
+ +
+

Détails

+
${result.stdout || 'Pas de sortie'}
+
+ + +
+ `); + + this.showNotification(`Bootstrap réussi pour ${host}`, 'success'); + await this.loadAllData(); + + } catch (error) { + this.hideLoading(); + + // Extraire les détails de l'erreur + let errorDetail = error.message; + let stdout = ''; + let stderr = ''; + + if (error.detail && typeof error.detail === 'object') { + stdout = error.detail.stdout || ''; + stderr = error.detail.stderr || ''; + } + + this.showModal('Erreur Bootstrap', ` +
+
+
+ +
+

Bootstrap échoué

+

${errorDetail}

+
+
+
+ + ${stderr ? ` +
+

Erreur

+
${stderr}
+
+ ` : ''} + + ${stdout ? ` +
+

Sortie

+
${stdout}
+
+ ` : ''} + + +
+ `); + } + } + + manageHost(hostNameOrId) { + // Support both host name and ID + let host; + if (typeof hostNameOrId === 'number') { + host = this.hosts.find(h => h.id === hostNameOrId); + } else { + host = this.hosts.find(h => h.name === hostNameOrId); + } + if (!host) return; + + const lastSeen = host.last_seen + ? new Date(host.last_seen).toLocaleString('fr-FR') + : 'Jamais vérifié'; + + // Bootstrap status + const bootstrapOk = host.bootstrap_ok || false; + const bootstrapDate = host.bootstrap_date + ? new Date(host.bootstrap_date).toLocaleString('fr-FR') + : null; + const bootstrapStatusHtml = bootstrapOk + ? `
+ + Ansible Ready + (${bootstrapDate || 'N/A'}) +
` + : `
+ + Non configuré - Bootstrap requis +
`; + + this.showModal(`Gérer ${host.name}`, ` +
+
+

Informations de l'hôte

+
+

Nom:

+

${host.name}

+

IP:

+

${host.ip}

+

OS:

+

${host.os}

+

Statut:

+

${host.status}

+

Dernière connexion:

+

${lastSeen}

+
+
+
+

Statut Bootstrap Ansible

+ ${bootstrapStatusHtml} +
+
+ + + + + +
+
+ `); + } + + removeHost(hostId) { + if (confirm('Êtes-vous sûr de vouloir supprimer cet hôte?')) { + this.hosts = this.hosts.filter(h => h.id !== hostId); + this.renderHosts(); + this.closeModal(); + this.showNotification('Hôte supprimé avec succès!', 'success'); + } + } + + addTaskToList(taskType) { + const taskNames = { + 'upgrade-all': 'Mise à jour système', + 'reboot-all': 'Redémarrage système', + 'health-check': 'Vérification de santé', + 'backup': 'Sauvegarde' + }; + + const newTask = { + id: Math.max(...this.tasks.map(t => t.id)) + 1, + name: taskNames[taskType] || 'Tâche inconnue', + host: 'Multiple', + status: 'running', + progress: 0, + startTime: new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }), + duration: '0s' + }; + + this.tasks.unshift(newTask); + this.renderTasks(); + + // Simulate task completion + setTimeout(() => { + const task = this.tasks.find(t => t.id === newTask.id); + if (task) { + task.status = 'completed'; + task.progress = 100; + this.renderTasks(); + } + }, 5000); + } + + stopTask(taskId) { + const task = this.tasks.find(t => t.id === taskId); + if (task && task.status === 'running') { + task.status = 'failed'; + task.progress = 0; + this.renderTasks(); + this.showNotification('Tâche arrêtée', 'warning'); + } + } + + viewTaskDetails(taskId) { + const task = this.tasks.find(t => t.id === taskId); + if (!task) return; + + this.showModal(`Détails de la tâche`, ` +
+
+

${task.name}

+

Hôte: ${task.host}

+

Statut: ${this.getStatusBadge(task.status)}

+

Progression: ${task.progress}%

+

Durée: ${task.duration}

+
+
+
Logs de la tâche
+
+

• Démarrage de la tâche...

+

• Connexion SSH établie

+

• Exécution des commandes...

+

• Tâche terminée avec succès

+
+
+
+ `); + } + + refreshTasks() { + this.showLoading(); + setTimeout(() => { + this.hideLoading(); + this.showNotification('Tâches rafraîchies', 'success'); + }, 1000); + } + + clearLogs() { + if (confirm('Êtes-vous sûr de vouloir effacer tous les logs?')) { + this.logs = []; + this.renderLogs(); + this.showNotification('Logs effacés avec succès!', 'success'); + } + } + + exportLogs() { + const logText = this.logs.map(log => `${log.timestamp} [${log.level}] ${log.message}`).join('\n'); + const blob = new Blob([logText], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + + const a = document.createElement('a'); + a.href = url; + a.download = `homelab-logs-${new Date().toISOString().slice(0, 10)}.txt`; + a.click(); + + URL.revokeObjectURL(url); + this.showNotification('Logs exportés avec succès!', 'success'); + } + + // ===== GESTION DES PLAYBOOKS ===== + + renderPlaybooks() { + const container = document.getElementById('playbooks-list'); + if (!container) return; + + // Filtrer les playbooks + let filteredPlaybooks = this.playbooks; + + // Filtre par catégorie + if (this.currentPlaybookCategoryFilter && this.currentPlaybookCategoryFilter !== 'all') { + filteredPlaybooks = filteredPlaybooks.filter(pb => + (pb.category || 'general').toLowerCase() === this.currentPlaybookCategoryFilter.toLowerCase() + ); + } + + // Filtre par recherche + if (this.currentPlaybookSearch) { + const search = this.currentPlaybookSearch.toLowerCase(); + filteredPlaybooks = filteredPlaybooks.filter(pb => + pb.name.toLowerCase().includes(search) || + pb.filename.toLowerCase().includes(search) || + (pb.description || '').toLowerCase().includes(search) + ); + } + + // Mettre à jour le compteur + const countEl = document.getElementById('playbooks-count'); + if (countEl) { + countEl.innerHTML = `${filteredPlaybooks.length} playbook${filteredPlaybooks.length > 1 ? 's' : ''}`; + } + + if (filteredPlaybooks.length === 0) { + container.innerHTML = ` +
+ +

Aucun playbook trouvé

+ ${this.currentPlaybookSearch || this.currentPlaybookCategoryFilter !== 'all' + ? '

Essayez de modifier vos filtres

' + : ''} +
+ `; + return; + } + + container.innerHTML = filteredPlaybooks.map(pb => this.createPlaybookCardHTML(pb)).join(''); + + // Mettre à jour dynamiquement les boutons de filtre de catégorie en fonction des playbooks présents + this.updatePlaybookCategoryFilters(); + } + + updatePlaybookCategoryFilters() { + const container = document.getElementById('playbook-category-filters'); + if (!container) return; + + // Construire la liste des catégories présentes à partir des playbooks + const categorySet = new Set(); + (this.playbooks || []).forEach(pb => { + const cat = (pb.category || 'general').toLowerCase(); + categorySet.add(cat); + }); + + const categories = Array.from(categorySet).sort((a, b) => a.localeCompare(b, 'fr')); + + const buttonsHtml = [ + ``, + ...categories.map(cat => { + const label = this.getCategoryLabel(cat); + const icon = this.getPlaybookCategoryIcon(cat); + const isActive = this.currentPlaybookCategoryFilter && this.currentPlaybookCategoryFilter.toLowerCase() === cat; + const activeClasses = isActive ? 'bg-purple-600 text-white' : 'bg-gray-700 text-gray-300 hover:bg-gray-600'; + return ` + + `; + }) + ].join(''); + + container.innerHTML = ` + Catégorie: + ${buttonsHtml} + `; + } + + createPlaybookCardHTML(playbook) { + const category = (playbook.category || 'general').toLowerCase(); + const categoryClass = `playbook-category-${category}`; + const categoryLabel = this.getCategoryLabel(category); + + // Calculer la taille formatée + const sizeKb = playbook.size ? (playbook.size / 1024).toFixed(1) : '?'; + + // Calculer le temps relatif + const modifiedAgo = playbook.modified ? this.getRelativeTime(playbook.modified) : 'Date inconnue'; + + return ` +
+
+
+
+ +

${this.escapeHtml(playbook.filename)}

+ ${categoryLabel} +
+ ${playbook.description ? `

${this.escapeHtml(playbook.description)}

` : ''} +
+ ${modifiedAgo} + ${sizeKb} KB + ${playbook.subcategory ? `${playbook.subcategory}` : ''} +
+
+
+ + + +
+
+
+ `; + } + + getCategoryLabel(category) { + const labels = { + 'maintenance': 'Maintenance', + 'deploy': 'Deploy', + 'backup': 'Backup', + 'monitoring': 'Monitoring', + 'system': 'System', + 'general': 'Général' + }; + return labels[category] || category; + } + + getPlaybookCategoryIcon(category) { + const icons = { + 'maintenance': 'fa-wrench', + 'deploy': 'fa-rocket', + 'backup': 'fa-save', + 'monitoring': 'fa-heartbeat', + 'system': 'fa-cogs' + }; + return icons[category] || null; + } + + getRelativeTime(dateString) { + if (!dateString) return 'Date inconnue'; + const date = new Date(dateString); + const now = new Date(); + const diffMs = now - date; + const diffSec = Math.floor(diffMs / 1000); + const diffMin = Math.floor(diffSec / 60); + const diffHour = Math.floor(diffMin / 60); + const diffDay = Math.floor(diffHour / 24); + + if (diffSec < 60) return 'À l\'instant'; + if (diffMin < 60) return `il y a ${diffMin} min`; + if (diffHour < 24) return `il y a ${diffHour}h`; + if (diffDay === 1) return 'Hier'; + if (diffDay < 7) return `il y a ${diffDay} jours`; + if (diffDay < 30) return `il y a ${Math.floor(diffDay / 7)} sem.`; + return date.toLocaleDateString('fr-FR'); + } + + filterPlaybooks(searchText) { + this.currentPlaybookSearch = searchText; + this.renderPlaybooks(); + } + + filterPlaybooksByCategory(category) { + this.currentPlaybookCategoryFilter = category; + + // Mettre à jour les boutons de filtre + document.querySelectorAll('.playbook-filter-btn').forEach(btn => { + const btnCategory = btn.dataset.category; + if (btnCategory === category) { + btn.classList.remove('bg-gray-700', 'text-gray-300'); + btn.classList.add('bg-purple-600', 'text-white'); + } else { + btn.classList.remove('bg-purple-600', 'text-white'); + btn.classList.add('bg-gray-700', 'text-gray-300'); + } + }); + + this.renderPlaybooks(); + } + + async refreshPlaybooks() { + this.showLoading(); + try { + const playbooksData = await this.apiCall('/api/ansible/playbooks'); + this.playbooks = playbooksData.playbooks || []; + this.playbookCategories = playbooksData.categories || {}; + this.renderPlaybooks(); + this.hideLoading(); + this.showNotification('Playbooks rechargés', 'success'); + } catch (error) { + this.hideLoading(); + this.showNotification(`Erreur: ${error.message}`, 'error'); + } + } + + async editPlaybook(filename) { + this.showLoading(); + try { + const result = await this.apiCall(`/api/playbooks/${encodeURIComponent(filename)}/content`); + this.hideLoading(); + this.showPlaybookEditor(filename, result.content, false); + } catch (error) { + this.hideLoading(); + this.showNotification(`Erreur chargement playbook: ${error.message}`, 'error'); + } + } + + showCreatePlaybookModal() { + const defaultContent = `--- +# Nouveau Playbook Ansible +# Documentation: https://docs.ansible.com/ansible/latest/playbook_guide/ + +- name: Mon nouveau playbook + hosts: all + become: yes + vars: + category: general + subcategory: other + + tasks: + - name: Exemple de tâche + ansible.builtin.debug: + msg: "Hello from Ansible!" +`; + + this.showModal('Créer un Playbook', ` +
+
+ +
+ + .yml +
+

Lettres, chiffres, tirets et underscores uniquement

+
+ +
+ + +
+
+ `); + } + + async createNewPlaybook() { + const nameInput = document.getElementById('new-playbook-name'); + const name = nameInput?.value.trim(); + + if (!name) { + this.showNotification('Veuillez saisir un nom de fichier', 'warning'); + return; + } + + if (!/^[a-zA-Z0-9_-]+$/.test(name)) { + this.showNotification('Nom invalide: utilisez uniquement lettres, chiffres, tirets et underscores', 'error'); + return; + } + + const filename = `${name}.yml`; + + // Vérifier si le fichier existe déjà + const exists = this.playbooks.some(pb => pb.filename.toLowerCase() === filename.toLowerCase()); + if (exists) { + this.showNotification(`Le playbook "${filename}" existe déjà`, 'error'); + return; + } + + const defaultContent = `--- +# ${filename} +# Créé le ${new Date().toLocaleDateString('fr-FR')} + +- name: ${name.replace(/-/g, ' ').replace(/_/g, ' ')} + hosts: all + become: yes + vars: + category: general + subcategory: other + + tasks: + - name: Exemple de tâche + ansible.builtin.debug: + msg: "Playbook ${name} exécuté avec succès!" +`; + + // On ouvre directement l'éditeur avec le contenu par défaut + this.showPlaybookEditor(filename, defaultContent, true); +} + +showPlaybookEditor(filename, content, isNew = false) { + const title = isNew ? `Créer: ${filename}` : `Modifier: ${filename}`; + + const modalContent = ` +
+ +
+
+ + ${this.escapeHtml(filename)} + ${isNew ? 'Nouveau' : ''} +
+ +
+ + +
+ +
+ + +
+
+ + YAML valide +
+
+ + +
+
+
+ `; + + this.showModal(title, modalContent); + + // Setup de la validation YAML basique + support de la touche Tab + setTimeout(() => { + const textarea = document.getElementById('playbook-editor-content'); + if (textarea) { + textarea.addEventListener('input', () => this.validateYamlContent(textarea.value)); + textarea.addEventListener('keydown', (e) => { + if (e.key === 'Tab') { + e.preventDefault(); + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + textarea.value = textarea.value.substring(0, start) + ' ' + textarea.value.substring(end); + textarea.selectionStart = textarea.selectionEnd = start + 2; + } + }); + } + }, 100); +} + +validateYamlContent(content) { + const statusEl = document.getElementById('yaml-validation-status'); + if (!statusEl) return; + + // Validation basique du YAML + const errors = []; + const lines = content.split('\n'); + + lines.forEach((line, index) => { + // Vérifier les tabs (doit utiliser des espaces) + if (line.includes('\t')) { + errors.push(`Ligne ${index + 1}: Utilisez des espaces au lieu des tabs`); + } + // Vérifier l'indentation impaire + const leadingSpaces = line.match(/^(\s*)/)[1].length; + if (leadingSpaces % 2 !== 0 && line.trim()) { + errors.push(`Ligne ${index + 1}: Indentation impaire détectée`); + } + }); + + if (errors.length > 0) { + statusEl.innerHTML = ` + + ${errors[0]} + `; + } else { + statusEl.innerHTML = ` + + YAML valide + `; + } +} + +async savePlaybook(filename, isNew = false) { + const textarea = document.getElementById('playbook-editor-content'); + if (!textarea) return; + + const content = textarea.value; + + if (!content.trim()) { + this.showNotification('Le contenu ne peut pas être vide', 'warning'); + return; + } + + this.showLoading(); + + try { + const result = await this.apiCall(`/api/playbooks/${encodeURIComponent(filename)}/content`, { + method: 'PUT', + body: JSON.stringify({ content: content }) + }); + + this.hideLoading(); + this.closeModal(); + + // Retirer la classe spéciale + const modalCard = document.querySelector('#modal .glass-card'); + if (modalCard) { + modalCard.classList.remove('playbook-editor-modal'); + } + + this.showNotification(isNew ? `Playbook "${filename}" créé avec succès` : `Playbook "${filename}" sauvegardé`, 'success'); + + // Rafraîchir la liste + await this.refreshPlaybooks(); + + } catch (error) { + this.hideLoading(); + this.showNotification(`Erreur sauvegarde: ${error.message}`, 'error'); + } +} + +async runPlaybook(filename) { + // Ouvrir le modal d'exécution pour un playbook existant + const targetOptions = [ + '', + ...this.ansibleGroups.map(g => ``) + ].join(''); + + this.showModal(`Exécuter: ${this.escapeHtml(filename)}`, ` +
+
+ + Configurez les options d'exécution du playbook + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ `); + } + + async executePlaybookFromModal(filename) { + const target = document.getElementById('run-playbook-target')?.value || 'all'; + const varsText = document.getElementById('run-playbook-vars')?.value || ''; + const checkMode = document.getElementById('run-playbook-check')?.checked || false; + const verbose = document.getElementById('run-playbook-verbose')?.checked || false; + + let extraVars = {}; + if (varsText.trim()) { + try { + extraVars = JSON.parse(varsText); + } catch (e) { + this.showNotification('Variables JSON invalides', 'error'); + return; + } + } + + this.closeModal(); + this.showLoading(); + + try { + const result = await this.apiCall('/api/ansible/execute', { + method: 'POST', + body: JSON.stringify({ + playbook: filename, + target: target, + extra_vars: extraVars, + check_mode: checkMode, + verbose: verbose + }) + }); + + this.hideLoading(); + + // Afficher le résultat + const statusColor = result.success ? 'bg-green-900/30 border-green-600' : 'bg-red-900/30 border-red-600'; + const statusIcon = result.success ? 'fa-check-circle text-green-500' : 'fa-times-circle text-red-500'; + + this.showModal(`Résultat: ${filename}`, ` +
+
+
+ +
+

${result.success ? 'Exécution réussie' : 'Échec de l\'exécution'}

+

Cible: ${target} • Durée: ${result.execution_time || '?'}s

+
+
+
+
+
${this.escapeHtml(result.stdout || '(pas de sortie)')}
+
+ ${result.stderr ? ` +
+

Erreurs:

+
${this.escapeHtml(result.stderr)}
+
+ ` : ''} + +
+ `); + + this.showNotification( + result.success ? `Playbook exécuté avec succès` : `Échec du playbook`, + result.success ? 'success' : 'error' + ); + + // Rafraîchir les tâches + await this.loadTaskLogsWithFilters(); + + } catch (error) { + this.hideLoading(); + this.showNotification(`Erreur: ${error.message}`, 'error'); + } + } + + confirmDeletePlaybook(filename) { + this.showModal('Confirmer la suppression', ` +
+
+
+ +
+

Attention !

+

+ Vous êtes sur le point de supprimer le playbook ${this.escapeHtml(filename)}. +

+

+ Cette action est irréversible. +

+
+
+
+ +
+ + +
+
+ `); + } + + async deletePlaybook(filename) { + this.closeModal(); + this.showLoading(); + + try { + await this.apiCall(`/api/playbooks/${encodeURIComponent(filename)}`, { + method: 'DELETE' + }); + + this.hideLoading(); + this.showNotification(`Playbook "${filename}" supprimé`, 'success'); + + // Rafraîchir la liste + await this.refreshPlaybooks(); + + } catch (error) { + this.hideLoading(); + this.showNotification(`Erreur suppression: ${error.message}`, 'error'); + } + } + + showModal(title, content, options = {}) { + const modalCard = document.querySelector('#modal .glass-card'); + document.getElementById('modal-title').textContent = title; + document.getElementById('modal-content').innerHTML = content; + document.getElementById('modal').classList.remove('hidden'); + + // Appliquer classe spéciale pour Ad-Hoc console + if (title.includes('Ad-Hoc')) { + modalCard.classList.add('adhoc-modal'); + } else { + modalCard.classList.remove('adhoc-modal'); + } + + // Animate modal appearance + anime({ + targets: '#modal .glass-card', + scale: [0.8, 1], + opacity: [0, 1], + duration: 300, + easing: 'easeOutExpo' + }); + } + + closeModal() { + const modal = document.getElementById('modal'); + anime({ + targets: '#modal .glass-card', + scale: [1, 0.8], + opacity: [1, 0], + duration: 200, + easing: 'easeInExpo', + complete: () => { + modal.classList.add('hidden'); + } + }); + } + + showLoading() { + document.getElementById('loading-overlay').classList.remove('hidden'); + } + + hideLoading() { + document.getElementById('loading-overlay').classList.add('hidden'); + } + + showNotification(message, type = 'info') { + const notification = document.createElement('div'); + notification.className = `fixed top-20 right-6 z-50 p-4 rounded-lg shadow-lg transition-all duration-300 ${ + type === 'success' ? 'bg-green-600' : + type === 'warning' ? 'bg-yellow-600' : + type === 'error' ? 'bg-red-600' : 'bg-blue-600' + } text-white`; + notification.innerHTML = ` +
+ + ${message} +
+ `; + + document.body.appendChild(notification); + + // Animate in + anime({ + targets: notification, + translateX: [300, 0], + opacity: [0, 1], + duration: 300, + easing: 'easeOutExpo' + }); + + // Remove after 3 seconds + setTimeout(() => { + anime({ + targets: notification, + translateX: [0, 300], + opacity: [1, 0], + duration: 300, + easing: 'easeInExpo', + complete: () => { + notification.remove(); + } + }); + }, 3000); + } +} + +// Initialize dashboard when DOM is loaded +document.addEventListener('DOMContentLoaded', () => { + window.dashboard = new DashboardManager(); +}); + +// Global functions for onclick handlers +function showQuickActions() { + dashboard.showQuickActions(); +} + +function executeTask(taskType) { + dashboard.executeTask(taskType); +} + +function addHost() { + dashboard.addHost(); +} + +function refreshTasks() { + dashboard.refreshTasks(); +} + +function clearLogs() { + dashboard.clearLogs(); +} + +function exportLogs() { + dashboard.exportLogs(); +} + +function closeModal() { + dashboard.closeModal(); +} \ No newline at end of file diff --git a/app/requirements.txt b/app/requirements.txt new file mode 100644 index 0000000..9053c57 --- /dev/null +++ b/app/requirements.txt @@ -0,0 +1,10 @@ +fastapi>=0.115.0 +uvicorn[standard]>=0.32.0 +pydantic>=2.12.0 +python-multipart>=0.0.9 +PyYAML>=6.0.2 +websockets>=14.0 +aiofiles>=24.1.0 +python-dotenv>=1.0.1 +requests>=2.32.0 +httpx>=0.28.0 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d26e518 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,58 @@ +--- +services: + homelab-dashboard: + build: + context: . + dockerfile: Dockerfile + container_name: homelab-automation-dashboard + restart: unless-stopped + ports: + - "8008:8000" + environment: + # Clé API pour l'authentification + - API_KEY=${API_KEY:-dev-key-12345} + # Utilisateur SSH pour Ansible + - SSH_USER=${SSH_USER:-automation} + # Chemin de la clé SSH dans le container + - SSH_KEY_PATH=/app/ssh_keys/id_rsa + # Désactiver la vérification des clés SSH (pour les tests) + - ANSIBLE_HOST_KEY_CHECKING=False + # Timeout SSH + - ANSIBLE_TIMEOUT=30 + # Répertoire des logs de tâches (format YYYY/MM/JJ) + - DIR_LOGS_TASKS=/app/tasks_logs + # Ansible inventory + - ANSIBLE_INVENTORY=./ansible/inventory + # Ansible playbooks + - ANSIBLE_PLAYBOOKS=./ansible/playbooks + # Ansible group_vars + - ANSIBLE_GROUP_VARS=./ansible/inventory/group_vars + volumes: + # Monter l'inventaire Ansible (permet de modifier sans rebuild) + - ${ANSIBLE_INVENTORY:-./ansible/inventory}:/ansible/inventory + # Monter les playbooks (permet de modifier sans rebuild) + - ${ANSIBLE_PLAYBOOKS:-./ansible/playbooks}:/ansible/playbooks + # Monter les variables de groupe + - ${ANSIBLE_GROUP_VARS:-./ansible/inventory/group_vars}:/ansible/inventory/group_vars + # Monter les clés SSH depuis le host + - ${SSH_KEY_DIR:-~/.ssh}:/app/ssh_keys:ro + # Volume pour les logs (optionnel) + - homelab_logs:/app/logs + # Monter le répertoire des logs de tâches depuis le host + - ${DIR_LOGS_TASKS:-./tasks_logs}:/app/tasks_logs + networks: + - homelab-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/api/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + +networks: + homelab-network: + driver: bridge + +volumes: + homelab_logs: + driver: local diff --git a/tasks_logs/.bootstrap_status.json b/tasks_logs/.bootstrap_status.json new file mode 100644 index 0000000..4c9c969 --- /dev/null +++ b/tasks_logs/.bootstrap_status.json @@ -0,0 +1,79 @@ +{ + "hosts": { + "dev.lab.home": { + "bootstrap_ok": true, + "bootstrap_date": "2025-12-02T20:20:03.555927+00:00", + "details": "Bootstrap réussi via API (user: automation)" + }, + "media.labb.home": { + "bootstrap_ok": true, + "bootstrap_date": "2025-12-02T01:52:34.259129+00:00", + "details": "Bootstrap réussi via API (user: automation)" + }, + "ali2v.xeon.home": { + "bootstrap_ok": true, + "bootstrap_date": "2025-12-02T14:35:47.874004+00:00", + "details": "Bootstrap réussi via API (user: automation)" + }, + "raspi.4gb.home": { + "bootstrap_ok": true, + "bootstrap_date": "2025-12-02T16:09:22.961007+00:00", + "details": "Bootstrap réussi via API (user: automation)" + }, + "raspi.8gb.home": { + "bootstrap_ok": true, + "bootstrap_date": "2025-12-02T16:10:53.117121+00:00", + "details": "Bootstrap réussi via API (user: automation)" + }, + "orangepi.pc.home": { + "bootstrap_ok": true, + "bootstrap_date": "2025-12-02T16:11:47.008381+00:00", + "details": "Bootstrap réussi via API (user: automation)" + }, + "jump.point.home": { + "bootstrap_ok": true, + "bootstrap_date": "2025-12-02T18:56:57.635706+00:00", + "details": "Bootstrap réussi via API (user: automation)" + }, + "hp.nas.home": { + "bootstrap_ok": true, + "bootstrap_date": "2025-12-02T20:25:44.595352+00:00", + "details": "Bootstrap réussi via API (user: automation)" + }, + "hp2.i7.home": { + "bootstrap_ok": true, + "bootstrap_date": "2025-12-02T20:25:51.895846+00:00", + "details": "Bootstrap réussi via API (user: automation)" + }, + "hp3.i5.home": { + "bootstrap_ok": true, + "bootstrap_date": "2025-12-02T20:25:59.998069+00:00", + "details": "Bootstrap réussi via API (user: automation)" + }, + "mimi.pc.home": { + "bootstrap_ok": true, + "bootstrap_date": "2025-12-02T20:26:08.419143+00:00", + "details": "Bootstrap réussi via API (user: automation)" + }, + "dev.prod.home": { + "bootstrap_ok": true, + "bootstrap_date": "2025-12-02T21:02:48.893923+00:00", + "details": "Bootstrap réussi via API (user: automation)" + }, + "automate.prod.home": { + "bootstrap_ok": true, + "bootstrap_date": "2025-12-02T21:03:44.363353+00:00", + "details": "Bootstrap réussi via API (user: automation)" + }, + "ali2v.truenas.home": { + "bootstrap_ok": true, + "bootstrap_date": "2025-12-02T21:47:48.804941+00:00", + "details": "Bootstrap réussi via API (user: automation)" + }, + "hp.truenas.home": { + "bootstrap_ok": true, + "bootstrap_date": "2025-12-03T00:43:57.196419+00:00", + "details": "Bootstrap réussi via API (user: automation)" + } + } +} \ No newline at end of file diff --git a/tasks_logs/.gitkeep b/tasks_logs/.gitkeep new file mode 100644 index 0000000..1898ddc --- /dev/null +++ b/tasks_logs/.gitkeep @@ -0,0 +1,2 @@ +# 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/