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:
Luca
2026-03-15 15:19:36 +01:00
commit d94e340d31
10 changed files with 1109 additions and 0 deletions
+19
View File
@@ -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"]
View File
+536
View File
@@ -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)
BIN
View File
Binary file not shown.
+27
View File
@@ -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
+8
View File
@@ -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
+59
View File
@@ -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>
+179
View File
@@ -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 %}
+162
View File
@@ -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 %}
+119
View File
@@ -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 %}