- FastAPI backend avec APScheduler - API REST complète (CRUD + toggle + run + logs) - UI Jinja2 + Tailwind (charte orange/slate) - SQLite pour la persistance - Docker + Traefik labels - Swagger docs auto sur /docs
537 lines
17 KiB
Python
537 lines
17 KiB
Python
import asyncio
|
|
import os
|
|
import subprocess
|
|
import sqlite3
|
|
import uuid
|
|
from contextlib import asynccontextmanager
|
|
from datetime import datetime
|
|
from typing import Optional, List
|
|
|
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|
from apscheduler.triggers.cron import CronTrigger
|
|
from fastapi import FastAPI, Request, Form, HTTPException
|
|
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
|
|
from fastapi.templating import Jinja2Templates
|
|
from pydantic import BaseModel
|
|
|
|
# ──────────────────────────────────────────────
|
|
# Config
|
|
# ──────────────────────────────────────────────
|
|
DB_PATH = os.environ.get("DB_PATH", "/data/cronhub.db")
|
|
scheduler = AsyncIOScheduler(timezone="Europe/Paris")
|
|
|
|
|
|
# ──────────────────────────────────────────────
|
|
# DB helpers
|
|
# ──────────────────────────────────────────────
|
|
def get_db():
|
|
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
|
conn = sqlite3.connect(DB_PATH)
|
|
conn.row_factory = sqlite3.Row
|
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
conn.execute("PRAGMA foreign_keys = ON")
|
|
return conn
|
|
|
|
|
|
def init_db():
|
|
conn = get_db()
|
|
conn.executescript("""
|
|
CREATE TABLE IF NOT EXISTS jobs (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
schedule TEXT NOT NULL,
|
|
command TEXT NOT NULL,
|
|
description TEXT DEFAULT '',
|
|
enabled INTEGER DEFAULT 1,
|
|
last_run TEXT,
|
|
last_status TEXT DEFAULT 'never',
|
|
created_at TEXT DEFAULT (datetime('now'))
|
|
);
|
|
CREATE TABLE IF NOT EXISTS logs (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
|
|
started_at TEXT NOT NULL,
|
|
ended_at TEXT,
|
|
status TEXT NOT NULL DEFAULT 'running',
|
|
exit_code INTEGER,
|
|
stdout TEXT DEFAULT '',
|
|
stderr TEXT DEFAULT ''
|
|
);
|
|
""")
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
|
|
def row_to_dict(row) -> dict:
|
|
return dict(row) if row else None
|
|
|
|
|
|
def get_all_jobs() -> list:
|
|
conn = get_db()
|
|
rows = conn.execute("SELECT * FROM jobs ORDER BY created_at DESC").fetchall()
|
|
conn.close()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
def get_job(job_id: str) -> Optional[dict]:
|
|
conn = get_db()
|
|
row = conn.execute("SELECT * FROM jobs WHERE id = ?", (job_id,)).fetchone()
|
|
conn.close()
|
|
return row_to_dict(row)
|
|
|
|
|
|
def get_job_logs(job_id: str, limit: int = 50) -> list:
|
|
conn = get_db()
|
|
rows = conn.execute(
|
|
"SELECT * FROM logs WHERE job_id = ? ORDER BY started_at DESC LIMIT ?",
|
|
(job_id, limit)
|
|
).fetchall()
|
|
conn.close()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
# ──────────────────────────────────────────────
|
|
# Job execution
|
|
# ──────────────────────────────────────────────
|
|
def run_job_sync(job_id: str):
|
|
"""Called by APScheduler (sync) or direct trigger."""
|
|
conn = get_db()
|
|
job = conn.execute("SELECT * FROM jobs WHERE id = ?", (job_id,)).fetchone()
|
|
if not job:
|
|
conn.close()
|
|
return
|
|
if not job["enabled"]:
|
|
conn.close()
|
|
return
|
|
|
|
started_at = datetime.now().isoformat()
|
|
log_id = conn.execute(
|
|
"INSERT INTO logs (job_id, started_at, status) VALUES (?, ?, 'running')",
|
|
(job_id, started_at)
|
|
).lastrowid
|
|
conn.execute("UPDATE jobs SET last_run = ?, last_status = 'running' WHERE id = ?",
|
|
(started_at, job_id))
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
job["command"],
|
|
shell=True,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=3600,
|
|
)
|
|
status = "success" if result.returncode == 0 else "failed"
|
|
stdout = result.stdout[-10000:] if result.stdout else ""
|
|
stderr = result.stderr[-10000:] if result.stderr else ""
|
|
exit_code = result.returncode
|
|
except subprocess.TimeoutExpired:
|
|
status = "failed"
|
|
stdout = ""
|
|
stderr = "Timeout (3600s)"
|
|
exit_code = -1
|
|
except Exception as e:
|
|
status = "failed"
|
|
stdout = ""
|
|
stderr = str(e)
|
|
exit_code = -1
|
|
|
|
ended_at = datetime.now().isoformat()
|
|
conn2 = get_db()
|
|
conn2.execute(
|
|
"UPDATE logs SET ended_at=?, status=?, exit_code=?, stdout=?, stderr=? WHERE id=?",
|
|
(ended_at, status, exit_code, stdout, stderr, log_id)
|
|
)
|
|
conn2.execute(
|
|
"UPDATE jobs SET last_run=?, last_status=? WHERE id=?",
|
|
(started_at, status, job_id)
|
|
)
|
|
conn2.commit()
|
|
conn2.close()
|
|
|
|
|
|
# ──────────────────────────────────────────────
|
|
# Scheduler helpers
|
|
# ──────────────────────────────────────────────
|
|
def _parse_cron(schedule: str) -> CronTrigger:
|
|
parts = schedule.strip().split()
|
|
if len(parts) != 5:
|
|
raise ValueError(f"Invalid cron: {schedule}")
|
|
minute, hour, day, month, dow = parts
|
|
return CronTrigger(
|
|
minute=minute, hour=hour, day=day, month=month, day_of_week=dow,
|
|
timezone="Europe/Paris"
|
|
)
|
|
|
|
|
|
def schedule_job(job: dict):
|
|
job_id = job["id"]
|
|
if not job.get("enabled"):
|
|
return
|
|
try:
|
|
trigger = _parse_cron(job["schedule"])
|
|
if scheduler.get_job(job_id):
|
|
scheduler.remove_job(job_id)
|
|
scheduler.add_job(run_job_sync, trigger, id=job_id, args=[job_id],
|
|
replace_existing=True, misfire_grace_time=60)
|
|
except Exception as e:
|
|
print(f"[CronHub] Failed to schedule {job_id}: {e}")
|
|
|
|
|
|
def unschedule_job(job_id: str):
|
|
if scheduler.get_job(job_id):
|
|
scheduler.remove_job(job_id)
|
|
|
|
|
|
def reschedule_all():
|
|
for job in get_all_jobs():
|
|
if job["enabled"]:
|
|
schedule_job(job)
|
|
else:
|
|
unschedule_job(job["id"])
|
|
|
|
|
|
# ──────────────────────────────────────────────
|
|
# Lifespan
|
|
# ──────────────────────────────────────────────
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
init_db()
|
|
scheduler.start()
|
|
reschedule_all()
|
|
yield
|
|
scheduler.shutdown(wait=False)
|
|
|
|
|
|
# ──────────────────────────────────────────────
|
|
# App
|
|
# ──────────────────────────────────────────────
|
|
app = FastAPI(title="CronHub", version="1.0.0",
|
|
description="API REST de gestion de cron jobs", lifespan=lifespan)
|
|
templates = Jinja2Templates(directory="templates")
|
|
|
|
|
|
# ──────────────────────────────────────────────
|
|
# Pydantic models
|
|
# ──────────────────────────────────────────────
|
|
class JobCreate(BaseModel):
|
|
name: str
|
|
schedule: str
|
|
command: str
|
|
description: Optional[str] = ""
|
|
enabled: Optional[bool] = True
|
|
|
|
|
|
class JobUpdate(BaseModel):
|
|
name: Optional[str] = None
|
|
schedule: Optional[str] = None
|
|
command: Optional[str] = None
|
|
description: Optional[str] = None
|
|
enabled: Optional[bool] = None
|
|
|
|
|
|
# ──────────────────────────────────────────────
|
|
# API Routes
|
|
# ──────────────────────────────────────────────
|
|
@app.get("/api/health")
|
|
def health():
|
|
return {"status": "ok", "timestamp": datetime.now().isoformat()}
|
|
|
|
|
|
@app.get("/api/jobs")
|
|
def api_list_jobs():
|
|
return get_all_jobs()
|
|
|
|
|
|
@app.post("/api/jobs", status_code=201)
|
|
def api_create_job(payload: JobCreate):
|
|
try:
|
|
_parse_cron(payload.schedule)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=422, detail=str(e))
|
|
|
|
job_id = str(uuid.uuid4())
|
|
now = datetime.now().isoformat()
|
|
conn = get_db()
|
|
conn.execute(
|
|
"INSERT INTO jobs (id,name,schedule,command,description,enabled,created_at) VALUES (?,?,?,?,?,?,?)",
|
|
(job_id, payload.name, payload.schedule, payload.command,
|
|
payload.description or "", int(payload.enabled), now)
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
job = get_job(job_id)
|
|
if job["enabled"]:
|
|
schedule_job(job)
|
|
return job
|
|
|
|
|
|
@app.get("/api/jobs/{job_id}")
|
|
def api_get_job(job_id: str):
|
|
job = get_job(job_id)
|
|
if not job:
|
|
raise HTTPException(status_code=404, detail="Job not found")
|
|
return job
|
|
|
|
|
|
@app.put("/api/jobs/{job_id}")
|
|
def api_update_job(job_id: str, payload: JobUpdate):
|
|
job = get_job(job_id)
|
|
if not job:
|
|
raise HTTPException(status_code=404, detail="Job not found")
|
|
|
|
updates = {}
|
|
if payload.name is not None:
|
|
updates["name"] = payload.name
|
|
if payload.schedule is not None:
|
|
try:
|
|
_parse_cron(payload.schedule)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=422, detail=str(e))
|
|
updates["schedule"] = payload.schedule
|
|
if payload.command is not None:
|
|
updates["command"] = payload.command
|
|
if payload.description is not None:
|
|
updates["description"] = payload.description
|
|
if payload.enabled is not None:
|
|
updates["enabled"] = int(payload.enabled)
|
|
|
|
if updates:
|
|
set_clause = ", ".join(f"{k}=?" for k in updates)
|
|
vals = list(updates.values()) + [job_id]
|
|
conn = get_db()
|
|
conn.execute(f"UPDATE jobs SET {set_clause} WHERE id=?", vals)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
job = get_job(job_id)
|
|
unschedule_job(job_id)
|
|
if job["enabled"]:
|
|
schedule_job(job)
|
|
return job
|
|
|
|
|
|
@app.delete("/api/jobs/{job_id}", status_code=204)
|
|
def api_delete_job(job_id: str):
|
|
job = get_job(job_id)
|
|
if not job:
|
|
raise HTTPException(status_code=404, detail="Job not found")
|
|
unschedule_job(job_id)
|
|
conn = get_db()
|
|
conn.execute("DELETE FROM jobs WHERE id=?", (job_id,))
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
|
|
@app.post("/api/jobs/{job_id}/toggle")
|
|
def api_toggle_job(job_id: str):
|
|
job = get_job(job_id)
|
|
if not job:
|
|
raise HTTPException(status_code=404, detail="Job not found")
|
|
new_state = 0 if job["enabled"] else 1
|
|
conn = get_db()
|
|
conn.execute("UPDATE jobs SET enabled=? WHERE id=?", (new_state, job_id))
|
|
conn.commit()
|
|
conn.close()
|
|
job = get_job(job_id)
|
|
if job["enabled"]:
|
|
schedule_job(job)
|
|
else:
|
|
unschedule_job(job_id)
|
|
return job
|
|
|
|
|
|
@app.post("/api/jobs/{job_id}/run")
|
|
def api_run_job(job_id: str):
|
|
job = get_job(job_id)
|
|
if not job:
|
|
raise HTTPException(status_code=404, detail="Job not found")
|
|
# Run in background thread via scheduler one-shot
|
|
scheduler.add_job(run_job_sync, args=[job_id], id=f"manual_{job_id}_{uuid.uuid4().hex[:6]}",
|
|
replace_existing=False, misfire_grace_time=60)
|
|
return {"status": "triggered", "job_id": job_id}
|
|
|
|
|
|
@app.get("/api/jobs/{job_id}/logs")
|
|
def api_get_logs(job_id: str, limit: int = 50):
|
|
job = get_job(job_id)
|
|
if not job:
|
|
raise HTTPException(status_code=404, detail="Job not found")
|
|
return get_job_logs(job_id, limit)
|
|
|
|
|
|
# ──────────────────────────────────────────────
|
|
# UI Routes
|
|
# ──────────────────────────────────────────────
|
|
@app.get("/", response_class=HTMLResponse)
|
|
def ui_index(request: Request):
|
|
jobs = get_all_jobs()
|
|
total = len(jobs)
|
|
active = sum(1 for j in jobs if j["enabled"])
|
|
failed = sum(1 for j in jobs if j["last_status"] == "failed")
|
|
success = sum(1 for j in jobs if j["last_status"] == "success")
|
|
return templates.TemplateResponse("index.html", {
|
|
"request": request,
|
|
"jobs": jobs,
|
|
"page": "dashboard",
|
|
"stats": {"total": total, "active": active, "failed": failed, "success": success},
|
|
})
|
|
|
|
|
|
@app.get("/jobs/new", response_class=HTMLResponse)
|
|
def ui_new_job(request: Request):
|
|
return templates.TemplateResponse("job_form.html", {
|
|
"request": request,
|
|
"page": "jobs",
|
|
"job": None,
|
|
"action": "/jobs",
|
|
"method": "POST",
|
|
})
|
|
|
|
|
|
@app.post("/jobs", response_class=HTMLResponse)
|
|
def ui_create_job(
|
|
request: Request,
|
|
name: str = Form(...),
|
|
schedule: str = Form(...),
|
|
command: str = Form(...),
|
|
description: str = Form(""),
|
|
enabled: str = Form("on"),
|
|
):
|
|
try:
|
|
_parse_cron(schedule)
|
|
except ValueError as e:
|
|
return templates.TemplateResponse("job_form.html", {
|
|
"request": request,
|
|
"page": "jobs",
|
|
"job": None,
|
|
"action": "/jobs",
|
|
"method": "POST",
|
|
"error": str(e),
|
|
"form": {"name": name, "schedule": schedule, "command": command, "description": description},
|
|
})
|
|
|
|
job_id = str(uuid.uuid4())
|
|
now = datetime.now().isoformat()
|
|
is_enabled = 1 if enabled == "on" else 0
|
|
conn = get_db()
|
|
conn.execute(
|
|
"INSERT INTO jobs (id,name,schedule,command,description,enabled,created_at) VALUES (?,?,?,?,?,?,?)",
|
|
(job_id, name, schedule, command, description, is_enabled, now)
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
job = get_job(job_id)
|
|
if job["enabled"]:
|
|
schedule_job(job)
|
|
return RedirectResponse("/", status_code=303)
|
|
|
|
|
|
@app.get("/jobs/{job_id}", response_class=HTMLResponse)
|
|
def ui_job_detail(request: Request, job_id: str):
|
|
job = get_job(job_id)
|
|
if not job:
|
|
raise HTTPException(status_code=404)
|
|
logs = get_job_logs(job_id, 20)
|
|
return templates.TemplateResponse("job_detail.html", {
|
|
"request": request,
|
|
"job": job,
|
|
"logs": logs,
|
|
"page": "jobs",
|
|
})
|
|
|
|
|
|
@app.get("/jobs/{job_id}/edit", response_class=HTMLResponse)
|
|
def ui_edit_job(request: Request, job_id: str):
|
|
job = get_job(job_id)
|
|
if not job:
|
|
raise HTTPException(status_code=404)
|
|
return templates.TemplateResponse("job_form.html", {
|
|
"request": request,
|
|
"page": "jobs",
|
|
"job": job,
|
|
"action": f"/jobs/{job_id}/edit",
|
|
"method": "POST",
|
|
})
|
|
|
|
|
|
@app.post("/jobs/{job_id}/edit", response_class=HTMLResponse)
|
|
def ui_update_job(
|
|
request: Request,
|
|
job_id: str,
|
|
name: str = Form(...),
|
|
schedule: str = Form(...),
|
|
command: str = Form(...),
|
|
description: str = Form(""),
|
|
enabled: str = Form("off"),
|
|
):
|
|
job = get_job(job_id)
|
|
if not job:
|
|
raise HTTPException(status_code=404)
|
|
try:
|
|
_parse_cron(schedule)
|
|
except ValueError as e:
|
|
return templates.TemplateResponse("job_form.html", {
|
|
"request": request,
|
|
"page": "jobs",
|
|
"job": job,
|
|
"action": f"/jobs/{job_id}/edit",
|
|
"method": "POST",
|
|
"error": str(e),
|
|
})
|
|
|
|
is_enabled = 1 if enabled == "on" else 0
|
|
conn = get_db()
|
|
conn.execute(
|
|
"UPDATE jobs SET name=?,schedule=?,command=?,description=?,enabled=? WHERE id=?",
|
|
(name, schedule, command, description, is_enabled, job_id)
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
job = get_job(job_id)
|
|
unschedule_job(job_id)
|
|
if job["enabled"]:
|
|
schedule_job(job)
|
|
return RedirectResponse(f"/jobs/{job_id}", status_code=303)
|
|
|
|
|
|
@app.post("/jobs/{job_id}/delete")
|
|
def ui_delete_job(job_id: str):
|
|
unschedule_job(job_id)
|
|
conn = get_db()
|
|
conn.execute("DELETE FROM jobs WHERE id=?", (job_id,))
|
|
conn.commit()
|
|
conn.close()
|
|
return RedirectResponse("/", status_code=303)
|
|
|
|
|
|
@app.post("/jobs/{job_id}/toggle")
|
|
def ui_toggle_job(job_id: str):
|
|
job = get_job(job_id)
|
|
if not job:
|
|
raise HTTPException(status_code=404)
|
|
new_state = 0 if job["enabled"] else 1
|
|
conn = get_db()
|
|
conn.execute("UPDATE jobs SET enabled=? WHERE id=?", (new_state, job_id))
|
|
conn.commit()
|
|
conn.close()
|
|
job = get_job(job_id)
|
|
if job["enabled"]:
|
|
schedule_job(job)
|
|
else:
|
|
unschedule_job(job_id)
|
|
return RedirectResponse("/", status_code=303)
|
|
|
|
|
|
@app.post("/jobs/{job_id}/run-now")
|
|
def ui_run_now(job_id: str):
|
|
job = get_job(job_id)
|
|
if not job:
|
|
raise HTTPException(status_code=404)
|
|
scheduler.add_job(run_job_sync, args=[job_id],
|
|
id=f"manual_{job_id}_{uuid.uuid4().hex[:6]}",
|
|
replace_existing=False, misfire_grace_time=60)
|
|
return RedirectResponse(f"/jobs/{job_id}", status_code=303)
|