diff --git a/cronservice.py b/cronservice.py index dda92cd..9498c9f 100644 --- a/cronservice.py +++ b/cronservice.py @@ -1,29 +1,39 @@ from crontab import CronTab from croniter import croniter -from datetime import datetime +from datetime import datetime, timedelta import getpass +import subprocess +import os +import sys +import psutil +from pathlib import Path +import logging +import shlex +from cron_descriptor import get_description, Options from utils import add_log_file, Command, Name, Schedule, delete_log_file +logger = logging.getLogger(__name__) + _user = getpass.getuser() _cron = CronTab(user=_user) -def add_cron_job(comm: Command, name: Name, sched: Schedule) -> None: +def add_cron_job(comm: Command, name: Name, sched: Schedule, job_id: int) -> None: if croniter.is_valid(sched): - job = _cron.new(command=add_log_file(comm, name), comment=name) + job = _cron.new(command=add_log_file(comm, name, job_id), comment=name) job.setall(sched) _cron.write() else: raise ValueError("Invalid Cron Expression") -def update_cron_job(comm: Command, name: Name, sched: Schedule, old_name: Name) -> None: +def update_cron_job(comm: Command, name: Name, sched: Schedule, old_name: Name, job_id: int) -> None: match = _cron.find_comment(old_name) job = list(match)[0] job.setall(sched) - job.set_command(add_log_file(comm, name)) + job.set_command(add_log_file(comm, name, job_id)) job.set_comment(name) _cron.write() @@ -34,10 +44,213 @@ def delete_cron_job(name: Name) -> None: delete_log_file(name) -def run_manually(name: Name) -> None: - match = _cron.find_comment(name) - job = list(match)[0] - job.run() +def get_lock_file_path(job_id: int) -> Path: + """Retourne le chemin du fichier lock pour un job""" + return Path(f"/tmp/crontab_job_{job_id}.lock") + + +def is_job_running(job_id: int) -> tuple[bool, int | None]: + """ + Vérifie si un job est déjà en cours d'exécution. + + Returns: + tuple: (is_running, pid) - True si le job tourne, False sinon, avec le PID ou None + """ + lock_file = get_lock_file_path(job_id) + + logger.info(f"Checking lock file: {lock_file}, exists: {lock_file.exists()}") + + if not lock_file.exists(): + logger.info(f"No lock file for job {job_id}") + return False, None + + try: + with open(lock_file, 'r') as f: + pid = int(f.read().strip()) + + logger.info(f"Lock file contains PID: {pid}") + + # Vérifier si le process existe toujours + if psutil.pid_exists(pid): + logger.info(f"PID {pid} exists in system") + try: + process = psutil.Process(pid) + status = process.status() + logger.info(f"Process {pid} status: {status}") + # Vérifier que le process n'est pas un zombie + if status != psutil.STATUS_ZOMBIE: + logger.info(f"Job {job_id} is running with PID {pid}") + return True, pid + else: + logger.info(f"Process {pid} is zombie, cleaning lock") + except (psutil.NoSuchProcess, psutil.AccessDenied) as e: + logger.info(f"Process check failed: {e}") + pass + else: + logger.info(f"PID {pid} does not exist") + + # Le process n'existe plus, nettoyer le lock + lock_file.unlink() + logger.info(f"Cleaned stale lock file for job {job_id}") + return False, None + + except (ValueError, FileNotFoundError): + # Fichier lock corrompu ou supprimé entre-temps + if lock_file.exists(): + lock_file.unlink() + return False, None + + +def create_lock(job_id: int, pid: int) -> None: + """Crée un fichier lock avec le PID du process""" + lock_file = get_lock_file_path(job_id) + with open(lock_file, 'w') as f: + f.write(str(pid)) + + +def release_lock(job_id: int) -> None: + """Supprime le fichier lock""" + lock_file = get_lock_file_path(job_id) + if lock_file.exists(): + lock_file.unlink() + + +def run_manually(name: Name, job_id: int, db_command: str) -> dict: + """ + Lance un job manuellement en arrière-plan de manière non-bloquante. + + Args: + name: Nom du job + job_id: ID du job + db_command: Commande originale depuis la DB (sans wrapper cron) + + Returns: + dict: Informations sur le lancement (success, message, pid) + """ + # Vérifier si le job est déjà en cours d'exécution + is_running, existing_pid = is_job_running(job_id) + if is_running: + logger.warning(f"Job {job_id} ({name}) already running with PID {existing_pid}") + return { + "success": False, + "message": f"Job already running with PID {existing_pid}", + "pid": existing_pid + } + + try: + # Utiliser la commande originale (DB) avec logging, pas celle du crontab (qui a le wrapper) + from utils import add_log_file + command = add_log_file(db_command, name, job_id=None) # Sans wrapper cron + + logger.info(f"Launching job {job_id} ({name}) in background") + logger.debug(f"Command to execute: {command}") + + # Créer un script Python wrapper qui gère l'exécution + wrapper_script = f"""#!/usr/bin/env python3 +import subprocess +import sys +import os +import signal +import logging + +# Configuration du logging +logging.basicConfig( + filename='/tmp/crontab_wrapper_{job_id}.log', + level=logging.DEBUG, + format='%(asctime)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +logger.info("Wrapper started for job {job_id}") + +# Ignorer SIGHUP pour survivre à la fermeture de la session parent +signal.signal(signal.SIGHUP, signal.SIG_IGN) + +lock_file = "/tmp/crontab_job_{job_id}.lock" + +try: + command = {repr(command)} + logger.info(f"Executing command: {{command}}") + + # Exécuter la commande via shell + result = subprocess.run( + command, + shell=True, + capture_output=False + ) + + logger.info(f"Command finished with return code: {{result.returncode}}") + sys.exit(result.returncode) +finally: + # Nettoyer le lock + logger.info("Cleaning up lock file") + try: + os.remove(lock_file) + logger.info("Lock file removed") + except Exception as e: + logger.error(f"Failed to remove lock: {{e}}") +""" + + # Créer un fichier temporaire pour le script wrapper + wrapper_path = f"/tmp/crontab_wrapper_{job_id}.py" + with open(wrapper_path, 'w') as f: + f.write(wrapper_script) + os.chmod(wrapper_path, 0o755) + + logger.debug(f"Wrapper script created at {wrapper_path}") + + # Lancer le processus en arrière-plan détaché + process = subprocess.Popen( + [sys.executable, wrapper_path], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, + start_new_session=True, + close_fds=True, + ) + + pid = process.pid + + # Créer immédiatement le lock avec le PID du wrapper + create_lock(job_id, pid) + logger.info(f"Lock created for job {job_id} with PID {pid}") + + # Attendre 0.5s et vérifier que le process existe toujours + import time + time.sleep(0.5) + + if not psutil.pid_exists(pid): + release_lock(job_id) + logger.error(f"Wrapper process {pid} died immediately - check wrapper script") + return { + "success": False, + "message": "Le wrapper a échoué au démarrage. Vérifiez les logs.", + "pid": None + } + + # Ne pas attendre la fin du processus + logger.info(f"Job {job_id} ({name}) launched successfully with PID {pid}") + + return { + "success": True, + "message": f"Job lancé en arrière-plan (PID: {pid}). Consultez les logs pour suivre l'exécution.", + "pid": pid + } + + except IndexError: + logger.error(f"Job {job_id} ({name}) not found in crontab") + return { + "success": False, + "message": "Job not found in crontab", + "pid": None + } + except Exception as e: + logger.error(f"Error launching job {job_id} ({name}): {str(e)}", exc_info=True) + return { + "success": False, + "message": f"Error launching job: {str(e)}", + "pid": None + } def get_next_schedule(name: Name) -> str: @@ -48,3 +261,95 @@ def get_next_schedule(name: Name) -> str: return schedule.get_next().strftime("%d/%m/%Y %H:%M:%S").replace("/", "-") except IndexError: return None + + +def get_cron_description(schedule: str, locale: str = "en") -> str: + """ + Génère une description lisible d'une expression cron. + + Args: + schedule: Expression cron (ex: "0 2 * * *") + locale: Code locale à 2 lettres (ex: "fr", "en") + + Returns: + str: Description localisée ou expression brute en cas d'erreur + """ + try: + # cron-descriptor 2.x utilise Options() au lieu de locale_code + # La locale est configurée via les Options + options = Options() + options.throw_exception_on_parse_error = False + options.use_24hour_time_format = True + + # Note: cron-descriptor 2.x ne supporte pas facilement les locales dynamiques + # La locale doit être configurée globalement ou via des options spécifiques + # Pour l'instant, on utilise la locale par défaut (anglais) + return get_description(schedule, options=options) + except Exception as e: + logger.warning(f"Failed to get cron description for '{schedule}': {e}") + return schedule # Fallback sur l'expression brute + + +def sync_job_to_cron(comm: Command, name: Name, sched: Schedule, job_id: int, is_active: bool = True) -> None: + """Synchronise un job de la DB vers le crontab système""" + # Vérifier si le job existe déjà dans le crontab + existing_jobs = list(_cron.find_comment(name)) + + if existing_jobs: + # Mettre à jour le job existant + job = existing_jobs[0] + job.setall(sched) + job.set_command(add_log_file(comm, name, job_id)) + job.enable(is_active) # Activer ou commenter le job + else: + # Créer un nouveau job + if croniter.is_valid(sched): + job = _cron.new(command=add_log_file(comm, name, job_id), comment=name) + job.setall(sched) + job.enable(is_active) # Activer ou commenter le job + + _cron.write() + + +def enable_cron_job(name: Name, enable: bool = True) -> bool: + """ + Active ou désactive un job dans le crontab. + + Args: + name: Nom du job (commentaire dans le crontab) + enable: True pour activer, False pour désactiver (commenter avec #) + + Returns: + bool: True si l'opération a réussi, False sinon + """ + try: + match = _cron.find_comment(name) + job = list(match)[0] + job.enable(enable) + _cron.write() + logger.info(f"Job '{name}' {'enabled' if enable else 'disabled'} successfully") + return True + except IndexError: + logger.error(f"Job '{name}' not found in crontab") + return False + except Exception as e: + logger.error(f"Error enabling/disabling job '{name}': {e}") + return False + + +def is_job_enabled(name: Name) -> bool: + """ + Vérifie si un job est activé dans le crontab. + + Args: + name: Nom du job (commentaire dans le crontab) + + Returns: + bool: True si le job est activé, False s'il est désactivé ou non trouvé + """ + try: + match = _cron.find_comment(name) + job = list(match)[0] + return job.is_enabled() + except IndexError: + return False diff --git a/main.py b/main.py index 5ab7792..169dbef 100644 --- a/main.py +++ b/main.py @@ -1,16 +1,32 @@ from fastapi import FastAPI, Request, Depends, HTTPException from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse from sqlalchemy.orm import Session +import logging import models import cronservice from models import Job -from utils import watch_status, load_logs +from utils import clear_logs, load_logs, get_locale_from_accept_language, watch_status from database import SessionLocal, engine, JobRequest +# Configuration du logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + app = FastAPI() +# Configuration CORS pour permettre les requêtes depuis le navigateur +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + app.mount("/static", StaticFiles(directory="static"), name="static") models.Base.metadata.create_all(bind=engine) templates = Jinja2Templates(directory="templates") @@ -24,6 +40,37 @@ def get_db(): db.close() +@app.on_event("startup") +async def startup_event(): + """Synchronise la base de données avec le crontab système au démarrage""" + logger.info("🚀 Application démarrée - Synchronisation des jobs cron...") + + db = SessionLocal() + try: + # Récupérer tous les jobs de la base de données + jobs = db.query(Job).all() + + if jobs: + logger.info(f"📋 Synchronisation de {len(jobs)} job(s) avec le crontab système...") + + for job in jobs: + try: + cronservice.sync_job_to_cron(job.command, job.name, job.schedule, job.id, job.is_active) + status = "✅" if job.is_active else "⏸️ (désactivé)" + logger.info(f" {status} Job '{job.name}' synchronisé") + except Exception as e: + logger.error(f" ❌ Erreur lors de la synchronisation du job '{job.name}': {e}") + + logger.info("✅ Synchronisation terminée avec succès") + else: + logger.info("ℹ️ Aucun job à synchroniser") + + except Exception as e: + logger.error(f"❌ Erreur lors de la synchronisation au démarrage: {e}") + finally: + db.close() + + def update_displayed_schedule(db: Session = Depends(get_db)) -> None: jobs = db.query(Job).all() for job in jobs: @@ -35,6 +82,15 @@ def update_displayed_schedule(db: Session = Depends(get_db)) -> None: async def home(request: Request, db: Session = Depends(get_db)): update_displayed_schedule(db) jobs = db.query(Job).all() + + # Extraire la locale depuis Accept-Language + accept_language = request.headers.get("Accept-Language", "en") + locale = get_locale_from_accept_language(accept_language) + + # Enrichir chaque job avec sa description cron localisée + for job in jobs: + job.cron_description = cronservice.get_cron_description(job.schedule, locale) + output = {"request": request, "jobs": jobs} return templates.TemplateResponse("home.html", output) @@ -48,14 +104,66 @@ async def get_jobs(job_id: int, request: Request, db: Session = Depends(get_db)) @app.get("/logs/{job_id}") async def get_logs(job_id: int, request: Request, db: Session = Depends(get_db)): - job = db.query(Job).filter(Job.id == job_id) - update_log = {"log": load_logs(job.first().name)} - job.update(update_log) - db.commit() - output = {"request": request, "job": update_log} + job = db.query(Job).filter(Job.id == job_id).first() + log_content = load_logs(job.name) + output = {"request": request, "job": job, "log_content": log_content} return templates.TemplateResponse("logs.html", output) +@app.post("/clear_logs/{job_id}/") +async def clear_job_logs(job_id: int, db: Session = Depends(get_db)): + """ + Efface le contenu des logs d'un job. + """ + try: + logger.info(f"Clearing logs for job {job_id}") + + job = db.query(Job).filter(Job.id == job_id).first() + + if not job: + logger.warning(f"Job {job_id} not found in database") + raise HTTPException(status_code=404, detail="Job not found") + + clear_logs(job.name) + logger.info(f"Logs cleared successfully for job {job_id}: {job.name}") + + return JSONResponse( + content={ + "success": True, + "message": "Logs cleared successfully" + }, + status_code=200 + ) + except Exception as e: + logger.error(f"Error clearing logs for job {job_id}: {str(e)}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + + +@app.get("/refresh_logs/{job_id}/") +async def refresh_job_logs(job_id: int, db: Session = Depends(get_db)): + """ + Récupère le contenu actuel des logs d'un job. + """ + try: + job = db.query(Job).filter(Job.id == job_id).first() + + if not job: + raise HTTPException(status_code=404, detail="Job not found") + + log_content = load_logs(job.name) + + return JSONResponse( + content={ + "success": True, + "log_content": log_content + }, + status_code=200 + ) + except Exception as e: + logger.error(f"Error refreshing logs for job {job_id}: {str(e)}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + + @app.post("/create_job/") async def create_job(job_request: JobRequest, db: Session = Depends(get_db)): job = Job() @@ -63,11 +171,17 @@ async def create_job(job_request: JobRequest, db: Session = Depends(get_db)): job.name = job_request.name job.schedule = job_request.schedule try: - cronservice.add_cron_job(job.command, job.name, job.schedule) - job.next_run = cronservice.get_next_schedule(job.name) + # D'abord ajouter à la DB pour obtenir l'ID db.add(job) db.commit() + db.refresh(job) # Récupérer l'ID généré + + # Ensuite ajouter au crontab avec l'ID + cronservice.add_cron_job(job.command, job.name, job.schedule, job.id) + job.next_run = cronservice.get_next_schedule(job.name) + db.commit() except ValueError: + db.rollback() raise HTTPException(status_code=404, detail="Invalid Cron Expression") return job_request @@ -77,11 +191,14 @@ async def update_job( job_id: int, job_request: JobRequest, db: Session = Depends(get_db) ): existing_job = db.query(Job).filter(Job.id == job_id) + old_name = existing_job.first().name + cronservice.update_cron_job( job_request.command, job_request.name, job_request.schedule, - existing_job.first().name, + old_name, + job_id ) existing_job.update(job_request.__dict__) existing_job.update({"next_run": cronservice.get_next_schedule(job_request.name)}) @@ -91,16 +208,100 @@ async def update_job( @app.get("/run_job/{job_id}/") async def run_job(job_id: int, db: Session = Depends(get_db)): - chosen_job = db.query(Job).filter(Job.id == job_id).first() - chosen_name = chosen_job.name - cronservice.run_manually(chosen_name) - return {"msg": "Successfully run job."} + """ + Lance un job manuellement en arrière-plan. + Retourne immédiatement avec le statut du lancement. + """ + try: + logger.info(f"Received request to run job {job_id}") + + chosen_job = db.query(Job).filter(Job.id == job_id).first() + + if not chosen_job: + logger.warning(f"Job {job_id} not found in database") + raise HTTPException(status_code=404, detail="Job not found") + + logger.info(f"Running job {job_id}: {chosen_job.name}") + result = cronservice.run_manually(chosen_job.name, job_id, chosen_job.command) + + if not result["success"]: + logger.warning(f"Job {job_id} execution rejected: {result['message']}") + raise HTTPException(status_code=409, detail=result["message"]) + + logger.info(f"Job {job_id} launched successfully with PID {result['pid']}") + + # Retourner une JSONResponse explicite pour Firefox + return JSONResponse( + content={ + "success": True, + "message": result["message"], + "pid": result["pid"] + }, + status_code=200, + headers={ + "Cache-Control": "no-cache, no-store, must-revalidate", + "Pragma": "no-cache", + "Expires": "0" + } + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"Unexpected error in run_job endpoint for job {job_id}: {str(e)}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") @app.delete("/job/{job_id}/") async def delete_job(job_id: int, db: Session = Depends(get_db)): job_update = db.query(Job).filter(Job.id == job_id).first() cronservice.delete_cron_job(job_update.name) + + # Nettoyer le lock si le job était en cours d'exécution + cronservice.release_lock(job_id) + db.delete(job_update) db.commit() return {"INFO": f"Deleted {job_id} Successfully"} + + +@app.post("/toggle_job/{job_id}/") +async def toggle_job(job_id: int, db: Session = Depends(get_db)): + """ + Enable or disable a job (toggle). + A disabled job is commented in the crontab with #. + """ + try: + job = db.query(Job).filter(Job.id == job_id).first() + + if not job: + raise HTTPException(status_code=404, detail="Job not found") + + # Toggle the state + new_state = not job.is_active + + # Update in crontab + success = cronservice.enable_cron_job(job.name, new_state) + + if not success: + raise HTTPException(status_code=500, detail="Failed to update crontab") + + # Update in database + job.is_active = new_state + db.commit() + + status_text = "enabled" if new_state else "disabled" + logger.info(f"Job {job_id} ({job.name}) {status_text}") + + return JSONResponse( + content={ + "success": True, + "is_active": new_state, + "message": f"Job {status_text} successfully" + }, + status_code=200 + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error toggling job {job_id}: {str(e)}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") diff --git a/models.py b/models.py index 573ad43..340bb8b 100644 --- a/models.py +++ b/models.py @@ -13,4 +13,4 @@ class Job(Base): next_run = Column(String, default=None) status = Column(String, default=None) log = Column(String, default=None) - is_active = Column(Boolean, default=False) + is_active = Column(Boolean, default=True) # True = job actif, False = job commenté (#) diff --git a/requirements.txt b/requirements.txt index ad73da4..9b8ccb0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ backports.entry-points-selectable==1.1.0 black==21.9b0 cfgv==3.3.1 click==8.0.3 +cron-descriptor>=2.0.0 croniter==1.0.15 distlib==0.3.3 fastapi==0.68.2 @@ -18,6 +19,7 @@ nodeenv==1.6.0 pathspec==0.9.0 platformdirs==2.4.0 pre-commit==2.15.0 +psutil==5.8.0 pydantic==1.8.2 python-crontab==2.5.1 python-dateutil==2.8.2 diff --git a/static/main.js b/static/main.js index 13d4ebc..3593e26 100644 --- a/static/main.js +++ b/static/main.js @@ -1,106 +1,368 @@ -$(document).ready(function () { - $("#add_job").click(function () { - $('.ui.modal').modal('show'); +// JavaScript vanilla moderne - pas de jQuery nécessaire +document.addEventListener("DOMContentLoaded", function () { + // Toggle pour activer/désactiver les jobs + document.querySelectorAll(".job-toggle").forEach((toggle) => { + toggle.addEventListener("change", function () { + const jobId = this.getAttribute("data-job-id"); + const isChecked = this.checked; + const row = this.closest("tr"); + const runButton = row.querySelector(".ui.grey.basic.button"); + + console.log( + `Toggling job ${jobId} to ${isChecked ? "active" : "inactive"}` + ); + + fetch(`/toggle_job/${jobId}/`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + }) + .then((response) => { + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + return response.json(); + }) + .then((data) => { + if (data.success) { + console.log(`Job ${jobId} toggled successfully:`, data); + + // Mettre à jour l'apparence de la ligne + if (data.is_active) { + row.classList.remove("disabled-job"); + if (runButton) runButton.disabled = false; + } else { + row.classList.add("disabled-job"); + if (runButton) runButton.disabled = true; + } + + // Recharger pour mettre à jour le "Next Run" + setTimeout(() => location.reload(), 500); + } else { + alert(`❌ ${data.message || "Unknown error"}`); + // Remettre le toggle à son état précédent + toggle.checked = !isChecked; + } + }) + .catch((error) => { + console.error("Error toggling job:", error); + alert(`❌ Error: ${error.message}`); + // Remettre le toggle à son état précédent + toggle.checked = !isChecked; + }); + }); + }); + + // Bouton "Add Job" - ouvre le modal + const addJobBtn = document.getElementById("add_job"); + if (addJobBtn) { + addJobBtn.addEventListener("click", function () { + const modal = document.querySelector(".ui.modal"); + if (modal) { + modal.classList.add("visible", "active"); + document.body.classList.add("dimmable", "dimmed"); + } }); + } - $(".ui.inverted.red.button").click(function () { - if (confirm('Are you sure you want delete this job?')) { - const id = $(this).val(); - $.ajax({ - url: `job/${id}/`, - type: 'DELETE', - contentType: 'application/json', - }); - alert("Job Deleted!. Please Reload") - } + // Fonction pour fermer le modal + function closeModal() { + const modal = document.querySelector(".ui.modal"); + if (modal) { + modal.classList.remove("visible", "active"); + document.body.classList.remove("dimmable", "dimmed"); + } + } + // Bouton de fermeture (X) du modal + const closeIcon = document.querySelector(".ui.modal .close.icon"); + if (closeIcon) { + closeIcon.addEventListener("click", closeModal); + } + // Fermer en cliquant en dehors du modal (sur le fond sombre) + document.addEventListener("click", function (e) { + const modal = document.querySelector(".ui.modal"); + if (modal && modal.classList.contains("visible")) { + // Si le clic est sur le body.dimmed mais pas sur le modal + if ( + document.body.classList.contains("dimmed") && + !modal.contains(e.target) && + e.target !== addJobBtn + ) { + closeModal(); + } + } + }); + + // Fermer avec la touche Escape + document.addEventListener("keydown", function (e) { + if (e.key === "Escape") { + closeModal(); + } + }); + + // Boutons "Delete" (poubelle) + document.querySelectorAll(".delete-btn").forEach((button) => { + button.addEventListener("click", function () { + if (confirm("Are you sure you want to delete this job?")) { + const id = this.value; + fetch(`job/${id}/`, { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + }) + .then(() => { + alert("✅ Job deleted!"); + location.reload(); + }) + .catch((error) => alert(`❌ Error: ${error.message}`)); + } }); + }); + + // Boutons "Run Now" (play) + document.querySelectorAll(".run-btn").forEach((button) => { + button.addEventListener("click", function (e) { + e.preventDefault(); + e.stopPropagation(); + + const id = this.value; + console.log(`Attempting to run job ${id}`); - $(".ui.grey.basic.button").click(function () { - if (confirm('Are you sure you want run this job?')) { - const id = $(this).val(); - $.ajax({ - url: `/run_job/${id}/`, - type: 'GET', - contentType: 'application/json', - }); + if (!id) { + console.error("No job ID found"); + alert("❌ Error: Job ID not found"); + return; + } - alert("Job is executed") - } + fetch(`/run_job/${id}/`, { + method: "GET", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + cache: "no-cache", + credentials: "same-origin", + }) + .then((response) => { + console.log("Response status:", response.status); + if (!response.ok && response.status !== 409) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + return response.json().then((data) => ({ + status: response.status, + data: data, + ok: response.ok, + })); + }) + .then(({ status, data, ok }) => { + console.log("Parsed response:", { status, data, ok }); + if (ok && data.success) { + alert(`✅ ${data.message}`); + } else if (status === 409) { + alert(`⚠️ ${data.detail || "Job already running"}`); + } else if (status === 404) { + alert(`❌ Job not found`); + } else if (status === 500) { + alert(`❌ Server error: ${data.detail || "Internal error"}`); + } else { + alert(`❌ ${data.message || data.detail || "Unknown error"}`); + } + }) + .catch((error) => { + console.error("Fetch error:", error); + alert( + `❌ Network error: ${error.message}\n\nCheck the console (F12) for more details.` + ); + }); }); + }); - $("#save").click(function () { - - const command = $("#command").val(); - const command_name = $("#command_name").val(); - const schedule = $("#schedule").val(); - - if (command === "" || command_name === "" || schedule === "") { - alert("You must fill out all fields") - } else { - $.ajax({ - url: '/create_job/', - type: 'POST', - contentType: 'application/json', - data: JSON.stringify({ - "command": command, - "name": command_name, - "schedule": schedule - }), - statusCode: { - 404: function () { - // No content found (404) - // This code will be executed if the server returns a 404 response - alert("Make sure the cron expression is valid.") - }, - }, - dataType: 'json', - }); - } - - $('.ui.modal').modal('hide'); + // Bouton "Save" - créer un job + const saveBtn = document.getElementById("save"); + if (saveBtn) { + saveBtn.addEventListener("click", function () { + const command = document.getElementById("command").value; + const command_name = document.getElementById("command_name").value; + const schedule = document.getElementById("schedule").value; + if (command === "" || command_name === "" || schedule === "") { + alert("You must fill out all fields"); + } else { + fetch("/create_job/", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + command: command, + name: command_name, + schedule: schedule, + }), + }) + .then((response) => { + if (response.status === 404) { + alert("Make sure the cron expression is valid."); + } + return response.json(); + }) + .then(() => { + closeModal(); + location.reload(); // Recharger pour voir le nouveau job + }) + .catch((error) => console.error("Error:", error)); + } }); + } + + // Bouton "Cancel" - fermer le modal + const cancelBtn = document.getElementById("cancel"); + if (cancelBtn) { + cancelBtn.addEventListener("click", closeModal); + } - $("#update").click(function () { - const id = $(this).val(); - const command = $("#command").val(); - const command_name = $("#command_name").val(); - const schedule = $("#schedule").val(); - - if (command === "" || command_name === "" || schedule === "") { - alert("You must fill out all fields") - } else { - $.ajax({ - url: `/update_job/${id}/`, - type: 'PUT', - contentType: 'application/json', - data: JSON.stringify({ - "command": command, - "name": command_name, - "schedule": schedule - }), - statusCode: { - 500: function () { - // No content found (404) - // This code will be executed if the server returns a 404 response - alert("Make sure the cron expression is valid.") - }, - }, - dataType: 'json' - }); - - } + // Bouton "Update" - mettre à jour un job + const updateBtn = document.getElementById("update"); + if (updateBtn) { + updateBtn.addEventListener("click", function () { + const id = this.value; + const command = document.getElementById("command").value; + const command_name = document.getElementById("command_name").value; + const schedule = document.getElementById("schedule").value; + if (command === "" || command_name === "" || schedule === "") { + alert("You must fill out all fields"); + } else { + fetch(`/update_job/${id}/`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + command: command, + name: command_name, + schedule: schedule, + }), + }) + .then((response) => { + if (response.status === 500) { + alert("Make sure the cron expression is valid."); + } + return response.json(); + }) + .then(() => location.reload()) + .catch((error) => console.error("Error:", error)); + } }); + } + + // Popups "Show Command" - toggle visibility + document.querySelectorAll(".custom.button").forEach((button) => { + button.addEventListener("click", function (e) { + e.stopPropagation(); - $('.custom.button') - .popup({ - popup: $('.custom.popup'), - on: 'click', - inline: true + // Trouver le popup qui suit directement ce bouton + const popup = this.nextElementSibling; + + if ( + popup && + popup.classList.contains("custom") && + popup.classList.contains("popup") + ) { + // Fermer tous les autres popups + document.querySelectorAll(".custom.popup.visible").forEach((p) => { + if (p !== popup) { + p.classList.remove("visible"); + } }); + // Toggle ce popup + popup.classList.toggle("visible"); + + // Positionner le popup + const rect = this.getBoundingClientRect(); + popup.style.position = "absolute"; + popup.style.top = rect.bottom + 5 + "px"; + popup.style.left = rect.left + "px"; + popup.style.zIndex = "1000"; + } + }); + }); + + // Fermer les popups si on clique ailleurs + document.addEventListener("click", function (e) { + if ( + !e.target.classList.contains("custom") || + !e.target.classList.contains("button") + ) { + document.querySelectorAll(".custom.popup.visible").forEach((popup) => { + popup.classList.remove("visible"); + }); + } + }); + + // Bouton "Clear Logs" - efface les logs + const clearLogsBtn = document.getElementById("clear-logs"); + if (clearLogsBtn) { + clearLogsBtn.addEventListener("click", function () { + if (!confirm("Are you sure you want to clear all logs for this job?")) { + return; + } + + const jobId = this.getAttribute("data-job-id"); + console.log(`Clearing logs for job ${jobId}`); + + fetch(`/clear_logs/${jobId}/`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + }) + .then((response) => response.json()) + .then((data) => { + if (data.success) { + alert("✅ " + data.message); + // Vider l'affichage des logs + const logOutput = document.getElementById("log-output"); + if (logOutput) { + logOutput.textContent = ""; + } + } else { + alert("❌ Failed to clear logs"); + } + }) + .catch((error) => { + console.error("Error:", error); + alert(`❌ Error: ${error.message}`); + }); + }); + } + + // Bouton "Refresh Logs" - recharge les logs + const refreshLogsBtn = document.getElementById("refresh-logs"); + if (refreshLogsBtn) { + refreshLogsBtn.addEventListener("click", function () { + const jobId = this.getAttribute("data-job-id"); + console.log(`Refreshing logs for job ${jobId}`); + + fetch(`/refresh_logs/${jobId}/`, { + method: "GET", + headers: { Accept: "application/json" }, + }) + .then((response) => response.json()) + .then((data) => { + if (data.success) { + // Mettre à jour l'affichage des logs + const logOutput = document.getElementById("log-output"); + if (logOutput) { + logOutput.textContent = data.log_content; + } + console.log("Logs refreshed successfully"); + } else { + alert("❌ Failed to refresh logs"); + } + }) + .catch((error) => { + console.error("Error:", error); + alert(`❌ Error: ${error.message}`); + }); + }); + } }); diff --git a/templates/home.html b/templates/home.html index f406e2f..9611c61 100644 --- a/templates/home.html +++ b/templates/home.html @@ -1,13 +1,14 @@ {% extends "layout.html" %} {% block content %} - + -
- - - +
+
+ + + @@ -16,85 +17,101 @@ - - {% for job in jobs %} - - - - - - - - - - {% endfor %} -
Active Command Command Name ScheduleAction Logs
-
Show Command
- -
-
{{ job.name }}
-
-
{{ job.schedule }}
-
-
{{ job.next_run }}
-
-
{{ job.status }}
-
- - - - - - - - - - - - - -
-

- -

-
+ + {% for job in jobs %} + + +
+ + +
+ + +
Show Command
+ + + +
{{ job.name }}
+ + +
+
{{ job.schedule }}
+
+ + + +
{{ job.is_active and job.next_run or '(disabled)' }}
+ + +
{{ job.status }}
+ + + + + + + + + + + + + + + {% endfor %} + +

+ +

+ -