From 28276f326b897db7fc3d192ae09e24db87b2db36 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Thu, 4 Dec 2025 16:01:10 -0500 Subject: [PATCH] Remove UI/UX zip file and add comprehensive scheduler functionality with APScheduler integration, cron support, and detailed documentation --- OKComputer_Optimiser UI_UX.zip | Bin 19697 -> 0 bytes README.md | 109 +++ ansible/.host_status.json | 3 + app/app_optimized.py | 1250 +++++++++++++++++++++++++++++- app/index.html | 432 +++++++++++ app/main.js | 1300 +++++++++++++++++++++++++++++--- app/requirements.txt | 5 +- tasks_logs/.schedule_runs.json | 268 +++++++ tasks_logs/.schedules.json | 38 + 9 files changed, 3306 insertions(+), 99 deletions(-) delete mode 100644 OKComputer_Optimiser UI_UX.zip create mode 100644 ansible/.host_status.json create mode 100644 tasks_logs/.schedule_runs.json create mode 100644 tasks_logs/.schedules.json diff --git a/OKComputer_Optimiser UI_UX.zip b/OKComputer_Optimiser UI_UX.zip deleted file mode 100644 index d6dbc99edd8b9cfcc936152b70008ae42661a23a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 19697 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//*.json`** + - Fichiers horodatés contenant les logs détaillés de chaque exécution de tâche/schedule. + - Permettent de tracer finement ce qui s'est passé pour chaque run (playbook lancé, cible, sortie Ansible, statut). + +- **`test_schedule.json`** + - Exemple de définition de schedule utilisé pour les tests et la validation de l'API de planification. + - Peut servir de modèle pour comprendre la structure JSON attendue lors de la création de nouveaux schedules. + ## 🚀 Installation et Lancement ### Prérequis @@ -175,6 +217,19 @@ curl -H "X-API-Key: dev-key-12345" http://localhost:8000/api/hosts - `POST /api/ansible/bootstrap` - Bootstrap un hôte pour Ansible (crée user, SSH, sudo, Python) - `GET /api/ansible/ssh-config` - Diagnostic de la configuration SSH +**Planificateur (Schedules)** +- `GET /api/schedules` - Liste tous les schedules +- `POST /api/schedules` - Crée un nouveau schedule +- `GET /api/schedules/{id}` - Récupère un schedule spécifique +- `PUT /api/schedules/{id}` - Met à jour un schedule +- `DELETE /api/schedules/{id}` - Supprime un schedule +- `POST /api/schedules/{id}/run` - Exécution forcée immédiate +- `POST /api/schedules/{id}/pause` - Met en pause un schedule +- `POST /api/schedules/{id}/resume` - Reprend un schedule +- `GET /api/schedules/{id}/runs` - Historique des exécutions +- `GET /api/schedules/stats` - Statistiques globales +- `POST /api/schedules/validate-cron` - Valide une expression cron + #### Exemples d'utilisation Ansible **Lister les playbooks disponibles :** @@ -214,6 +269,60 @@ curl -X POST -H "X-API-Key: dev-key-12345" -H "Content-Type: application/json" \ http://localhost:8000/api/ansible/adhoc ``` +#### Exemples d'utilisation du Planificateur + +**Créer un schedule quotidien :** +```bash +curl -X POST -H "X-API-Key: dev-key-12345" -H "Content-Type: application/json" \ + -d '{ + "name": "Backup quotidien", + "playbook": "backup-config.yml", + "target": "all", + "schedule_type": "recurring", + "recurrence": {"type": "daily", "time": "02:00"}, + "tags": ["Backup", "Production"] + }' \ + http://localhost:8000/api/schedules +``` + +**Créer un schedule hebdomadaire (lundi et vendredi) :** +```bash +curl -X POST -H "X-API-Key: dev-key-12345" -H "Content-Type: application/json" \ + -d '{ + "name": "Health check bi-hebdo", + "playbook": "health-check.yml", + "target": "proxmox", + "schedule_type": "recurring", + "recurrence": {"type": "weekly", "time": "08:00", "days": [1, 5]} + }' \ + http://localhost:8000/api/schedules +``` + +**Créer un schedule avec expression cron :** +```bash +curl -X POST -H "X-API-Key: dev-key-12345" -H "Content-Type: application/json" \ + -d '{ + "name": "Maintenance mensuelle", + "playbook": "vm-upgrade.yml", + "target": "lab", + "schedule_type": "recurring", + "recurrence": {"type": "custom", "cron_expression": "0 3 1 * *"} + }' \ + http://localhost:8000/api/schedules +``` + +**Lancer un schedule immédiatement :** +```bash +curl -X POST -H "X-API-Key: dev-key-12345" \ + http://localhost:8000/api/schedules/{schedule_id}/run +``` + +**Voir l'historique des exécutions :** +```bash +curl -H "X-API-Key: dev-key-12345" \ + http://localhost:8000/api/schedules/{schedule_id}/runs +``` + ### Documentation API - **Swagger UI** : `http://localhost:8000/api/docs` diff --git a/ansible/.host_status.json b/ansible/.host_status.json new file mode 100644 index 0000000..213a4da --- /dev/null +++ b/ansible/.host_status.json @@ -0,0 +1,3 @@ +{ + "hosts": {} +} \ No newline at end of file diff --git a/app/app_optimized.py b/app/app_optimized.py index b0c026f..11506f7 100644 --- a/app/app_optimized.py +++ b/app/app_optimized.py @@ -3,7 +3,7 @@ Homelab Automation Dashboard - Backend Optimisé API REST moderne avec FastAPI pour la gestion d'homelab """ -from datetime import datetime, timezone +from datetime import datetime, timezone, timedelta from pathlib import Path from time import perf_counter, time import os @@ -17,6 +17,16 @@ from typing import Literal, Any, List, Dict, Optional from threading import Lock import asyncio import json +import uuid + +# APScheduler imports +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.triggers.cron import CronTrigger +from apscheduler.triggers.date import DateTrigger +from apscheduler.jobstores.memory import MemoryJobStore +from apscheduler.executors.asyncio import AsyncIOExecutor +from croniter import croniter +import pytz from fastapi import FastAPI, HTTPException, Depends, Request, Form, WebSocket, WebSocketDisconnect from fastapi.responses import HTMLResponse, JSONResponse, FileResponse @@ -322,6 +332,135 @@ class TasksFilterParams(BaseModel): search: Optional[str] = None +# ===== MODÈLES PLANIFICATEUR (SCHEDULER) ===== + +class ScheduleRecurrence(BaseModel): + """Configuration de récurrence pour un schedule""" + type: Literal["daily", "weekly", "monthly", "custom"] = "daily" + time: str = Field(default="02:00", description="Heure d'exécution HH:MM") + days: Optional[List[int]] = Field(default=None, description="Jours de la semaine (1-7, lundi=1) pour weekly") + day_of_month: Optional[int] = Field(default=None, ge=1, le=31, description="Jour du mois (1-31) pour monthly") + cron_expression: Optional[str] = Field(default=None, description="Expression cron pour custom") + + +class Schedule(BaseModel): + """Modèle d'un schedule de playbook""" + id: str = Field(default_factory=lambda: f"sched_{uuid.uuid4().hex[:12]}") + name: str = Field(..., min_length=3, max_length=100, description="Nom du schedule") + description: Optional[str] = Field(default=None, max_length=500) + playbook: str = Field(..., description="Nom du playbook à exécuter") + target_type: Literal["group", "host"] = Field(default="group", description="Type de cible") + target: str = Field(default="all", description="Nom du groupe ou hôte cible") + extra_vars: Optional[Dict[str, Any]] = Field(default=None, description="Variables supplémentaires") + schedule_type: Literal["once", "recurring"] = Field(default="recurring") + recurrence: Optional[ScheduleRecurrence] = Field(default=None) + timezone: str = Field(default="America/Montreal", description="Fuseau horaire") + start_at: Optional[datetime] = Field(default=None, description="Date de début (optionnel)") + end_at: Optional[datetime] = Field(default=None, description="Date de fin (optionnel)") + next_run_at: Optional[datetime] = Field(default=None, description="Prochaine exécution calculée") + last_run_at: Optional[datetime] = Field(default=None, description="Dernière exécution") + last_status: Literal["success", "failed", "running", "never"] = Field(default="never") + enabled: bool = Field(default=True, description="Schedule actif ou en pause") + retry_on_failure: int = Field(default=0, ge=0, le=3, description="Nombre de tentatives en cas d'échec") + timeout: int = Field(default=3600, ge=60, le=86400, description="Timeout en secondes") + tags: List[str] = Field(default=[], description="Tags pour catégorisation") + run_count: int = Field(default=0, description="Nombre total d'exécutions") + success_count: int = Field(default=0, description="Nombre de succès") + failure_count: int = Field(default=0, description="Nombre d'échecs") + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + class Config: + json_encoders = { + datetime: lambda v: v.isoformat() if v else None + } + + @field_validator('recurrence', mode='before') + @classmethod + def validate_recurrence(cls, v, info): + # Si schedule_type est 'once', recurrence n'est pas obligatoire + return v + + +class ScheduleRun(BaseModel): + """Historique d'une exécution de schedule""" + id: str = Field(default_factory=lambda: f"run_{uuid.uuid4().hex[:12]}") + schedule_id: str = Field(..., description="ID du schedule parent") + task_id: Optional[int] = Field(default=None, description="ID de la tâche créée") + started_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + finished_at: Optional[datetime] = Field(default=None) + status: Literal["running", "success", "failed", "canceled"] = Field(default="running") + duration_seconds: Optional[float] = Field(default=None) + hosts_impacted: int = Field(default=0) + error_message: Optional[str] = Field(default=None) + retry_attempt: int = Field(default=0, description="Numéro de la tentative (0 = première)") + + class Config: + json_encoders = { + datetime: lambda v: v.isoformat() if v else None + } + + +class ScheduleCreateRequest(BaseModel): + """Requête de création d'un schedule""" + name: str = Field(..., min_length=3, max_length=100) + description: Optional[str] = Field(default=None, max_length=500) + playbook: str = Field(...) + target_type: Literal["group", "host"] = Field(default="group") + target: str = Field(default="all") + extra_vars: Optional[Dict[str, Any]] = Field(default=None) + schedule_type: Literal["once", "recurring"] = Field(default="recurring") + recurrence: Optional[ScheduleRecurrence] = Field(default=None) + timezone: str = Field(default="America/Montreal") + start_at: Optional[datetime] = Field(default=None) + end_at: Optional[datetime] = Field(default=None) + enabled: bool = Field(default=True) + retry_on_failure: int = Field(default=0, ge=0, le=3) + timeout: int = Field(default=3600, ge=60, le=86400) + tags: List[str] = Field(default=[]) + + @field_validator('timezone') + @classmethod + def validate_timezone(cls, v: str) -> str: + try: + pytz.timezone(v) + return v + except pytz.exceptions.UnknownTimeZoneError: + raise ValueError(f"Fuseau horaire invalide: {v}") + + +class ScheduleUpdateRequest(BaseModel): + """Requête de mise à jour d'un schedule""" + name: Optional[str] = Field(default=None, min_length=3, max_length=100) + description: Optional[str] = Field(default=None, max_length=500) + playbook: Optional[str] = Field(default=None) + target_type: Optional[Literal["group", "host"]] = Field(default=None) + target: Optional[str] = Field(default=None) + extra_vars: Optional[Dict[str, Any]] = Field(default=None) + schedule_type: Optional[Literal["once", "recurring"]] = Field(default=None) + recurrence: Optional[ScheduleRecurrence] = Field(default=None) + timezone: Optional[str] = Field(default=None) + start_at: Optional[datetime] = Field(default=None) + end_at: Optional[datetime] = Field(default=None) + enabled: Optional[bool] = Field(default=None) + retry_on_failure: Optional[int] = Field(default=None, ge=0, le=3) + timeout: Optional[int] = Field(default=None, ge=60, le=86400) + tags: Optional[List[str]] = Field(default=None) + + +class ScheduleStats(BaseModel): + """Statistiques globales des schedules""" + total: int = 0 + active: int = 0 + paused: int = 0 + expired: int = 0 + next_execution: Optional[datetime] = None + next_schedule_name: Optional[str] = None + failures_24h: int = 0 + executions_24h: int = 0 + success_rate_7d: float = 0.0 + + # ===== SERVICE DE LOGGING MARKDOWN ===== class TaskLogService: @@ -993,11 +1132,736 @@ class HostStatusService: return False +# ===== SERVICE PLANIFICATEUR (SCHEDULER) ===== + +SCHEDULES_FILE = DIR_LOGS_TASKS / ".schedules.json" +SCHEDULE_RUNS_FILE = DIR_LOGS_TASKS / ".schedule_runs.json" + + +class SchedulerService: + """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() + + # Configurer APScheduler + jobstores = {'default': MemoryJobStore()} + executors = {'default': AsyncIOExecutor()} + job_defaults = {'coalesce': True, 'max_instances': 1, 'misfire_grace_time': 300} + + self.scheduler = AsyncIOScheduler( + jobstores=jobstores, + executors=executors, + job_defaults=job_defaults, + timezone=pytz.UTC + ) + self._started = False + + 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""" + 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") + + def shutdown(self): + """Arrête le scheduler proprement""" + if self._started: + 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}") + + def _build_cron_trigger(self, schedule: Schedule) -> Optional[CronTrigger]: + """Construit un trigger cron à partir de la configuration du schedule""" + if schedule.schedule_type == "once": + return None + + recurrence = schedule.recurrence + if not recurrence: + return None + + tz = pytz.timezone(schedule.timezone) + hour, minute = recurrence.time.split(':') if recurrence.time else ("2", "0") + + try: + if recurrence.type == "daily": + return CronTrigger(hour=int(hour), minute=int(minute), timezone=tz) + + elif recurrence.type == "weekly": + # Convertir jours (1-7 lundi=1) en format cron (0-6 lundi=0) + days = recurrence.days or [1] + day_of_week = ','.join(str(d - 1) for d in days) + return CronTrigger(day_of_week=day_of_week, hour=int(hour), minute=int(minute), timezone=tz) + + elif recurrence.type == "monthly": + day = recurrence.day_of_month or 1 + return CronTrigger(day=day, hour=int(hour), minute=int(minute), timezone=tz) + + elif recurrence.type == "custom" and recurrence.cron_expression: + # Parser l'expression cron + parts = recurrence.cron_expression.split() + if len(parts) == 5: + return CronTrigger.from_crontab(recurrence.cron_expression, timezone=tz) + else: + # Expression cron étendue (6 champs avec secondes) + return CronTrigger( + second=parts[0] if len(parts) > 5 else '0', + minute=parts[0] if len(parts) == 5 else parts[1], + hour=parts[1] if len(parts) == 5 else parts[2], + day=parts[2] if len(parts) == 5 else parts[3], + month=parts[3] if len(parts) == 5 else parts[4], + day_of_week=parts[4] if len(parts) == 5 else parts[5], + timezone=tz + ) + except Exception as e: + print(f"Erreur construction trigger cron: {e}") + return None + + return None + + def _add_job_for_schedule(self, schedule: Schedule): + """Ajoute un job APScheduler pour un schedule""" + job_id = f"schedule_{schedule.id}" + + # Supprimer le job existant s'il existe + try: + self.scheduler.remove_job(job_id) + except: + pass + + if schedule.schedule_type == "once": + # Exécution unique + if schedule.start_at and schedule.start_at > datetime.now(timezone.utc): + trigger = DateTrigger(run_date=schedule.start_at, timezone=pytz.UTC) + self.scheduler.add_job( + self._execute_schedule, + trigger, + id=job_id, + args=[schedule.id], + replace_existing=True + ) + else: + # Exécution récurrente + trigger = self._build_cron_trigger(schedule) + if trigger: + self.scheduler.add_job( + self._execute_schedule, + trigger, + id=job_id, + args=[schedule.id], + replace_existing=True + ) + + # Calculer et mettre à jour next_run_at + self._update_next_run(schedule.id) + + def _update_next_run(self, schedule_id: str): + """Met à jour le champ next_run_at d'un schedule""" + 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) + except: + pass + + 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) + + if not sched_data: + 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 + + # Vérifier la fenêtre temporelle + now = datetime.now(timezone.utc) + if schedule.end_at and now > schedule.end_at: + # Schedule expiré, le désactiver + schedule.enabled = False + self._update_schedule_in_storage(schedule) + return + + # Créer un ScheduleRun + 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) + + # Notifier via WebSocket + try: + await ws_manager.broadcast({ + "type": "schedule_run_started", + "data": { + "schedule_id": schedule_id, + "schedule_name": schedule.name, + "run": run.dict(), + "status": "running" + } + }) + except: + pass + + # Créer une tâche + task_id = db.get_next_id("tasks") + playbook_name = schedule.playbook.replace('.yml', '').replace('-', ' ').title() + task = Task( + id=task_id, + name=f"[Planifié] {playbook_name}", + host=schedule.target, + status="running", + progress=0, + start_time=now + ) + db.tasks.insert(0, task) + + # 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: + await ws_manager.broadcast({ + "type": "task_created", + "data": task.dict() + }) + except: + pass + + # Exécuter le playbook + start_time = perf_counter() + try: + result = await ansible_service.execute_playbook( + playbook=schedule.playbook, + target=schedule.target, + extra_vars=schedule.extra_vars, + check_mode=False, + verbose=True + ) + + execution_time = perf_counter() - start_time + success = result.get("success", False) + + # Mettre à jour la tâche + task.status = "completed" if success else "failed" + task.progress = 100 + task.end_time = datetime.now(timezone.utc) + task.duration = f"{execution_time:.1f}s" + task.output = result.get("stdout", "") + task.error = result.get("stderr", "") if not success else None + + # Mettre à jour le run + run.status = "success" if success else "failed" + run.finished_at = datetime.now(timezone.utc) + run.duration_seconds = execution_time + run.error_message = result.get("stderr", "") if not success else None + + # Compter les hôtes impactés + stdout = result.get("stdout", "") + host_count = len(re.findall(r'^[a-zA-Z0-9][a-zA-Z0-9._-]+\s*:\s*ok=', stdout, re.MULTILINE)) + run.hosts_impacted = host_count + + # Mettre à jour le schedule + schedule.last_status = "success" if success else "failed" + if success: + schedule.success_count += 1 + 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 log markdown + try: + task_log_service.save_task_log( + task=task, + output=result.get("stdout", ""), + error=result.get("stderr", "") + ) + except: + pass + + # Notifier + await ws_manager.broadcast({ + "type": "schedule_run_finished", + "data": { + "schedule_id": schedule_id, + "schedule_name": schedule.name, + "run": run.dict(), + "status": run.status, + "success": success + } + }) + + await ws_manager.broadcast({ + "type": "task_completed", + "data": { + "id": task_id, + "status": task.status, + "progress": 100, + "duration": task.duration, + "success": success + } + }) + + # Log + log_entry = LogEntry( + id=db.get_next_id("logs"), + timestamp=datetime.now(timezone.utc), + level="INFO" if success else "ERROR", + message=f"Schedule '{schedule.name}' exécuté: {'succès' if success else 'échec'}", + source="scheduler", + host=schedule.target + ) + db.logs.insert(0, log_entry) + + except Exception as e: + # Échec de l'exécution + execution_time = perf_counter() - start_time + + task.status = "failed" + task.end_time = datetime.now(timezone.utc) + task.error = str(e) + + run.status = "failed" + run.finished_at = datetime.now(timezone.utc) + run.duration_seconds = execution_time + run.error_message = str(e) + + 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) + + try: + task_log_service.save_task_log(task=task, error=str(e)) + except: + pass + + try: + await ws_manager.broadcast({ + "type": "schedule_run_finished", + "data": { + "schedule_id": schedule_id, + "run": run.dict(), + "status": "failed", + "error": str(e) + } + }) + + await ws_manager.broadcast({ + "type": "task_failed", + "data": {"id": task_id, "status": "failed", "error": str(e)} + }) + except: + pass + + log_entry = LogEntry( + id=db.get_next_id("logs"), + timestamp=datetime.now(timezone.utc), + level="ERROR", + message=f"Erreur schedule '{schedule.name}': {str(e)}", + source="scheduler", + host=schedule.target + ) + db.logs.insert(0, log_entry) + + # 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 ===== + + 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 = [] + + 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 + + # 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 + + def create_schedule(self, request: ScheduleCreateRequest) -> Schedule: + """Crée un nouveau schedule""" + schedule = Schedule( + name=request.name, + description=request.description, + playbook=request.playbook, + 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 + ) + + # Sauvegarder + schedules = self._load_schedules() + schedules.append(schedule.dict()) + self._save_schedules(schedules) + + # Ajouter le job si actif + if schedule.enabled and self._started: + self._add_job_for_schedule(schedule) + + return schedule + + def update_schedule(self, schedule_id: str, request: ScheduleUpdateRequest) -> Optional[Schedule]: + """Met à jour un schedule existant""" + schedule = self.get_schedule(schedule_id) + if not schedule: + return None + + # Appliquer les modifications + update_data = request.dict(exclude_unset=True, exclude_none=True) + for key, value in update_data.items(): + if hasattr(schedule, key): + setattr(schedule, key, value) + + schedule.updated_at = datetime.now(timezone.utc) + + # Sauvegarder + self._update_schedule_in_storage(schedule) + + # Mettre à jour le job + if self._started: + job_id = f"schedule_{schedule_id}" + try: + self.scheduler.remove_job(job_id) + except: + pass + + if schedule.enabled: + self._add_job_for_schedule(schedule) + + 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] + + 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 + + def pause_schedule(self, schedule_id: str) -> Optional[Schedule]: + """Met en pause un schedule""" + schedule = self.get_schedule(schedule_id) + if not schedule: + return None + + schedule.enabled = False + self._update_schedule_in_storage(schedule) + + # Supprimer le job + job_id = f"schedule_{schedule_id}" + try: + self.scheduler.remove_job(job_id) + except: + pass + + return schedule + + def resume_schedule(self, schedule_id: str) -> Optional[Schedule]: + """Reprend un schedule en pause""" + schedule = self.get_schedule(schedule_id) + if not schedule: + return None + + schedule.enabled = True + self._update_schedule_in_storage(schedule) + + # Ajouter le job + if self._started: + self._add_job_for_schedule(schedule) + + return schedule + + async def run_now(self, schedule_id: str) -> Optional[ScheduleRun]: + """Exécute immédiatement un schedule""" + schedule = self.get_schedule(schedule_id) + if not schedule: + return None + + # 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] + + def get_stats(self) -> ScheduleStats: + """Calcule les statistiques globales des schedules""" + 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) + stats.active = len([s for s in schedules if s.enabled]) + stats.paused = len([s for s in schedules if not s.enabled]) + + # Schedules expirés + stats.expired = len([s for s in schedules if s.end_at and s.end_at < now]) + + # Prochaine exécution + active_schedules = [s for s in schedules if s.enabled and s.next_run_at] + if active_schedules: + next_schedule = min(active_schedules, key=lambda x: x.next_run_at) + 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.executions_24h = len(runs_24h) + stats.failures_24h = len([r for r in runs_24h if r.get('status') == 'failed']) + + # 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) + + return stats + + def get_upcoming_executions(self, limit: int = 5) -> List[Dict]: + """Retourne les prochaines exécutions planifiées""" + schedules = self.get_all_schedules(enabled=True) + upcoming = [] + + for s in schedules: + if s.next_run_at: + upcoming.append({ + "schedule_id": s.id, + "schedule_name": s.name, + "playbook": s.playbook, + "target": s.target, + "next_run_at": s.next_run_at.isoformat() if s.next_run_at else None, + "tags": s.tags + }) + + upcoming.sort(key=lambda x: x['next_run_at'] or '') + return upcoming[:limit] + + def validate_cron_expression(self, expression: str) -> Dict: + """Valide une expression cron et retourne les prochaines exécutions""" + try: + cron = croniter(expression, datetime.now()) + next_runs = [cron.get_next(datetime).isoformat() for _ in range(5)] + return { + "valid": True, + "next_runs": next_runs, + "expression": expression + } + except Exception as e: + return { + "valid": False, + "error": str(e), + "expression": expression + } + + 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) + + # 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) class WebSocketManager: @@ -3832,6 +4696,390 @@ async def execute_ansible_task( }) +# ===== ENDPOINTS PLANIFICATEUR (SCHEDULER) ===== + +@app.get("/api/schedules") +async def get_schedules( + enabled: Optional[bool] = None, + playbook: Optional[str] = None, + tag: Optional[str] = None, + api_key_valid: bool = Depends(verify_api_key) +): + """Liste tous les schedules avec filtrage optionnel + + Args: + enabled: Filtrer par statut (true = actifs, false = en pause) + playbook: Filtrer par nom de playbook (recherche partielle) + tag: Filtrer par tag + """ + schedules = scheduler_service.get_all_schedules(enabled=enabled, playbook=playbook, tag=tag) + return { + "schedules": [s.dict() for s in schedules], + "count": len(schedules) + } + + +@app.post("/api/schedules") +async def create_schedule( + request: ScheduleCreateRequest, + api_key_valid: bool = Depends(verify_api_key) +): + """Crée un nouveau schedule + + Exemple de body: + { + "name": "Backup quotidien", + "playbook": "backup-config.yml", + "target": "all", + "schedule_type": "recurring", + "recurrence": { + "type": "daily", + "time": "02:00" + }, + "tags": ["Backup", "Production"] + } + """ + # Vérifier que le playbook existe + playbooks = ansible_service.get_playbooks() + playbook_names = [p['filename'] for p in playbooks] + [p['name'] for p in playbooks] + + # Normaliser le nom du playbook + playbook_file = request.playbook + if not playbook_file.endswith(('.yml', '.yaml')): + playbook_file = f"{playbook_file}.yml" + + if playbook_file not in playbook_names and request.playbook not in playbook_names: + raise HTTPException(status_code=400, detail=f"Playbook '{request.playbook}' non trouvé") + + # Vérifier la cible + if request.target_type == "group": + groups = ansible_service.get_groups() + if request.target not in groups and request.target != "all": + raise HTTPException(status_code=400, detail=f"Groupe '{request.target}' non trouvé") + else: + if not ansible_service.host_exists(request.target): + raise HTTPException(status_code=400, detail=f"Hôte '{request.target}' non trouvé") + + # Valider la récurrence si nécessaire + if request.schedule_type == "recurring" and not request.recurrence: + raise HTTPException(status_code=400, detail="La récurrence est requise pour un schedule récurrent") + + # Valider l'expression cron si custom + if request.recurrence and request.recurrence.type == "custom": + if not request.recurrence.cron_expression: + raise HTTPException(status_code=400, detail="Expression cron requise pour le type 'custom'") + validation = scheduler_service.validate_cron_expression(request.recurrence.cron_expression) + if not validation["valid"]: + raise HTTPException(status_code=400, detail=f"Expression cron invalide: {validation.get('error')}") + + schedule = scheduler_service.create_schedule(request) + + # Log + log_entry = LogEntry( + id=db.get_next_id("logs"), + timestamp=datetime.now(timezone.utc), + level="INFO", + message=f"Schedule '{schedule.name}' créé pour {schedule.playbook} sur {schedule.target}", + source="scheduler" + ) + db.logs.insert(0, log_entry) + + # Notifier via WebSocket + await ws_manager.broadcast({ + "type": "schedule_created", + "data": schedule.dict() + }) + + return { + "success": True, + "message": f"Schedule '{schedule.name}' créé avec succès", + "schedule": schedule.dict() + } + + +@app.get("/api/schedules/stats") +async def get_schedules_stats(api_key_valid: bool = Depends(verify_api_key)): + """Récupère les statistiques globales des schedules""" + stats = scheduler_service.get_stats() + upcoming = scheduler_service.get_upcoming_executions(limit=5) + + return { + "stats": stats.dict(), + "upcoming": upcoming + } + + +@app.get("/api/schedules/upcoming") +async def get_upcoming_schedules( + limit: int = 10, + api_key_valid: bool = Depends(verify_api_key) +): + """Récupère les prochaines exécutions planifiées""" + upcoming = scheduler_service.get_upcoming_executions(limit=limit) + return { + "upcoming": upcoming, + "count": len(upcoming) + } + + +@app.get("/api/schedules/validate-cron") +async def validate_cron_expression( + expression: str, + api_key_valid: bool = Depends(verify_api_key) +): + """Valide une expression cron et retourne les 5 prochaines exécutions""" + result = scheduler_service.validate_cron_expression(expression) + return result + + +@app.get("/api/schedules/{schedule_id}") +async def get_schedule( + schedule_id: str, + api_key_valid: bool = Depends(verify_api_key) +): + """Récupère les détails d'un schedule spécifique""" + schedule = scheduler_service.get_schedule(schedule_id) + if not schedule: + raise HTTPException(status_code=404, detail=f"Schedule '{schedule_id}' non trouvé") + + return schedule.dict() + + +@app.put("/api/schedules/{schedule_id}") +async def update_schedule( + schedule_id: str, + request: ScheduleUpdateRequest, + api_key_valid: bool = Depends(verify_api_key) +): + """Met à jour un schedule existant""" + schedule = scheduler_service.get_schedule(schedule_id) + if not schedule: + raise HTTPException(status_code=404, detail=f"Schedule '{schedule_id}' non trouvé") + + # Valider le playbook si modifié + if request.playbook: + playbooks = ansible_service.get_playbooks() + playbook_names = [p['filename'] for p in playbooks] + [p['name'] for p in playbooks] + playbook_file = request.playbook + if not playbook_file.endswith(('.yml', '.yaml')): + playbook_file = f"{playbook_file}.yml" + if playbook_file not in playbook_names and request.playbook not in playbook_names: + raise HTTPException(status_code=400, detail=f"Playbook '{request.playbook}' non trouvé") + + # Valider l'expression cron si modifiée + if request.recurrence and request.recurrence.type == "custom": + if request.recurrence.cron_expression: + validation = scheduler_service.validate_cron_expression(request.recurrence.cron_expression) + if not validation["valid"]: + raise HTTPException(status_code=400, detail=f"Expression cron invalide: {validation.get('error')}") + + updated = scheduler_service.update_schedule(schedule_id, request) + + # Log + log_entry = LogEntry( + id=db.get_next_id("logs"), + timestamp=datetime.now(timezone.utc), + level="INFO", + message=f"Schedule '{updated.name}' mis à jour", + source="scheduler" + ) + db.logs.insert(0, log_entry) + + # Notifier via WebSocket + await ws_manager.broadcast({ + "type": "schedule_updated", + "data": updated.dict() + }) + + return { + "success": True, + "message": f"Schedule '{updated.name}' mis à jour", + "schedule": updated.dict() + } + + +@app.delete("/api/schedules/{schedule_id}") +async def delete_schedule( + schedule_id: str, + api_key_valid: bool = Depends(verify_api_key) +): + """Supprime un schedule""" + schedule = scheduler_service.get_schedule(schedule_id) + if not schedule: + raise HTTPException(status_code=404, detail=f"Schedule '{schedule_id}' non trouvé") + + schedule_name = schedule.name + success = scheduler_service.delete_schedule(schedule_id) + + if not success: + raise HTTPException(status_code=500, detail="Erreur lors de la suppression") + + # Log + log_entry = LogEntry( + id=db.get_next_id("logs"), + timestamp=datetime.now(timezone.utc), + level="WARN", + message=f"Schedule '{schedule_name}' supprimé", + source="scheduler" + ) + db.logs.insert(0, log_entry) + + # Notifier via WebSocket + await ws_manager.broadcast({ + "type": "schedule_deleted", + "data": {"id": schedule_id, "name": schedule_name} + }) + + return { + "success": True, + "message": f"Schedule '{schedule_name}' supprimé" + } + + +@app.post("/api/schedules/{schedule_id}/run") +async def run_schedule_now( + schedule_id: str, + api_key_valid: bool = Depends(verify_api_key) +): + """Exécute immédiatement un schedule (exécution forcée)""" + schedule = scheduler_service.get_schedule(schedule_id) + if not schedule: + raise HTTPException(status_code=404, detail=f"Schedule '{schedule_id}' non trouvé") + + # Lancer l'exécution en arrière-plan + run = await scheduler_service.run_now(schedule_id) + + return { + "success": True, + "message": f"Schedule '{schedule.name}' lancé", + "run": run.dict() if run else None + } + + +@app.post("/api/schedules/{schedule_id}/pause") +async def pause_schedule( + schedule_id: str, + api_key_valid: bool = Depends(verify_api_key) +): + """Met en pause un schedule""" + schedule = scheduler_service.pause_schedule(schedule_id) + if not schedule: + raise HTTPException(status_code=404, detail=f"Schedule '{schedule_id}' non trouvé") + + # Log + log_entry = LogEntry( + id=db.get_next_id("logs"), + timestamp=datetime.now(timezone.utc), + level="INFO", + message=f"Schedule '{schedule.name}' mis en pause", + source="scheduler" + ) + db.logs.insert(0, log_entry) + + # Notifier via WebSocket + await ws_manager.broadcast({ + "type": "schedule_updated", + "data": schedule.dict() + }) + + return { + "success": True, + "message": f"Schedule '{schedule.name}' mis en pause", + "schedule": schedule.dict() + } + + +@app.post("/api/schedules/{schedule_id}/resume") +async def resume_schedule( + schedule_id: str, + api_key_valid: bool = Depends(verify_api_key) +): + """Reprend un schedule en pause""" + schedule = scheduler_service.resume_schedule(schedule_id) + if not schedule: + raise HTTPException(status_code=404, detail=f"Schedule '{schedule_id}' non trouvé") + + # Log + log_entry = LogEntry( + id=db.get_next_id("logs"), + timestamp=datetime.now(timezone.utc), + level="INFO", + message=f"Schedule '{schedule.name}' repris", + source="scheduler" + ) + db.logs.insert(0, log_entry) + + # Notifier via WebSocket + await ws_manager.broadcast({ + "type": "schedule_updated", + "data": schedule.dict() + }) + + return { + "success": True, + "message": f"Schedule '{schedule.name}' repris", + "schedule": schedule.dict() + } + + +@app.get("/api/schedules/{schedule_id}/runs") +async def get_schedule_runs( + schedule_id: str, + limit: int = 50, + api_key_valid: bool = Depends(verify_api_key) +): + """Récupère l'historique des exécutions d'un schedule""" + schedule = scheduler_service.get_schedule(schedule_id) + if not schedule: + raise HTTPException(status_code=404, detail=f"Schedule '{schedule_id}' non trouvé") + + runs = scheduler_service.get_schedule_runs(schedule_id, limit=limit) + + return { + "schedule_id": schedule_id, + "schedule_name": schedule.name, + "runs": [r.dict() for r in runs], + "count": len(runs) + } + + +# ===== ÉVÉNEMENTS STARTUP/SHUTDOWN ===== + +@app.on_event("startup") +async def startup_event(): + """Événement de démarrage de l'application""" + print("🚀 Homelab Automation Dashboard démarré") + + # Démarrer le scheduler + scheduler_service.start() + + # Log de démarrage + log_entry = LogEntry( + id=db.get_next_id("logs"), + timestamp=datetime.now(timezone.utc), + level="INFO", + message="Application démarrée - Scheduler initialisé", + source="system" + ) + db.logs.insert(0, log_entry) + + # 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(): + """Événement d'arrêt de l'application""" + print("👋 Arrêt de l'application...") + + # Arrêter le scheduler + scheduler_service.shutdown() + + print("✅ Scheduler arrêté proprement") + + # Démarrer l'application if __name__ == "__main__": uvicorn.run( diff --git a/app/index.html b/app/index.html index 377cbd2..5ff34a4 100644 --- a/app/index.html +++ b/app/index.html @@ -1273,6 +1273,257 @@ border-color: #d1d5db; color: #111827; } + + /* ===== SCHEDULER PAGE STYLES ===== */ + .schedule-card { + background: rgba(42, 42, 42, 0.4); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + padding: 16px 20px; + transition: all 0.2s ease; + } + + .schedule-card:hover { + background: rgba(42, 42, 42, 0.7); + border-color: rgba(124, 58, 237, 0.3); + transform: translateX(4px); + } + + .schedule-card.paused { + opacity: 0.7; + border-left: 3px solid #f59e0b; + } + + .schedule-card.active { + border-left: 3px solid #10b981; + } + + .schedule-status-chip { + font-size: 0.7rem; + padding: 2px 8px; + border-radius: 9999px; + font-weight: 500; + } + + .schedule-status-chip.active { + background-color: rgba(16, 185, 129, 0.2); + color: #10b981; + } + + .schedule-status-chip.paused { + background-color: rgba(245, 158, 11, 0.2); + color: #f59e0b; + } + + .schedule-status-chip.running { + background-color: rgba(59, 130, 246, 0.2); + color: #3b82f6; + } + + .schedule-status-chip.success { + background-color: rgba(16, 185, 129, 0.2); + color: #10b981; + } + + .schedule-status-chip.failed { + background-color: rgba(239, 68, 68, 0.2); + color: #ef4444; + } + + .schedule-status-chip.scheduled { + background-color: rgba(124, 58, 237, 0.2); + color: #a78bfa; + } + + .schedule-tag { + font-size: 0.65rem; + padding: 2px 6px; + border-radius: 4px; + background-color: rgba(107, 114, 128, 0.3); + color: #9ca3af; + } + + .schedule-action-btn { + padding: 6px 10px; + border-radius: 6px; + font-size: 0.75rem; + transition: all 0.15s ease; + } + + .schedule-action-btn:hover { + transform: scale(1.05); + } + + .schedule-action-btn.run { + background: rgba(16, 185, 129, 0.2); + color: #10b981; + } + + .schedule-action-btn.run:hover { + background: rgba(16, 185, 129, 0.4); + } + + .schedule-action-btn.pause { + background: rgba(245, 158, 11, 0.2); + color: #f59e0b; + } + + .schedule-action-btn.pause:hover { + background: rgba(245, 158, 11, 0.4); + } + + .schedule-action-btn.edit { + background: rgba(59, 130, 246, 0.2); + color: #60a5fa; + } + + .schedule-action-btn.edit:hover { + background: rgba(59, 130, 246, 0.4); + } + + .schedule-action-btn.delete { + background: rgba(239, 68, 68, 0.2); + color: #f87171; + } + + .schedule-action-btn.delete:hover { + background: rgba(239, 68, 68, 0.4); + } + + .schedule-filter-btn.active { + background-color: #7c3aed !important; + color: white !important; + } + + /* Calendar styles */ + .schedule-calendar-day { + min-height: 80px; + background: rgba(42, 42, 42, 0.3); + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 8px; + padding: 8px; + transition: all 0.2s ease; + } + + .schedule-calendar-day:hover { + background: rgba(42, 42, 42, 0.6); + border-color: rgba(124, 58, 237, 0.3); + } + + .schedule-calendar-day.today { + border-color: #7c3aed; + background: rgba(124, 58, 237, 0.1); + } + + .schedule-calendar-day.other-month { + opacity: 0.4; + } + + .schedule-calendar-event { + font-size: 0.65rem; + padding: 2px 4px; + border-radius: 4px; + margin-top: 2px; + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .schedule-calendar-event.success { + background-color: rgba(16, 185, 129, 0.3); + color: #10b981; + } + + .schedule-calendar-event.failed { + background-color: rgba(239, 68, 68, 0.3); + color: #ef4444; + } + + .schedule-calendar-event.scheduled { + background-color: rgba(59, 130, 246, 0.3); + color: #60a5fa; + } + + /* Modal multi-step */ + .schedule-modal-step { + display: none; + } + + .schedule-modal-step.active { + display: block; + animation: fadeIn 0.3s ease; + } + + .schedule-step-indicator { + display: flex; + justify-content: center; + gap: 8px; + margin-bottom: 24px; + } + + .schedule-step-dot { + width: 32px; + height: 32px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.875rem; + font-weight: 600; + background: rgba(107, 114, 128, 0.3); + color: #9ca3af; + transition: all 0.3s ease; + } + + .schedule-step-dot.active { + background: #7c3aed; + color: white; + } + + .schedule-step-dot.completed { + background: #10b981; + color: white; + } + + .schedule-step-connector { + width: 40px; + height: 2px; + background: rgba(107, 114, 128, 0.3); + align-self: center; + } + + .schedule-step-connector.active { + background: #7c3aed; + } + + /* Recurrence preview */ + .recurrence-preview { + background: rgba(124, 58, 237, 0.1); + border: 1px solid rgba(124, 58, 237, 0.3); + border-radius: 8px; + padding: 12px 16px; + font-size: 0.875rem; + } + + /* Light theme overrides */ + body.light-theme .schedule-card { + background: rgba(255, 255, 255, 0.6); + border-color: #d1d5db; + } + + body.light-theme .schedule-card:hover { + background: rgba(255, 255, 255, 0.9); + } + + body.light-theme .schedule-calendar-day { + background: rgba(255, 255, 255, 0.6); + border-color: #e5e7eb; + } + + body.light-theme .schedule-calendar-day:hover { + background: rgba(255, 255, 255, 0.9); + } @@ -1291,6 +1542,7 @@ Hosts Playbooks Tasks + Schedules Logs Aide + + +
+
+

+ Planificateur +

+ Voir tout → +
+ + +
+
+
0
+
Actifs
+
+
+
--
+
Prochaine
+
+
+
0
+
Échecs 24h
+
+
+ + +
+

Chargement...

+
+ + +
@@ -1702,6 +1989,141 @@ + +
+
+
+ +
+

+ Planificateur des Playbooks +

+

Planifiez et orchestrez vos playbooks dans le temps - Exécutions automatiques ponctuelles ou récurrentes

+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+
0
+
Schedules actifs
+
+
+
0
+
En pause
+
+
+
--:--
+
Prochaine exécution
+
+
+
0
+
Échecs 24h
+
+
+ + +
+
+
+ Filtres: + + + +
+
+
+ + +
+
+
+ + +
+
+ +
+ + + +
+ + + + + +
+

+ Prochaines exécutions +

+
+ +
+
+
+
+
+ +
@@ -2385,6 +2807,16 @@ homelab-automation/
+