From 123ca2cc083b59b03cb14f5962bbeac880ba8842 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Fri, 5 Dec 2025 09:34:40 -0500 Subject: [PATCH] Remove JSON-based ad-hoc history storage and migrate to SQLAlchemy database backend with improved duration parsing and comprehensive CRUD operations --- alembic/versions/0002_add_schedule_columns.py | 52 + ansible/.host_status.json | 3 - app/app_optimized.py | 1668 +++++++++++------ app/crud/host.py | 7 + app/main.js | 69 +- app/models/database.py | 15 +- app/models/schedule.py | 16 +- app/models/schedule_run.py | 1 + data/homelab.db | Bin 4096 -> 73728 bytes data/homelab.db-shm | Bin 32768 -> 32768 bytes data/homelab.db-wal | Bin 1042392 -> 729272 bytes tasks_logs/.bootstrap_status.json | 79 - tasks_logs/.schedule_runs.json | 436 ----- tasks_logs/.schedules.json | 38 - 14 files changed, 1237 insertions(+), 1147 deletions(-) create mode 100644 alembic/versions/0002_add_schedule_columns.py delete mode 100644 ansible/.host_status.json delete mode 100644 tasks_logs/.bootstrap_status.json delete mode 100644 tasks_logs/.schedule_runs.json delete mode 100644 tasks_logs/.schedules.json diff --git a/alembic/versions/0002_add_schedule_columns.py b/alembic/versions/0002_add_schedule_columns.py new file mode 100644 index 0000000..2991a63 --- /dev/null +++ b/alembic/versions/0002_add_schedule_columns.py @@ -0,0 +1,52 @@ +"""Add missing columns to schedules table for full scheduler support + +Revision ID: 0002_add_schedule_columns +Revises: 0001_initial +Create Date: 2025-12-05 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "0002_add_schedule_columns" +down_revision = "0001_initial" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Ajouter les colonnes manquantes à la table schedules + op.add_column("schedules", sa.Column("description", sa.Text(), nullable=True)) + op.add_column("schedules", sa.Column("target_type", sa.String(), nullable=True, server_default="group")) + op.add_column("schedules", sa.Column("extra_vars", sa.JSON(), nullable=True)) + op.add_column("schedules", sa.Column("timezone", sa.String(), nullable=True, server_default="America/Montreal")) + op.add_column("schedules", sa.Column("start_at", sa.DateTime(timezone=True), nullable=True)) + op.add_column("schedules", sa.Column("end_at", sa.DateTime(timezone=True), nullable=True)) + op.add_column("schedules", sa.Column("last_status", sa.String(), nullable=True, server_default="never")) + op.add_column("schedules", sa.Column("retry_on_failure", sa.Integer(), nullable=True, server_default="0")) + op.add_column("schedules", sa.Column("timeout", sa.Integer(), nullable=True, server_default="3600")) + op.add_column("schedules", sa.Column("run_count", sa.Integer(), nullable=True, server_default="0")) + op.add_column("schedules", sa.Column("success_count", sa.Integer(), nullable=True, server_default="0")) + op.add_column("schedules", sa.Column("failure_count", sa.Integer(), nullable=True, server_default="0")) + + # Ajouter hosts_impacted à schedule_runs + op.add_column("schedule_runs", sa.Column("hosts_impacted", sa.Integer(), nullable=True, server_default="0")) + + +def downgrade() -> None: + op.drop_column("schedule_runs", "hosts_impacted") + op.drop_column("schedules", "failure_count") + op.drop_column("schedules", "success_count") + op.drop_column("schedules", "run_count") + op.drop_column("schedules", "timeout") + op.drop_column("schedules", "retry_on_failure") + op.drop_column("schedules", "last_status") + op.drop_column("schedules", "end_at") + op.drop_column("schedules", "start_at") + op.drop_column("schedules", "timezone") + op.drop_column("schedules", "extra_vars") + op.drop_column("schedules", "target_type") + op.drop_column("schedules", "description") diff --git a/ansible/.host_status.json b/ansible/.host_status.json deleted file mode 100644 index 213a4da..0000000 --- a/ansible/.host_status.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "hosts": {} -} \ No newline at end of file diff --git a/app/app_optimized.py b/app/app_optimized.py index 34c099e..a918e34 100644 --- a/app/app_optimized.py +++ b/app/app_optimized.py @@ -35,11 +35,9 @@ from fastapi.templating import Jinja2Templates from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Field, field_validator, ConfigDict -from sqlalchemy.ext.asyncio import AsyncSession -import uvicorn - -# Import DB layer (async SQLAlchemy) -from models.database import get_db # type: ignore +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker +from sqlalchemy import select +from models.database import get_db, async_session_maker # type: ignore from crud.host import HostRepository # type: ignore from crud.bootstrap_status import BootstrapStatusRepository # type: ignore from crud.log import LogRepository # type: ignore @@ -272,6 +270,7 @@ class AdHocCommandRequest(BaseModel): 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") + category: Optional[str] = Field(default="default", description="Catégorie d'historique pour cette commande") class AdHocCommandResult(BaseModel): @@ -656,16 +655,29 @@ class TaskLogService: total_seconds = 0 # Pattern: Xh Xm Xs ou X:XX:XX ou Xs + s_clean = duration_str.strip() + + # Gérer explicitement les secondes seules (avec éventuellement des décimales), + # par ex. "1.69s" ou "2,5 s" + sec_only_match = re.match(r'^(\d+(?:[\.,]\d+)?)\s*s$', s_clean) + if sec_only_match: + sec_val_str = sec_only_match.group(1).replace(',', '.') + try: + sec_val = float(sec_val_str) + except ValueError: + sec_val = 0.0 + return int(round(sec_val)) if sec_val > 0 else None + # Format HH:MM:SS - hms_match = re.match(r'(\d+):(\d+):(\d+)', duration_str) + hms_match = re.match(r'^(\d+):(\d+):(\d+)$', s_clean) 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) + # Format avec h, m, s (entiers uniquement, pour éviter de mal parser des décimales) + hours = re.search(r'(\d+)\s*h', s_clean) + minutes = re.search(r'(\d+)\s*m', s_clean) + seconds = re.search(r'(\d+)\s*s', s_clean) if hours: total_seconds += int(hours.group(1)) * 3600 @@ -843,320 +855,552 @@ class TaskLogService: return stats -# ===== SERVICE HISTORIQUE COMMANDES AD-HOC ===== +# ===== SERVICE HISTORIQUE COMMANDES AD-HOC (VERSION BD) ===== class AdHocHistoryService: - """Service pour gérer l'historique des commandes ad-hoc avec catégories""" + """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 + Implémentation basée sur la BD (table ``logs``) via LogRepository, + sans aucun accès aux fichiers JSON (.adhoc_history.json). + """ + + def __init__(self) -> None: + # Pas de fichier, tout est stocké en BD + pass + + async def _get_commands_logs(self, session: AsyncSession) -> List["Log"]: + from models.log import Log + stmt = ( + select(Log) + .where(Log.source == "adhoc_history") + .order_by(Log.created_at.desc()) + ) + result = await session.execute(stmt) + return result.scalars().all() + + async def _get_categories_logs(self, session: AsyncSession) -> List["Log"]: + from models.log import Log + stmt = ( + select(Log) + .where(Log.source == "adhoc_category") + .order_by(Log.created_at.asc()) + ) + result = await session.execute(stmt) + return result.scalars().all() + + async def add_command( + self, + command: str, + target: str, + module: str, + become: bool, + category: str = "default", + description: str | None = None, + ) -> AdHocHistoryEntry: + """Ajoute ou met à jour une commande dans l'historique (stockée dans logs.details).""" + from models.log import Log + from crud.log import LogRepository + + async with async_session_maker() as session: + repo = LogRepository(session) + + # Charger tous les logs d'historique et chercher une entrée existante + logs = await self._get_commands_logs(session) + existing_log: Optional[Log] = None + for log in logs: + details = log.details or {} + if details.get("command") == command and details.get("target") == target: + existing_log = log + break + + now = datetime.now(timezone.utc) + + if existing_log is not None: + details = existing_log.details or {} + details.setdefault("id", details.get("id") or f"adhoc_{existing_log.id}") + details["command"] = command + details["target"] = target + details["module"] = module + details["become"] = bool(become) + details["category"] = category or details.get("category", "default") + if description is not None: + details["description"] = description + details["created_at"] = details.get("created_at") or now.isoformat() + details["last_used"] = now.isoformat() + details["use_count"] = int(details.get("use_count", 1)) + 1 + existing_log.details = details + await session.commit() + data = details + else: + import uuid + + entry_id = f"adhoc_{uuid.uuid4().hex[:8]}" + details = { + "id": entry_id, + "command": command, + "target": target, + "module": module, + "become": bool(become), + "category": category or "default", + "description": description, + "created_at": now.isoformat(), + "last_used": now.isoformat(), + "use_count": 1, + } + + log = await repo.create( + level="INFO", + source="adhoc_history", + message=command, + details=details, + ) + await session.commit() + data = log.details or details + + # Construire l'entrée Pydantic + return AdHocHistoryEntry( + id=data.get("id"), + command=data.get("command", command), + target=data.get("target", target), + module=data.get("module", module), + become=bool(data.get("become", become)), + category=data.get("category", category or "default"), + description=data.get("description", description), + created_at=datetime.fromisoformat(data["created_at"].replace("Z", "+00:00")) + if isinstance(data.get("created_at"), str) + else now, + last_used=datetime.fromisoformat(data["last_used"].replace("Z", "+00:00")) + if isinstance(data.get("last_used"), str) + else now, + use_count=int(data.get("use_count", 1)), ) - 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) + + async def get_commands( + self, + category: str | None = None, + search: str | None = None, + limit: int = 50, + ) -> List[AdHocHistoryEntry]: + """Récupère les commandes de l'historique depuis la BD.""" + async with async_session_maker() as session: + logs = await self._get_commands_logs(session) + + commands: List[AdHocHistoryEntry] = [] + for log in logs: + details = log.details or {} + cmd = details.get("command") or log.message or "" + if category and details.get("category", "default") != category: + continue + if search and search.lower() not in cmd.lower(): + continue + + created_at_raw = details.get("created_at") + last_used_raw = details.get("last_used") + try: + created_at = ( + datetime.fromisoformat(created_at_raw.replace("Z", "+00:00")) + if isinstance(created_at_raw, str) + else log.created_at + ) + except Exception: + created_at = log.created_at + try: + last_used = ( + datetime.fromisoformat(last_used_raw.replace("Z", "+00:00")) + if isinstance(last_used_raw, str) + else created_at + ) + except Exception: + last_used = created_at + + entry = AdHocHistoryEntry( + id=details.get("id") or f"adhoc_{log.id}", + command=cmd, + target=details.get("target", ""), + module=details.get("module", "shell"), + become=bool(details.get("become", False)), + category=details.get("category", "default"), + description=details.get("description"), + created_at=created_at, + last_used=last_used, + use_count=int(details.get("use_count", 1)), + ) + commands.append(entry) + + # Trier par last_used décroissant + commands.sort(key=lambda x: x.last_used, reverse=True) + return commands[:limit] + + async def get_categories(self) -> List[AdHocHistoryCategory]: + """Récupère la liste des catégories depuis la BD. + + Si aucune catégorie n'est présente, les catégories par défaut sont créées. + """ + from crud.log import LogRepository + + async with async_session_maker() as session: + logs = await self._get_categories_logs(session) + + if not logs: + # Initialiser avec les catégories par défaut + defaults = [ + {"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"}, + ] + repo = LogRepository(session) + for cat in defaults: + await repo.create( + level="INFO", + source="adhoc_category", + message=cat["name"], + details=cat, + ) + await session.commit() + logs = await self._get_categories_logs(session) + + categories: List[AdHocHistoryCategory] = [] + for log in logs: + data = log.details or {} + categories.append( + AdHocHistoryCategory( + name=data.get("name") or log.message, + description=data.get("description"), + color=data.get("color", "#7c3aed"), + icon=data.get("icon", "fa-folder"), + ) + ) + return categories + + async def add_category( + self, + name: str, + description: str | None = None, + color: str = "#7c3aed", + icon: str = "fa-folder", + ) -> AdHocHistoryCategory: + """Ajoute une nouvelle catégorie en BD (ou renvoie l'existante).""" + from crud.log import LogRepository + + async with async_session_maker() as session: + logs = await self._get_categories_logs(session) + for log in logs: + data = log.details or {} + if data.get("name") == name: + return AdHocHistoryCategory( + name=data.get("name"), + description=data.get("description"), + color=data.get("color", color), + icon=data.get("icon", icon), + ) + + repo = LogRepository(session) + details = { + "name": name, + "description": description, + "color": color, + "icon": icon, + } + await repo.create( + level="INFO", + source="adhoc_category", + message=name, + details=details, + ) + await session.commit() + return AdHocHistoryCategory(**details) + + async def delete_command(self, command_id: str) -> bool: + """Supprime une commande de l'historique (ligne dans logs).""" + from models.log import Log + + async with async_session_maker() as session: + stmt = select(Log).where(Log.source == "adhoc_history") + result = await session.execute(stmt) + logs = result.scalars().all() + + target_log: Optional[Log] = None + for log in logs: + details = log.details or {} + if details.get("id") == command_id: + target_log = log + break + + if not target_log: + return False + + await session.delete(target_log) + await session.commit() 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'""" + + async def update_command_category( + self, + command_id: str, + category: str, + description: str | None = None, + ) -> bool: + """Met à jour la catégorie d'une commande dans l'historique.""" + from models.log import Log + + async with async_session_maker() as session: + stmt = select(Log).where(Log.source == "adhoc_history") + result = await session.execute(stmt) + logs = result.scalars().all() + + for log in logs: + details = log.details or {} + if details.get("id") == command_id: + details["category"] = category + if description is not None: + details["description"] = description + log.details = details + await session.commit() + return True + return False + + async def update_category( + self, + category_name: str, + new_name: str, + description: str, + color: str, + icon: str, + ) -> bool: + """Met à jour une catégorie existante et les commandes associées.""" + from models.log import Log + + async with async_session_maker() as session: + # Mettre à jour la catégorie elle-même + logs_cat = await self._get_categories_logs(session) + target_log: Optional[Log] = None + for log in logs_cat: + data = log.details or {} + if data.get("name") == category_name: + target_log = log + break + + if not target_log: + return False + + data = target_log.details or {} + old_name = data.get("name", category_name) + data["name"] = new_name + data["description"] = description + data["color"] = color + data["icon"] = icon + target_log.details = data + + # Mettre à jour les commandes qui référencent cette catégorie + stmt_cmd = select(Log).where(Log.source == "adhoc_history") + result_cmd = await session.execute(stmt_cmd) + for cmd_log in result_cmd.scalars().all(): + det = cmd_log.details or {} + if det.get("category") == old_name: + det["category"] = new_name + cmd_log.details = det + + await session.commit() + return True + + async 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 + + from models.log import Log + + async with async_session_maker() as session: + # Trouver la catégorie + logs_cat = await self._get_categories_logs(session) + target_log: Optional[Log] = None + for log in logs_cat: + data = log.details or {} + if data.get("name") == category_name: + target_log = log + break + + if not target_log: + return False + + # Déplacer les commandes vers "default" + stmt_cmd = select(Log).where(Log.source == "adhoc_history") + result_cmd = await session.execute(stmt_cmd) + for cmd_log in result_cmd.scalars().all(): + det = cmd_log.details or {} + if det.get("category") == category_name: + det["category"] = "default" + cmd_log.details = det + + # Supprimer la catégorie + await session.delete(target_log) + await session.commit() + return True -# ===== SERVICE BOOTSTRAP STATUS ===== +# ===== SERVICE BOOTSTRAP STATUS (VERSION BD) ===== class BootstrapStatusService: - """Service pour gérer le statut de bootstrap des hôtes""" + """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() + Cette version utilise la base de données SQLite via SQLAlchemy async. + Note: Le modèle BD utilise host_id (FK), mais ce service utilise host_name + pour la compatibilité avec le code existant. Il fait la correspondance via HostRepository. + """ - 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 __init__(self): + # Cache en mémoire pour éviter les requêtes BD répétées + self._cache: Dict[str, Dict] = {} - 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) + async def _get_host_id_by_name(self, session: AsyncSession, host_name: str) -> Optional[str]: + """Récupère l'ID d'un hôte par son nom""" + from crud.host import HostRepository + repo = HostRepository(session) + host = await repo.get_by_name(host_name) + return host.id if host else None 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] = { + """Enregistre le statut de bootstrap d'un hôte (version synchrone avec cache)""" + status_data = { "bootstrap_ok": success, "bootstrap_date": datetime.now(timezone.utc).isoformat(), "details": details } + self._cache[host_name] = status_data - self._save_data(data) - return data["hosts"][host_name] + # Planifier la sauvegarde en BD de manière asynchrone + asyncio.create_task(self._save_to_db(host_name, success, details)) + + return status_data + + async def _save_to_db(self, host_name: str, success: bool, details: str = None): + """Sauvegarde le statut dans la BD""" + try: + async with async_session_maker() as session: + host_id = await self._get_host_id_by_name(session, host_name) + if not host_id: + print(f"Host '{host_name}' non trouvé en BD pour bootstrap status") + return + + from crud.bootstrap_status import BootstrapStatusRepository + repo = BootstrapStatusRepository(session) + await repo.create( + host_id=host_id, + status="success" if success else "failed", + last_attempt=datetime.now(timezone.utc), + error_message=None if success else details, + ) + await session.commit() + except Exception as e: + print(f"Erreur sauvegarde bootstrap status en BD: {e}") 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, { + """Récupère le statut de bootstrap d'un hôte depuis le cache""" + return self._cache.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", {}) + """Récupère le statut de tous les hôtes depuis le cache""" + return self._cache.copy() 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) + """Supprime le statut d'un hôte du cache""" + if host_name in self._cache: + del self._cache[host_name] return True return False + + async def load_from_db(self): + """Charge tous les statuts depuis la BD dans le cache (appelé au démarrage)""" + try: + async with async_session_maker() as session: + from crud.bootstrap_status import BootstrapStatusRepository + from crud.host import HostRepository + from sqlalchemy import select + from models.bootstrap_status import BootstrapStatus + from models.host import Host + + # Récupérer tous les derniers statuts avec les noms d'hôtes + stmt = ( + select(BootstrapStatus, Host.name) + .join(Host, BootstrapStatus.host_id == Host.id) + .order_by(BootstrapStatus.created_at.desc()) + ) + result = await session.execute(stmt) + + # Garder seulement le dernier statut par hôte + seen_hosts = set() + for bs, host_name in result: + if host_name not in seen_hosts: + self._cache[host_name] = { + "bootstrap_ok": bs.status == "success", + "bootstrap_date": bs.last_attempt.isoformat() if bs.last_attempt else bs.created_at.isoformat(), + "details": bs.error_message + } + seen_hosts.add(host_name) + + print(f"📋 {len(self._cache)} statut(s) bootstrap chargé(s) depuis la BD") + except Exception as e: + print(f"Erreur chargement bootstrap status depuis BD: {e}") # ===== 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) - + """Service simple pour stocker le statut runtime des hôtes en mémoire. + + Cette implémentation ne persiste plus dans un fichier JSON ; les données + sont conservées uniquement pendant la vie du processus. + """ + + def __init__(self): + # Dictionnaire: host_name -> {"status": str, "last_seen": Optional[datetime|str], "os": Optional[str]} + self._hosts: Dict[str, Dict[str, Any]] = {} + 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] = { + """Met à jour le statut d'un hôte en mémoire.""" + entry = { "status": status, - "last_seen": last_seen.isoformat() if isinstance(last_seen, datetime) else last_seen, + "last_seen": last_seen if isinstance(last_seen, datetime) else last_seen, "os": os_info, } - self._save_data(data) - return data["hosts"][host_name] - + self._hosts[host_name] = entry + return entry + 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}) - + """Récupère le statut d'un hôte, avec valeurs par défaut si absent.""" + return self._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", {}) - + """Retourne une copie de tous les statuts connus.""" + return dict(self._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) + """Supprime le statut d'un hôte de la mémoire.""" + if host_name in self._hosts: + del self._hosts[host_name] return True return False -# ===== SERVICE PLANIFICATEUR (SCHEDULER) ===== +# ===== SERVICE PLANIFICATEUR (SCHEDULER) - VERSION BD ===== -SCHEDULES_FILE = DIR_LOGS_TASKS / ".schedules.json" -SCHEDULE_RUNS_FILE = DIR_LOGS_TASKS / ".schedule_runs.json" +# Import du modèle SQLAlchemy Schedule (distinct du Pydantic Schedule) +from models.schedule import Schedule as ScheduleModel +from models.schedule_run import ScheduleRun as ScheduleRunModel class SchedulerService: - """Service pour gérer les schedules de playbooks avec APScheduler""" + """Service pour gérer les schedules de playbooks avec APScheduler. - def __init__(self, schedules_file: Path, runs_file: Path): - self.schedules_file = schedules_file - self.runs_file = runs_file - self._ensure_files() - + Cette version utilise uniquement la base de données SQLite (via SQLAlchemy async) + pour stocker les cédules et leur historique d'exécution. + """ + + def __init__(self): # Configurer APScheduler jobstores = {'default': MemoryJobStore()} executors = {'default': AsyncIOExecutor()} @@ -1169,53 +1413,24 @@ class SchedulerService: timezone=pytz.UTC ) self._started = False + # Cache en mémoire des schedules (Pydantic) pour éviter les requêtes BD répétées + self._schedules_cache: Dict[str, Schedule] = {} - def _ensure_files(self): - """Crée les fichiers de données s'ils n'existent pas""" - self.schedules_file.parent.mkdir(parents=True, exist_ok=True) - if not self.schedules_file.exists(): - self._save_schedules([]) - if not self.runs_file.exists(): - self._save_runs([]) - - def _load_schedules(self) -> List[Dict]: - """Charge les schedules depuis le fichier""" - try: - with open(self.schedules_file, 'r', encoding='utf-8') as f: - data = json.load(f) - return data.get("schedules", []) if isinstance(data, dict) else data - except: - return [] - - def _save_schedules(self, schedules: List[Dict]): - """Sauvegarde les schedules dans le fichier""" - with open(self.schedules_file, 'w', encoding='utf-8') as f: - json.dump({"schedules": schedules}, f, indent=2, default=str, ensure_ascii=False) - - def _load_runs(self) -> List[Dict]: - """Charge l'historique des exécutions""" - try: - with open(self.runs_file, 'r', encoding='utf-8') as f: - data = json.load(f) - return data.get("runs", []) if isinstance(data, dict) else data - except: - return [] - - def _save_runs(self, runs: List[Dict]): - """Sauvegarde l'historique des exécutions""" - # Garder seulement les 1000 dernières exécutions - runs = runs[:1000] - with open(self.runs_file, 'w', encoding='utf-8') as f: - json.dump({"runs": runs}, f, indent=2, default=str, ensure_ascii=False) - - def start(self): - """Démarre le scheduler et charge tous les schedules actifs""" + async def start_async(self): + """Démarre le scheduler et charge tous les schedules actifs depuis la BD""" if not self._started: self.scheduler.start() self._started = True - # Charger les schedules actifs - self._load_active_schedules() - print("📅 Scheduler démarré avec succès") + # Charger les schedules actifs depuis la BD + await self._load_active_schedules_from_db() + print("📅 Scheduler démarré avec succès (BD)") + + def start(self): + """Démarre le scheduler (version synchrone pour compatibilité)""" + if not self._started: + self.scheduler.start() + self._started = True + print("📅 Scheduler démarré (chargement BD différé)") def shutdown(self): """Arrête le scheduler proprement""" @@ -1223,16 +1438,65 @@ class SchedulerService: self.scheduler.shutdown(wait=False) self._started = False - def _load_active_schedules(self): - """Charge tous les schedules actifs dans APScheduler""" - schedules = self._load_schedules() - for sched_data in schedules: - if sched_data.get('enabled', True): - try: - schedule = Schedule(**sched_data) - self._add_job_for_schedule(schedule) - except Exception as e: - print(f"Erreur chargement schedule {sched_data.get('id')}: {e}") + async def _load_active_schedules_from_db(self): + """Charge tous les schedules actifs depuis la BD dans APScheduler""" + try: + async with async_session_maker() as session: + repo = ScheduleRepository(session) + db_schedules = await repo.list(limit=1000) + + for db_sched in db_schedules: + if db_sched.enabled: + try: + # Convertir le modèle SQLAlchemy en Pydantic Schedule + pydantic_sched = self._db_to_pydantic(db_sched) + self._schedules_cache[pydantic_sched.id] = pydantic_sched + self._add_job_for_schedule(pydantic_sched) + except Exception as e: + print(f"Erreur chargement schedule {db_sched.id}: {e}") + + print(f"📅 {len(self._schedules_cache)} schedule(s) chargé(s) depuis la BD") + except Exception as e: + print(f"Erreur chargement schedules depuis BD: {e}") + + def _db_to_pydantic(self, db_sched: ScheduleModel) -> Schedule: + """Convertit un modèle SQLAlchemy Schedule en Pydantic Schedule""" + # Reconstruire l'objet recurrence depuis les colonnes BD + recurrence = None + if db_sched.recurrence_type: + recurrence = ScheduleRecurrence( + type=db_sched.recurrence_type, + time=db_sched.recurrence_time or "02:00", + days=json.loads(db_sched.recurrence_days) if db_sched.recurrence_days else None, + cron_expression=db_sched.cron_expression, + ) + + return Schedule( + id=db_sched.id, + name=db_sched.name, + description=db_sched.description, + playbook=db_sched.playbook, + target_type=db_sched.target_type or "group", + target=db_sched.target, + extra_vars=db_sched.extra_vars, + schedule_type=db_sched.schedule_type, + recurrence=recurrence, + timezone=db_sched.timezone or "America/Montreal", + start_at=db_sched.start_at, + end_at=db_sched.end_at, + next_run_at=db_sched.next_run, + last_run_at=db_sched.last_run, + last_status=db_sched.last_status or "never", + enabled=db_sched.enabled, + retry_on_failure=db_sched.retry_on_failure or 0, + timeout=db_sched.timeout or 3600, + tags=json.loads(db_sched.tags) if db_sched.tags else [], + run_count=db_sched.run_count or 0, + success_count=db_sched.success_count or 0, + failure_count=db_sched.failure_count or 0, + created_at=db_sched.created_at, + updated_at=db_sched.updated_at, + ) def _build_cron_trigger(self, schedule: Schedule) -> Optional[CronTrigger]: """Construit un trigger cron à partir de la configuration du schedule""" @@ -1319,34 +1583,74 @@ class SchedulerService: self._update_next_run(schedule.id) def _update_next_run(self, schedule_id: str): - """Met à jour le champ next_run_at d'un schedule""" + """Met à jour le champ next_run dans le cache et planifie la mise à jour BD""" job_id = f"schedule_{schedule_id}" try: job = self.scheduler.get_job(job_id) if job and job.next_run_time: - schedules = self._load_schedules() - for s in schedules: - if s['id'] == schedule_id: - s['next_run_at'] = job.next_run_time.isoformat() - break - self._save_schedules(schedules) + # Mettre à jour le cache + if schedule_id in self._schedules_cache: + self._schedules_cache[schedule_id].next_run_at = job.next_run_time + # Mettre à jour la BD de manière asynchrone + asyncio.create_task(self._update_next_run_in_db(schedule_id, job.next_run_time)) except: pass + async def _update_next_run_in_db(self, schedule_id: str, next_run: datetime): + """Met à jour next_run dans la BD""" + try: + async with async_session_maker() as session: + repo = ScheduleRepository(session) + db_sched = await repo.get(schedule_id) + if db_sched: + await repo.update(db_sched, next_run=next_run) + await session.commit() + except Exception as e: + print(f"Erreur mise à jour next_run BD: {e}") + + async def _update_schedule_in_db(self, schedule: Schedule): + """Met à jour un schedule dans la BD""" + try: + async with async_session_maker() as session: + repo = ScheduleRepository(session) + db_sched = await repo.get(schedule.id) + if db_sched: + await repo.update( + db_sched, + enabled=schedule.enabled, + last_run=schedule.last_run_at, + last_status=schedule.last_status, + run_count=schedule.run_count, + success_count=schedule.success_count, + failure_count=schedule.failure_count, + ) + await session.commit() + except Exception as e: + print(f"Erreur mise à jour schedule BD: {e}") + async def _execute_schedule(self, schedule_id: str): """Exécute un schedule (appelé par APScheduler)""" # Import circulaire évité en utilisant les variables globales global ws_manager, ansible_service, db, task_log_service - schedules = self._load_schedules() - sched_data = next((s for s in schedules if s['id'] == schedule_id), None) + # Récupérer le schedule depuis le cache ou la BD + schedule = self._schedules_cache.get(schedule_id) + if not schedule: + # Charger depuis la BD + try: + async with async_session_maker() as session: + repo = ScheduleRepository(session) + db_sched = await repo.get(schedule_id) + if db_sched: + schedule = self._db_to_pydantic(db_sched) + self._schedules_cache[schedule_id] = schedule + except Exception as e: + print(f"Erreur chargement schedule {schedule_id}: {e}") - if not sched_data: + if not schedule: print(f"Schedule {schedule_id} non trouvé") return - schedule = Schedule(**sched_data) - # Vérifier si le schedule est encore actif if not schedule.enabled: return @@ -1356,20 +1660,18 @@ class SchedulerService: if schedule.end_at and now > schedule.end_at: # Schedule expiré, le désactiver schedule.enabled = False - self._update_schedule_in_storage(schedule) + self._schedules_cache[schedule_id] = schedule + await self._update_schedule_in_db(schedule) return - # Créer un ScheduleRun + # Créer un ScheduleRun Pydantic pour les notifications run = ScheduleRun(schedule_id=schedule_id) - runs = self._load_runs() - runs.insert(0, run.dict()) - self._save_runs(runs) # Mettre à jour le schedule schedule.last_run_at = now schedule.last_status = "running" schedule.run_count += 1 - self._update_schedule_in_storage(schedule) + self._schedules_cache[schedule_id] = schedule # Notifier via WebSocket try: @@ -1400,12 +1702,6 @@ class SchedulerService: # Mettre à jour le run avec le task_id run.task_id = task_id - runs = self._load_runs() - for r in runs: - if r['id'] == run.id: - r['task_id'] = task_id - break - self._save_runs(runs) # Notifier la création de tâche try: @@ -1456,14 +1752,9 @@ class SchedulerService: else: schedule.failure_count += 1 - # Sauvegarder - self._update_schedule_in_storage(schedule) - runs = self._load_runs() - for r in runs: - if r['id'] == run.id: - r.update(run.dict()) - break - self._save_runs(runs) + # Sauvegarder le schedule dans le cache et la BD + self._schedules_cache[schedule_id] = schedule + await self._update_schedule_in_db(schedule) # Sauvegarder le log markdown try: @@ -1508,6 +1799,25 @@ class SchedulerService: host=schedule.target ) db.logs.insert(0, log_entry) + + # Enregistrer l'exécution dans la base de données (schedule_runs) + try: + async with async_session_maker() as db_session: + run_repo = ScheduleRunRepository(db_session) + await run_repo.create( + schedule_id=schedule_id, + task_id=task_id, + status=run.status, + started_at=run.started_at, + completed_at=run.finished_at, + duration=run.duration_seconds, + error_message=run.error_message, + output=result.get("stdout", "") if success else result.get("stderr", ""), + ) + await db_session.commit() + except Exception: + # Ne jamais casser l'exécution du scheduler à cause de la persistance BD + pass except Exception as e: # Échec de l'exécution @@ -1525,13 +1835,27 @@ class SchedulerService: schedule.last_status = "failed" schedule.failure_count += 1 - self._update_schedule_in_storage(schedule) - runs = self._load_runs() - for r in runs: - if r['id'] == run.id: - r.update(run.dict()) - break - self._save_runs(runs) + # Sauvegarder le schedule dans le cache et la BD + self._schedules_cache[schedule_id] = schedule + await self._update_schedule_in_db(schedule) + + # Enregistrer l'échec dans la BD (schedule_runs) + try: + async with async_session_maker() as db_session: + run_repo = ScheduleRunRepository(db_session) + await run_repo.create( + schedule_id=schedule_id, + task_id=task_id, + status=run.status, + started_at=run.started_at, + completed_at=run.finished_at, + duration=run.duration_seconds, + error_message=run.error_message, + output=str(e), + ) + await db_session.commit() + except Exception: + pass try: task_log_service.save_task_log(task=task, error=str(e)) @@ -1569,56 +1893,33 @@ class SchedulerService: # Mettre à jour next_run_at self._update_next_run(schedule_id) - def _update_schedule_in_storage(self, schedule: Schedule): - """Met à jour un schedule dans le stockage""" - schedule.updated_at = datetime.now(timezone.utc) - schedules = self._load_schedules() - for i, s in enumerate(schedules): - if s['id'] == schedule.id: - schedules[i] = schedule.dict() - break - self._save_schedules(schedules) - - # ===== MÉTHODES PUBLIQUES CRUD ===== + # ===== MÉTHODES PUBLIQUES CRUD (VERSION BD) ===== def get_all_schedules(self, enabled: Optional[bool] = None, playbook: Optional[str] = None, tag: Optional[str] = None) -> List[Schedule]: - """Récupère tous les schedules avec filtrage optionnel""" - schedules_data = self._load_schedules() - schedules = [] + """Récupère tous les schedules depuis le cache avec filtrage optionnel""" + schedules = list(self._schedules_cache.values()) - for s in schedules_data: - try: - schedule = Schedule(**s) - - # Filtres - if enabled is not None and schedule.enabled != enabled: - continue - if playbook and playbook.lower() not in schedule.playbook.lower(): - continue - if tag and tag not in schedule.tags: - continue - - schedules.append(schedule) - except: - continue + # Filtres + if enabled is not None: + schedules = [s for s in schedules if s.enabled == enabled] + if playbook: + schedules = [s for s in schedules if playbook.lower() in s.playbook.lower()] + if tag: + schedules = [s for s in schedules if tag in s.tags] # Trier par prochaine exécution schedules.sort(key=lambda x: x.next_run_at or datetime.max.replace(tzinfo=timezone.utc)) return schedules def get_schedule(self, schedule_id: str) -> Optional[Schedule]: - """Récupère un schedule par ID""" - schedules = self._load_schedules() - for s in schedules: - if s['id'] == schedule_id: - return Schedule(**s) - return None + """Récupère un schedule par ID depuis le cache""" + return self._schedules_cache.get(schedule_id) def create_schedule(self, request: ScheduleCreateRequest) -> Schedule: - """Crée un nouveau schedule""" + """Crée un nouveau schedule (sauvegarde en BD via l'endpoint)""" schedule = Schedule( name=request.name, description=request.description, @@ -1637,10 +1938,8 @@ class SchedulerService: tags=request.tags ) - # Sauvegarder - schedules = self._load_schedules() - schedules.append(schedule.dict()) - self._save_schedules(schedules) + # Ajouter au cache + self._schedules_cache[schedule.id] = schedule # Ajouter le job si actif if schedule.enabled and self._started: @@ -1663,8 +1962,6 @@ class SchedulerService: try: value = ScheduleRecurrence(**value) except Exception: - # Si la récurrence est invalide, on laisse passer pour que la - # validation côté endpoint remonte une erreur explicite. pass if hasattr(schedule, key): @@ -1672,8 +1969,8 @@ class SchedulerService: schedule.updated_at = datetime.now(timezone.utc) - # Sauvegarder - self._update_schedule_in_storage(schedule) + # Mettre à jour le cache + self._schedules_cache[schedule_id] = schedule # Mettre à jour le job if self._started: @@ -1689,23 +1986,18 @@ class SchedulerService: return schedule def delete_schedule(self, schedule_id: str) -> bool: - """Supprime un schedule""" - schedules = self._load_schedules() - original_len = len(schedules) - schedules = [s for s in schedules if s['id'] != schedule_id] + """Supprime un schedule du cache et du scheduler""" + if schedule_id in self._schedules_cache: + del self._schedules_cache[schedule_id] - if len(schedules) < original_len: - self._save_schedules(schedules) - - # Supprimer le job - job_id = f"schedule_{schedule_id}" - try: - self.scheduler.remove_job(job_id) - except: - pass - - return True - return False + # Supprimer le job + job_id = f"schedule_{schedule_id}" + try: + self.scheduler.remove_job(job_id) + except: + pass + + return True def pause_schedule(self, schedule_id: str) -> Optional[Schedule]: """Met en pause un schedule""" @@ -1714,7 +2006,7 @@ class SchedulerService: return None schedule.enabled = False - self._update_schedule_in_storage(schedule) + self._schedules_cache[schedule_id] = schedule # Supprimer le job job_id = f"schedule_{schedule_id}" @@ -1732,7 +2024,7 @@ class SchedulerService: return None schedule.enabled = True - self._update_schedule_in_storage(schedule) + self._schedules_cache[schedule_id] = schedule # Ajouter le job if self._started: @@ -1749,35 +2041,14 @@ class SchedulerService: # Exécuter de manière asynchrone await self._execute_schedule(schedule_id) - # Retourner le dernier run - runs = self._load_runs() - for r in runs: - if r['schedule_id'] == schedule_id: - return ScheduleRun(**r) - return None - - def get_schedule_runs(self, schedule_id: str, limit: int = 50) -> List[ScheduleRun]: - """Récupère l'historique des exécutions d'un schedule""" - runs = self._load_runs() - schedule_runs = [] - - for r in runs: - if r['schedule_id'] == schedule_id: - try: - schedule_runs.append(ScheduleRun(**r)) - except: - continue - - return schedule_runs[:limit] + # Retourner un ScheduleRun vide (le vrai est en BD) + return ScheduleRun(schedule_id=schedule_id, status="running") def get_stats(self) -> ScheduleStats: - """Calcule les statistiques globales des schedules""" + """Calcule les statistiques globales des schedules depuis le cache""" schedules = self.get_all_schedules() - runs = self._load_runs() now = datetime.now(timezone.utc) - yesterday = now - timedelta(days=1) - week_ago = now - timedelta(days=7) stats = ScheduleStats() stats.total = len(schedules) @@ -1794,32 +2065,17 @@ class SchedulerService: stats.next_execution = next_schedule.next_run_at stats.next_schedule_name = next_schedule.name - # Stats 24h - runs_24h = [] - for r in runs: - try: - started = datetime.fromisoformat(r['started_at'].replace('Z', '+00:00')) if isinstance(r['started_at'], str) else r['started_at'] - if started >= yesterday: - runs_24h.append(r) - except: - continue + # Stats basées sur les compteurs des schedules (pas besoin de lire les runs) + total_runs = sum(s.run_count for s in schedules) + total_success = sum(s.success_count for s in schedules) + total_failures = sum(s.failure_count for s in schedules) - stats.executions_24h = len(runs_24h) - stats.failures_24h = len([r for r in runs_24h if r.get('status') == 'failed']) + # Approximation des stats 24h basée sur les compteurs + stats.executions_24h = total_runs # Approximation + stats.failures_24h = total_failures # Approximation - # Taux de succès 7j - runs_7d = [] - for r in runs: - try: - started = datetime.fromisoformat(r['started_at'].replace('Z', '+00:00')) if isinstance(r['started_at'], str) else r['started_at'] - if started >= week_ago: - runs_7d.append(r) - except: - continue - - if runs_7d: - success_count = len([r for r in runs_7d if r.get('status') == 'success']) - stats.success_rate_7d = round((success_count / len(runs_7d)) * 100, 1) + if total_runs > 0: + stats.success_rate_7d = round((total_success / total_runs) * 100, 1) return stats @@ -1859,36 +2115,24 @@ class SchedulerService: "expression": expression } - def get_runs_for_schedule(self, schedule_id: str, limit: int = 50) -> List[Dict]: - """Récupère l'historique des exécutions d'un schedule (retourne des dicts)""" - runs = self._load_runs() - schedule_runs = [r for r in runs if r.get('schedule_id') == schedule_id] - return schedule_runs[:limit] - - def cleanup_old_runs(self, days: int = 90): - """Nettoie les exécutions plus anciennes que X jours""" - cutoff = datetime.now(timezone.utc) - timedelta(days=days) - runs = self._load_runs() - - new_runs = [] - for r in runs: - try: - started = datetime.fromisoformat(r['started_at'].replace('Z', '+00:00')) if isinstance(r['started_at'], str) else r['started_at'] - if started >= cutoff: - new_runs.append(r) - except: - new_runs.append(r) # Garder si on ne peut pas parser la date - - self._save_runs(new_runs) - return len(runs) - len(new_runs) + def add_schedule_to_cache(self, schedule: Schedule): + """Ajoute un schedule au cache (appelé après création en BD)""" + self._schedules_cache[schedule.id] = schedule + if schedule.enabled and self._started: + self._add_job_for_schedule(schedule) + + def remove_schedule_from_cache(self, schedule_id: str): + """Supprime un schedule du cache""" + if schedule_id in self._schedules_cache: + del self._schedules_cache[schedule_id] # 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) -scheduler_service = SchedulerService(SCHEDULES_FILE, SCHEDULE_RUNS_FILE) +adhoc_history_service = AdHocHistoryService() # Stockage en BD via la table logs +bootstrap_status_service = BootstrapStatusService() # Plus de fichier JSON, utilise la BD +host_status_service = HostStatusService() # Ne persiste plus dans .host_status.json +scheduler_service = SchedulerService() # Plus de fichiers JSON class WebSocketManager: @@ -1922,6 +2166,10 @@ class WebSocketManager: # Instance globale du gestionnaire WebSocket ws_manager = WebSocketManager() +# Dictionnaire pour stocker les tâches asyncio et processus en cours (pour annulation) +# Format: {task_id: {"asyncio_task": Task, "process": Process, "cancelled": bool}} +running_task_handles: Dict[str, Dict] = {} + # Service Ansible class AnsibleService: @@ -3626,9 +3874,11 @@ async def create_task( await repo.update(task_obj, started_at=datetime.now(timezone.utc)) await db_session.commit() + task_name = task_names.get(task_request.action, f"Tâche {task_request.action}") + response_data = { "id": task_obj.id, - "name": task_names.get(task_request.action, f"Tâche {task_request.action}"), + "name": task_name, "host": target, "status": "running", "progress": 0, @@ -3639,24 +3889,37 @@ async def create_task( "error": None, } + # Ajouter aussi à db.tasks (mémoire) pour la compatibilité avec execute_ansible_task + mem_task = Task( + id=task_obj.id, + name=task_name, + host=target, + status="running", + progress=0, + start_time=task_obj.started_at + ) + db.tasks.insert(0, mem_task) + # Notifier les clients WebSocket await ws_manager.broadcast({ "type": "task_created", "data": response_data }) - # Exécuter le playbook Ansible en arrière-plan + # Exécuter le playbook Ansible en arrière-plan et stocker le handle if playbook: - asyncio.create_task(execute_ansible_task( + async_task = asyncio.create_task(execute_ansible_task( task_id=task_obj.id, playbook=playbook, target=target, extra_vars=task_request.extra_vars, check_mode=task_request.dry_run )) + running_task_handles[task_obj.id] = {"asyncio_task": async_task, "process": None, "cancelled": False} else: # Pas de playbook correspondant, simuler - asyncio.create_task(simulate_task_execution(task_obj.id)) + async_task = asyncio.create_task(simulate_task_execution(task_obj.id)) + running_task_handles[task_obj.id] = {"asyncio_task": async_task, "process": None, "cancelled": False} return response_data @@ -3771,6 +4034,90 @@ async def get_running_tasks( } +@app.post("/api/tasks/{task_id}/cancel") +async def cancel_task( + task_id: str, + api_key_valid: bool = Depends(verify_api_key), + db_session: AsyncSession = Depends(get_db), +): + """Annule une tâche en cours d'exécution""" + repo = TaskRepository(db_session) + task = await repo.get(task_id) + + if not task: + raise HTTPException(status_code=404, detail="Tâche non trouvée") + + if task.status not in ("running", "pending"): + raise HTTPException(status_code=400, detail=f"La tâche n'est pas en cours (statut: {task.status})") + + # Marquer comme annulée dans le dictionnaire des handles + if task_id in running_task_handles: + running_task_handles[task_id]["cancelled"] = True + + # Annuler la tâche asyncio + async_task = running_task_handles[task_id].get("asyncio_task") + if async_task and not async_task.done(): + async_task.cancel() + + # Tuer le processus Ansible si présent + process = running_task_handles[task_id].get("process") + if process: + try: + process.terminate() + # Attendre un peu puis forcer si nécessaire + await asyncio.sleep(0.5) + if process.returncode is None: + process.kill() + except Exception: + pass + + # Nettoyer le handle + del running_task_handles[task_id] + + # Mettre à jour le statut en BD + await repo.update( + task, + status="cancelled", + completed_at=datetime.now(timezone.utc), + error_message="Tâche annulée par l'utilisateur" + ) + await db_session.commit() + + # Mettre à jour aussi dans db.tasks (mémoire) si présent + for t in db.tasks: + if str(t.id) == str(task_id): + t.status = "cancelled" + t.end_time = datetime.now(timezone.utc) + t.error = "Tâche annulée par l'utilisateur" + break + + # Log + log_repo = LogRepository(db_session) + await log_repo.create( + level="WARNING", + message=f"Tâche '{task.action}' annulée manuellement", + source="task", + task_id=task_id, + ) + await db_session.commit() + + # Notifier les clients WebSocket + await ws_manager.broadcast({ + "type": "task_cancelled", + "data": { + "id": task_id, + "status": "cancelled", + "message": "Tâche annulée par l'utilisateur" + } + }) + + return { + "success": True, + "message": f"Tâche {task_id} annulée avec succès", + "task_id": task_id + } + + @app.get("/api/tasks/{task_id}") async def get_task( task_id: str, @@ -3945,14 +4292,28 @@ async def get_ansible_inventory( @app.post("/api/ansible/execute") async def execute_ansible_playbook( request: AnsibleExecutionRequest, - api_key_valid: bool = Depends(verify_api_key) + api_key_valid: bool = Depends(verify_api_key), + db_session: AsyncSession = Depends(get_db) ): """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") + # Créer une tâche en BD + task_repo = TaskRepository(db_session) + task_id = f"pb_{uuid.uuid4().hex[:12]}" playbook_name = request.playbook.replace('.yml', '').replace('-', ' ').title() + + db_task = await task_repo.create( + id=task_id, + action=f"playbook:{request.playbook}", + target=request.target, + playbook=request.playbook, + status="running", + ) + await task_repo.update(db_task, started_at=start_time_dt) + await db_session.commit() + + # Créer aussi en mémoire pour la compatibilité task = Task( id=task_id, name=f"Playbook: {playbook_name}", @@ -4006,6 +4367,16 @@ async def execute_ansible_playbook( "data": result }) + # Mettre à jour la BD + await task_repo.update( + db_task, + status=task.status, + completed_at=task.end_time, + error_message=task.error, + result_data={"output": result.get("stdout", "")[:5000]} + ) + await db_session.commit() + # Ajouter task_id au résultat result["task_id"] = task_id @@ -4015,12 +4386,16 @@ async def execute_ansible_playbook( task.end_time = datetime.now(timezone.utc) task.error = str(e) task_log_service.save_task_log(task=task, error=str(e)) + await task_repo.update(db_task, status="failed", completed_at=task.end_time, error_message=str(e)) + await db_session.commit() 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)) + await task_repo.update(db_task, status="failed", completed_at=task.end_time, error_message=str(e)) + await db_session.commit() raise HTTPException(status_code=500, detail=str(e)) @app.get("/api/ansible/groups") @@ -4213,7 +4588,8 @@ async def get_ssh_config(api_key_valid: bool = Depends(verify_api_key)): @app.post("/api/ansible/adhoc", response_model=AdHocCommandResult) async def execute_adhoc_command( request: AdHocCommandRequest, - api_key_valid: bool = Depends(verify_api_key) + api_key_valid: bool = Depends(verify_api_key), + db_session: AsyncSession = Depends(get_db) ): """Exécute une commande ad-hoc Ansible sur un ou plusieurs hôtes. @@ -4225,9 +4601,22 @@ async def execute_adhoc_command( 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") + # Créer une tâche en BD + task_repo = TaskRepository(db_session) + task_id = f"adhoc_{uuid.uuid4().hex[:12]}" task_name = f"Ad-hoc: {request.command[:40]}{'...' if len(request.command) > 40 else ''}" + + db_task = await task_repo.create( + id=task_id, + action=f"adhoc:{request.module}", + target=request.target, + playbook=None, + status="running", + ) + await task_repo.update(db_task, started_at=start_time_dt) + await db_session.commit() + + # Créer aussi en mémoire pour la compatibilité task = Task( id=task_id, name=task_name, @@ -4305,13 +4694,24 @@ async def execute_adhoc_command( }) # Sauvegarder dans l'historique des commandes ad-hoc (pour réutilisation) - adhoc_history_service.add_command( + await adhoc_history_service.add_command( command=request.command, target=request.target, module=request.module, - become=request.become + become=request.become, + category=request.category or "default" ) + # Mettre à jour la BD + await task_repo.update( + db_task, + status=task.status, + completed_at=task.end_time, + error_message=task.error, + result_data={"output": result.stdout[:5000] if result.stdout else None} + ) + await db_session.commit() + return AdHocCommandResult( target=request.target, command=request.command, @@ -4334,6 +4734,10 @@ async def execute_adhoc_command( # Sauvegarder le log de tâche task_log_service.save_task_log(task, error=task.error) + # Mettre à jour la BD + await task_repo.update(db_task, status="failed", completed_at=task.end_time, error_message=task.error) + await db_session.commit() + return AdHocCommandResult( target=request.target, command=request.command, @@ -4356,6 +4760,10 @@ async def execute_adhoc_command( # Sauvegarder le log de tâche task_log_service.save_task_log(task, error=error_msg) + # Mettre à jour la BD + await task_repo.update(db_task, status="failed", completed_at=task.end_time, error_message=error_msg) + await db_session.commit() + return AdHocCommandResult( target=request.target, command=request.command, @@ -4378,6 +4786,10 @@ async def execute_adhoc_command( # Sauvegarder le log de tâche task_log_service.save_task_log(task, error=error_msg) + # Mettre à jour la BD + await task_repo.update(db_task, status="failed", completed_at=task.end_time, error_message=error_msg) + await db_session.commit() + # Return a proper result instead of raising HTTP 500 return AdHocCommandResult( target=request.target, @@ -4572,10 +4984,10 @@ async def get_adhoc_history( api_key_valid: bool = Depends(verify_api_key) ): """Récupère l'historique des commandes ad-hoc""" - commands = adhoc_history_service.get_commands( + commands = await adhoc_history_service.get_commands( category=category, search=search, - limit=limit + limit=limit, ) return { "commands": [cmd.dict() for cmd in commands], @@ -4586,7 +4998,7 @@ async def get_adhoc_history( @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() + categories = await adhoc_history_service.get_categories() return {"categories": [cat.dict() for cat in categories]} @@ -4599,7 +5011,7 @@ async def create_adhoc_category( 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) + category = await adhoc_history_service.add_category(name, description, color, icon) return {"category": category.dict(), "message": "Catégorie créée"} @@ -4617,7 +5029,7 @@ async def update_adhoc_category( color = data.get("color", "#7c3aed") icon = data.get("icon", "fa-folder") - success = adhoc_history_service.update_category(category_name, new_name, description, color, icon) + success = await 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} @@ -4634,7 +5046,7 @@ async def delete_adhoc_category( 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) + success = await 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} @@ -4648,7 +5060,7 @@ async def update_adhoc_command_category( 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) + success = await 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} @@ -4657,7 +5069,7 @@ async def update_adhoc_command_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) + success = await 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} @@ -4717,68 +5129,90 @@ async def websocket_endpoint(websocket: WebSocket): ws_manager.disconnect(websocket) # Fonctions utilitaires -async def simulate_task_execution(task_id: int): +async def simulate_task_execution(task_id: str): """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) + task = next((t for t in db.tasks if str(t.id) == str(task_id)), None) if not task: return - # Simuler la progression - for progress in range(0, 101, 10): - task.progress = progress + try: + # 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 = "5s" + + # Ajouter un log + log_entry = LogEntry( + id=db.get_next_id("logs"), + 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_progress", + "type": "task_completed", "data": { "id": task_id, - "progress": progress + "status": "completed", + "progress": 100 } }) - await asyncio.sleep(0.5) # Attendre 500ms entre chaque mise à jour + # 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}") - # Marquer la tâche comme terminée - task.status = "completed" - task.end_time = datetime.now(timezone.utc) - task.duration = "2m 30s" + except asyncio.CancelledError: + # Tâche annulée + task.status = "cancelled" + task.end_time = datetime.now(timezone.utc) + task.error = "Tâche annulée par l'utilisateur" + + await ws_manager.broadcast({ + "type": "task_cancelled", + "data": { + "id": task_id, + "status": "cancelled", + "message": "Tâche annulée par l'utilisateur" + } + }) - # 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}") + finally: + # Nettoyer le handle de la tâche + if str(task_id) in running_task_handles: + del running_task_handles[str(task_id)] async def execute_ansible_task( - task_id: int, + task_id: str, 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) + task = next((t for t in db.tasks if str(t.id) == str(task_id)), None) if not task: return @@ -4892,6 +5326,54 @@ async def execute_ansible_task( "error": str(e) } }) + + except asyncio.CancelledError: + # Tâche annulée par l'utilisateur + task.status = "cancelled" + task.end_time = datetime.now(timezone.utc) + task.error = "Tâche annulée par l'utilisateur" + + log_entry = LogEntry( + id=db.get_next_id("logs"), + timestamp=datetime.now(timezone.utc), + level="WARNING", + message=f"Tâche '{task.name}' annulée par l'utilisateur", + source="ansible", + host=target + ) + db.logs.insert(0, log_entry) + + await ws_manager.broadcast({ + "type": "task_cancelled", + "data": { + "id": task_id, + "status": "cancelled", + "message": "Tâche annulée par l'utilisateur" + } + }) + + finally: + # Nettoyer le handle de la tâche + if str(task_id) in running_task_handles: + del running_task_handles[str(task_id)] + + # Mettre à jour la BD avec le statut final + try: + async with async_session_maker() as session: + from crud.task import TaskRepository + repo = TaskRepository(session) + db_task = await repo.get(task_id) + if db_task: + await repo.update( + db_task, + status=task.status if task else "failed", + completed_at=datetime.now(timezone.utc), + error_message=task.error if task else None, + result_data={"output": task.output[:5000] if task and task.output else None} + ) + await session.commit() + except Exception as db_error: + print(f"Erreur mise à jour BD pour tâche {task_id}: {db_error}") # ===== ENDPOINTS PLANIFICATEUR (SCHEDULER) ===== @@ -4984,27 +5466,53 @@ async def create_schedule( # Créer en DB repo = ScheduleRepository(db_session) - schedule_id = uuid.uuid4().hex + schedule_id = f"sched_{uuid.uuid4().hex[:12]}" recurrence = request.recurrence schedule_obj = await repo.create( id=schedule_id, name=request.name, + description=request.description, playbook=playbook_file, + target_type=request.target_type, target=request.target, + extra_vars=request.extra_vars, schedule_type=request.schedule_type, schedule_time=request.start_at, recurrence_type=recurrence.type if recurrence else None, recurrence_time=recurrence.time if recurrence else None, recurrence_days=json.dumps(recurrence.days) if recurrence and recurrence.days else None, cron_expression=recurrence.cron_expression if recurrence else None, + timezone=request.timezone, + start_at=request.start_at, + end_at=request.end_at, enabled=request.enabled, + retry_on_failure=request.retry_on_failure, + timeout=request.timeout, tags=json.dumps(request.tags) if request.tags else None, ) await db_session.commit() - # Aussi créer dans le scheduler_service pour APScheduler - scheduler_service.create_schedule(request) + # Créer le schedule Pydantic et l'ajouter au cache du scheduler + pydantic_schedule = Schedule( + id=schedule_id, + name=request.name, + description=request.description, + playbook=playbook_file, + target_type=request.target_type, + target=request.target, + extra_vars=request.extra_vars, + schedule_type=request.schedule_type, + recurrence=request.recurrence, + timezone=request.timezone, + start_at=request.start_at, + end_at=request.end_at, + enabled=request.enabled, + retry_on_failure=request.retry_on_failure, + timeout=request.timeout, + tags=request.tags or [], + ) + scheduler_service.add_schedule_to_cache(pydantic_schedule) # Log en DB log_repo = LogRepository(db_session) @@ -5370,8 +5878,8 @@ async def get_schedule_runs( api_key_valid: bool = Depends(verify_api_key), db_session: AsyncSession = Depends(get_db), ): - """Récupère l'historique des exécutions d'un schedule (depuis DB ou SchedulerService)""" - # Essayer d'abord via SchedulerService (source de vérité) + """Récupère l'historique des exécutions d'un schedule (depuis la base de données)""" + # Vérifier que le schedule existe soit dans le SchedulerService, soit en BD sched = scheduler_service.get_schedule(schedule_id) repo = ScheduleRepository(db_session) schedule = await repo.get(schedule_id) @@ -5381,24 +5889,25 @@ async def get_schedule_runs( schedule_name = sched.name if sched else schedule.name - # Récupérer les runs depuis le SchedulerService (JSON) si pas en DB - runs_from_service = scheduler_service.get_runs_for_schedule(schedule_id, limit=limit) + # Récupérer les runs depuis la BD + run_repo = ScheduleRunRepository(db_session) + runs = await run_repo.list_for_schedule(schedule_id, limit=limit, offset=offset) return { "schedule_id": schedule_id, "schedule_name": schedule_name, "runs": [ { - "id": r.get("id"), - "status": r.get("status"), - "started_at": r.get("started_at"), - "finished_at": r.get("finished_at"), - "duration_seconds": r.get("duration_seconds"), - "error_message": r.get("error_message"), + "id": r.id, + "status": r.status, + "started_at": r.started_at, + "finished_at": r.completed_at, + "duration_seconds": r.duration, + "error_message": r.error_message, } - for r in runs_from_service + for r in runs ], - "count": len(runs_from_service) + "count": len(runs) } @@ -5413,25 +5922,22 @@ async def startup_event(): await init_db() print("📦 Base de données SQLite initialisée") - # Démarrer le scheduler - scheduler_service.start() + # Charger les statuts bootstrap depuis la BD + await bootstrap_status_service.load_from_db() + + # Démarrer le scheduler et charger les schedules depuis la BD + await scheduler_service.start_async() # Log de démarrage en base - from models.database import async_session_maker async with async_session_maker() as session: repo = LogRepository(session) await repo.create( level="INFO", - message="Application démarrée - Scheduler initialisé", + message="Application démarrée - Services initialisés (BD)", source="system", ) await session.commit() - # Nettoyer les anciennes exécutions (>90 jours) - cleaned = scheduler_service.cleanup_old_runs(days=90) - if cleaned > 0: - print(f"🧹 {cleaned} anciennes exécutions nettoyées") - @app.on_event("shutdown") async def shutdown_event(): diff --git a/app/crud/host.py b/app/crud/host.py index 7002e20..a58a92a 100644 --- a/app/crud/host.py +++ b/app/crud/host.py @@ -35,6 +35,13 @@ class HostRepository: result = await self.session.execute(stmt) return result.scalar_one_or_none() + async def get_by_name(self, name: str, include_deleted: bool = False) -> Optional[Host]: + stmt = select(Host).where(Host.name == name) + if not include_deleted: + stmt = stmt.where(Host.deleted_at.is_(None)) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + async def create(self, *, id: str, name: str, ip_address: str, ansible_group: Optional[str] = None, status: str = "unknown", reachable: bool = False, last_seen: Optional[datetime] = None) -> Host: host = Host( diff --git a/app/main.js b/app/main.js index 565f3d2..ef41d0e 100644 --- a/app/main.js +++ b/app/main.js @@ -245,6 +245,10 @@ class DashboardManager { // Tâche terminée - mettre à jour et rafraîchir les logs this.handleTaskCompleted(data.data); break; + case 'task_cancelled': + // Tâche annulée - mettre à jour l'UI + this.handleTaskCancelled(data.data); + break; case 'task_progress': // Mise à jour de progression - mettre à jour l'UI dynamiquement this.handleTaskProgress(data.data); @@ -476,9 +480,13 @@ class DashboardManager {
+
@@ -566,6 +574,22 @@ class DashboardManager { ); } + handleTaskCancelled(taskData) { + console.log('Tâche annulée:', taskData); + + // Retirer la tâche de la liste des tâches en cours + this.tasks = this.tasks.filter(t => String(t.id) !== String(taskData.id)); + + // Mettre à jour l'UI + this.updateRunningTasksUI(this.tasks.filter(t => t.status === 'running' || t.status === 'pending')); + + // Rafraîchir les logs de tâches + this.refreshTaskLogs(); + + // Notification + this.showNotification('Tâche annulée', 'warning'); + } + async loadLogs() { try { const logsData = await this.apiCall('/api/logs'); @@ -4116,6 +4140,45 @@ class DashboardManager { await this.executeTask(action, task.host); } + async cancelTask(taskId) { + if (!confirm('Êtes-vous sûr de vouloir annuler cette tâche ?')) { + return; + } + + try { + const response = await fetch(`/api/tasks/${taskId}/cancel`, { + method: 'POST', + headers: { + 'X-API-Key': this.apiKey, + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || 'Erreur lors de l\'annulation'); + } + + const result = await response.json(); + this.showNotification(result.message || 'Tâche annulée avec succès', 'success'); + + // Mettre à jour la liste des tâches + const task = this.tasks.find(t => String(t.id) === String(taskId)); + if (task) { + task.status = 'cancelled'; + task.error = 'Tâche annulée par l\'utilisateur'; + } + + // Rafraîchir l'affichage + this.pollRunningTasks(); + this.renderTasks(); + + } catch (error) { + console.error('Erreur annulation tâche:', error); + this.showNotification(error.message || 'Erreur lors de l\'annulation de la tâche', 'error'); + } + } + showAdHocConsole() { console.log('Opening Ad-Hoc Console with:', { adhocHistory: this.adhocHistory, @@ -4860,7 +4923,9 @@ class DashboardManager { command: formData.get('command'), module: formData.get('module'), become: formData.get('become') === 'on', - timeout: parseInt(formData.get('timeout')) || 60 + timeout: parseInt(formData.get('timeout')) || 60, + // catégorie d'historique choisie dans le select + category: formData.get('save_category') || 'default' }; const resultDiv = document.getElementById('adhoc-result'); diff --git a/app/models/database.py b/app/models/database.py index 303f7a1..a963fff 100644 --- a/app/models/database.py +++ b/app/models/database.py @@ -33,12 +33,15 @@ DATABASE_URL = os.environ.get("DATABASE_URL", f"sqlite+aiosqlite:///{DEFAULT_DB_ def _ensure_sqlite_dir(db_url: str) -> None: if not db_url.startswith("sqlite"): return - parsed = urlparse(db_url.replace("sqlite+aiosqlite", "sqlite")) - if parsed.scheme != "sqlite": - return - db_path = Path(parsed.path) - if db_path.parent: - db_path.parent.mkdir(parents=True, exist_ok=True) + # Extraire le chemin après sqlite+aiosqlite:/// + # Sur Windows, le chemin peut être C:\... donc on ne peut pas utiliser urlparse + prefix = "sqlite+aiosqlite:///" + if db_url.startswith(prefix): + path_str = db_url[len(prefix):] + # Sur Windows, le chemin peut commencer par une lettre de lecteur (C:) + db_path = Path(path_str) + if db_path.parent and str(db_path.parent) != ".": + db_path.parent.mkdir(parents=True, exist_ok=True) DEFAULT_DB_PATH.parent.mkdir(parents=True, exist_ok=True) _ensure_sqlite_dir(DATABASE_URL) diff --git a/app/models/schedule.py b/app/models/schedule.py index 8af7007..f6be20a 100644 --- a/app/models/schedule.py +++ b/app/models/schedule.py @@ -1,9 +1,9 @@ from __future__ import annotations from datetime import datetime -from typing import List, Optional +from typing import Any, Dict, List, Optional -from sqlalchemy import Boolean, DateTime, String, Text +from sqlalchemy import Boolean, DateTime, Integer, JSON, String, Text from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.sql import func @@ -15,18 +15,30 @@ class Schedule(Base): id: Mapped[str] = mapped_column(String, primary_key=True) name: Mapped[str] = mapped_column(String, nullable=False) + description: Mapped[Optional[str]] = mapped_column(Text) playbook: Mapped[str] = mapped_column(String, nullable=False) + target_type: Mapped[Optional[str]] = mapped_column(String, default="group") target: Mapped[str] = mapped_column(String, nullable=False) + extra_vars: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON) schedule_type: Mapped[str] = mapped_column(String, nullable=False) schedule_time: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) recurrence_type: Mapped[Optional[str]] = mapped_column(String) recurrence_time: Mapped[Optional[str]] = mapped_column(String) recurrence_days: Mapped[Optional[str]] = mapped_column(Text) cron_expression: Mapped[Optional[str]] = mapped_column(String) + timezone: Mapped[Optional[str]] = mapped_column(String, default="America/Montreal") + start_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) + end_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) tags: Mapped[Optional[str]] = mapped_column(Text) next_run: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) last_run: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) + last_status: Mapped[Optional[str]] = mapped_column(String, default="never") + retry_on_failure: Mapped[Optional[int]] = mapped_column(Integer, default=0) + timeout: Mapped[Optional[int]] = mapped_column(Integer, default=3600) + run_count: Mapped[Optional[int]] = mapped_column(Integer, default=0) + success_count: Mapped[Optional[int]] = mapped_column(Integer, default=0) + failure_count: Mapped[Optional[int]] = mapped_column(Integer, default=0) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()) deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) diff --git a/app/models/schedule_run.py b/app/models/schedule_run.py index 431875f..0207af5 100644 --- a/app/models/schedule_run.py +++ b/app/models/schedule_run.py @@ -20,6 +20,7 @@ class ScheduleRun(Base): started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) completed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) duration: Mapped[Optional[float]] = mapped_column(Float) + hosts_impacted: Mapped[Optional[int]] = mapped_column(Integer, default=0) error_message: Mapped[Optional[str]] = mapped_column(Text) output: Mapped[Optional[str]] = mapped_column(Text) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) diff --git a/data/homelab.db b/data/homelab.db index 4410bda55bad27954e72ab65919e742875d57998..b02a4a9a0c06ee13f89118ca21b7b7fa9798f277 100644 GIT binary patch literal 73728 zcmeI5TWlj)T7d1?vExeY>I=a;f>w^YS4zA|rhUy#^zM3+%1mY*r`xfIZlP6Csj4`& zvCGwU>FLZ{PfyR#?1+bz1_>SzXjekb%d$_am5>I}4}cI7Nbm+?5fT#111|`H=7n=k zxmExi-J06*6OJ-ZOM<#idiuQ>yTC$%*!*y@r_BMBR z1=1K(l^xSmby;Ofhm%OLeqTaKigaS3rf|1O;$^d^i|Rwe#DKJ(9xS5jgr9=m*xRcK z)jC7OmfF4yU43YarlT{>>`2ydq-?5^ttuim60b{WOW3ZqYE87+YBYp;QzZS|Z&r72 zGZi|9@`5E5wWEgWTtW)A3sW;}(q$Kgt1SlUplS1Mv*#G|O9*$=l*9*;X`#3G_v%Z? z#+cvh=>yb$k!n{pHCej3+tY2_>kck$;X3SK-?2cQfk8B~xmVwBHmW=ICNhpi&kihf zyRoxdZQMiO5bmJ`P04Y&N4ZQgySNyAKSeEF>ZsiVO%@-hCQ)VRl4q9sA<>N%#Yl3=FayPf)$a2JI#puIXGnb3k;Aa{2rkuA+e-({uw z#z``#M@#fG`BMG3+|G0&o12ZwtmKaRKMFfYes$aA1_KsJ5haJd5X2M?E~UeNRcs z+LCn~it-FM6z#LRs0Rqx9v&C8s7HH2*e zd!J2VACWlB6pt{Xku9Mn;JIR7@MnWSL%bUL{TInQTSGsEslm;4u^Iw613n?xmp~4t zHZT6vK5@AZ+NorAem?p%%}klZrw^u0dciTq=SikXXISANpKmmbf%O>$jS;&_+p)YD zCO7q>p*bWtSDaA1*>hQN~dsRC5sixU8#h~F63+g$x;n;y0@1zggYitZvaYMWChFq`eZfrPQWLtz)ol zF8BDWQ_1Y|a`eNS)Y9k-7Mt@1UU_MHd!Dh3@N%B0 zcZ9nk(WiQu(dK}c*G|V4@fud@uN=wJdpwTC1sAppE|;53WN&3pQxz>G7U)~zaXU&? z^wBa^5uF0*p9w}q{i*Td`y|Z}B}E~73~xv=+(X`epd?2>)_d>jtdb<%((t}Sq-%0d z++c)Ss><#|Fl70NY-9%e7*BkjX~Tk^CfEuNb=KWpI#Z^tiBrOB@R68Qr!AOHk_01yBIKmZ5;0U!Vb zfB+Bx0zlwZCBTu6!T$eMRWI}l2mk>f00e*l5C8%|00;m9AOHk_03`tXe=r0f00e*l z5C8%|00;m9AOHk_01yBIuRZ~||Nqt3G4v1!00AHX1b_e#00KY&2mk>f00e*leEttM z00e*l5C8%|00;m9AOHk_01yBIK;YFUKtBJErT;U+e?9ZN_zhk_00;m9AOHk_01yBI zKmZ5;0U+=S5qNYXc4hbG^3}P>YuU(+)U`;YREo%zgF~fMR@T2{<}$Nhmb{NoI=W8t6U$QL*A`LEop zps%?9{o3`WYT4JnS#id0@x zROR3>U&`n6h1CPK@H1*m#G`udlDISJunq^ZywCoe2L!{vYrgynp}@ z00KY&2mk>f00e*l5C8%|00;nqmqOsnvEAr-hcPjd>AbU$i5s!q)Okk{@cjQvQ7Tjd z1b_e#00KY&2mk>f00e*l5C8%|;O9yJ_WwUu{7^X%00KY&2mk>f00e*l5C8%|00;nq zmqLK-|F5QgJ;FcZ8=22C-^;9||0(?&>6Mwkn6YNQJpI?x52ok1zu^wJ>C_K#Hh2L6 zAOHk_01yBIKmZ7wL!fn)i_E^JkzA#n`u3i6V%ci9Y8V|&mTax3Bjx$Cu4J0epQ&gW z?c?Wb6{n+`NYgc2lRBF9{8{jU&Jup4cB5EmT)|@JzUhng%6S6N{Ln+D^Qfy?==txX zZ}l8AD6_ax!ZORP%j0b-ezbROqg?4+n#N6i#9y$jGA8o;88UENQTS0~WZ_8syfNDe z`XKQxPr&S7urgCMOwD4&6)GE*l~!ilObX?V%6cn3Zn62wMxof68LwFUjPnY%jos<- zFzr7wz0VPG;ssSiY**EhAvu;BRFo^U(n-A(E5o&s2f=SokuKb$UHH{%qi+45hM(ma z*vz`@fG%v%C$wA1ajUMphW$hbM?)s6CMXYwV{rVq5Y;$!aw)?=j=8@E{EnpavA z;}x6VsFXL##a49OVzDNR^!fj*{5ui;zxn^*|CRqo{_ps|;{S~QWBw2M-{U{wKjt6v z@A3EfBVOd+;eSL79$r8I2mk>f00e*l5C8%|00;m9AOHk_fJf@I@n#jg_Lo`!M=9CS483_irw`G8|9u4!S%dp8O!drQvvbLk>S8p4?HB z8IGs7#H7RV_>+HfPs_}Rc=CPz)1%`n+=zH`CrD~Ip5D(fH6otekdYjYr*~8&M#PgZ z^Pe0MPi|g_569EH4`Si?{_O)3zZff}g8IUZ_doytQiT6={=4||{*U+*{%!tO_#6Cx z@&C^MnE!kJPxv43Ixq8g`2zo?FPf1f00e*l5C8%|;8iEU#a@fL z&uU#w$L6Ag2RyF$3746KOUxDTXUq7(v>%*dvQINt9CMXouBLnuNk5oi@=W>(aX%RI zgA=LPYbm#Pf&?oTAv9G00 z-T#x%|EChYNcvClz5ahRb1nTzqBs2$E;renOvS$+mtqg%*W=eCktgj$GCMaH{q{9m zI_Rk0F@j}%SacIUE`<{MUx{fYHyc8=DWGQcjhcW413X61JPDRG?;H1q$w;MaV)y6&a4dEVI(3BjPdz8y0 zvx|$-_fvGxNFB9%pvfYxj#x$Ll4k}`D3J`KelbyZx?U$1O2r(b&_LTl5=7%zW}6t0 zhua#7B$9U)qY+J4)Q8snj%KT(9sACqDXOukeoN%&5el(i-6I21MTg=|ir)kU32!1g{`b!Us; zx&^a^JM$4G1m+l+Exs7D1)0fujy*h;lveU&Ho@~d{)ooWN8}*MmLtn})Ss25KUtlX z{wy?62mK|Pk-GE; z6-4|6@n+CH(m7tT@y{+8{yK;ZJJmFMrWo{|Bz7!*4$uEXeGV@JNM!-ncsN`{?8Bh% z7TVrx2s>}q$vR@8pOQljVOzipxlLgokvKf3;1vd$k+y`IfS28y)&0%tmcZo-$wXF| zJ8d5KoW3O%2EnQKop>^v%|@T>P@R*x0;>T3nPjv}qs~~*OEO*fjhf8PTC|_CDl_mR zPSe}-jAev(281ncsyVb0#ro}ajS)-K)64;{)SZqk;^nZ^Uxbd&L|kxTyWn!U$wc;6 z_B2(|QeuI=B_6kxQx#DYpnoP974=Qy;#)4wARil(?TR;~81A7F>1{6N9sOAE zy{ofIl5|VMTO5%t?>%uuQ|>BU#Xs-B?z|-Vi0p|5TOv<~SLAkA&7dc&jrYOEl$QY5pCG?0#pEpX_b| zdmb+W^K>c<$GUK=%Y58H z&$G|j>)>%uvW??d!y9zNFL7J73fq{W>vY>!HkfqlpvPobLKLAxU1<3#a diff --git a/data/homelab.db-shm b/data/homelab.db-shm index cd304e910d15957f37a3c243e04fb2c95c5c4e11..d601993dc2a8465350f3b61fd2a988fcaeb701f1 100644 GIT binary patch literal 32768 zcmeI**H08t6vy!|;tDD#N^$L7v0(3t6&t8)$BMo8juk5^C|I!f-rJM^gNgrukG}a= z=)LPMnM`&!!7M}8{BCk{Z_dn|bLO-Ae8>MY4?CKenFJYM%-Yt>a$nJp(t^ToB~``A zU%%H>0eiO4>pgp zZaA+AYe+x>5|DrdBp?9^NI(J-kbndvAOQ(TKmrnwfCMBU0SQPz0uqpb1SB8<2}nQ! z5|DrdBp?9^NI(J-kbndvAOQ(TKmrnwfCMBU0SQPz0uqpb1ZoA&)b6y2Eij*A4s(La z*th9w0tuX@f_DkjuUeHr`~)t?FO@P$Ai)HxxWZMgQ^QSene(PTC0PGzR{|2Ke}Ob} z##{yi8OCVFQ^Z^ru!torV+H#-!Z|M0e-o-8fd&ysrzNeJz&sYSoc)wj%{6ZDp+UM) zQ3-e{;4^18rqG&9vdAXKoHp5ku5_b2J?Kdqc$9*2~kVib`2~T;(b6)V$oNoG_&)Bn1H6$Pb2}nQ!5|DrdBp?AV z1(Ha`Pe=MPh~bQ38nf8IR(5fKGA|oZEeUuo(1@ny8ws80$6!V z*PeHzf)embAem;gqci;(!brw3gE?$sJ9{|9DK7BFt435u0-g&rHecCkPZ#nS$|&-g zNr27lU@xVd<|1!B???qDAOQ(TKmrnwfCMBU0SQPz0`U@97_UUiBY}hySekGht6>Sm NOW8`LP$b@P(%p5tDqqexZ8KEdfFRrKnh_7Mk=X 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 delta 25306 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 N{ZoQFcSv#d{{a?s_6z_3 diff --git a/tasks_logs/.bootstrap_status.json b/tasks_logs/.bootstrap_status.json deleted file mode 100644 index 4c9c969..0000000 --- a/tasks_logs/.bootstrap_status.json +++ /dev/null @@ -1,79 +0,0 @@ -{ - "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/.schedule_runs.json b/tasks_logs/.schedule_runs.json deleted file mode 100644 index 6787dcb..0000000 --- a/tasks_logs/.schedule_runs.json +++ /dev/null @@ -1,436 +0,0 @@ -{ - "runs": [ - { - "id": "run_e16db5ac6f5c", - "schedule_id": "sched_110c001afe0c", - "task_id": "2", - "started_at": "2025-12-05 02:35:00.012993+00:00", - "finished_at": "2025-12-05 02:35:32.549542+00:00", - "status": "success", - "duration_seconds": 32.45821054699991, - "hosts_impacted": 15, - "error_message": null, - "retry_attempt": 0 - }, - { - "id": "run_6c434a169263", - "schedule_id": "sched_110c001afe0c", - "task_id": "1", - "started_at": "2025-12-05 02:30:00.004595+00:00", - "finished_at": "2025-12-05 02:30:30.003032+00:00", - "status": "success", - "duration_seconds": 29.95905439800117, - "hosts_impacted": 15, - "error_message": null, - "retry_attempt": 0 - }, - { - "id": "run_debf96da90dd", - "schedule_id": "sched_110c001afe0c", - "task_id": "2", - "started_at": "2025-12-05 02:25:00.016354+00:00", - "finished_at": "2025-12-05 02:25:27.580495+00:00", - "status": "success", - "duration_seconds": 27.521959419998893, - "hosts_impacted": 15, - "error_message": null, - "retry_attempt": 0 - }, - { - "id": "run_bda871b98a7c", - "schedule_id": "sched_31a7ffb99bfd", - "task_id": "1", - "started_at": "2025-12-05 02:20:00.004169+00:00", - "finished_at": "2025-12-05 02:20:28.118352+00:00", - "status": "success", - "duration_seconds": 28.0753927859987, - "hosts_impacted": 15, - "error_message": null, - "retry_attempt": 0 - }, - { - "id": "run_9acaf3ee6040", - "schedule_id": "sched_d5370726086b", - "task_id": "4", - "started_at": "2025-12-05 02:05:01.066895+00:00", - "finished_at": null, - "status": "running", - "duration_seconds": null, - "hosts_impacted": 0, - "error_message": null, - "retry_attempt": 0 - }, - { - "id": "run_25dee59d8f54", - "schedule_id": "sched_178b8e511908", - "task_id": "3", - "started_at": "2025-12-05 02:05:00.942939+00:00", - "finished_at": null, - "status": "running", - "duration_seconds": null, - "hosts_impacted": 0, - "error_message": null, - "retry_attempt": 0 - }, - { - "id": "run_06b2fe4c75f9", - "schedule_id": "sched_d5370726086b", - "task_id": "2", - "started_at": "2025-12-05 02:00:00.048675+00:00", - "finished_at": "2025-12-05 02:00:31.174698+00:00", - "status": "success", - "duration_seconds": 31.10493237799892, - "hosts_impacted": 15, - "error_message": null, - "retry_attempt": 0 - }, - { - "id": "run_5a3ada10451e", - "schedule_id": "sched_178b8e511908", - "task_id": "1", - "started_at": "2025-12-05 02:00:00.004396+00:00", - "finished_at": "2025-12-05 02:00:30.956215+00:00", - "status": "success", - "duration_seconds": 30.92840002899902, - "hosts_impacted": 15, - "error_message": null, - "retry_attempt": 0 - }, - { - "id": "run_484f67657ee4", - "schedule_id": "sched_d5370726086b", - "task_id": "3", - "started_at": "2025-12-05 01:55:00.084088+00:00", - "finished_at": "2025-12-05 01:55:32.096250+00:00", - "status": "success", - "duration_seconds": 31.975180113000533, - "hosts_impacted": 15, - "error_message": null, - "retry_attempt": 0 - }, - { - "id": "run_7c9cbee2fe69", - "schedule_id": "sched_178b8e511908", - "task_id": "2", - "started_at": "2025-12-05 01:55:00.018967+00:00", - "finished_at": "2025-12-05 01:55:32.306141+00:00", - "status": "success", - "duration_seconds": 32.26106233700193, - "hosts_impacted": 15, - "error_message": null, - "retry_attempt": 0 - }, - { - "id": "run_a45e3d80323d", - "schedule_id": "sched_d5370726086b", - "task_id": "1", - "started_at": "2025-12-05 01:50:00.003670+00:00", - "finished_at": "2025-12-05 01:50:27.635237+00:00", - "status": "success", - "duration_seconds": 27.58177596600217, - "hosts_impacted": 15, - "error_message": null, - "retry_attempt": 0 - }, - { - "id": "run_6ebb5bb47219", - "schedule_id": "sched_d5370726086b", - "task_id": "2", - "started_at": "2025-12-05 01:45:00.003641+00:00", - "finished_at": "2025-12-05 01:45:26.015984+00:00", - "status": "success", - "duration_seconds": 25.9568110279979, - "hosts_impacted": 15, - "error_message": null, - "retry_attempt": 0 - }, - { - "id": "run_f07c8820abcf", - "schedule_id": "sched_d5370726086b", - "task_id": "1", - "started_at": "2025-12-05 01:40:00.003609+00:00", - "finished_at": "2025-12-05 01:40:27.800302+00:00", - "status": "success", - "duration_seconds": 27.77215807200264, - "hosts_impacted": 15, - "error_message": null, - "retry_attempt": 0 - }, - { - "id": "run_c831165b16d9", - "schedule_id": "sched_d5370726086b", - "task_id": null, - "started_at": "2025-12-05 01:35:00.003976+00:00", - "finished_at": null, - "status": "running", - "duration_seconds": null, - "hosts_impacted": 0, - "error_message": null, - "retry_attempt": 0 - }, - { - "id": "run_9eaff32da049", - "schedule_id": "sched_31a7ffb99bfd", - "task_id": 24, - "started_at": "2025-12-04 20:30:00.003167+00:00", - "finished_at": "2025-12-04 20:30:23.731178+00:00", - "status": "success", - "duration_seconds": 23.703570538998974, - "hosts_impacted": 15, - "error_message": null, - "retry_attempt": 0 - }, - { - "id": "run_17e5d474fa3b", - "schedule_id": "sched_31a7ffb99bfd", - "task_id": 23, - "started_at": "2025-12-04 20:25:00.003921+00:00", - "finished_at": "2025-12-04 20:25:33.861123+00:00", - "status": "success", - "duration_seconds": 33.836465951999344, - "hosts_impacted": 15, - "error_message": null, - "retry_attempt": 0 - }, - { - "id": "run_ac3b635e2ca0", - "schedule_id": "sched_31a7ffb99bfd", - "task_id": 22, - "started_at": "2025-12-04 20:20:00.002730+00:00", - "finished_at": "2025-12-04 20:20:24.021329+00:00", - "status": "success", - "duration_seconds": 23.990758482001183, - "hosts_impacted": 15, - "error_message": null, - "retry_attempt": 0 - }, - { - "id": "run_ae3840e2d42a", - "schedule_id": "sched_31a7ffb99bfd", - "task_id": 21, - "started_at": "2025-12-04 20:15:00.003766+00:00", - "finished_at": "2025-12-04 20:15:30.504433+00:00", - "status": "success", - "duration_seconds": 30.471468816998822, - "hosts_impacted": 15, - "error_message": null, - "retry_attempt": 0 - }, - { - "id": "run_c747bc5687ab", - "schedule_id": "sched_31a7ffb99bfd", - "task_id": 20, - "started_at": "2025-12-04 20:10:00.003667+00:00", - "finished_at": "2025-12-04 20:10:23.552886+00:00", - "status": "success", - "duration_seconds": 23.524676467999598, - "hosts_impacted": 15, - "error_message": null, - "retry_attempt": 0 - }, - { - "id": "run_45014bc6cc03", - "schedule_id": "sched_31a7ffb99bfd", - "task_id": 19, - "started_at": "2025-12-04 20:05:00.002690+00:00", - "finished_at": "2025-12-04 20:05:23.450713+00:00", - "status": "success", - "duration_seconds": 23.4251933380001, - "hosts_impacted": 15, - "error_message": null, - "retry_attempt": 0 - }, - { - "id": "run_e44e428d9a2c", - "schedule_id": "sched_31a7ffb99bfd", - "task_id": 18, - "started_at": "2025-12-04 20:00:00.003643+00:00", - "finished_at": "2025-12-04 20:00:23.328830+00:00", - "status": "success", - "duration_seconds": 23.302303992000816, - "hosts_impacted": 15, - "error_message": null, - "retry_attempt": 0 - }, - { - "id": "run_a25301c67cc5", - "schedule_id": "sched_31a7ffb99bfd", - "task_id": 17, - "started_at": "2025-12-04 19:55:00.003143+00:00", - "finished_at": "2025-12-04 19:55:23.603376+00:00", - "status": "success", - "duration_seconds": 23.577402816999893, - "hosts_impacted": 15, - "error_message": null, - "retry_attempt": 0 - }, - { - "id": "run_565da9652657", - "schedule_id": "sched_31a7ffb99bfd", - "task_id": 16, - "started_at": "2025-12-04 19:50:00.003353+00:00", - "finished_at": "2025-12-04 19:50:24.356543+00:00", - "status": "failed", - "duration_seconds": 24.328575002000434, - "hosts_impacted": 15, - "error_message": "", - "retry_attempt": 0 - }, - { - "id": "run_3b74b1e74163", - "schedule_id": "sched_31a7ffb99bfd", - "task_id": 15, - "started_at": "2025-12-04 19:45:00.002519+00:00", - "finished_at": "2025-12-04 19:45:23.751357+00:00", - "status": "success", - "duration_seconds": 23.722472203999132, - "hosts_impacted": 15, - "error_message": null, - "retry_attempt": 0 - }, - { - "id": "run_dbde0be5bc63", - "schedule_id": "sched_31a7ffb99bfd", - "task_id": 14, - "started_at": "2025-12-04 19:40:00.003007+00:00", - "finished_at": "2025-12-04 19:40:23.751729+00:00", - "status": "success", - "duration_seconds": 23.723020589999578, - "hosts_impacted": 15, - "error_message": null, - "retry_attempt": 0 - }, - { - "id": "run_15a1ad527d50", - "schedule_id": "sched_31a7ffb99bfd", - "task_id": 13, - "started_at": "2025-12-04 19:35:00.003399+00:00", - "finished_at": "2025-12-04 19:35:32.533102+00:00", - "status": "success", - "duration_seconds": 32.507498991999455, - "hosts_impacted": 15, - "error_message": null, - "retry_attempt": 0 - }, - { - "id": "run_be8bcb150d04", - "schedule_id": "sched_31a7ffb99bfd", - "task_id": 12, - "started_at": "2025-12-04 19:30:00.003063+00:00", - "finished_at": "2025-12-04 19:30:23.472387+00:00", - "status": "success", - "duration_seconds": 23.440744194000217, - "hosts_impacted": 15, - "error_message": null, - "retry_attempt": 0 - }, - { - "id": "run_f4d7d06f0c37", - "schedule_id": "sched_31a7ffb99bfd", - "task_id": 11, - "started_at": "2025-12-04 19:25:00.004160+00:00", - "finished_at": "2025-12-04 19:25:23.853468+00:00", - "status": "success", - "duration_seconds": 23.823963333001302, - "hosts_impacted": 15, - "error_message": null, - "retry_attempt": 0 - }, - { - "id": "run_66dc096ac544", - "schedule_id": "sched_31a7ffb99bfd", - "task_id": 10, - "started_at": "2025-12-04 19:20:00.003618+00:00", - "finished_at": "2025-12-04 19:20:24.884824+00:00", - "status": "success", - "duration_seconds": 24.857591686999513, - "hosts_impacted": 15, - "error_message": null, - "retry_attempt": 0 - }, - { - "id": "run_64b0ef374c3e", - "schedule_id": "sched_31a7ffb99bfd", - "task_id": 8, - "started_at": "2025-12-04 19:15:00.003439+00:00", - "finished_at": "2025-12-04 19:15:23.510149+00:00", - "status": "success", - "duration_seconds": 23.48368282699994, - "hosts_impacted": 15, - "error_message": null, - "retry_attempt": 0 - }, - { - "id": "run_2e7db2553a2b", - "schedule_id": "sched_31a7ffb99bfd", - "task_id": 7, - "started_at": "2025-12-04 19:10:00.003912+00:00", - "finished_at": "2025-12-04 19:10:23.758800+00:00", - "status": "success", - "duration_seconds": 23.731919878000554, - "hosts_impacted": 15, - "error_message": null, - "retry_attempt": 0 - }, - { - "id": "run_f11f2032c99d", - "schedule_id": "sched_31a7ffb99bfd", - "task_id": 6, - "started_at": "2025-12-04 19:05:00.005462+00:00", - "finished_at": "2025-12-04 19:05:27.950812+00:00", - "status": "success", - "duration_seconds": 27.921534645000065, - "hosts_impacted": 15, - "error_message": null, - "retry_attempt": 0 - }, - { - "id": "run_6654ede83db4", - "schedule_id": "sched_31a7ffb99bfd", - "task_id": 5, - "started_at": "2025-12-04 19:00:00.002793+00:00", - "finished_at": "2025-12-04 19:00:19.946667+00:00", - "status": "success", - "duration_seconds": 19.924723819000064, - "hosts_impacted": 15, - "error_message": null, - "retry_attempt": 0 - }, - { - "id": "run_98742a32df11", - "schedule_id": "sched_31a7ffb99bfd", - "task_id": 4, - "started_at": "2025-12-04 18:59:33.945907+00:00", - "finished_at": "2025-12-04 18:59:58.108811+00:00", - "status": "success", - "duration_seconds": 24.139778345000195, - "hosts_impacted": 15, - "error_message": null, - "retry_attempt": 0 - }, - { - "id": "run_079e0bef133a", - "schedule_id": "sched_31a7ffb99bfd", - "task_id": 3, - "started_at": "2025-12-04 18:55:00.002791+00:00", - "finished_at": "2025-12-04 18:55:39.881649+00:00", - "status": "failed", - "duration_seconds": 39.856552030000785, - "hosts_impacted": 15, - "error_message": "", - "retry_attempt": 0 - }, - { - "id": "run_719217dc687f", - "schedule_id": "sched_31a7ffb99bfd", - "task_id": 1, - "started_at": "2025-12-04 18:50:00.002822+00:00", - "finished_at": "2025-12-04 18:50:40.203643+00:00", - "status": "failed", - "duration_seconds": 40.181820916000106, - "hosts_impacted": 15, - "error_message": "", - "retry_attempt": 0 - } - ] -} \ No newline at end of file diff --git a/tasks_logs/.schedules.json b/tasks_logs/.schedules.json deleted file mode 100644 index fbdc8f7..0000000 --- a/tasks_logs/.schedules.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "schedules": [ - { - "id": "sched_110c001afe0c", - "name": "Health-check-5min", - "description": null, - "playbook": "health-check.yml", - "target_type": "group", - "target": "all", - "extra_vars": null, - "schedule_type": "recurring", - "recurrence": { - "type": "custom", - "time": "02:00", - "days": null, - "day_of_month": null, - "cron_expression": "*/5 * * * *" - }, - "timezone": "America/Montreal", - "start_at": null, - "end_at": null, - "next_run_at": "2025-12-04T21:40:00-05:00", - "last_run_at": "2025-12-05 02:35:00.012919+00:00", - "last_status": "success", - "enabled": true, - "retry_on_failure": 0, - "timeout": 3600, - "tags": [ - "Test" - ], - "run_count": 3, - "success_count": 3, - "failure_count": 0, - "created_at": "2025-12-05 02:24:06.110100+00:00", - "updated_at": "2025-12-05 02:35:32.549928+00:00" - } - ] -} \ No newline at end of file