Selaa lähdekoodia

feat: Phase 2 — destinations rsync SSH

Modèle:
- Table destinations (host, port, user, remote_path, key_name)
- destination_id (nullable) sur Job
- Migration SQLite automatique dans init_db.py

Transfer:
- Génération clé ed25519 par destination (data_dir/keys/)
- rsync -az via sudo (archives YNH owned root)
- StrictHostKeyChecking=accept-new (TOFU)
- Test de connexion SSH
- Auto-transfer après backup si destination configurée
- Transfert manuel depuis l'historique

UI:
- Page /destinations : liste, test, éditer, supprimer
- Formulaire destination : hôte/port/user/chemin + affichage clé publique
- Sélecteur destination dans le formulaire job
- Bouton "↑ Transférer" dans l'historique des runs
- Lien "Destinations" dans la navbar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Cédric Hansen 1 päivä sitten
vanhempi
commit
3402e0407b

+ 1 - 1
doc/CDC_backupmanager_ynh.md

@@ -359,7 +359,7 @@ rsync -az -e "ssh -i $key -p $port" archive.tar archive.info.json user@host:/hom
 - [x] Jobs mysql et postgresql
 - [x] Restauration complète custom_dir
 - [x] Restauration complète DB (mysql + postgresql)
-- [ ] Destinations rsync SSH / SFTP
+- [x] Destinations rsync SSH / SFTP
 - [ ] Notifications email
 
 ### Phase 3 — Fédération

+ 107 - 3
sources/app.py

@@ -43,7 +43,7 @@ logging.basicConfig(
 
 # --- Extensions --------------------------------------------------------------
 
-from db import db, Job, Run
+from db import db, Job, Run, Destination
 
 db.init_app(app)
 
@@ -124,7 +124,8 @@ def index():
 def job_new():
     if request.method == "POST":
         return _save_job(None)
-    return render_template("job_form.html", job=None, ynh_apps=_get_ynh_apps())
+    return render_template("job_form.html", job=None, ynh_apps=_get_ynh_apps(),
+                           destinations=Destination.query.filter_by(enabled=True).all())
 
 
 @app.route("/jobs/<int:job_id>/edit", methods=["GET", "POST"])
@@ -132,7 +133,8 @@ 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())
+    return render_template("job_form.html", job=job, ynh_apps=_get_ynh_apps(),
+                           destinations=Destination.query.filter_by(enabled=True).all())
 
 
 @app.route("/jobs/<int:job_id>/delete", methods=["POST"])
@@ -266,6 +268,7 @@ def _save_job(job):
         job = Job()
         db.session.add(job)
 
+    dest_id = f.get("destination_id", "").strip()
     job.name = name
     job.type = job_type
     job.config_json = json.dumps(cfg)
@@ -274,6 +277,7 @@ def _save_job(job):
     job.retention_value = int(f.get("retention_value", 7))
     job.enabled = f.get("enabled") == "1"
     job.core_only = cfg.get("core_only", False)
+    job.destination_id = int(dest_id) if dest_id else None
     job.updated_at = datetime.utcnow()
 
     db.session.commit()
@@ -286,6 +290,106 @@ def _save_job(job):
     flash(f"Job « {job.name} » enregistré.", "success")
     return redirect(url_for("index"))
 
+# --- Destinations ------------------------------------------------------------
+
+@app.route("/destinations")
+def destinations_list():
+    destinations = Destination.query.order_by(Destination.name).all()
+    return render_template("destinations.html", destinations=destinations)
+
+
+@app.route("/destinations/new", methods=["GET", "POST"])
+def destination_new():
+    if request.method == "POST":
+        return _save_destination(None)
+    return render_template("destination_form.html", dest=None)
+
+
+@app.route("/destinations/<int:dest_id>/edit", methods=["GET", "POST"])
+def destination_edit(dest_id):
+    dest = db.get_or_404(Destination, dest_id)
+    if request.method == "POST":
+        return _save_destination(dest)
+    pub_key = _get_pub_key(dest)
+    return render_template("destination_form.html", dest=dest, pub_key=pub_key)
+
+
+@app.route("/destinations/<int:dest_id>/delete", methods=["POST"])
+def destination_delete(dest_id):
+    dest = db.get_or_404(Destination, dest_id)
+    db.session.delete(dest)
+    db.session.commit()
+    flash(f"Destination « {dest.name} » supprimée.", "success")
+    return redirect(url_for("destinations_list"))
+
+
+@app.route("/destinations/<int:dest_id>/test", methods=["POST"])
+def destination_test(dest_id):
+    dest = db.get_or_404(Destination, dest_id)
+    from jobs.transfer import test_connection
+    ok, msg = test_connection(dest, app.config["DATA_DIR"])
+    flash(msg, "success" if ok else "error")
+    return redirect(url_for("destinations_list"))
+
+
+@app.route("/archives/<path:archive_name>/transfer", methods=["POST"])
+def archive_transfer(archive_name):
+    dest_id = request.form.get("destination_id", type=int)
+    dest = db.get_or_404(Destination, dest_id)
+
+    def _do_transfer():
+        with app.app_context():
+            try:
+                from jobs.transfer import transfer_archive
+                transfer_archive(archive_name, dest, app.config["YUNOHOST_BACKUP_DIR"],
+                                 app.config["DATA_DIR"])
+                app.logger.info(f"Transfert {archive_name} → {dest.remote_str} OK")
+            except Exception as exc:
+                app.logger.error(f"Transfert {archive_name} échoué : {exc}")
+
+    import threading
+    threading.Thread(target=_do_transfer, daemon=True).start()
+    flash(f"Transfert de « {archive_name} » vers {dest.remote_str} démarré.", "success")
+    return redirect(request.referrer or url_for("index"))
+
+
+def _save_destination(dest):
+    f = request.form
+    name = f.get("name", "").strip()
+    host = f.get("host", "").strip()
+    if not name or not host:
+        flash("Nom et hôte sont requis.", "error")
+        return render_template("destination_form.html", dest=dest)
+
+    is_new = dest is None
+    if is_new:
+        dest = Destination()
+        db.session.add(dest)
+
+    dest.name = name
+    dest.host = host
+    dest.port = int(f.get("port", 22) or 22)
+    dest.user = f.get("user", "root").strip() or "root"
+    dest.remote_path = f.get("remote_path", "/home/yunohost.backup/archives").strip()
+    dest.enabled = f.get("enabled") == "1"
+    db.session.flush()  # obtenir l'id si nouveau
+
+    # Génération de la clé SSH si absente
+    if not dest.key_name:
+        from jobs.transfer import generate_key
+        dest.key_name = generate_key(dest.name, app.config["DATA_DIR"])
+
+    db.session.commit()
+    flash(f"Destination « {dest.name} » enregistrée.", "success")
+    return redirect(url_for("destination_edit", dest_id=dest.id))
+
+
+def _get_pub_key(dest):
+    if not dest.key_name:
+        return None
+    from jobs.transfer import get_public_key
+    return get_public_key(dest.key_name, app.config["DATA_DIR"])
+
 # --- API v1 ------------------------------------------------------------------
 
 @app.route("/api/v1/health")

+ 21 - 0
sources/db.py

@@ -4,6 +4,26 @@ from datetime import datetime
 db = SQLAlchemy()
 
 
+class Destination(db.Model):
+    __tablename__ = "destinations"
+
+    id = db.Column(db.Integer, primary_key=True)
+    name = db.Column(db.Text, nullable=False)
+    host = db.Column(db.Text, nullable=False)
+    port = db.Column(db.Integer, default=22)
+    user = db.Column(db.Text, nullable=False, default="root")
+    remote_path = db.Column(db.Text, nullable=False)
+    key_name = db.Column(db.Text)  # nom du fichier clé dans data_dir/keys/
+    enabled = db.Column(db.Boolean, default=True)
+    created_at = db.Column(db.DateTime, default=datetime.utcnow)
+
+    jobs = db.relationship("Job", backref="destination", lazy=True)
+
+    @property
+    def remote_str(self):
+        return f"{self.user}@{self.host}:{self.remote_path}"
+
+
 class Job(db.Model):
     __tablename__ = "jobs"
 
@@ -16,6 +36,7 @@ class Job(db.Model):
     retention_value = db.Column(db.Integer, nullable=False)
     enabled = db.Column(db.Boolean, default=True)
     core_only = db.Column(db.Boolean, default=False)
+    destination_id = db.Column(db.Integer, db.ForeignKey("destinations.id"), nullable=True)
     created_at = db.Column(db.DateTime, default=datetime.utcnow)
     updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
 

+ 12 - 0
sources/init_db.py

@@ -7,7 +7,19 @@ if len(sys.argv) > 1:
     os.environ["BACKUPMANAGER_CONFIG"] = sys.argv[1]
 
 from app import app, db
+from sqlalchemy import text
 
 with app.app_context():
     db.create_all()
+
+    # Migrations manuelles pour les colonnes ajoutées après la création initiale
+    with db.engine.connect() as conn:
+        existing = [row[1] for row in conn.execute(text("PRAGMA table_info(jobs)"))]
+        if "destination_id" not in existing:
+            conn.execute(text(
+                "ALTER TABLE jobs ADD COLUMN destination_id INTEGER REFERENCES destinations(id)"
+            ))
+            conn.commit()
+            print("Migration : colonne destination_id ajoutée à jobs.")
+
     print("Base de données initialisée.")

+ 100 - 2
sources/jobs/transfer.py

@@ -1,2 +1,100 @@
-# Phase 2 — Transfert vers destination distante (rsync SSH / SFTP)
-raise NotImplementedError("transfer — Phase 2")
+import os
+import re
+import subprocess
+
+
+def generate_key(dest_name, data_dir):
+    """Génère une paire de clés ed25519 pour cette destination si elle n'existe pas encore."""
+    key_name = f"dest_{_slugify(dest_name)}_ed25519"
+    key_path = os.path.join(data_dir, "keys", key_name)
+    if not os.path.exists(key_path):
+        subprocess.run(
+            [
+                "ssh-keygen", "-t", "ed25519",
+                "-f", key_path,
+                "-N", "",
+                "-C", f"backupmanager@{dest_name}",
+            ],
+            check=True,
+            capture_output=True,
+        )
+        os.chmod(key_path, 0o600)
+    return key_name
+
+
+def get_public_key(key_name, data_dir):
+    """Retourne la clé publique à copier dans authorized_keys sur le serveur distant."""
+    pub_path = os.path.join(data_dir, "keys", key_name + ".pub")
+    try:
+        with open(pub_path) as f:
+            return f.read().strip()
+    except FileNotFoundError:
+        return None
+
+
+def transfer_archive(archive_name, destination, backup_dir, data_dir):
+    """Transfère .tar + .info.json vers la destination via rsync SSH."""
+    key_path = os.path.join(data_dir, "keys", destination.key_name)
+    if not os.path.exists(key_path):
+        raise FileNotFoundError(
+            f"Clé SSH introuvable : {key_path}\n"
+            "Accédez à la page de la destination pour afficher la clé publique."
+        )
+
+    files = []
+    for ext in (".tar", ".info.json"):
+        path = os.path.join(backup_dir, archive_name + ext)
+        if os.path.exists(path):
+            files.append(path)
+
+    if not files:
+        raise FileNotFoundError(f"Aucun fichier à transférer pour {archive_name}.")
+
+    remote = f"{destination.user}@{destination.host}:{destination.remote_path}/"
+    ssh_opts = (
+        f"ssh -i {key_path} -p {destination.port}"
+        " -o StrictHostKeyChecking=accept-new"
+        " -o BatchMode=yes"
+        " -o ConnectTimeout=30"
+    )
+
+    # sudo car les archives YNH (ynh_app/ynh_system) sont owned root
+    cmd = ["sudo", "rsync", "-az", "--stats", "-e", ssh_opts] + files + [remote]
+    result = subprocess.run(cmd, capture_output=True, text=True, timeout=7200)
+    log = (result.stdout + result.stderr).strip()
+
+    if result.returncode != 0:
+        raise RuntimeError(
+            f"rsync vers {destination.host} échoué (code {result.returncode}) :\n{log}"
+        )
+    return log
+
+
+def test_connection(destination, data_dir):
+    """Teste la connexion SSH vers la destination. Retourne (True, msg) ou (False, msg)."""
+    key_path = os.path.join(data_dir, "keys", destination.key_name)
+    if not os.path.exists(key_path):
+        return False, "Clé SSH non générée. Affichez la destination pour la créer."
+
+    result = subprocess.run(
+        [
+            "ssh",
+            "-i", key_path,
+            "-p", str(destination.port),
+            "-o", "StrictHostKeyChecking=accept-new",
+            "-o", "BatchMode=yes",
+            "-o", "ConnectTimeout=10",
+            f"{destination.user}@{destination.host}",
+            "echo ok",
+        ],
+        capture_output=True,
+        text=True,
+        timeout=20,
+    )
+    if result.returncode == 0:
+        return True, f"Connexion réussie vers {destination.host}:{destination.port}."
+    return False, result.stderr.strip() or f"Connexion échouée (code {result.returncode})."
+
+
+def _slugify(s):
+    return re.sub(r'[^a-z0-9]+', '-', s.lower().strip()).strip('-')

+ 14 - 0
sources/jobs/ynh_backup.py

@@ -50,6 +50,20 @@ def execute_job(job_id):
         if deleted:
             run.log_text += f"\n\nRétention : {len(deleted)} archive(s) supprimée(s) : {', '.join(deleted)}"
 
+        # Transfert automatique vers la destination configurée
+        if job.destination_id:
+            from db import Destination
+            from flask import current_app
+            dest = db.session.get(Destination, job.destination_id)
+            if dest and dest.enabled:
+                data_dir = current_app.config["DATA_DIR"]
+                try:
+                    from jobs.transfer import transfer_archive
+                    transfer_log = transfer_archive(archive_name, dest, backup_dir, data_dir)
+                    run.log_text += f"\n\nTransfert → {dest.remote_str} :\n{transfer_log}"
+                except Exception as transfer_exc:
+                    run.log_text += f"\n\n⚠ Transfert échoué vers {dest.remote_str} :\n{transfer_exc}"
+
     except Exception as exc:
         run.status = "error"
         run.log_text = str(exc)

+ 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('destinations_list') }}" class="text-gray-300 hover:text-white transition">Destinations</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

+ 102 - 0
sources/templates/destination_form.html

@@ -0,0 +1,102 @@
+{% extends "base.html" %}
+{% block title %}{{ 'Éditer' if dest else 'Nouvelle destination' }}{% endblock %}
+
+{% block content %}
+<div class="max-w-xl">
+  <div class="mb-6">
+    <a href="{{ url_for('destinations_list') }}" class="text-gray-400 hover:text-gray-600 text-sm">← Destinations</a>
+  </div>
+  <h1 class="text-xl font-bold text-gray-900 mb-6">
+    {{ 'Éditer « ' + dest.name + ' »' if dest else 'Nouvelle destination rsync SSH' }}
+  </h1>
+
+  <form method="post" class="space-y-5">
+
+    <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">Connexion SSH</h2>
+
+      <div>
+        <label class="block text-sm font-medium text-gray-700 mb-1">Nom</label>
+        <input type="text" name="name" required
+               value="{{ dest.name if dest else '' }}"
+               placeholder="ex: VPS Hetzner"
+               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 class="grid grid-cols-3 gap-3">
+        <div class="col-span-2">
+          <label class="block text-sm font-medium text-gray-700 mb-1">Hôte</label>
+          <input type="text" name="host" required
+                 value="{{ dest.host if dest else '' }}"
+                 placeholder="backup.exemple.fr"
+                 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">
+        </div>
+        <div>
+          <label class="block text-sm font-medium text-gray-700 mb-1">Port</label>
+          <input type="number" name="port" min="1" max="65535"
+                 value="{{ dest.port if dest else 22 }}"
+                 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">
+        </div>
+      </div>
+
+      <div>
+        <label class="block text-sm font-medium text-gray-700 mb-1">Utilisateur SSH</label>
+        <input type="text" name="user"
+               value="{{ dest.user if dest else 'root' }}"
+               placeholder="root"
+               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">
+      </div>
+
+      <div>
+        <label class="block text-sm font-medium text-gray-700 mb-1">Chemin distant</label>
+        <input type="text" name="remote_path"
+               value="{{ dest.remote_path if dest else '/home/yunohost.backup/archives' }}"
+               placeholder="/home/yunohost.backup/archives"
+               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">
+          Répertoire de destination sur le serveur distant. Doit exister et être accessible par l'utilisateur SSH.
+        </p>
+      </div>
+
+      <div class="flex items-center gap-2">
+        <input type="checkbox" name="enabled" value="1" id="dest_enabled"
+               {% if not dest or dest.enabled %}checked{% endif %}
+               class="rounded border-gray-300 text-blue-600">
+        <label for="dest_enabled" class="text-sm font-medium text-gray-700">Destination activée</label>
+      </div>
+    </div>
+
+    {% if pub_key %}
+    <div class="bg-white rounded-xl border border-gray-200 p-6 space-y-3">
+      <h2 class="text-sm font-semibold text-gray-700 uppercase tracking-wide">Clé publique SSH</h2>
+      <p class="text-xs text-gray-500">
+        Copiez cette clé dans <code class="bg-gray-100 px-1 rounded">~{{ dest.user }}/.ssh/authorized_keys</code>
+        sur <strong>{{ dest.host }}</strong> pour autoriser le transfert.
+      </p>
+      <div class="relative">
+        <pre id="pubkey" class="bg-gray-900 text-green-400 text-xs p-3 rounded-lg overflow-x-auto whitespace-pre-wrap break-all select-all">{{ pub_key }}</pre>
+        <button type="button" onclick="navigator.clipboard.writeText(document.getElementById('pubkey').textContent)"
+                class="absolute top-2 right-2 bg-gray-700 hover:bg-gray-600 text-gray-300 text-xs px-2 py-1 rounded transition">
+          Copier
+        </button>
+      </div>
+    </div>
+    {% elif dest %}
+    <div class="bg-amber-50 border border-amber-200 rounded-xl p-4 text-sm text-amber-800">
+      ⚠ La clé SSH sera générée lors de l'enregistrement.
+    </div>
+    {% endif %}
+
+    <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 dest else 'Créer la destination' }}
+      </button>
+      <a href="{{ url_for('destinations_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 %}

+ 69 - 0
sources/templates/destinations.html

@@ -0,0 +1,69 @@
+{% extends "base.html" %}
+{% block title %}Destinations{% endblock %}
+
+{% block content %}
+<div class="flex items-center justify-between mb-6">
+  <h1 class="text-xl font-bold text-gray-900">Destinations de transfert</h1>
+  <a href="{{ url_for('destination_new') }}"
+     class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition">
+    + Nouvelle destination
+  </a>
+</div>
+
+{% if not destinations %}
+  <div class="bg-white rounded-xl border border-gray-200 px-6 py-12 text-center text-gray-400">
+    <p class="text-lg">Aucune destination configurée.</p>
+    <p class="text-sm mt-2">Les archives sont conservées localement uniquement.</p>
+    <a href="{{ url_for('destination_new') }}" class="mt-4 inline-block text-blue-600 hover:underline text-sm">
+      Configurer une première destination →
+    </a>
+  </div>
+{% else %}
+  <div class="space-y-4">
+    {% for dest in destinations %}
+    <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-1 min-w-0">
+          <div class="flex items-center gap-2">
+            <span class="font-semibold text-gray-900">{{ dest.name }}</span>
+            {% if dest.enabled %}
+              <span class="bg-green-100 text-green-700 text-xs px-2 py-0.5 rounded-full font-medium">actif</span>
+            {% else %}
+              <span class="bg-gray-100 text-gray-500 text-xs px-2 py-0.5 rounded-full font-medium">inactif</span>
+            {% endif %}
+          </div>
+          <p class="text-sm font-mono text-gray-600">
+            {{ dest.user }}@{{ dest.host }}:{{ dest.port }} → {{ dest.remote_path }}
+          </p>
+          {% if dest.jobs %}
+            <p class="text-xs text-gray-400">
+              Utilisée par : {{ dest.jobs | map(attribute='name') | join(', ') }}
+            </p>
+          {% endif %}
+        </div>
+
+        <div class="flex items-center gap-2 shrink-0">
+          <form method="post" action="{{ url_for('destination_test', dest_id=dest.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>
+          <a href="{{ url_for('destination_edit', dest_id=dest.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('destination_delete', dest_id=dest.id) }}"
+                onsubmit="return confirm('Supprimer la destination « {{ dest.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 %}

+ 21 - 0
sources/templates/job_form.html

@@ -250,6 +250,27 @@
       </div>
     </div>
 
+    {# ── Destination ── #}
+    <div class="bg-white rounded-xl border border-gray-200 p-6 space-y-3">
+      <h2 class="text-sm font-semibold text-gray-700 uppercase tracking-wide">Transfert après sauvegarde</h2>
+      <select name="destination_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">
+        <option value="">Aucune — stockage local uniquement</option>
+        {% for d in destinations %}
+          <option value="{{ d.id }}"
+                  {% if job and job.destination_id == d.id %}selected{% endif %}>
+            {{ d.name }} — {{ d.remote_str }}
+          </option>
+        {% endfor %}
+      </select>
+      {% if not destinations %}
+        <p class="text-xs text-gray-400">
+          Aucune destination configurée.
+          <a href="{{ url_for('destination_new') }}" class="text-blue-600 hover:underline">En créer une →</a>
+        </p>
+      {% endif %}
+    </div>
+
     {# ── Options ── #}
     <div class="bg-white rounded-xl border border-gray-200 p-6">
       <div class="flex items-center gap-3">

+ 15 - 4
sources/templates/job_history.html

@@ -85,10 +85,21 @@
               </td>
               <td class="px-6 py-3">
                 {% if run.status == 'success' and run.archive_name %}
-                  <a href="{{ url_for('archive_restore', archive_name=run.archive_name) }}"
-                     class="text-xs text-orange-600 hover:text-orange-800 hover:underline whitespace-nowrap">
-                    ↩ Restaurer
-                  </a>
+                  <div class="flex flex-col gap-1">
+                    <a href="{{ url_for('archive_restore', archive_name=run.archive_name) }}"
+                       class="text-xs text-orange-600 hover:text-orange-800 hover:underline whitespace-nowrap">
+                      ↩ Restaurer
+                    </a>
+                    {% set destinations = job.destination_id and [job.destination] or [] %}
+                    {% if destinations %}
+                      <form method="post" action="{{ url_for('archive_transfer', archive_name=run.archive_name) }}">
+                        <input type="hidden" name="destination_id" value="{{ job.destination_id }}">
+                        <button type="submit" class="text-xs text-blue-600 hover:text-blue-800 hover:underline whitespace-nowrap text-left">
+                          ↑ Transférer
+                        </button>
+                      </form>
+                    {% endif %}
+                  </div>
                 {% else %}
                   <span class="text-gray-300 text-xs">—</span>
                 {% endif %}