Просмотр исходного кода

feat: Phase 3A+3B — DB fédération, API REST complète, instances distantes UI + client HTTP

Cédric Hansen 23 часов назад
Родитель
Сommit
f46202ff84

+ 16 - 6
doc/CDC_backupmanager_ynh.md

@@ -388,11 +388,21 @@ rsync -az -e "ssh -i $key -p $port" archive.tar archive.info.json user@host:/hom
 - [x] Accès archives root-owned via sudo (stat/find/tar/rsync)
 
 ### Phase 3 — Fédération
-- [ ] API REST complète
-- [ ] Enregistrement instances distantes
-- [ ] Dashboard vue réseau
-- [ ] Transfert HTTP chunked + SSH/rsync optionnel
-- [ ] Restauration distante
+**Sous-phases :**
+- **3A** — Fondations : DB (RemoteInstance, RemoteRun, Upload) + API REST complète
+- **3B** — Instances distantes : UI enregistrement + test connexion + sync état
+- **3C** — Dashboard réseau : vue agrégée multi-instances
+- **3D** — Transfert HTTP chunked : push/pull archives entre instances
+- **3E** — Contrôle distant : déclencher backup/restauration sur instance distante
+
+**Avancement :**
+- [x] 3A — Modèles DB RemoteInstance / RemoteRun / Upload
+- [x] 3A — API : /summary, /archives/<name>/info, /archives/<name>/restore (+status), upload chunked
+- [x] 3B — UI instances distantes (liste, ajout, édition, suppression, test, sync)
+- [x] 3B — federation/client.py (FederationClient + sync_instance)
+- [ ] 3C — Dashboard réseau (vue agrégée)
+- [ ] 3D — Envoi d'archive HTTP chunked depuis le dashboard
+- [ ] 3E — Contrôle distant (run/restore depuis dashboard)
 
 ### Phase 4 — Finitions
 - [ ] Rétention gfs
@@ -402,4 +412,4 @@ rsync -az -e "ssh -i $key -p $port" archive.tar archive.info.json user@host:/hom
 
 ---
 
-*backupmanager_ynh — CDC v4.1 — Avancement mis à jour le 2026-05-09 | Phase 2 complète ✅ | Phase 3 à démarrer*
+*backupmanager_ynh — CDC v4.2 — Avancement mis à jour le 2026-05-09 | Phase 2 ✅ | Phase 3 en cours (3A+3B fait)*

+ 310 - 56
sources/app.py

@@ -1,7 +1,13 @@
+import glob
+import hashlib
 import json
 import logging
+import math
 import os
+import shutil
 import subprocess
+import threading
+import uuid
 from datetime import datetime
 
 from flask import (
@@ -43,7 +49,7 @@ logging.basicConfig(
 
 # --- Extensions --------------------------------------------------------------
 
-from db import db, Job, Run, Destination, Setting
+from db import db, Job, Run, Destination, Setting, RemoteInstance, RemoteRun, Upload
 
 db.init_app(app)
 
@@ -167,15 +173,59 @@ def job_history(job_id):
     return render_template("job_history.html", job=job, runs=runs)
 
 
-@app.route("/archives/<path:archive_name>/restore", methods=["GET", "POST"])
-def archive_restore(archive_name):
+def _do_restore_job(archive_name, archive_type, restore_run_id):
+    """Exécute la restauration en arrière-plan et met à jour le Run."""
+    with app.app_context():
+        run = db.session.get(Run, restore_run_id) if restore_run_id else None
+        try:
+            backup_dir = app.config["YUNOHOST_BACKUP_DIR"]
+            if archive_type == "custom_dir":
+                from jobs.custom_dir import restore_custom_dir
+                log = restore_custom_dir(archive_name, backup_dir)
+            elif archive_type in ("mysql", "postgresql"):
+                from jobs.db_dump import restore_db_dump
+                log = restore_db_dump(archive_name, backup_dir)
+            elif archive_type == "ynh_app":
+                result = subprocess.run(
+                    ["sudo", "yunohost", "backup", "restore", archive_name,
+                     "--apps", "--force"],
+                    capture_output=True, text=True, timeout=3600,
+                )
+                log = (result.stdout + result.stderr).strip()
+                if result.returncode != 0:
+                    raise RuntimeError(f"yunohost backup restore a échoué :\n{log}")
+            elif archive_type == "ynh_system":
+                result = subprocess.run(
+                    ["sudo", "yunohost", "backup", "restore", archive_name,
+                     "--system", "--force"],
+                    capture_output=True, text=True, timeout=3600,
+                )
+                log = (result.stdout + result.stderr).strip()
+                if result.returncode != 0:
+                    raise RuntimeError(f"yunohost backup restore a échoué :\n{log}")
+            else:
+                raise NotImplementedError(
+                    f"Restauration non supportée pour le type '{archive_type}'."
+                )
+            if run:
+                run.status = "success"
+                run.finished_at = datetime.utcnow()
+                run.log_text = f"[RESTAURATION]\n{log or 'OK'}"
+                db.session.commit()
+        except Exception as exc:
+            app.logger.error(f"Restauration {archive_name} échouée : {exc}")
+            if run:
+                run.status = "error"
+                run.finished_at = datetime.utcnow()
+                run.log_text = f"[RESTAURATION]\n{exc}"
+                db.session.commit()
+
+
+def _start_restore(archive_name):
+    """Crée un Run de restauration et lance le thread. Retourne (restore_run_id, archive_type)."""
     info = _read_archive_info(archive_name)
     archive_type = info.get("type", "")
 
-    if request.method == "GET":
-        return render_template("restore_confirm.html", archive_name=archive_name, info=info)
-
-    # Trouver le job_id depuis le Run original pour pouvoir tracer la restauration
     original_run = Run.query.filter_by(archive_name=archive_name).first()
     restore_run_id = None
     if original_run:
@@ -190,54 +240,22 @@ def archive_restore(archive_name):
         db.session.commit()
         restore_run_id = restore_run.id
 
-    def _do_restore():
-        with app.app_context():
-            run = db.session.get(Run, restore_run_id) if restore_run_id else None
-            try:
-                backup_dir = app.config["YUNOHOST_BACKUP_DIR"]
-                if archive_type == "custom_dir":
-                    from jobs.custom_dir import restore_custom_dir
-                    log = restore_custom_dir(archive_name, backup_dir)
-                elif archive_type in ("mysql", "postgresql"):
-                    from jobs.db_dump import restore_db_dump
-                    log = restore_db_dump(archive_name, backup_dir)
-                elif archive_type == "ynh_app":
-                    result = subprocess.run(
-                        ["sudo", "yunohost", "backup", "restore", archive_name,
-                         "--apps", "--force"],
-                        capture_output=True, text=True, timeout=3600,
-                    )
-                    log = (result.stdout + result.stderr).strip()
-                    if result.returncode != 0:
-                        raise RuntimeError(f"yunohost backup restore a échoué :\n{log}")
-                elif archive_type == "ynh_system":
-                    result = subprocess.run(
-                        ["sudo", "yunohost", "backup", "restore", archive_name,
-                         "--system", "--force"],
-                        capture_output=True, text=True, timeout=3600,
-                    )
-                    log = (result.stdout + result.stderr).strip()
-                    if result.returncode != 0:
-                        raise RuntimeError(f"yunohost backup restore a échoué :\n{log}")
-                else:
-                    raise NotImplementedError(
-                        f"Restauration non supportée pour le type '{archive_type}'."
-                    )
-                if run:
-                    run.status = "success"
-                    run.finished_at = datetime.utcnow()
-                    run.log_text = f"[RESTAURATION]\n{log or 'OK'}"
-                    db.session.commit()
-            except Exception as exc:
-                app.logger.error(f"Restauration {archive_name} échouée : {exc}")
-                if run:
-                    run.status = "error"
-                    run.finished_at = datetime.utcnow()
-                    run.log_text = f"[RESTAURATION]\n{exc}"
-                    db.session.commit()
+    threading.Thread(
+        target=_do_restore_job,
+        args=(archive_name, archive_type, restore_run_id),
+        daemon=True,
+    ).start()
+    return restore_run_id, archive_type
 
-    import threading
-    threading.Thread(target=_do_restore, daemon=True).start()
+
+@app.route("/archives/<path:archive_name>/restore", methods=["GET", "POST"])
+def archive_restore(archive_name):
+    info = _read_archive_info(archive_name)
+
+    if request.method == "GET":
+        return render_template("restore_confirm.html", archive_name=archive_name, info=info)
+
+    _start_restore(archive_name)
     flash(f"Restauration de « {archive_name} » démarrée en arrière-plan.", "success")
     return redirect(url_for("index"))
 
@@ -600,8 +618,244 @@ def api_archives():
 @app.route("/api/v1/archives/<name>", methods=["DELETE"])
 def api_archive_delete(name):
     backup_dir = app.config["YUNOHOST_BACKUP_DIR"]
+    from jobs.utils import sudo_exists
     for ext in (".tar", ".info.json"):
         path = os.path.join(backup_dir, name + ext)
-        if os.path.exists(path):
-            os.remove(path)
+        if sudo_exists(path):
+            subprocess.run(["sudo", "rm", "-f", path], capture_output=True)
     return jsonify({"status": "deleted", "name": name})
+
+
+@app.route("/api/v1/archives/<name>/info")
+def api_archive_info(name):
+    return jsonify(_read_archive_info(name))
+
+
+@app.route("/api/v1/archives/<name>/restore", methods=["POST"])
+def api_archive_restore(name):
+    restore_run_id, _ = _start_restore(name)
+    return jsonify({"status": "started", "run_id": restore_run_id})
+
+
+@app.route("/api/v1/archives/<name>/restore/status")
+def api_archive_restore_status(name):
+    run = (Run.query
+           .filter(Run.archive_name == name, Run.log_text.like("[RESTAURATION%"))
+           .order_by(Run.started_at.desc())
+           .first())
+    if not run:
+        return jsonify({"error": "Aucune restauration trouvée pour cette archive."}), 404
+    return jsonify({
+        "status": run.status,
+        "log": run.log_text,
+        "started_at": run.started_at.isoformat() if run.started_at else None,
+        "finished_at": run.finished_at.isoformat() if run.finished_at else None,
+    })
+
+
+@app.route("/api/v1/summary")
+def api_summary():
+    jobs = Job.query.all()
+    result = []
+    for job in jobs:
+        last_run = (Run.query.filter_by(job_id=job.id)
+                    .order_by(Run.started_at.desc()).first())
+        result.append({
+            "id": job.id,
+            "name": job.name,
+            "type": job.type,
+            "cron_expr": job.cron_expr,
+            "enabled": job.enabled,
+            "last_run": {
+                "id": last_run.id,
+                "started_at": last_run.started_at.isoformat() if last_run.started_at else None,
+                "status": last_run.status,
+                "archive_name": last_run.archive_name,
+                "size_bytes": last_run.size_bytes,
+            } if last_run else None,
+        })
+    return jsonify({"instance": app.config.get("INSTANCE_NAME"), "jobs": result})
+
+
+# --- Upload chunked -----------------------------------------------------------
+
+@app.route("/api/v1/archives/upload/start", methods=["POST"])
+def api_upload_start():
+    data = request.get_json(force=True) or {}
+    filename = data.get("filename", "")
+    total_size = int(data.get("total_size", 0))
+    chunk_size = int(data.get("chunk_size", 50 * 1024 * 1024))
+    chunks_total = int(data.get("chunks_total", math.ceil(total_size / chunk_size) if chunk_size else 1))
+    checksum = data.get("checksum", "")
+
+    if not filename:
+        return jsonify({"error": "filename requis"}), 400
+
+    upload_id = str(uuid.uuid4())
+    upload = Upload(
+        upload_id=upload_id,
+        filename=filename,
+        total_size=total_size,
+        chunk_size=chunk_size,
+        chunks_total=chunks_total,
+        chunks_received=0,
+        checksum=checksum,
+        status="pending",
+    )
+    db.session.add(upload)
+    db.session.commit()
+    return jsonify({"upload_id": upload_id, "chunks_total": chunks_total})
+
+
+@app.route("/api/v1/archives/upload/<upload_id>/chunk/<int:n>", methods=["POST"])
+def api_upload_chunk(upload_id, n):
+    upload = db.get_or_404(Upload, upload_id)
+    if upload.status == "complete":
+        return jsonify({"error": "upload déjà terminé"}), 400
+
+    tmp_dir = os.path.join(app.config["DATA_DIR"], "uploads", upload_id)
+    os.makedirs(tmp_dir, exist_ok=True)
+
+    chunk_path = os.path.join(tmp_dir, f"chunk_{n:06d}")
+    with open(chunk_path, "wb") as f:
+        f.write(request.data)
+
+    upload.chunks_received = (upload.chunks_received or 0) + 1
+    upload.status = "in_progress"
+    db.session.commit()
+    return jsonify({"chunk": n, "received": upload.chunks_received})
+
+
+@app.route("/api/v1/archives/upload/<upload_id>/finish", methods=["POST"])
+def api_upload_finish(upload_id):
+    upload = db.get_or_404(Upload, upload_id)
+    tmp_dir = os.path.join(app.config["DATA_DIR"], "uploads", upload_id)
+    backup_dir = app.config["YUNOHOST_BACKUP_DIR"]
+
+    chunk_files = sorted(glob.glob(os.path.join(tmp_dir, "chunk_*")))
+    if not chunk_files:
+        return jsonify({"error": "aucun chunk reçu"}), 400
+
+    tmp_archive = os.path.join(tmp_dir, upload.filename)
+    sha256 = hashlib.sha256()
+    with open(tmp_archive, "wb") as out:
+        for chunk_file in chunk_files:
+            with open(chunk_file, "rb") as f:
+                data = f.read()
+                out.write(data)
+                sha256.update(data)
+
+    if upload.checksum and sha256.hexdigest() != upload.checksum:
+        upload.status = "error"
+        db.session.commit()
+        shutil.rmtree(tmp_dir, ignore_errors=True)
+        return jsonify({"error": "checksum invalide"}), 400
+
+    dest_path = os.path.join(backup_dir, upload.filename)
+    result = subprocess.run(
+        ["sudo", "rsync", tmp_archive, dest_path],
+        capture_output=True, text=True,
+    )
+    shutil.rmtree(tmp_dir, ignore_errors=True)
+
+    if result.returncode != 0:
+        upload.status = "error"
+        db.session.commit()
+        return jsonify({"error": result.stderr.strip()}), 500
+
+    upload.status = "complete"
+    db.session.commit()
+    return jsonify({"status": "complete", "filename": upload.filename})
+
+
+@app.route("/api/v1/archives/upload/<upload_id>", methods=["DELETE"])
+def api_upload_cancel(upload_id):
+    upload = db.get_or_404(Upload, upload_id)
+    tmp_dir = os.path.join(app.config["DATA_DIR"], "uploads", upload_id)
+    shutil.rmtree(tmp_dir, ignore_errors=True)
+    db.session.delete(upload)
+    db.session.commit()
+    return jsonify({"status": "cancelled"})
+
+
+# --- Instances distantes (3B) -------------------------------------------------
+
+@app.route("/remote-instances")
+def remote_instances_list():
+    instances = RemoteInstance.query.order_by(RemoteInstance.name).all()
+    return render_template("remote_instances.html", instances=instances)
+
+
+@app.route("/remote-instances/new", methods=["GET", "POST"])
+def remote_instance_new():
+    if request.method == "POST":
+        return _save_remote_instance(None)
+    return render_template("remote_instance_form.html", inst=None)
+
+
+@app.route("/remote-instances/<int:inst_id>/edit", methods=["GET", "POST"])
+def remote_instance_edit(inst_id):
+    inst = db.get_or_404(RemoteInstance, inst_id)
+    if request.method == "POST":
+        return _save_remote_instance(inst)
+    return render_template("remote_instance_form.html", inst=inst)
+
+
+@app.route("/remote-instances/<int:inst_id>/delete", methods=["POST"])
+def remote_instance_delete(inst_id):
+    inst = db.get_or_404(RemoteInstance, inst_id)
+    db.session.delete(inst)
+    db.session.commit()
+    flash(f"Instance « {inst.name} » supprimée.", "success")
+    return redirect(url_for("remote_instances_list"))
+
+
+@app.route("/remote-instances/<int:inst_id>/test", methods=["POST"])
+def remote_instance_test(inst_id):
+    inst = db.get_or_404(RemoteInstance, inst_id)
+    from federation.client import FederationClient
+    try:
+        data = FederationClient(inst).health()
+        inst.status = "online"
+        inst.last_seen = datetime.utcnow()
+        db.session.commit()
+        flash(f"Instance « {inst.name} » en ligne — {data.get('instance', '?')}.", "success")
+    except Exception as exc:
+        inst.status = "error"
+        db.session.commit()
+        flash(f"Connexion échouée vers « {inst.name} » : {exc}", "error")
+    return redirect(url_for("remote_instances_list"))
+
+
+@app.route("/remote-instances/<int:inst_id>/sync", methods=["POST"])
+def remote_instance_sync(inst_id):
+    inst = db.get_or_404(RemoteInstance, inst_id)
+    from federation.client import sync_instance
+    try:
+        sync_instance(inst)
+        flash(f"Instance « {inst.name} » synchronisée.", "success")
+    except Exception as exc:
+        flash(f"Synchronisation échouée pour « {inst.name} » : {exc}", "error")
+    return redirect(url_for("remote_instances_list"))
+
+
+def _save_remote_instance(inst):
+    f = request.form
+    name = f.get("name", "").strip()
+    url = f.get("url", "").strip().rstrip("/")
+    api_key = f.get("api_key", "").strip()
+
+    if not name or not url or not api_key:
+        flash("Nom, URL et token API sont requis.", "error")
+        return render_template("remote_instance_form.html", inst=inst)
+
+    if inst is None:
+        inst = RemoteInstance()
+        db.session.add(inst)
+
+    inst.name = name
+    inst.url = url
+    inst.api_key = api_key
+    db.session.commit()
+    flash(f"Instance « {inst.name} » enregistrée.", "success")
+    return redirect(url_for("remote_instances_list"))

+ 71 - 8
sources/db.py

@@ -4,6 +4,21 @@ from datetime import datetime
 db = SQLAlchemy()
 
 
+# ---------------------------------------------------------------------------
+# Helpers partagés
+# ---------------------------------------------------------------------------
+
+def _size_human(size_bytes):
+    if not size_bytes:
+        return "—"
+    n = float(size_bytes)
+    for unit in ("o", "Ko", "Mo", "Go"):
+        if n < 1024:
+            return f"{n:.0f} {unit}"
+        n /= 1024
+    return f"{n:.1f} To"
+
+
 class Destination(db.Model):
     __tablename__ = "destinations"
 
@@ -74,11 +89,59 @@ class Run(db.Model):
 
     @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"
+        return _size_human(self.size_bytes)
+
+
+# ---------------------------------------------------------------------------
+# Fédération
+# ---------------------------------------------------------------------------
+
+class RemoteInstance(db.Model):
+    __tablename__ = "remote_instances"
+
+    id = db.Column(db.Integer, primary_key=True)
+    name = db.Column(db.Text, nullable=False)
+    url = db.Column(db.Text, nullable=False)       # https://tom.domaine.fr
+    api_key = db.Column(db.Text, nullable=False)
+    last_seen = db.Column(db.DateTime)
+    status = db.Column(db.Text, default="unknown") # online|offline|error|unknown
+    created_at = db.Column(db.DateTime, default=datetime.utcnow)
+
+    remote_runs = db.relationship("RemoteRun", backref="instance", lazy=True,
+                                  cascade="all, delete-orphan")
+
+    @property
+    def url_display(self):
+        return self.url.rstrip("/")
+
+
+class RemoteRun(db.Model):
+    __tablename__ = "remote_runs"
+
+    id = db.Column(db.Integer, primary_key=True)
+    instance_id = db.Column(db.Integer, db.ForeignKey("remote_instances.id"), nullable=False)
+    job_id = db.Column(db.Integer)       # id sur l'instance distante
+    job_name = db.Column(db.Text)
+    job_type = db.Column(db.Text)
+    last_run_at = db.Column(db.DateTime)
+    last_status = db.Column(db.Text)
+    last_archive_name = db.Column(db.Text)
+    last_size_bytes = db.Column(db.Integer)
+
+    @property
+    def last_size_human(self):
+        return _size_human(self.last_size_bytes)
+
+
+class Upload(db.Model):
+    __tablename__ = "uploads"
+
+    upload_id = db.Column(db.Text, primary_key=True)  # uuid4
+    filename = db.Column(db.Text)
+    total_size = db.Column(db.Integer)
+    chunk_size = db.Column(db.Integer)
+    chunks_total = db.Column(db.Integer)
+    chunks_received = db.Column(db.Integer, default=0)
+    checksum = db.Column(db.Text)          # SHA256 de l'archive complète
+    started_at = db.Column(db.DateTime, default=datetime.utcnow)
+    status = db.Column(db.Text, default="pending")  # pending|in_progress|complete|error

+ 126 - 0
sources/federation/client.py

@@ -0,0 +1,126 @@
+import math
+from datetime import datetime
+
+import requests
+
+
+class FederationClient:
+    """Client HTTP vers une instance BackupManager distante."""
+
+    def __init__(self, instance):
+        self.base = instance.url.rstrip("/")
+        self.headers = {"X-BackupManager-Key": instance.api_key}
+        self.timeout = 15
+
+    def _get(self, path):
+        r = requests.get(f"{self.base}{path}", headers=self.headers,
+                         timeout=self.timeout, verify=True)
+        r.raise_for_status()
+        return r.json()
+
+    def _post(self, path, json=None, data=None, extra_headers=None):
+        h = dict(self.headers)
+        if extra_headers:
+            h.update(extra_headers)
+        r = requests.post(f"{self.base}{path}", headers=h,
+                          json=json, data=data, timeout=self.timeout, verify=True)
+        r.raise_for_status()
+        return r.json()
+
+    def health(self):
+        return self._get("/api/v1/health")
+
+    def summary(self):
+        return self._get("/api/v1/summary")
+
+    def get_jobs(self):
+        return self._get("/api/v1/jobs")
+
+    def get_job_runs(self, job_id):
+        return self._get(f"/api/v1/jobs/{job_id}/runs")
+
+    def get_archives(self):
+        return self._get("/api/v1/archives")
+
+    def run_job(self, job_id):
+        return self._post(f"/api/v1/jobs/{job_id}/run")
+
+    def restore_archive(self, archive_name):
+        return self._post(f"/api/v1/archives/{archive_name}/restore")
+
+    def restore_status(self, archive_name):
+        return self._get(f"/api/v1/archives/{archive_name}/restore/status")
+
+    def upload_start(self, filename, total_size, checksum, chunk_size=50 * 1024 * 1024):
+        chunks_total = math.ceil(total_size / chunk_size) if chunk_size else 1
+        return self._post("/api/v1/archives/upload/start", json={
+            "filename": filename,
+            "total_size": total_size,
+            "chunk_size": chunk_size,
+            "chunks_total": chunks_total,
+            "checksum": checksum,
+        })
+
+    def upload_chunk(self, upload_id, n, data):
+        r = requests.post(
+            f"{self.base}/api/v1/archives/upload/{upload_id}/chunk/{n}",
+            headers=self.headers,
+            data=data,
+            timeout=300,
+            verify=True,
+        )
+        r.raise_for_status()
+        return r.json()
+
+    def upload_finish(self, upload_id):
+        return self._post(f"/api/v1/archives/upload/{upload_id}/finish")
+
+    def upload_cancel(self, upload_id):
+        r = requests.delete(
+            f"{self.base}/api/v1/archives/upload/{upload_id}",
+            headers=self.headers,
+            timeout=self.timeout,
+            verify=True,
+        )
+        r.raise_for_status()
+        return r.json()
+
+
+def sync_instance(instance):
+    """Synchronise l'état d'une instance distante dans remote_runs."""
+    from db import db, RemoteRun
+
+    client = FederationClient(instance)
+    try:
+        summary = client.summary()
+        instance.last_seen = datetime.utcnow()
+        instance.status = "online"
+
+        RemoteRun.query.filter_by(instance_id=instance.id).delete()
+
+        for job_data in summary.get("jobs", []):
+            last_run = job_data.get("last_run")
+            last_run_at = None
+            if last_run and last_run.get("started_at"):
+                try:
+                    last_run_at = datetime.fromisoformat(last_run["started_at"])
+                except ValueError:
+                    pass
+            rr = RemoteRun(
+                instance_id=instance.id,
+                job_id=job_data["id"],
+                job_name=job_data["name"],
+                job_type=job_data["type"],
+                last_run_at=last_run_at,
+                last_status=last_run["status"] if last_run else None,
+                last_archive_name=last_run.get("archive_name") if last_run else None,
+                last_size_bytes=last_run.get("size_bytes") if last_run else None,
+            )
+            db.session.add(rr)
+
+        db.session.commit()
+    except Exception as exc:
+        instance.status = "error"
+        instance.last_seen = datetime.utcnow()
+        db.session.commit()
+        raise exc

+ 1 - 0
sources/requirements.txt

@@ -3,3 +3,4 @@ Flask-SQLAlchemy==3.1.1
 APScheduler==3.10.4
 gunicorn==22.0.0
 Werkzeug==3.0.3
+requests==2.32.3

+ 1 - 0
sources/templates/base.html

@@ -20,6 +20,7 @@
       </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('remote_instances_list') }}" class="text-gray-300 hover:text-white transition">Instances</a>
         <a href="{{ url_for('destinations_list') }}" class="text-gray-300 hover:text-white transition">Destinations</a>
         <a href="{{ url_for('settings') }}" class="text-gray-300 hover:text-white transition">Paramètres</a>
         <a href="{{ url_for('job_new') }}"

+ 64 - 0
sources/templates/remote_instance_form.html

@@ -0,0 +1,64 @@
+{% extends "base.html" %}
+{% block title %}{{ 'Éditer' if inst else 'Nouvelle instance' }}{% endblock %}
+
+{% block content %}
+<div class="max-w-lg">
+  <h1 class="text-xl font-bold text-gray-900 mb-6">
+    {{ 'Éditer « ' + inst.name + ' »' if inst else 'Nouvelle instance distante' }}
+  </h1>
+
+  <form method="post"
+        action="{{ url_for('remote_instance_edit', inst_id=inst.id) if inst else url_for('remote_instance_new') }}"
+        class="space-y-6">
+
+    <div class="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
+
+      <div>
+        <label class="block text-sm font-medium text-gray-700 mb-1">Nom</label>
+        <input type="text" name="name" required
+               value="{{ inst.name if inst else '' }}"
+               placeholder="ex: jerry, tom, serveur-2"
+               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">
+        <p class="text-xs text-gray-400 mt-1">Identifiant court pour affichage.</p>
+      </div>
+
+      <div>
+        <label class="block text-sm font-medium text-gray-700 mb-1">URL de l'instance</label>
+        <input type="url" name="url" required
+               value="{{ inst.url if inst else '' }}"
+               placeholder="https://mon-serveur.domaine.fr/backupmanager"
+               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">
+          URL complète avec le sous-chemin si applicable.
+          L'API sera contactée sur <code class="bg-gray-100 px-1 rounded">/api/v1/</code>.
+        </p>
+      </div>
+
+      <div>
+        <label class="block text-sm font-medium text-gray-700 mb-1">Token API</label>
+        <input type="text" name="api_key" required
+               value="{{ inst.api_key if inst else '' }}"
+               placeholder="ex: a3f8c2d1..."
+               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">
+          Valeur du header <code class="bg-gray-100 px-1 rounded">X-BackupManager-Key</code>
+          configurée sur l'instance distante (visible dans sa config YunoHost).
+        </p>
+      </div>
+
+    </div>
+
+    <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 inst else 'Ajouter l\'instance' }}
+      </button>
+      <a href="{{ url_for('remote_instances_list') }}"
+         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>
+{% endblock %}

+ 111 - 0
sources/templates/remote_instances.html

@@ -0,0 +1,111 @@
+{% extends "base.html" %}
+{% block title %}Instances distantes{% endblock %}
+
+{% block content %}
+<div class="flex items-center justify-between mb-6">
+  <h1 class="text-xl font-bold text-gray-900">Instances distantes</h1>
+  <a href="{{ url_for('remote_instance_new') }}"
+     class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition">
+    + Ajouter une instance
+  </a>
+</div>
+
+{% if not instances %}
+  <div class="bg-white rounded-xl border border-gray-200 px-6 py-12 text-center text-gray-400">
+    <p class="text-lg">Aucune instance distante configurée.</p>
+    <p class="text-sm mt-2">Ajoutez une instance pour voir son état et déclencher des sauvegardes à distance.</p>
+    <a href="{{ url_for('remote_instance_new') }}" class="mt-4 inline-block text-blue-600 hover:underline text-sm">
+      Ajouter une première instance →
+    </a>
+  </div>
+{% else %}
+  <div class="space-y-4">
+    {% for inst in instances %}
+    {% set status_color = {
+      'online':  'bg-green-100 text-green-700',
+      'error':   'bg-red-100 text-red-700',
+      'offline': 'bg-gray-100 text-gray-500',
+    }.get(inst.status, 'bg-gray-100 text-gray-400') %}
+
+    <div class="bg-white rounded-xl border border-gray-200 shadow-sm p-5">
+      <div class="flex items-start justify-between gap-4">
+
+        <div class="space-y-2 min-w-0 flex-1">
+          <div class="flex items-center gap-2 flex-wrap">
+            <span class="font-semibold text-gray-900">{{ inst.name }}</span>
+            <span class="text-xs px-2 py-0.5 rounded-full font-medium {{ status_color }}">
+              {{ inst.status or 'unknown' }}
+            </span>
+            {% if inst.last_seen %}
+              <span class="text-xs text-gray-400">
+                vu {{ inst.last_seen.strftime('%d/%m %H:%M') }}
+              </span>
+            {% endif %}
+          </div>
+
+          <p class="text-sm font-mono text-gray-500">{{ inst.url_display }}</p>
+
+          {% if inst.remote_runs %}
+          <div class="mt-3 border-t border-gray-100 pt-3">
+            <p class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">
+              Jobs ({{ inst.remote_runs | length }})
+            </p>
+            <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
+              {% for rr in inst.remote_runs %}
+              {% set run_color = {
+                'success': 'text-green-600',
+                'error':   'text-red-600',
+                'running': 'text-blue-600',
+              }.get(rr.last_status, 'text-gray-400') %}
+              <div class="text-xs bg-gray-50 rounded-lg px-3 py-2 space-y-0.5">
+                <p class="font-medium text-gray-800 truncate">{{ rr.job_name }}</p>
+                <p class="text-gray-400">{{ rr.job_type }}</p>
+                {% if rr.last_run_at %}
+                  <p class="{{ run_color }} font-medium">
+                    {{ rr.last_status }} — {{ rr.last_run_at.strftime('%d/%m %H:%M') }}
+                  </p>
+                {% else %}
+                  <p class="text-gray-300">jamais exécuté</p>
+                {% endif %}
+                {% if rr.last_size_bytes %}
+                  <p class="text-gray-400">{{ rr.last_size_human }}</p>
+                {% endif %}
+              </div>
+              {% endfor %}
+            </div>
+          </div>
+          {% endif %}
+        </div>
+
+        <div class="flex items-center gap-2 shrink-0 flex-wrap justify-end">
+          <form method="post" action="{{ url_for('remote_instance_test', inst_id=inst.id) }}">
+            <button type="submit"
+              class="bg-gray-50 hover:bg-gray-100 text-gray-700 text-xs px-3 py-1.5 rounded border border-gray-200 transition">
+              Tester
+            </button>
+          </form>
+          <form method="post" action="{{ url_for('remote_instance_sync', inst_id=inst.id) }}">
+            <button type="submit"
+              class="bg-gray-50 hover:bg-gray-100 text-gray-700 text-xs px-3 py-1.5 rounded border border-gray-200 transition">
+              Synchroniser
+            </button>
+          </form>
+          <a href="{{ url_for('remote_instance_edit', inst_id=inst.id) }}"
+             class="bg-gray-50 hover:bg-gray-100 text-gray-700 text-xs px-3 py-1.5 rounded border border-gray-200 transition">
+            Éditer
+          </a>
+          <form method="post" action="{{ url_for('remote_instance_delete', inst_id=inst.id) }}"
+                onsubmit="return confirm('Supprimer l\'instance « {{ inst.name }} » ?')">
+            <button type="submit"
+              class="text-red-300 hover:text-red-600 text-xs px-2 py-1.5 rounded hover:bg-red-50 transition">
+              ✕
+            </button>
+          </form>
+        </div>
+
+      </div>
+    </div>
+    {% endfor %}
+  </div>
+{% endif %}
+{% endblock %}