Kaynağa Gözat

feat: Phase 1 MVP — packaging YunoHost + application Flask complète

- manifest.toml format 2 (helpers 2.1, multi_instance=false)
- Scripts install/remove/upgrade/backup/restore idiomatiques YNH
- Conf nginx, systemd, sudoers, template config Flask (app.conf)
- Flask + SQLAlchemy + APScheduler : jobs ynh_app et ynh_system
- Rétention count et daily
- Dashboard Tailwind : jobs, historique, Run Now, activer/désactiver
- API REST v1 : /health, /jobs, /runs, /archives, /run
- Stubs Phase 2 (custom_dir, db_dump, transfer) et Phase 3 (federation)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Cédric Hansen 1 gün önce
işleme
e5fcc2f19c

+ 34 - 0
.gitignore

@@ -0,0 +1,34 @@
+# Python
+__pycache__/
+*.py[cod]
+*.pyo
+*.pyd
+.Python
+*.egg-info/
+dist/
+build/
+.eggs/
+
+# Environnement virtuel (généré à l'install YunoHost)
+sources/venv/
+
+# Config générée par YunoHost (contient les secrets)
+sources/config.py
+
+# Base de données locale de dev
+sources/*.db
+sources/backupmanager.db
+
+# Logs
+sources/logs/
+*.log
+
+# IDE
+.vscode/
+.idea/
+*.swp
+*.swo
+
+# OS
+.DS_Store
+Thumbs.db

+ 11 - 0
conf/app.conf

@@ -0,0 +1,11 @@
+# config.py — généré par YunoHost à l'installation. Ne pas éditer manuellement.
+SECRET_KEY = "__SECRET_KEY__"
+API_TOKEN = "__API_TOKEN__"
+DATA_DIR = "__DATA_DIR__"
+INSTALL_DIR = "__INSTALL_DIR__"
+INSTANCE_NAME = "__INSTANCE_NAME__"
+INSTANCE_URL = "https://__DOMAIN____PATH__"
+DB_PATH = "__DATA_DIR__/backupmanager.db"
+LOG_PATH = "__DATA_DIR__/logs/backupmanager.log"
+PORT = __PORT__
+YUNOHOST_BACKUP_DIR = "/home/yunohost.backup/archives"

+ 17 - 0
conf/nginx.conf

@@ -0,0 +1,17 @@
+#sub_path_only rewrite ^__PATH__$ __PATH__/ permanent;
+location __PATH__/ {
+
+  proxy_pass http://127.0.0.1:__PORT__;
+  proxy_set_header Host $host;
+  proxy_set_header X-Real-IP $remote_addr;
+  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+  proxy_set_header X-Forwarded-Proto $scheme;
+  proxy_set_header Ynh-User $http_ynh_user;
+  proxy_set_header X-Script-Name __PATH__;
+  proxy_read_timeout 300;
+  proxy_connect_timeout 300;
+
+  client_max_body_size 0;
+
+  include conf.d/yunohost_panel.conf.inc;
+}

+ 6 - 0
conf/sudoers

@@ -0,0 +1,6 @@
+Defaults:__APP__ !requiretty
+__APP__ ALL=(root) NOPASSWD: /usr/bin/yunohost backup create *
+__APP__ ALL=(root) NOPASSWD: /usr/bin/yunohost backup delete *
+__APP__ ALL=(root) NOPASSWD: /usr/bin/yunohost backup restore *
+__APP__ ALL=(root) NOPASSWD: /usr/bin/yunohost app list *
+__APP__ ALL=(root) NOPASSWD: /usr/bin/yunohost tools version

+ 25 - 0
conf/systemd.service

@@ -0,0 +1,25 @@
+[Unit]
+Description=Backup Manager for YunoHost (__INSTANCE_NAME__)
+After=network.target
+
+[Service]
+Type=simple
+User=__APP__
+Group=__APP__
+WorkingDirectory=__INSTALL_DIR__
+ExecStart=__INSTALL_DIR__/venv/bin/gunicorn \
+    --bind 127.0.0.1:__PORT__ \
+    --workers 1 \
+    --timeout 300 \
+    --log-level info \
+    app:app
+
+Restart=always
+RestartSec=10
+
+Environment="BACKUPMANAGER_CONFIG=__INSTALL_DIR__/config.py"
+StandardOutput=append:__DATA_DIR__/logs/backupmanager.log
+StandardError=append:__DATA_DIR__/logs/backupmanager.log
+
+[Install]
+WantedBy=multi-user.target

+ 68 - 0
manifest.toml

@@ -0,0 +1,68 @@
+packaging_format = 2
+id = "backupmanager"
+name = "Backup Manager"
+description.en = "Centralized backup manager for YunoHost"
+description.fr = "Gestionnaire de sauvegardes centralisé pour YunoHost"
+
+version = "1.0~ynh1"
+
+maintainers = []
+
+[upstream]
+license = "AGPL-3.0-only"
+
+[integration]
+yunohost = ">= 12.0"
+helpers_version = "2.1"
+architectures = "all"
+multi_instance = false
+ldap = false
+sso = true
+disk = "200M"
+ram.build = "300M"
+ram.runtime = "100M"
+
+[install]
+
+[install.domain]
+type = "domain"
+
+[install.path]
+type = "path"
+default = "/"
+
+[install.instance_name]
+ask.en = "Short name for this instance (e.g. jerry)"
+ask.fr = "Nom court de cette instance (ex: jerry)"
+type = "string"
+example = "jerry"
+
+[install.init_main_permission]
+type = "group"
+default = "admins"
+
+[resources]
+
+[resources.system_user]
+
+[resources.install_dir]
+
+[resources.data_dir]
+
+[resources.ports]
+main.default = 5000
+
+[resources.apt]
+packages = "python3, python3-venv, python3-pip, python3-dev, rsync"
+
+[resources.permissions]
+main.url = "/"
+main.auth_header = true
+main.show_tile = true
+main.protected = false
+
+api.url = "/api"
+api.allowed = "visitors"
+api.auth_header = false
+api.show_tile = false
+api.protected = true

+ 4 - 0
scripts/_common.sh

@@ -0,0 +1,4 @@
+#!/bin/bash
+# Variables et fonctions partagées entre les scripts YunoHost.
+# $app, $install_dir, $data_dir, $port, $domain, $path, $instance_name
+# sont automatiquement injectés par YunoHost (helpers 2.1).

+ 11 - 0
scripts/backup

@@ -0,0 +1,11 @@
+#!/bin/bash
+# Sauvegarde de l'application backupmanager elle-même via YunoHost.
+source _common.sh
+source /usr/share/yunohost/helpers
+
+ynh_script_progression "Sauvegarde de l'application..."
+ynh_backup "$install_dir"
+ynh_backup "$data_dir"
+ynh_backup "/etc/sudoers.d/$app"
+
+ynh_script_progression "Sauvegarde terminée !"

+ 51 - 0
scripts/install

@@ -0,0 +1,51 @@
+#!/bin/bash
+source _common.sh
+source /usr/share/yunohost/helpers
+
+ynh_script_progression "Copie des sources de l'application..."
+cp -a "$YNH_APP_BASEDIR/sources/." "$install_dir/"
+
+ynh_script_progression "Création du virtualenv Python et installation des dépendances..."
+python3 -m venv "$install_dir/venv"
+"$install_dir/venv/bin/pip" install --upgrade pip wheel --quiet
+"$install_dir/venv/bin/pip" install -r "$install_dir/requirements.txt" --quiet
+
+ynh_script_progression "Création de la structure de données..."
+mkdir -p "$data_dir/keys" "$data_dir/logs"
+
+ynh_script_progression "Génération des secrets..."
+secret_key=$(ynh_string_random --length=64)
+api_token=$(ynh_string_random --length=64)
+ynh_app_setting_set --key=secret_key --value="$secret_key"
+ynh_app_setting_set --key=api_token --value="$api_token"
+
+ynh_script_progression "Génération du fichier de configuration..."
+ynh_add_config --template="app.conf" --destination="$install_dir/config.py"
+
+ynh_script_progression "Initialisation de la base de données..."
+"$install_dir/venv/bin/python3" "$install_dir/init_db.py" "$install_dir/config.py"
+
+ynh_script_progression "Configuration des permissions..."
+chown -R "$app:$app" "$install_dir" "$data_dir"
+chmod 600 "$install_dir/config.py"
+chmod 700 "$data_dir/keys"
+
+ynh_script_progression "Ajout des règles sudo..."
+ynh_add_config --template="sudoers" --destination="/etc/sudoers.d/$app"
+chmod 440 "/etc/sudoers.d/$app"
+
+ynh_script_progression "Configuration du service systemd..."
+ynh_add_systemd_config
+yunohost service add "$app" \
+    --description="Backup Manager ($instance_name)" \
+    --log="$data_dir/logs/backupmanager.log"
+
+ynh_script_progression "Configuration de nginx..."
+ynh_add_nginx_config
+
+ynh_script_progression "Démarrage du service..."
+ynh_systemctl --action="start" --service="$app"
+
+ynh_script_progression "Installation terminée !"
+ynh_print_info "Token API : $api_token"
+ynh_print_info "Conservez ce token, il ne sera plus affiché."

+ 16 - 0
scripts/remove

@@ -0,0 +1,16 @@
+#!/bin/bash
+source _common.sh
+source /usr/share/yunohost/helpers
+
+ynh_script_progression "Arrêt et suppression du service..."
+yunohost service remove "$app"
+ynh_systemctl --action="stop" --service="$app"
+ynh_remove_systemd_config
+
+ynh_script_progression "Suppression de la configuration nginx..."
+ynh_remove_nginx_config
+
+ynh_script_progression "Suppression des règles sudo..."
+ynh_safe_rm "/etc/sudoers.d/$app"
+
+ynh_script_progression "Désinstallation terminée !"

+ 28 - 0
scripts/restore

@@ -0,0 +1,28 @@
+#!/bin/bash
+source _common.sh
+source /usr/share/yunohost/helpers
+
+ynh_script_progression "Restauration des fichiers..."
+ynh_restore "$install_dir"
+ynh_restore "$data_dir"
+ynh_restore "/etc/sudoers.d/$app"
+chmod 440 "/etc/sudoers.d/$app"
+
+ynh_script_progression "Reconfiguration des permissions..."
+chown -R "$app:$app" "$install_dir" "$data_dir"
+chmod 600 "$install_dir/config.py"
+chmod 700 "$data_dir/keys"
+
+ynh_script_progression "Restauration du service systemd..."
+ynh_add_systemd_config
+yunohost service add "$app" \
+    --description="Backup Manager ($instance_name)" \
+    --log="$data_dir/logs/backupmanager.log"
+
+ynh_script_progression "Restauration de nginx..."
+ynh_add_nginx_config
+
+ynh_script_progression "Démarrage du service..."
+ynh_systemctl --action="start" --service="$app"
+
+ynh_script_progression "Restauration terminée !"

+ 36 - 0
scripts/upgrade

@@ -0,0 +1,36 @@
+#!/bin/bash
+source _common.sh
+source /usr/share/yunohost/helpers
+
+ynh_script_progression "Arrêt du service..."
+ynh_systemctl --action="stop" --service="$app"
+
+ynh_script_progression "Mise à jour des sources..."
+cp -a "$YNH_APP_BASEDIR/sources/." "$install_dir/"
+
+ynh_script_progression "Mise à jour des dépendances Python..."
+"$install_dir/venv/bin/pip" install --upgrade pip wheel --quiet
+"$install_dir/venv/bin/pip" install -r "$install_dir/requirements.txt" --quiet
+
+ynh_script_progression "Régénération de la configuration..."
+ynh_add_config --template="app.conf" --destination="$install_dir/config.py"
+
+ynh_script_progression "Migration de la base de données..."
+"$install_dir/venv/bin/python3" "$install_dir/init_db.py" "$install_dir/config.py"
+
+ynh_script_progression "Mise à jour des permissions..."
+chown -R "$app:$app" "$install_dir" "$data_dir"
+chmod 600 "$install_dir/config.py"
+
+ynh_script_progression "Mise à jour des règles sudo..."
+ynh_add_config --template="sudoers" --destination="/etc/sudoers.d/$app"
+chmod 440 "/etc/sudoers.d/$app"
+
+ynh_script_progression "Mise à jour systemd et nginx..."
+ynh_add_systemd_config
+ynh_add_nginx_config
+
+ynh_script_progression "Redémarrage du service..."
+ynh_systemctl --action="start" --service="$app"
+
+ynh_script_progression "Mise à jour terminée !"

+ 280 - 0
sources/app.py

@@ -0,0 +1,280 @@
+import json
+import logging
+import os
+import subprocess
+from datetime import datetime
+
+from flask import (
+    Flask,
+    flash,
+    jsonify,
+    redirect,
+    render_template,
+    request,
+    url_for,
+)
+from werkzeug.middleware.proxy_fix import ProxyFix
+
+# --- Configuration -----------------------------------------------------------
+
+_config_path = os.environ.get(
+    "BACKUPMANAGER_CONFIG",
+    os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.py"),
+)
+
+app = Flask(__name__)
+app.config.from_pyfile(_config_path)
+app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///" + app.config["DB_PATH"]
+app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
+
+# Proxy headers Nginx → Flask (sous-chemin + HTTPS)
+app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)
+
+# Filtre Jinja2 pour désérialiser du JSON dans les templates
+app.jinja_env.filters["fromjson"] = json.loads
+
+# Logging
+os.makedirs(os.path.dirname(app.config["LOG_PATH"]), exist_ok=True)
+logging.basicConfig(
+    filename=app.config["LOG_PATH"],
+    level=logging.INFO,
+    format="%(asctime)s %(levelname)s %(message)s",
+)
+
+# --- Extensions --------------------------------------------------------------
+
+from db import db, Job, Run
+
+db.init_app(app)
+
+from scheduler import init_scheduler, schedule_job, remove_job
+
+# --- Démarrage ---------------------------------------------------------------
+
+with app.app_context():
+    db.create_all()
+    init_scheduler(app)
+    for _job in Job.query.filter_by(enabled=True).all():
+        schedule_job(_job)
+
+# --- Auth API ----------------------------------------------------------------
+
+@app.before_request
+def _check_api_auth():
+    if not request.path.startswith("/api/"):
+        return
+    if request.path == "/api/v1/health":
+        return
+    token = request.headers.get("X-BackupManager-Key", "")
+    if token != app.config["API_TOKEN"]:
+        return jsonify({"error": "Unauthorized"}), 401
+
+# --- Context processors ------------------------------------------------------
+
+@app.context_processor
+def _inject_globals():
+    return {
+        "instance_name": app.config.get("INSTANCE_NAME", ""),
+        "now": datetime.utcnow(),
+    }
+
+# --- Helpers -----------------------------------------------------------------
+
+def _get_ynh_apps():
+    try:
+        result = subprocess.run(
+            ["sudo", "yunohost", "app", "list", "--output-as", "json"],
+            capture_output=True,
+            text=True,
+            timeout=15,
+        )
+        if result.returncode == 0:
+            return json.loads(result.stdout).get("apps", [])
+    except Exception:
+        pass
+    return []
+
+# --- Routes dashboard --------------------------------------------------------
+
+@app.route("/")
+def index():
+    jobs = Job.query.order_by(Job.name).all()
+    last_runs = {
+        j.id: Run.query.filter_by(job_id=j.id).order_by(Run.started_at.desc()).first()
+        for j in jobs
+    }
+    return render_template("dashboard_local.html", jobs=jobs, last_runs=last_runs)
+
+
+@app.route("/jobs/new", methods=["GET", "POST"])
+def job_new():
+    if request.method == "POST":
+        return _save_job(None)
+    return render_template("job_form.html", job=None, ynh_apps=_get_ynh_apps())
+
+
+@app.route("/jobs/<int:job_id>/edit", methods=["GET", "POST"])
+def job_edit(job_id):
+    job = db.get_or_404(Job, job_id)
+    if request.method == "POST":
+        return _save_job(job)
+    return render_template("job_form.html", job=job, ynh_apps=_get_ynh_apps())
+
+
+@app.route("/jobs/<int:job_id>/delete", methods=["POST"])
+def job_delete(job_id):
+    job = db.get_or_404(Job, job_id)
+    remove_job(job.id)
+    db.session.delete(job)
+    db.session.commit()
+    flash(f"Job « {job.name} » supprimé.", "success")
+    return redirect(url_for("index"))
+
+
+@app.route("/jobs/<int:job_id>/run", methods=["POST"])
+def job_run_now(job_id):
+    job = db.get_or_404(Job, job_id)
+    from scheduler import _execute_job
+    import threading
+    t = threading.Thread(target=_execute_job, args=(job.id,), daemon=True)
+    t.start()
+    flash(f"Job « {job.name} » lancé manuellement.", "success")
+    return redirect(url_for("index"))
+
+
+@app.route("/jobs/<int:job_id>/history")
+def job_history(job_id):
+    job = db.get_or_404(Job, job_id)
+    runs = Run.query.filter_by(job_id=job_id).order_by(Run.started_at.desc()).limit(100).all()
+    return render_template("job_history.html", job=job, runs=runs)
+
+
+@app.route("/jobs/<int:job_id>/toggle", methods=["POST"])
+def job_toggle(job_id):
+    job = db.get_or_404(Job, job_id)
+    job.enabled = not job.enabled
+    job.updated_at = datetime.utcnow()
+    db.session.commit()
+    if job.enabled:
+        schedule_job(job)
+        flash(f"Job « {job.name} » activé.", "success")
+    else:
+        remove_job(job.id)
+        flash(f"Job « {job.name} » désactivé.", "info")
+    return redirect(url_for("index"))
+
+
+def _save_job(job):
+    f = request.form
+    job_type = f.get("type", "")
+    name = f.get("name", "").strip()
+
+    if not name:
+        flash("Le nom est requis.", "error")
+        return render_template("job_form.html", job=job, ynh_apps=_get_ynh_apps())
+
+    cfg = {}
+    if job_type == "ynh_app":
+        cfg = {"app_id": f.get("app_id", ""), "core_only": f.get("core_only") == "1"}
+    elif job_type == "ynh_system":
+        cfg = {}
+
+    if job is None:
+        job = Job()
+        db.session.add(job)
+
+    job.name = name
+    job.type = job_type
+    job.config_json = json.dumps(cfg)
+    job.cron_expr = f.get("cron_expr", "0 3 * * *").strip()
+    job.retention_mode = f.get("retention_mode", "count")
+    job.retention_value = int(f.get("retention_value", 7))
+    job.enabled = f.get("enabled") == "1"
+    job.core_only = cfg.get("core_only", False)
+    job.updated_at = datetime.utcnow()
+
+    db.session.commit()
+
+    if job.enabled:
+        schedule_job(job)
+    else:
+        remove_job(job.id)
+
+    flash(f"Job « {job.name} » enregistré.", "success")
+    return redirect(url_for("index"))
+
+# --- API v1 ------------------------------------------------------------------
+
+@app.route("/api/v1/health")
+def api_health():
+    return jsonify({"status": "ok", "instance": app.config.get("INSTANCE_NAME")})
+
+
+@app.route("/api/v1/jobs")
+def api_jobs():
+    jobs = Job.query.all()
+    return jsonify([
+        {
+            "id": j.id,
+            "name": j.name,
+            "type": j.type,
+            "cron_expr": j.cron_expr,
+            "enabled": j.enabled,
+            "retention_mode": j.retention_mode,
+            "retention_value": j.retention_value,
+        }
+        for j in jobs
+    ])
+
+
+@app.route("/api/v1/jobs/<int:job_id>/runs")
+def api_job_runs(job_id):
+    runs = Run.query.filter_by(job_id=job_id).order_by(Run.started_at.desc()).limit(50).all()
+    return jsonify([
+        {
+            "id": r.id,
+            "started_at": r.started_at.isoformat() if r.started_at else None,
+            "finished_at": r.finished_at.isoformat() if r.finished_at else None,
+            "status": r.status,
+            "archive_name": r.archive_name,
+            "size_bytes": r.size_bytes,
+        }
+        for r in runs
+    ])
+
+
+@app.route("/api/v1/jobs/<int:job_id>/run", methods=["POST"])
+def api_job_run(job_id):
+    job = db.get_or_404(Job, job_id)
+    from scheduler import _execute_job
+    import threading
+    threading.Thread(target=_execute_job, args=(job.id,), daemon=True).start()
+    return jsonify({"status": "triggered", "job_id": job_id})
+
+
+@app.route("/api/v1/archives")
+def api_archives():
+    backup_dir = app.config["YUNOHOST_BACKUP_DIR"]
+    archives = []
+    try:
+        for fname in sorted(os.listdir(backup_dir)):
+            if fname.endswith(".tar"):
+                path = os.path.join(backup_dir, fname)
+                archives.append({
+                    "name": fname[:-4],
+                    "size_bytes": os.path.getsize(path),
+                    "modified_at": datetime.utcfromtimestamp(os.path.getmtime(path)).isoformat(),
+                })
+    except OSError:
+        pass
+    return jsonify(archives)
+
+
+@app.route("/api/v1/archives/<name>", methods=["DELETE"])
+def api_archive_delete(name):
+    backup_dir = app.config["YUNOHOST_BACKUP_DIR"]
+    for ext in (".tar", ".info.json"):
+        path = os.path.join(backup_dir, name + ext)
+        if os.path.exists(path):
+            os.remove(path)
+    return jsonify({"status": "deleted", "name": name})

+ 56 - 0
sources/db.py

@@ -0,0 +1,56 @@
+from flask_sqlalchemy import SQLAlchemy
+from datetime import datetime
+
+db = SQLAlchemy()
+
+
+class Job(db.Model):
+    __tablename__ = "jobs"
+
+    id = db.Column(db.Integer, primary_key=True)
+    name = db.Column(db.Text, nullable=False)
+    type = db.Column(db.Text, nullable=False)  # ynh_app|ynh_system|custom_dir|mysql|postgresql
+    config_json = db.Column(db.Text)
+    cron_expr = db.Column(db.Text, nullable=False)
+    retention_mode = db.Column(db.Text, nullable=False)  # count|daily|gfs
+    retention_value = db.Column(db.Integer, nullable=False)
+    enabled = db.Column(db.Boolean, default=True)
+    core_only = db.Column(db.Boolean, default=False)
+    created_at = db.Column(db.DateTime, default=datetime.utcnow)
+    updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+
+    runs = db.relationship("Run", backref="job", lazy=True, cascade="all, delete-orphan")
+
+    def next_run(self):
+        from scheduler import get_next_run
+        return get_next_run(self.id)
+
+
+class Run(db.Model):
+    __tablename__ = "runs"
+
+    id = db.Column(db.Integer, primary_key=True)
+    job_id = db.Column(db.Integer, db.ForeignKey("jobs.id"), nullable=False)
+    started_at = db.Column(db.DateTime)
+    finished_at = db.Column(db.DateTime)
+    status = db.Column(db.Text)  # running|success|error
+    log_text = db.Column(db.Text)
+    archive_name = db.Column(db.Text)
+    size_bytes = db.Column(db.Integer)
+
+    @property
+    def duration_seconds(self):
+        if self.started_at and self.finished_at:
+            return int((self.finished_at - self.started_at).total_seconds())
+        return None
+
+    @property
+    def size_human(self):
+        if not self.size_bytes:
+            return "—"
+        n = float(self.size_bytes)
+        for unit in ("o", "Ko", "Mo", "Go"):
+            if n < 1024:
+                return f"{n:.0f} {unit}"
+            n /= 1024
+        return f"{n:.1f} To"

+ 0 - 0
sources/federation/__init__.py


+ 2 - 0
sources/federation/api.py

@@ -0,0 +1,2 @@
+# Phase 3 — Endpoints REST fédération inter-instances
+raise NotImplementedError("federation/api — Phase 3")

+ 13 - 0
sources/init_db.py

@@ -0,0 +1,13 @@
+#!/usr/bin/env python3
+"""Initialise (ou migre) la base de données SQLite. Appelé par les scripts install et upgrade."""
+import os
+import sys
+
+if len(sys.argv) > 1:
+    os.environ["BACKUPMANAGER_CONFIG"] = sys.argv[1]
+
+from app import app, db
+
+with app.app_context():
+    db.create_all()
+    print("Base de données initialisée.")

+ 0 - 0
sources/jobs/__init__.py


+ 2 - 0
sources/jobs/custom_dir.py

@@ -0,0 +1,2 @@
+# Phase 2 — Sauvegarde de répertoires personnalisés (tar + format compatible YunoHost)
+raise NotImplementedError("custom_dir — Phase 2")

+ 2 - 0
sources/jobs/db_dump.py

@@ -0,0 +1,2 @@
+# Phase 2 — Sauvegarde MySQL / PostgreSQL (mysqldump / pg_dump)
+raise NotImplementedError("db_dump — Phase 2")

+ 2 - 0
sources/jobs/transfer.py

@@ -0,0 +1,2 @@
+# Phase 2 — Transfert vers destination distante (rsync SSH / SFTP)
+raise NotImplementedError("transfer — Phase 2")

+ 102 - 0
sources/jobs/ynh_backup.py

@@ -0,0 +1,102 @@
+import json
+import os
+import subprocess
+from datetime import datetime
+
+from db import db, Job, Run
+
+
+BACKUP_DIR = None  # initialisé depuis app.config
+
+
+def execute_job(job_id):
+    """Point d'entrée appelé par APScheduler (dans app_context Flask)."""
+    from flask import current_app
+    backup_dir = current_app.config["YUNOHOST_BACKUP_DIR"]
+    instance = current_app.config["INSTANCE_NAME"]
+
+    job = db.session.get(Job, job_id)
+    if not job or not job.enabled:
+        return
+
+    run = Run(job_id=job_id, started_at=datetime.utcnow(), status="running")
+    db.session.add(run)
+    db.session.commit()
+
+    try:
+        if job.type == "ynh_app":
+            archive_name, log = _run_ynh_app(job, instance, backup_dir)
+        elif job.type == "ynh_system":
+            archive_name, log = _run_ynh_system(job, instance, backup_dir)
+        else:
+            raise ValueError(f"Type de job non géré dans ce module : {job.type}")
+
+        archive_path = os.path.join(backup_dir, archive_name + ".tar")
+        size_bytes = os.path.getsize(archive_path) if os.path.exists(archive_path) else None
+
+        run.status = "success"
+        run.archive_name = archive_name
+        run.size_bytes = size_bytes
+        run.log_text = log
+
+        from retention import apply_retention
+        deleted = apply_retention(job, archive_name, backup_dir)
+        if deleted:
+            run.log_text += f"\n\nRétention : {len(deleted)} archive(s) supprimée(s) : {', '.join(deleted)}"
+
+    except Exception as exc:
+        run.status = "error"
+        run.log_text = str(exc)
+
+    finally:
+        run.finished_at = datetime.utcnow()
+        db.session.commit()
+
+
+def _archive_name(instance, label):
+    date_str = datetime.utcnow().strftime("%Y%m%d")
+    return f"{instance}_{label}_{date_str}"
+
+
+def _run_ynh_app(job, instance, backup_dir):
+    cfg = json.loads(job.config_json or "{}")
+    app_id = cfg.get("app_id", "")
+    core_only = cfg.get("core_only", job.core_only)
+
+    archive = _archive_name(instance, app_id)
+    _abort_if_exists(archive, backup_dir)
+
+    cmd = ["sudo", "yunohost", "backup", "create", "--apps", app_id, "--name", archive]
+    if core_only:
+        cmd = ["sudo", "env", "BACKUP_CORE_ONLY=1"] + cmd[1:]
+
+    result = subprocess.run(cmd, capture_output=True, text=True, timeout=3600)
+    log = (result.stdout + result.stderr).strip()
+
+    if result.returncode != 0:
+        raise RuntimeError(f"yunohost backup create a échoué (code {result.returncode}) :\n{log}")
+
+    return archive, log
+
+
+def _run_ynh_system(job, instance, backup_dir):
+    archive = _archive_name(instance, "system")
+    _abort_if_exists(archive, backup_dir)
+
+    cmd = ["sudo", "yunohost", "backup", "create", "--system", "--name", archive]
+    result = subprocess.run(cmd, capture_output=True, text=True, timeout=3600)
+    log = (result.stdout + result.stderr).strip()
+
+    if result.returncode != 0:
+        raise RuntimeError(f"yunohost backup create a échoué (code {result.returncode}) :\n{log}")
+
+    return archive, log
+
+
+def _abort_if_exists(archive_name, backup_dir):
+    path = os.path.join(backup_dir, archive_name + ".tar")
+    if os.path.exists(path):
+        raise RuntimeError(
+            f"L'archive {archive_name}.tar existe déjà. "
+            "Supprimez-la manuellement ou attendez le prochain cycle."
+        )

+ 5 - 0
sources/requirements.txt

@@ -0,0 +1,5 @@
+Flask==3.0.3
+Flask-SQLAlchemy==3.1.1
+APScheduler==3.10.4
+gunicorn==22.0.0
+Werkzeug==3.0.3

+ 81 - 0
sources/retention.py

@@ -0,0 +1,81 @@
+import os
+import re
+from datetime import datetime, timedelta
+
+
+def apply_retention(job, new_archive_name, backup_dir):
+    """Applique la politique de rétention après une sauvegarde réussie."""
+    archives = _list_archives_for_job(job, backup_dir)
+    if job.retention_mode == "count":
+        to_delete = _retention_count(archives, job.retention_value)
+    elif job.retention_mode == "daily":
+        to_delete = _retention_daily(archives, job.retention_value)
+    else:
+        return []
+
+    deleted = []
+    for archive_filename in to_delete:
+        base = os.path.splitext(archive_filename)[0]
+        for ext in (".tar", ".info.json"):
+            full = os.path.join(backup_dir, base + ext)
+            if os.path.exists(full):
+                os.remove(full)
+                deleted.append(base + ext)
+    return deleted
+
+
+def _list_archives_for_job(job, backup_dir):
+    """Liste les archives correspondant à ce job, triées par date (plus ancienne en premier)."""
+    from flask import current_app
+    instance = current_app.config["INSTANCE_NAME"]
+
+    if job.type == "ynh_app":
+        import json
+        cfg = json.loads(job.config_json or "{}")
+        app_id = cfg.get("app_id", "")
+        prefix = f"{instance}_{app_id}_"
+    elif job.type == "ynh_system":
+        prefix = f"{instance}_system_"
+    else:
+        prefix = f"{instance}_{job.name.lower().replace(' ', '-')}_"
+
+    archives = []
+    for fname in os.listdir(backup_dir):
+        if fname.startswith(prefix) and fname.endswith(".tar"):
+            archives.append(fname)
+
+    archives.sort(key=_extract_date)
+    return archives
+
+
+def _extract_date(filename):
+    match = re.search(r'(\d{8})', filename)
+    if match:
+        try:
+            return datetime.strptime(match.group(1), "%Y%m%d")
+        except ValueError:
+            pass
+    return datetime.min
+
+
+def _retention_count(archives, keep_n):
+    if len(archives) <= keep_n:
+        return []
+    return archives[: len(archives) - keep_n]
+
+
+def _retention_daily(archives, days):
+    cutoff = datetime.utcnow() - timedelta(days=days)
+    to_delete = []
+    seen_dates = set()
+    for archive in reversed(archives):
+        date = _extract_date(archive)
+        if date < cutoff:
+            to_delete.append(archive)
+            continue
+        date_key = date.date()
+        if date_key in seen_dates:
+            to_delete.append(archive)
+        else:
+            seen_dates.add(date_key)
+    return to_delete

+ 54 - 0
sources/scheduler.py

@@ -0,0 +1,54 @@
+from apscheduler.schedulers.background import BackgroundScheduler
+from apscheduler.triggers.cron import CronTrigger
+
+_flask_app = None
+
+scheduler = BackgroundScheduler(
+    job_defaults={"coalesce": True, "max_instances": 1},
+    timezone="UTC",
+)
+
+
+def init_scheduler(flask_app):
+    global _flask_app
+    _flask_app = flask_app
+    if not scheduler.running:
+        scheduler.start()
+
+
+def _execute_job(job_id):
+    with _flask_app.app_context():
+        from jobs.ynh_backup import execute_job
+        execute_job(job_id)
+
+
+def schedule_job(job):
+    job_key = f"job_{job.id}"
+    try:
+        trigger = CronTrigger.from_crontab(job.cron_expr)
+    except Exception:
+        return
+    if scheduler.get_job(job_key):
+        scheduler.reschedule_job(job_key, trigger=trigger)
+    else:
+        scheduler.add_job(
+            func=_execute_job,
+            trigger=trigger,
+            id=job_key,
+            kwargs={"job_id": job.id},
+            replace_existing=True,
+        )
+
+
+def remove_job(job_id):
+    job_key = f"job_{job_id}"
+    if scheduler.get_job(job_key):
+        scheduler.remove_job(job_key)
+
+
+def get_next_run(job_id):
+    job_key = f"job_{job_id}"
+    apsjob = scheduler.get_job(job_key)
+    if apsjob and apsjob.next_run_time:
+        return apsjob.next_run_time
+    return None

+ 55 - 0
sources/templates/base.html

@@ -0,0 +1,55 @@
+<!DOCTYPE html>
+<html lang="fr" class="h-full bg-gray-50">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title>{% block title %}Backup Manager{% endblock %} — {{ instance_name }}</title>
+  <script src="https://cdn.tailwindcss.com"></script>
+</head>
+<body class="h-full flex flex-col">
+
+  <nav class="bg-gray-900 text-white shadow-lg">
+    <div class="max-w-7xl mx-auto px-6 py-3 flex items-center justify-between">
+      <div class="flex items-center gap-3">
+        <svg class="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
+            d="M5 12h14M5 12l4-4m-4 4l4 4M19 12l-4-4m4 4l-4 4"/>
+        </svg>
+        <a href="{{ url_for('index') }}" class="text-lg font-bold tracking-tight">Backup Manager</a>
+        <span class="bg-blue-600 text-xs font-medium px-2 py-0.5 rounded">{{ instance_name }}</span>
+      </div>
+      <div class="flex items-center gap-4 text-sm">
+        <a href="{{ url_for('index') }}" class="text-gray-300 hover:text-white transition">Dashboard</a>
+        <a href="{{ url_for('job_new') }}"
+           class="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1.5 rounded font-medium transition">
+          + Nouveau job
+        </a>
+      </div>
+    </div>
+  </nav>
+
+  <main class="flex-1 max-w-7xl mx-auto w-full px-6 py-8">
+    {% with messages = get_flashed_messages(with_categories=true) %}
+      {% if messages %}
+        <div class="mb-6 space-y-2">
+          {% for category, message in messages %}
+            <div class="px-4 py-3 rounded-lg text-sm font-medium
+              {% if category == 'error' %}bg-red-50 text-red-800 border border-red-200
+              {% elif category == 'info' %}bg-blue-50 text-blue-800 border border-blue-200
+              {% else %}bg-green-50 text-green-800 border border-green-200{% endif %}">
+              {{ message }}
+            </div>
+          {% endfor %}
+        </div>
+      {% endif %}
+    {% endwith %}
+
+    {% block content %}{% endblock %}
+  </main>
+
+  <footer class="text-center text-xs text-gray-400 py-4">
+    Backup Manager — instance <strong>{{ instance_name }}</strong>
+  </footer>
+
+</body>
+</html>

+ 152 - 0
sources/templates/dashboard_local.html

@@ -0,0 +1,152 @@
+{% extends "base.html" %}
+{% block title %}Dashboard{% endblock %}
+
+{% block content %}
+
+{# ── Statistiques rapides ─────────────────────────────────────────────────── #}
+{% set total = jobs | length %}
+{% set enabled_count = jobs | selectattr('enabled') | list | length %}
+{% set recent_runs = last_runs.values() | select | list %}
+{% set success_count = recent_runs | selectattr('status', 'equalto', 'success') | list | length %}
+{% set error_count = recent_runs | selectattr('status', 'equalto', 'error') | list | length %}
+{% set running_count = recent_runs | selectattr('status', 'equalto', 'running') | list | length %}
+
+<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-8">
+  <div class="bg-white rounded-xl border border-gray-200 px-5 py-4">
+    <p class="text-xs text-gray-500 uppercase tracking-wide">Jobs</p>
+    <p class="text-3xl font-bold text-gray-800 mt-1">{{ total }}</p>
+    <p class="text-xs text-gray-400 mt-1">{{ enabled_count }} actifs</p>
+  </div>
+  <div class="bg-white rounded-xl border border-gray-200 px-5 py-4">
+    <p class="text-xs text-gray-500 uppercase tracking-wide">Succès</p>
+    <p class="text-3xl font-bold text-green-600 mt-1">{{ success_count }}</p>
+    <p class="text-xs text-gray-400 mt-1">dernière exécution</p>
+  </div>
+  <div class="bg-white rounded-xl border border-gray-200 px-5 py-4">
+    <p class="text-xs text-gray-500 uppercase tracking-wide">Erreurs</p>
+    <p class="text-3xl font-bold {% if error_count > 0 %}text-red-600{% else %}text-gray-400{% endif %} mt-1">
+      {{ error_count }}
+    </p>
+    <p class="text-xs text-gray-400 mt-1">dernière exécution</p>
+  </div>
+  <div class="bg-white rounded-xl border border-gray-200 px-5 py-4">
+    <p class="text-xs text-gray-500 uppercase tracking-wide">En cours</p>
+    <p class="text-3xl font-bold {% if running_count > 0 %}text-blue-600{% else %}text-gray-400{% endif %} mt-1">
+      {{ running_count }}
+    </p>
+    <p class="text-xs text-gray-400 mt-1">actuellement</p>
+  </div>
+</div>
+
+{# ── Table des jobs ───────────────────────────────────────────────────────── #}
+<div class="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
+  <div class="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
+    <h2 class="text-base font-semibold text-gray-800">Jobs de sauvegarde</h2>
+  </div>
+
+  {% if not jobs %}
+    <div class="px-6 py-12 text-center text-gray-400">
+      <p class="text-lg">Aucun job configuré.</p>
+      <a href="{{ url_for('job_new') }}" class="mt-3 inline-block text-blue-600 hover:underline text-sm">
+        Créer le premier job →
+      </a>
+    </div>
+  {% else %}
+    <div class="overflow-x-auto">
+      <table class="w-full text-sm">
+        <thead>
+          <tr class="text-xs text-gray-500 uppercase tracking-wide bg-gray-50">
+            <th class="px-6 py-3 text-left font-medium">Nom</th>
+            <th class="px-6 py-3 text-left font-medium">Type</th>
+            <th class="px-6 py-3 text-left font-medium">Planification</th>
+            <th class="px-6 py-3 text-left font-medium">Prochaine exéc.</th>
+            <th class="px-6 py-3 text-left font-medium">Dernière exéc.</th>
+            <th class="px-6 py-3 text-left font-medium">Statut</th>
+            <th class="px-6 py-3 text-left font-medium">Taille</th>
+            <th class="px-6 py-3 text-right font-medium">Actions</th>
+          </tr>
+        </thead>
+        <tbody class="divide-y divide-gray-100">
+          {% for job in jobs %}
+            {% set run = last_runs.get(job.id) %}
+            <tr class="hover:bg-gray-50 {% if not job.enabled %}opacity-50{% endif %}">
+              <td class="px-6 py-4 font-medium text-gray-900">{{ job.name }}</td>
+              <td class="px-6 py-4">
+                <span class="bg-gray-100 text-gray-600 text-xs px-2 py-0.5 rounded font-mono">
+                  {{ job.type }}
+                </span>
+              </td>
+              <td class="px-6 py-4 font-mono text-xs text-gray-600">{{ job.cron_expr }}</td>
+              <td class="px-6 py-4 text-xs text-gray-500">
+                {% set next = job.next_run() %}
+                {% if next and job.enabled %}
+                  {{ next.strftime('%d/%m %H:%M') }}
+                {% else %}
+                  <span class="text-gray-300">—</span>
+                {% endif %}
+              </td>
+              <td class="px-6 py-4 text-xs text-gray-500">
+                {% if run and run.started_at %}
+                  {{ run.started_at.strftime('%d/%m/%Y %H:%M') }}
+                {% else %}
+                  <span class="text-gray-300">Jamais</span>
+                {% endif %}
+              </td>
+              <td class="px-6 py-4">
+                {% if run %}
+                  {% if run.status == 'success' %}
+                    <span class="bg-green-100 text-green-700 text-xs font-medium px-2 py-0.5 rounded-full">✓ succès</span>
+                  {% elif run.status == 'error' %}
+                    <span class="bg-red-100 text-red-700 text-xs font-medium px-2 py-0.5 rounded-full">✗ erreur</span>
+                  {% elif run.status == 'running' %}
+                    <span class="bg-blue-100 text-blue-700 text-xs font-medium px-2 py-0.5 rounded-full animate-pulse">⟳ en cours</span>
+                  {% endif %}
+                {% else %}
+                  <span class="text-gray-300 text-xs">—</span>
+                {% endif %}
+              </td>
+              <td class="px-6 py-4 text-xs text-gray-500">
+                {% if run and run.size_bytes %}{{ run.size_human }}{% else %}—{% endif %}
+              </td>
+              <td class="px-6 py-4 text-right">
+                <div class="flex items-center justify-end gap-2">
+                  <form method="post" action="{{ url_for('job_run_now', job_id=job.id) }}"
+                        onsubmit="return confirm('Lancer « {{ job.name }} » maintenant ?')">
+                    <button type="submit"
+                      class="bg-blue-50 hover:bg-blue-100 text-blue-700 text-xs font-medium px-2.5 py-1 rounded transition">
+                      ▶ Lancer
+                    </button>
+                  </form>
+                  <a href="{{ url_for('job_history', job_id=job.id) }}"
+                     class="text-gray-400 hover:text-gray-700 text-xs px-2 py-1 rounded hover:bg-gray-100 transition">
+                    Historique
+                  </a>
+                  <a href="{{ url_for('job_edit', job_id=job.id) }}"
+                     class="text-gray-400 hover:text-gray-700 text-xs px-2 py-1 rounded hover:bg-gray-100 transition">
+                    Éditer
+                  </a>
+                  <form method="post" action="{{ url_for('job_toggle', job_id=job.id) }}">
+                    <button type="submit"
+                      class="text-gray-400 hover:text-gray-700 text-xs px-2 py-1 rounded hover:bg-gray-100 transition"
+                      title="{{ 'Désactiver' if job.enabled else 'Activer' }}">
+                      {{ '⏸' if job.enabled else '▶' }}
+                    </button>
+                  </form>
+                  <form method="post" action="{{ url_for('job_delete', job_id=job.id) }}"
+                        onsubmit="return confirm('Supprimer définitivement « {{ job.name }} » et son historique ?')">
+                    <button type="submit"
+                      class="text-red-300 hover:text-red-600 text-xs px-2 py-1 rounded hover:bg-red-50 transition">
+                      ✕
+                    </button>
+                  </form>
+                </div>
+              </td>
+            </tr>
+          {% endfor %}
+        </tbody>
+      </table>
+    </div>
+  {% endif %}
+</div>
+
+{% endblock %}

+ 159 - 0
sources/templates/job_form.html

@@ -0,0 +1,159 @@
+{% extends "base.html" %}
+{% block title %}{{ 'Éditer' if job else 'Nouveau job' }}{% endblock %}
+
+{% block content %}
+<div class="max-w-2xl">
+  <h1 class="text-xl font-bold text-gray-900 mb-6">
+    {{ 'Éditer « ' + job.name + ' »' if job else 'Nouveau job de sauvegarde' }}
+  </h1>
+
+  <form method="post"
+        action="{{ url_for('job_edit', job_id=job.id) if job else url_for('job_new') }}"
+        class="space-y-6">
+
+    {# ── Infos générales ── #}
+    <div class="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
+      <h2 class="text-sm font-semibold text-gray-700 uppercase tracking-wide">Général</h2>
+
+      <div>
+        <label class="block text-sm font-medium text-gray-700 mb-1">Nom du job</label>
+        <input type="text" name="name" required
+               value="{{ job.name if job else '' }}"
+               placeholder="ex: Nextcloud quotidien"
+               class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
+      </div>
+
+      <div>
+        <label class="block text-sm font-medium text-gray-700 mb-1">Type</label>
+        <select name="type" id="job-type"
+                class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
+          {% for val, label in [('ynh_app','Application YunoHost'), ('ynh_system','Système YunoHost'),
+                                ('custom_dir','Répertoire custom (Phase 2)'), ('mysql','MySQL (Phase 2)'),
+                                ('postgresql','PostgreSQL (Phase 2)')] %}
+            <option value="{{ val }}"
+              {% if job and job.type == val %}selected{% endif %}
+              {% if val in ('custom_dir','mysql','postgresql') %}disabled class="text-gray-400"{% endif %}>
+              {{ label }}
+            </option>
+          {% endfor %}
+        </select>
+      </div>
+
+      {# Type-specific config : ynh_app #}
+      <div id="cfg-ynh_app" class="type-cfg space-y-3">
+        <div>
+          <label class="block text-sm font-medium text-gray-700 mb-1">Application YunoHost</label>
+          <select name="app_id"
+                  class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
+            {% set current_app_id = (job.config_json | fromjson).get('app_id', '') if job and job.config_json else '' %}
+            {% for ynh_app in ynh_apps %}
+              <option value="{{ ynh_app.id }}"
+                      {% if ynh_app.id == current_app_id %}selected{% endif %}>
+                {{ ynh_app.id }}{% if ynh_app.label %} — {{ ynh_app.label }}{% endif %}
+              </option>
+            {% endfor %}
+            {% if not ynh_apps %}
+              <option value="">Aucune application trouvée</option>
+            {% endif %}
+          </select>
+        </div>
+        <div class="flex items-center gap-2">
+          {% set core_only = (job.config_json | fromjson).get('core_only', False) if job and job.config_json else False %}
+          <input type="checkbox" name="core_only" value="1" id="core_only"
+                 {% if core_only %}checked{% endif %}
+                 class="rounded border-gray-300 text-blue-600">
+          <label for="core_only" class="text-sm text-gray-700">
+            Core only (BACKUP_CORE_ONLY=1) — exclut les données utilisateur
+          </label>
+        </div>
+      </div>
+
+      {# Type-specific config : ynh_system (rien de spécifique) #}
+      <div id="cfg-ynh_system" class="type-cfg hidden">
+        <p class="text-sm text-gray-500 bg-gray-50 rounded-lg p-3">
+          Sauvegarde la configuration système YunoHost complète.
+          Aucun paramètre supplémentaire.
+        </p>
+      </div>
+    </div>
+
+    {# ── Planification ── #}
+    <div class="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
+      <h2 class="text-sm font-semibold text-gray-700 uppercase tracking-wide">Planification</h2>
+      <div>
+        <label class="block text-sm font-medium text-gray-700 mb-1">Expression cron</label>
+        <input type="text" name="cron_expr" required
+               value="{{ job.cron_expr if job else '0 3 * * *' }}"
+               placeholder="0 3 * * *"
+               class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500">
+        <p class="text-xs text-gray-400 mt-1">
+          Format : minute heure jour mois jour_semaine
+          — ex: <code class="bg-gray-100 px-1 rounded">0 3 * * *</code> = tous les jours à 3h
+          · <code class="bg-gray-100 px-1 rounded">0 3 * * 1</code> = chaque lundi à 3h
+        </p>
+      </div>
+    </div>
+
+    {# ── Rétention ── #}
+    <div class="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
+      <h2 class="text-sm font-semibold text-gray-700 uppercase tracking-wide">Rétention</h2>
+      <div class="grid grid-cols-2 gap-4">
+        <div>
+          <label class="block text-sm font-medium text-gray-700 mb-1">Mode</label>
+          <select name="retention_mode"
+                  class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
+            {% for val, label in [('count','Count — N dernières archives'),
+                                  ('daily','Daily — 1 par jour sur N jours'),
+                                  ('gfs','GFS — Grand/Père/Fils (Phase 4)')] %}
+              <option value="{{ val }}"
+                {% if job and job.retention_mode == val %}selected{% endif %}
+                {% if val == 'gfs' %}disabled class="text-gray-400"{% endif %}>
+                {{ label }}
+              </option>
+            {% endfor %}
+          </select>
+        </div>
+        <div>
+          <label class="block text-sm font-medium text-gray-700 mb-1">Valeur</label>
+          <input type="number" name="retention_value" min="1" max="365" required
+                 value="{{ job.retention_value if job else 7 }}"
+                 class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
+        </div>
+      </div>
+    </div>
+
+    {# ── Options ── #}
+    <div class="bg-white rounded-xl border border-gray-200 p-6">
+      <div class="flex items-center gap-3">
+        <input type="checkbox" name="enabled" value="1" id="enabled"
+               {% if not job or job.enabled %}checked{% endif %}
+               class="rounded border-gray-300 text-blue-600">
+        <label for="enabled" class="text-sm font-medium text-gray-700">Job activé</label>
+      </div>
+    </div>
+
+    {# ── Actions ── #}
+    <div class="flex gap-3">
+      <button type="submit"
+              class="bg-blue-600 hover:bg-blue-700 text-white px-5 py-2 rounded-lg font-medium text-sm transition">
+        {{ 'Enregistrer' if job else 'Créer le job' }}
+      </button>
+      <a href="{{ url_for('index') }}"
+         class="bg-white hover:bg-gray-50 text-gray-700 border border-gray-300 px-5 py-2 rounded-lg font-medium text-sm transition">
+        Annuler
+      </a>
+    </div>
+  </form>
+</div>
+
+<script>
+  function showTypeConfig() {
+    document.querySelectorAll('.type-cfg').forEach(el => el.classList.add('hidden'));
+    const type = document.getElementById('job-type').value;
+    const el = document.getElementById('cfg-' + type);
+    if (el) el.classList.remove('hidden');
+  }
+  document.getElementById('job-type').addEventListener('change', showTypeConfig);
+  showTypeConfig();
+</script>
+{% endblock %}

+ 92 - 0
sources/templates/job_history.html

@@ -0,0 +1,92 @@
+{% extends "base.html" %}
+{% block title %}Historique — {{ job.name }}{% endblock %}
+
+{% block content %}
+<div class="mb-6 flex items-center gap-4">
+  <a href="{{ url_for('index') }}" class="text-gray-400 hover:text-gray-600 text-sm">← Dashboard</a>
+  <h1 class="text-xl font-bold text-gray-900">{{ job.name }}</h1>
+  <span class="bg-gray-100 text-gray-600 text-xs px-2 py-0.5 rounded font-mono">{{ job.type }}</span>
+  <span class="text-gray-400 text-sm font-mono">{{ job.cron_expr }}</span>
+</div>
+
+<div class="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
+  <div class="px-6 py-4 border-b border-gray-100">
+    <h2 class="text-sm font-semibold text-gray-700">
+      Historique des exécutions
+      <span class="text-gray-400 font-normal">({{ runs | length }} entrées)</span>
+    </h2>
+  </div>
+
+  {% if not runs %}
+    <div class="px-6 py-10 text-center text-gray-400 text-sm">
+      Aucune exécution enregistrée pour ce job.
+    </div>
+  {% else %}
+    <div class="overflow-x-auto">
+      <table class="w-full text-sm">
+        <thead>
+          <tr class="text-xs text-gray-500 uppercase tracking-wide bg-gray-50">
+            <th class="px-6 py-3 text-left font-medium">Début</th>
+            <th class="px-6 py-3 text-left font-medium">Fin</th>
+            <th class="px-6 py-3 text-left font-medium">Durée</th>
+            <th class="px-6 py-3 text-left font-medium">Statut</th>
+            <th class="px-6 py-3 text-left font-medium">Archive</th>
+            <th class="px-6 py-3 text-left font-medium">Taille</th>
+            <th class="px-6 py-3 text-left font-medium">Log</th>
+          </tr>
+        </thead>
+        <tbody class="divide-y divide-gray-100">
+          {% for run in runs %}
+            <tr class="hover:bg-gray-50">
+              <td class="px-6 py-3 text-xs text-gray-700 whitespace-nowrap">
+                {{ run.started_at.strftime('%d/%m/%Y %H:%M:%S') if run.started_at else '—' }}
+              </td>
+              <td class="px-6 py-3 text-xs text-gray-500 whitespace-nowrap">
+                {{ run.finished_at.strftime('%H:%M:%S') if run.finished_at else '—' }}
+              </td>
+              <td class="px-6 py-3 text-xs text-gray-500">
+                {% if run.duration_seconds is not none %}
+                  {% if run.duration_seconds >= 60 %}
+                    {{ (run.duration_seconds // 60) }}min {{ run.duration_seconds % 60 }}s
+                  {% else %}
+                    {{ run.duration_seconds }}s
+                  {% endif %}
+                {% else %}
+                  —
+                {% endif %}
+              </td>
+              <td class="px-6 py-3">
+                {% if run.status == 'success' %}
+                  <span class="bg-green-100 text-green-700 text-xs font-medium px-2 py-0.5 rounded-full">✓ succès</span>
+                {% elif run.status == 'error' %}
+                  <span class="bg-red-100 text-red-700 text-xs font-medium px-2 py-0.5 rounded-full">✗ erreur</span>
+                {% elif run.status == 'running' %}
+                  <span class="bg-blue-100 text-blue-700 text-xs font-medium px-2 py-0.5 rounded-full animate-pulse">⟳ en cours</span>
+                {% else %}
+                  <span class="text-gray-400 text-xs">{{ run.status or '—' }}</span>
+                {% endif %}
+              </td>
+              <td class="px-6 py-3 text-xs font-mono text-gray-600">
+                {{ run.archive_name or '—' }}
+              </td>
+              <td class="px-6 py-3 text-xs text-gray-500">
+                {{ run.size_human if run.size_bytes else '—' }}
+              </td>
+              <td class="px-6 py-3">
+                {% if run.log_text %}
+                  <details>
+                    <summary class="cursor-pointer text-xs text-blue-600 hover:underline">Voir</summary>
+                    <pre class="mt-2 text-xs bg-gray-900 text-gray-100 p-3 rounded-lg overflow-x-auto max-w-xl whitespace-pre-wrap">{{ run.log_text }}</pre>
+                  </details>
+                {% else %}
+                  <span class="text-gray-300 text-xs">—</span>
+                {% endif %}
+              </td>
+            </tr>
+          {% endfor %}
+        </tbody>
+      </table>
+    </div>
+  {% endif %}
+</div>
+{% endblock %}