From d94e340d3195d46144bd25b89afb64574351fd75 Mon Sep 17 00:00:00 2001 From: Luca Date: Sun, 15 Mar 2026 15:19:36 +0100 Subject: [PATCH] feat: initial CronHub release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- Dockerfile | 19 ++ app/__init__.py | 0 app/main.py | 536 ++++++++++++++++++++++++++++++++++++++ data/cronhub.db | Bin 0 -> 20480 bytes docker-compose.yml | 27 ++ requirements.txt | 8 + templates/base.html | 59 +++++ templates/index.html | 179 +++++++++++++ templates/job_detail.html | 162 ++++++++++++ templates/job_form.html | 119 +++++++++ 10 files changed, 1109 insertions(+) create mode 100644 Dockerfile create mode 100644 app/__init__.py create mode 100644 app/main.py create mode 100644 data/cronhub.db create mode 100644 docker-compose.yml create mode 100644 requirements.txt create mode 100644 templates/base.html create mode 100644 templates/index.html create mode 100644 templates/job_detail.html create mode 100644 templates/job_form.html diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a882290 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..8f7318f --- /dev/null +++ b/app/main.py @@ -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) diff --git a/data/cronhub.db b/data/cronhub.db new file mode 100644 index 0000000000000000000000000000000000000000..3629580fd8313479985211ab7744cf0f21a4611d GIT binary patch literal 20480 zcmeI%L2KJE6bEp}O}kOrwU>~q7y~=B8;IGl%du_aUb?|5Jm%P9M0SG_<0uX=z1Rwwb2>ky7SM8nd z(NSw6qU$Lu=2Q9R5Eg7+G9IsYxBEVI0!jks%%x;4*5=WS9m|ouyO&$fx}8qzw^ikw zW;cauZEdu=L+o9$a-_+!Gw=fXp8DjAgnXbA;*5jgz>`TXsTUk;BfnsW4 z$B-&Ye<)LkvZxlLrI4R{;f&pYSb52L%5N-v9J@_L7-tD1WC5T*FGP}+;y&*(-C7dXE_~uJ(JKui0%)?1?_ZX;g?%R!l8Mee?rQ7ZtB2)?~3b z`_A&U(!@E77Q0KIpPk72BAqeY;@JE8gd literal 0 HcmV?d00001 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2e3f0fb --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..27270a6 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..dc00bf7 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,59 @@ + + + + + + {% block title %}CronHub{% endblock %} + + + + + + + +
+
+

+ CronHub +

+

Gestionnaire de crons

+
+ +
CronHub v1.0
+
+ + +
+ {% block content %}{% endblock %} +
+ + + + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..6f5e34d --- /dev/null +++ b/templates/index.html @@ -0,0 +1,179 @@ +{% extends "base.html" %} +{% block title %}Dashboard — CronHub{% endblock %} + +{% block content %} +
+
+

Dashboard

+

Vue d'ensemble des cron jobs

+
+ + Nouveau job + +
+ + +
+
+
+
+ +
+
+

{{ stats.total }}

+

Total

+
+
+
+
+
+
+ +
+
+

{{ stats.active }}

+

Actifs

+
+
+
+
+
+
+ +
+
+

{{ stats.success }}

+

Succès

+
+
+
+
+
+
+ +
+
+

{{ stats.failed }}

+

Échecs

+
+
+
+
+ + +
+
+

Jobs planifiés

+ Rafraîchissement auto toutes les 30s +
+ + {% if jobs %} +
+ + + + + + + + + + + + + {% for job in jobs %} + + + + + + + + + {% endfor %} + +
NomScheduleCommandeStatutDernière exéc.Actions
+ + {{ job.name }} + + {% if job.description %} +

{{ job.description }}

+ {% endif %} +
+ {{ job.schedule }} + + {{ job.command }} + + {% if not job.enabled %} + + Désactivé + + {% elif job.last_status == 'running' %} + + En cours + + {% elif job.last_status == 'success' %} + + Succès + + {% elif job.last_status == 'failed' %} + + Échec + + {% else %} + + Actif + + {% endif %} + + {% if job.last_run %} + {{ job.last_run | replace("T", " ") | truncate(16, True, '') }} + {% else %} + Jamais + {% endif %} + +
+ +
+ +
+ +
+ +
+ + + + + +
+ +
+
+
+
+ {% else %} +
+ +

Aucun job planifié

+ + Créer votre premier job + +
+ {% endif %} +
+{% endblock %} diff --git a/templates/job_detail.html b/templates/job_detail.html new file mode 100644 index 0000000..4eef4ca --- /dev/null +++ b/templates/job_detail.html @@ -0,0 +1,162 @@ +{% extends "base.html" %} +{% block title %}{{ job.name }} — CronHub{% endblock %} + +{% block content %} +
+ + Retour au dashboard + +
+
+

{{ job.name }}

+ {% if job.description %} +

{{ job.description }}

+ {% endif %} +
+
+ +
+ +
+ +
+ +
+ + + Modifier + + +
+ +
+
+
+
+ + +
+
+

Schedule

+ {{ job.schedule }} +
+
+

Dernière exécution

+

+ {% if job.last_run %} + {{ job.last_run | replace("T", " ") | truncate(19, True, '') }} + {% else %} + Jamais + {% endif %} +

+
+
+

Dernier statut

+ {% if job.last_status == 'success' %} + + Succès + + {% elif job.last_status == 'failed' %} + + Échec + + {% elif job.last_status == 'running' %} + + En cours + + {% else %} + + {% endif %} +
+
+ + +
+

+ Commande +

+ {{ job.command }} +
+ + +
+
+

+ Historique d'exécution + (20 dernières) +

+
+ + {% if logs %} +
+ {% for log in logs %} +
+ +
+ {% if log.status == 'success' %} + + OK + + {% elif log.status == 'failed' %} + + Erreur + + {% elif log.status == 'running' %} + + En cours + + {% endif %} +
+
+ {{ log.started_at | replace("T", " ") | truncate(19, True, '') }} + {% if log.ended_at %} + + → {% set dur = log.ended_at | replace("T", " ") %}{{ log.ended_at | replace("T", " ") | truncate(19, True, '') }} + + {% endif %} +
+ {% if log.exit_code is not none %} + exit: {{ log.exit_code }} + {% endif %} + +
+
+ {% if log.stdout %} +
+

STDOUT

+
{{ log.stdout }}
+
+ {% endif %} + {% if log.stderr %} +
+

STDERR

+
{{ log.stderr }}
+
+ {% endif %} + {% if not log.stdout and not log.stderr %} +

Pas de sortie

+ {% endif %} +
+
+ {% endfor %} +
+ {% else %} +
+ +

Aucune exécution pour ce job

+
+ {% endif %} +
+{% endblock %} diff --git a/templates/job_form.html b/templates/job_form.html new file mode 100644 index 0000000..231a688 --- /dev/null +++ b/templates/job_form.html @@ -0,0 +1,119 @@ +{% extends "base.html" %} +{% block title %}{% if job %}Modifier{% else %}Nouveau job{% endif %} — CronHub{% endblock %} + +{% block content %} +
+ + Retour + +

+ {% if job %}Modifier le job{% else %}Nouveau cron job{% endif %} +

+
+ +
+
+ {% if error %} +
+ {{ error }} +
+ {% endif %} + +
+
+ + +
+ +
+ + +
+ + + + + +
+

Format : minute heure jour mois jour_semaine

+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + Annuler + +
+
+
+ + +
+

+ Aide — Format cron +

+
+┌───────────── minute       (0-59)
+│ ┌─────────── heure        (0-23)
+│ │ ┌───────── jour         (1-31)
+│ │ │ ┌─────── mois         (1-12)
+│ │ │ │ ┌───── jour semaine (0-7, 0=dim)
+│ │ │ │ │
+* * * * *
+        
+
+
* — toutes les valeurs
+
*/5 — toutes les 5 unités
+
1,3,5 — valeurs spécifiques
+
1-5 — plage
+
+
+
+ + +{% endblock %}