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 %}
-
+
-
-
-
-
+
+
+
+
+ | Active |
Command |
Command Name |
Schedule |
@@ -16,85 +17,101 @@
Action |
Logs |
-
- {% for job in jobs %}
-
- |
- Show Command
-
- |
-
- {{ job.name }}
- |
-
- {{ job.schedule }}
- |
-
- {{ job.next_run }}
- |
-
- {{ job.status }}
- |
-
-
-
-
-
-
-
-
-
-
- |
-
-
-
-
- |
-
- {% endfor %}
-
-
-
-
-
+
+ {% for job in jobs %}
+
+ |
+
+
+
+
+ |
+
+ Show Command
+
+ |
+
+ {{ job.name }}
+ |
+
+
+
+ |
+
+ {{ job.is_active and job.next_run or '(disabled)' }}
+ |
+
+ {{ job.status }}
+ |
+
+
+
+
+
+
+ |
+
+
+
+
+ |
+
+ {% endfor %}
+
+
+
+
+
-
-
-
-
+
+{% endblock %}
\ No newline at end of file
diff --git a/templates/jobs.html b/templates/jobs.html
index 26746e0..f2a9a5f 100644
--- a/templates/jobs.html
+++ b/templates/jobs.html
@@ -2,29 +2,27 @@
{% block content %}
-
+
-
+
-{% endblock %}
+{% endblock %}
\ No newline at end of file
diff --git a/templates/layout.html b/templates/layout.html
index a4e0f71..89c347d 100644
--- a/templates/layout.html
+++ b/templates/layout.html
@@ -1,44 +1,144 @@
+
Python Crontab WEB UI
-
-
-
+
+
+
+
-
-
-
-
-
-
-
- {% block content %}
- {% endblock %}
-
-
+
-
+
+