feat: initial CronHub release
- 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
This commit is contained in:
+19
@@ -0,0 +1,19 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Dépendances système
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY app/ ./app/
|
||||||
|
COPY templates/ ./templates/
|
||||||
|
|
||||||
|
VOLUME ["/data"]
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||||
+536
@@ -0,0 +1,536 @@
|
|||||||
|
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)
|
||||||
Binary file not shown.
@@ -0,0 +1,27 @@
|
|||||||
|
version: "3.8"
|
||||||
|
|
||||||
|
services:
|
||||||
|
cronhub:
|
||||||
|
build: .
|
||||||
|
container_name: cronhub
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
|
- /opt/container/cronmaster/scripts:/scripts:ro
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
environment:
|
||||||
|
- DB_PATH=/data/cronhub.db
|
||||||
|
- TZ=Europe/Paris
|
||||||
|
networks:
|
||||||
|
- proxy
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.cronhub.rule=Host(`cronhub.nas.percolouco.com`)"
|
||||||
|
- "traefik.http.routers.cronhub.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.cronhub.tls=true"
|
||||||
|
- "traefik.http.routers.cronhub.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.cronhub.loadbalancer.server.port=8000"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
proxy:
|
||||||
|
external: true
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fastapi==0.111.0
|
||||||
|
uvicorn[standard]==0.29.0
|
||||||
|
jinja2==3.1.4
|
||||||
|
python-multipart==0.0.9
|
||||||
|
apscheduler==3.10.4
|
||||||
|
aiosqlite==0.20.0
|
||||||
|
pydantic==2.7.1
|
||||||
|
croniter==2.0.5
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}CronHub{% endblock %}</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
|
||||||
|
<style>
|
||||||
|
.status-badge { @apply inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold; }
|
||||||
|
.pulse-dot { animation: pulse 2s cubic-bezier(0.4,0,0.6,1) infinite; }
|
||||||
|
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.5} }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-100 min-h-screen flex">
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<div class="w-64 bg-slate-800 min-h-screen flex flex-col fixed left-0 top-0 z-10">
|
||||||
|
<div class="p-6 border-b border-slate-700">
|
||||||
|
<h1 class="text-white text-xl font-bold flex items-center gap-2">
|
||||||
|
<i class="fas fa-clock text-orange-400"></i> CronHub
|
||||||
|
</h1>
|
||||||
|
<p class="text-slate-400 text-xs mt-1">Gestionnaire de crons</p>
|
||||||
|
</div>
|
||||||
|
<nav class="flex-1 p-4 space-y-1">
|
||||||
|
<a href="/" class="flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition
|
||||||
|
{% if page == 'dashboard' %}bg-orange-500 text-white{% else %}text-slate-300 hover:bg-slate-700 hover:text-white{% endif %}">
|
||||||
|
<i class="fas fa-gauge-high w-5"></i> Dashboard
|
||||||
|
</a>
|
||||||
|
<a href="/jobs/new" class="flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition
|
||||||
|
{% if page == 'new_job' %}bg-orange-500 text-white{% else %}text-slate-300 hover:bg-slate-700 hover:text-white{% endif %}">
|
||||||
|
<i class="fas fa-plus-circle w-5"></i> Nouveau job
|
||||||
|
</a>
|
||||||
|
<a href="/docs" target="_blank" class="flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition text-slate-300 hover:bg-slate-700 hover:text-white">
|
||||||
|
<i class="fas fa-book w-5"></i> API Docs
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
<div class="p-4 border-t border-slate-700 text-xs text-slate-500 text-center">CronHub v1.0</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<div class="ml-64 flex-1 p-8">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Auto-refresh toutes les 30s sur le dashboard
|
||||||
|
if (window.location.pathname === '/') {
|
||||||
|
setTimeout(() => location.reload(), 30000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(formId) {
|
||||||
|
if (confirm('Supprimer ce job ? Cette action est irréversible.')) {
|
||||||
|
document.getElementById(formId).submit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Dashboard — CronHub{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-8 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold text-slate-800">Dashboard</h2>
|
||||||
|
<p class="text-slate-500 text-sm mt-1">Vue d'ensemble des cron jobs</p>
|
||||||
|
</div>
|
||||||
|
<a href="/jobs/new"
|
||||||
|
class="flex items-center gap-2 bg-orange-500 hover:bg-orange-600 text-white px-4 py-2 rounded-lg font-medium transition">
|
||||||
|
<i class="fas fa-plus"></i> Nouveau job
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
|
<div class="grid grid-cols-4 gap-4 mb-8">
|
||||||
|
<div class="bg-white rounded-xl p-5 shadow-sm border border-slate-100">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||||
|
<i class="fas fa-list text-blue-600"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-2xl font-bold text-slate-800">{{ stats.total }}</p>
|
||||||
|
<p class="text-xs text-slate-500">Total</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-xl p-5 shadow-sm border border-slate-100">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
|
||||||
|
<i class="fas fa-circle-check text-green-600"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-2xl font-bold text-slate-800">{{ stats.active }}</p>
|
||||||
|
<p class="text-xs text-slate-500">Actifs</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-xl p-5 shadow-sm border border-slate-100">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-10 h-10 bg-emerald-100 rounded-lg flex items-center justify-center">
|
||||||
|
<i class="fas fa-check text-emerald-600"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-2xl font-bold text-slate-800">{{ stats.success }}</p>
|
||||||
|
<p class="text-xs text-slate-500">Succès</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-xl p-5 shadow-sm border border-slate-100">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-10 h-10 bg-red-100 rounded-lg flex items-center justify-center">
|
||||||
|
<i class="fas fa-triangle-exclamation text-red-600"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-2xl font-bold text-slate-800">{{ stats.failed }}</p>
|
||||||
|
<p class="text-xs text-slate-500">Échecs</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Jobs table -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden">
|
||||||
|
<div class="px-6 py-4 border-b border-slate-100 flex items-center justify-between">
|
||||||
|
<h3 class="font-semibold text-slate-700">Jobs planifiés</h3>
|
||||||
|
<span class="text-xs text-slate-400">Rafraîchissement auto toutes les 30s</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if jobs %}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-slate-50 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||||
|
<th class="px-6 py-3">Nom</th>
|
||||||
|
<th class="px-6 py-3">Schedule</th>
|
||||||
|
<th class="px-6 py-3">Commande</th>
|
||||||
|
<th class="px-6 py-3">Statut</th>
|
||||||
|
<th class="px-6 py-3">Dernière exéc.</th>
|
||||||
|
<th class="px-6 py-3">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-100">
|
||||||
|
{% for job in jobs %}
|
||||||
|
<tr class="hover:bg-slate-50 transition">
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<a href="/jobs/{{ job.id }}" class="font-semibold text-slate-800 hover:text-orange-600 transition">
|
||||||
|
{{ job.name }}
|
||||||
|
</a>
|
||||||
|
{% if job.description %}
|
||||||
|
<p class="text-xs text-slate-400 truncate max-w-xs">{{ job.description }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<code class="text-xs bg-slate-100 px-2 py-1 rounded font-mono text-slate-600">{{ job.schedule }}</code>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 max-w-xs">
|
||||||
|
<code class="text-xs text-slate-500 truncate block">{{ job.command }}</code>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
{% if not job.enabled %}
|
||||||
|
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-slate-100 text-slate-500">
|
||||||
|
<i class="fas fa-pause text-xs"></i> Désactivé
|
||||||
|
</span>
|
||||||
|
{% elif job.last_status == 'running' %}
|
||||||
|
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-700">
|
||||||
|
<span class="pulse-dot w-1.5 h-1.5 bg-blue-500 rounded-full"></span> En cours
|
||||||
|
</span>
|
||||||
|
{% elif job.last_status == 'success' %}
|
||||||
|
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-700">
|
||||||
|
<i class="fas fa-check text-xs"></i> Succès
|
||||||
|
</span>
|
||||||
|
{% elif job.last_status == 'failed' %}
|
||||||
|
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-red-100 text-red-700">
|
||||||
|
<i class="fas fa-xmark text-xs"></i> Échec
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-orange-100 text-orange-700">
|
||||||
|
<i class="fas fa-clock text-xs"></i> Actif
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-xs text-slate-400">
|
||||||
|
{% if job.last_run %}
|
||||||
|
{{ job.last_run | replace("T", " ") | truncate(16, True, '') }}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-slate-300">Jamais</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<!-- Toggle -->
|
||||||
|
<form method="POST" action="/jobs/{{ job.id }}/toggle">
|
||||||
|
<button type="submit"
|
||||||
|
title="{% if job.enabled %}Désactiver{% else %}Activer{% endif %}"
|
||||||
|
class="w-8 h-8 flex items-center justify-center rounded-lg transition
|
||||||
|
{% if job.enabled %}bg-green-100 text-green-600 hover:bg-green-200{% else %}bg-slate-100 text-slate-400 hover:bg-slate-200{% endif %}">
|
||||||
|
<i class="fas {% if job.enabled %}fa-toggle-on{% else %}fa-toggle-off{% endif %} text-sm"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<!-- Run now -->
|
||||||
|
<form method="POST" action="/jobs/{{ job.id }}/run-now">
|
||||||
|
<button type="submit" title="Exécuter maintenant"
|
||||||
|
class="w-8 h-8 flex items-center justify-center rounded-lg bg-orange-100 text-orange-600 hover:bg-orange-200 transition">
|
||||||
|
<i class="fas fa-play text-xs"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<!-- Edit -->
|
||||||
|
<a href="/jobs/{{ job.id }}/edit" title="Modifier"
|
||||||
|
class="w-8 h-8 flex items-center justify-center rounded-lg bg-blue-100 text-blue-600 hover:bg-blue-200 transition">
|
||||||
|
<i class="fas fa-pen text-xs"></i>
|
||||||
|
</a>
|
||||||
|
<!-- Delete -->
|
||||||
|
<form id="del-{{ job.id }}" method="POST" action="/jobs/{{ job.id }}/delete">
|
||||||
|
<button type="button" title="Supprimer"
|
||||||
|
onclick="confirmDelete('del-{{ job.id }}')"
|
||||||
|
class="w-8 h-8 flex items-center justify-center rounded-lg bg-red-100 text-red-600 hover:bg-red-200 transition">
|
||||||
|
<i class="fas fa-trash text-xs"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="py-16 text-center">
|
||||||
|
<i class="fas fa-clock text-4xl text-slate-200 mb-4"></i>
|
||||||
|
<p class="text-slate-400 font-medium">Aucun job planifié</p>
|
||||||
|
<a href="/jobs/new" class="mt-4 inline-flex items-center gap-2 text-orange-500 hover:text-orange-600 text-sm font-medium">
|
||||||
|
<i class="fas fa-plus"></i> Créer votre premier job
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ job.name }} — CronHub{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-6">
|
||||||
|
<a href="/" class="text-slate-400 hover:text-slate-600 text-sm flex items-center gap-1 mb-4 transition">
|
||||||
|
<i class="fas fa-arrow-left text-xs"></i> Retour au dashboard
|
||||||
|
</a>
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold text-slate-800">{{ job.name }}</h2>
|
||||||
|
{% if job.description %}
|
||||||
|
<p class="text-slate-500 text-sm mt-1">{{ job.description }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<!-- Toggle -->
|
||||||
|
<form method="POST" action="/jobs/{{ job.id }}/toggle">
|
||||||
|
<button type="submit"
|
||||||
|
class="flex items-center gap-2 px-4 py-2 rounded-lg font-medium text-sm transition
|
||||||
|
{% if job.enabled %}bg-green-100 text-green-700 hover:bg-green-200{% else %}bg-slate-100 text-slate-500 hover:bg-slate-200{% endif %}">
|
||||||
|
<i class="fas {% if job.enabled %}fa-toggle-on{% else %}fa-toggle-off{% endif %}"></i>
|
||||||
|
{% if job.enabled %}Actif{% else %}Inactif{% endif %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<!-- Run now -->
|
||||||
|
<form method="POST" action="/jobs/{{ job.id }}/run-now">
|
||||||
|
<button type="submit"
|
||||||
|
class="flex items-center gap-2 px-4 py-2 bg-orange-500 hover:bg-orange-600 text-white rounded-lg font-medium text-sm transition">
|
||||||
|
<i class="fas fa-play"></i> Exécuter
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<!-- Edit -->
|
||||||
|
<a href="/jobs/{{ job.id }}/edit"
|
||||||
|
class="flex items-center gap-2 px-4 py-2 bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-lg font-medium text-sm transition">
|
||||||
|
<i class="fas fa-pen"></i> Modifier
|
||||||
|
</a>
|
||||||
|
<!-- Delete -->
|
||||||
|
<form id="del-form" method="POST" action="/jobs/{{ job.id }}/delete">
|
||||||
|
<button type="button" onclick="confirmDelete('del-form')"
|
||||||
|
class="flex items-center gap-2 px-4 py-2 bg-red-100 hover:bg-red-200 text-red-700 rounded-lg font-medium text-sm transition">
|
||||||
|
<i class="fas fa-trash"></i> Supprimer
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Job details -->
|
||||||
|
<div class="grid grid-cols-3 gap-4 mb-8">
|
||||||
|
<div class="bg-white rounded-xl p-5 shadow-sm border border-slate-100">
|
||||||
|
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-2">Schedule</p>
|
||||||
|
<code class="text-lg font-bold text-slate-800 font-mono">{{ job.schedule }}</code>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-xl p-5 shadow-sm border border-slate-100">
|
||||||
|
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-2">Dernière exécution</p>
|
||||||
|
<p class="text-slate-800 font-semibold">
|
||||||
|
{% if job.last_run %}
|
||||||
|
{{ job.last_run | replace("T", " ") | truncate(19, True, '') }}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-slate-300">Jamais</span>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-xl p-5 shadow-sm border border-slate-100">
|
||||||
|
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-2">Dernier statut</p>
|
||||||
|
{% if job.last_status == 'success' %}
|
||||||
|
<span class="inline-flex items-center gap-1.5 text-green-700 font-semibold">
|
||||||
|
<i class="fas fa-circle-check"></i> Succès
|
||||||
|
</span>
|
||||||
|
{% elif job.last_status == 'failed' %}
|
||||||
|
<span class="inline-flex items-center gap-1.5 text-red-700 font-semibold">
|
||||||
|
<i class="fas fa-circle-xmark"></i> Échec
|
||||||
|
</span>
|
||||||
|
{% elif job.last_status == 'running' %}
|
||||||
|
<span class="inline-flex items-center gap-1.5 text-blue-700 font-semibold">
|
||||||
|
<span class="pulse-dot w-2 h-2 bg-blue-500 rounded-full inline-block"></span> En cours
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-slate-400">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Command -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-6 mb-6">
|
||||||
|
<h3 class="font-semibold text-slate-700 mb-3 flex items-center gap-2">
|
||||||
|
<i class="fas fa-terminal text-orange-400"></i> Commande
|
||||||
|
</h3>
|
||||||
|
<code class="block bg-slate-900 text-green-400 p-4 rounded-lg text-sm font-mono break-all">{{ job.command }}</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Logs -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden">
|
||||||
|
<div class="px-6 py-4 border-b border-slate-100">
|
||||||
|
<h3 class="font-semibold text-slate-700 flex items-center gap-2">
|
||||||
|
<i class="fas fa-scroll text-orange-400"></i> Historique d'exécution
|
||||||
|
<span class="text-xs text-slate-400 font-normal">(20 dernières)</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if logs %}
|
||||||
|
<div class="divide-y divide-slate-100">
|
||||||
|
{% for log in logs %}
|
||||||
|
<details class="group">
|
||||||
|
<summary class="px-6 py-4 flex items-center gap-4 cursor-pointer hover:bg-slate-50 transition list-none">
|
||||||
|
<div class="flex items-center gap-2 w-24">
|
||||||
|
{% if log.status == 'success' %}
|
||||||
|
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-700">
|
||||||
|
<i class="fas fa-check"></i> OK
|
||||||
|
</span>
|
||||||
|
{% elif log.status == 'failed' %}
|
||||||
|
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-red-100 text-red-700">
|
||||||
|
<i class="fas fa-xmark"></i> Erreur
|
||||||
|
</span>
|
||||||
|
{% elif log.status == 'running' %}
|
||||||
|
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-700">
|
||||||
|
<span class="pulse-dot w-1.5 h-1.5 bg-blue-500 rounded-full"></span> En cours
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 text-sm">
|
||||||
|
<span class="text-slate-700 font-medium">{{ log.started_at | replace("T", " ") | truncate(19, True, '') }}</span>
|
||||||
|
{% if log.ended_at %}
|
||||||
|
<span class="text-slate-400 ml-2 text-xs">
|
||||||
|
→ {% set dur = log.ended_at | replace("T", " ") %}{{ log.ended_at | replace("T", " ") | truncate(19, True, '') }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if log.exit_code is not none %}
|
||||||
|
<code class="text-xs bg-slate-100 px-2 py-0.5 rounded text-slate-500">exit: {{ log.exit_code }}</code>
|
||||||
|
{% endif %}
|
||||||
|
<i class="fas fa-chevron-down text-slate-400 text-xs group-open:rotate-180 transition-transform"></i>
|
||||||
|
</summary>
|
||||||
|
<div class="px-6 pb-4 bg-slate-50">
|
||||||
|
{% if log.stdout %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<p class="text-xs font-semibold text-slate-500 mb-1">STDOUT</p>
|
||||||
|
<pre class="bg-slate-900 text-green-300 p-3 rounded text-xs font-mono overflow-x-auto max-h-48 overflow-y-auto">{{ log.stdout }}</pre>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if log.stderr %}
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold text-slate-500 mb-1">STDERR</p>
|
||||||
|
<pre class="bg-slate-900 text-red-300 p-3 rounded text-xs font-mono overflow-x-auto max-h-48 overflow-y-auto">{{ log.stderr }}</pre>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if not log.stdout and not log.stderr %}
|
||||||
|
<p class="text-xs text-slate-400 italic">Pas de sortie</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="py-12 text-center">
|
||||||
|
<i class="fas fa-scroll text-3xl text-slate-200 mb-3"></i>
|
||||||
|
<p class="text-slate-400 text-sm">Aucune exécution pour ce job</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{% if job %}Modifier{% else %}Nouveau job{% endif %} — CronHub{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-8">
|
||||||
|
<a href="/" class="text-slate-400 hover:text-slate-600 text-sm flex items-center gap-1 mb-4 transition">
|
||||||
|
<i class="fas fa-arrow-left text-xs"></i> Retour
|
||||||
|
</a>
|
||||||
|
<h2 class="text-2xl font-bold text-slate-800">
|
||||||
|
{% if job %}Modifier le job{% else %}Nouveau cron job{% endif %}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-w-2xl">
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-8">
|
||||||
|
{% if error %}
|
||||||
|
<div class="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm flex items-center gap-2">
|
||||||
|
<i class="fas fa-circle-exclamation"></i> {{ error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="POST" action="{{ action }}" class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-slate-700 mb-2">
|
||||||
|
Nom <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input type="text" name="name" required
|
||||||
|
value="{{ form.name if form else (job.name if job else '') }}"
|
||||||
|
placeholder="Ex: Backup quotidien"
|
||||||
|
class="w-full px-4 py-3 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-400 text-slate-700 placeholder-slate-300">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-slate-700 mb-2">
|
||||||
|
Schedule (cron) <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input type="text" name="schedule" required
|
||||||
|
value="{{ form.schedule if form else (job.schedule if job else '0 3 * * *') }}"
|
||||||
|
placeholder="0 3 * * *"
|
||||||
|
class="w-full px-4 py-3 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-400 font-mono text-slate-700 placeholder-slate-300">
|
||||||
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
|
<button type="button" onclick="setCron('0 3 * * *')" class="text-xs bg-slate-100 hover:bg-orange-100 hover:text-orange-600 text-slate-500 px-2 py-1 rounded transition">Chaque jour 3h</button>
|
||||||
|
<button type="button" onclick="setCron('0 * * * *')" class="text-xs bg-slate-100 hover:bg-orange-100 hover:text-orange-600 text-slate-500 px-2 py-1 rounded transition">Chaque heure</button>
|
||||||
|
<button type="button" onclick="setCron('*/5 * * * *')" class="text-xs bg-slate-100 hover:bg-orange-100 hover:text-orange-600 text-slate-500 px-2 py-1 rounded transition">Toutes les 5min</button>
|
||||||
|
<button type="button" onclick="setCron('0 0 * * 0')" class="text-xs bg-slate-100 hover:bg-orange-100 hover:text-orange-600 text-slate-500 px-2 py-1 rounded transition">Chaque dimanche</button>
|
||||||
|
<button type="button" onclick="setCron('0 0 1 * *')" class="text-xs bg-slate-100 hover:bg-orange-100 hover:text-orange-600 text-slate-500 px-2 py-1 rounded transition">1er du mois</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-slate-400 mt-1">Format : minute heure jour mois jour_semaine</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-slate-700 mb-2">
|
||||||
|
Commande <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input type="text" name="command" required
|
||||||
|
value="{{ form.command if form else (job.command if job else '') }}"
|
||||||
|
placeholder="/app/scripts/mon-script.sh ou docker exec mon-container ..."
|
||||||
|
class="w-full px-4 py-3 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-400 font-mono text-sm text-slate-700 placeholder-slate-300">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-slate-700 mb-2">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea name="description" rows="3"
|
||||||
|
placeholder="Description optionnelle..."
|
||||||
|
class="w-full px-4 py-3 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-400 text-slate-700 placeholder-slate-300 resize-none">{{ job.description if job else '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<input type="checkbox" name="enabled" id="enabled" value="on"
|
||||||
|
{% if not job or job.enabled %}checked{% endif %}
|
||||||
|
class="w-4 h-4 accent-orange-500">
|
||||||
|
<label for="enabled" class="text-sm font-semibold text-slate-700">Activer immédiatement</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3 pt-2">
|
||||||
|
<button type="submit"
|
||||||
|
class="flex-1 bg-orange-500 hover:bg-orange-600 text-white py-3 rounded-lg font-semibold transition flex items-center justify-center gap-2">
|
||||||
|
<i class="fas {% if job %}fa-save{% else %}fa-plus{% endif %}"></i>
|
||||||
|
{% if job %}Enregistrer{% else %}Créer le job{% endif %}
|
||||||
|
</button>
|
||||||
|
<a href="/"
|
||||||
|
class="px-6 py-3 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-lg font-semibold transition flex items-center gap-2">
|
||||||
|
Annuler
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Aide cron -->
|
||||||
|
<div class="mt-6 bg-slate-800 rounded-xl p-6 text-slate-300 text-sm">
|
||||||
|
<h4 class="text-white font-semibold mb-3 flex items-center gap-2">
|
||||||
|
<i class="fas fa-lightbulb text-orange-400"></i> Aide — Format cron
|
||||||
|
</h4>
|
||||||
|
<pre class="font-mono text-xs text-slate-400">
|
||||||
|
┌───────────── minute (0-59)
|
||||||
|
│ ┌─────────── heure (0-23)
|
||||||
|
│ │ ┌───────── jour (1-31)
|
||||||
|
│ │ │ ┌─────── mois (1-12)
|
||||||
|
│ │ │ │ ┌───── jour semaine (0-7, 0=dim)
|
||||||
|
│ │ │ │ │
|
||||||
|
* * * * *
|
||||||
|
</pre>
|
||||||
|
<div class="mt-3 grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<div><code class="text-orange-400">*</code> — toutes les valeurs</div>
|
||||||
|
<div><code class="text-orange-400">*/5</code> — toutes les 5 unités</div>
|
||||||
|
<div><code class="text-orange-400">1,3,5</code> — valeurs spécifiques</div>
|
||||||
|
<div><code class="text-orange-400">1-5</code> — plage</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function setCron(val) {
|
||||||
|
document.querySelector('input[name="schedule"]').value = val;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user