Bläddra i källkod

feat: ajouter boutons rapatrier/restaurer dans la vue d'ensemble des archives

Chaque archive affiche désormais un bouton « Restaurer » si elle est
locale, ou « Rapatrier » si elle est distante (SSH ou instance
fédérée), en plus du bouton supprimer existant.

Corrige au passage /api/v1/archives (endpoint fédération) qui listait
les archives avec un sudo stat par fichier au lieu d'un seul sudo find
groupé, causant des timeouts HTTP côté client sur les instances ayant
beaucoup d'archives.
Cedric Hansen 1 månad sedan
förälder
incheckning
b145b5e005
3 ändrade filer med 109 tillägg och 37 borttagningar
  1. 10 13
      sources/blueprints/api.py
  2. 71 0
      sources/blueprints/overview.py
  3. 28 24
      sources/templates/archives_overview.html

+ 10 - 13
sources/blueprints/api.py

@@ -101,19 +101,16 @@ def api_job_run(job_id):
 @bp.route("/archives")
 def api_archives():
     backup_dir = current_app.config["YUNOHOST_BACKUP_DIR"]
-    archives = []
-    try:
-        from jobs.utils import sudo_listdir, sudo_getsize, sudo_getmtime
-        for fname in sorted(sudo_listdir(backup_dir)):
-            if fname.endswith(".tar"):
-                path = os.path.join(backup_dir, fname)
-                archives.append({
-                    "name": fname[:-4],
-                    "size_bytes": sudo_getsize(path),
-                    "modified_at": datetime.utcfromtimestamp(sudo_getmtime(path)).isoformat(),
-                })
-    except OSError:
-        pass
+    from jobs.utils import batch_list_archives
+    stats = batch_list_archives(backup_dir)
+    archives = [
+        {
+            "name": name,
+            "size_bytes": info["size_bytes"],
+            "modified_at": datetime.utcfromtimestamp(info["mtime"]).isoformat(),
+        }
+        for name, info in sorted(stats.items())
+    ]
     return jsonify(archives)
 
 

+ 71 - 0
sources/blueprints/overview.py

@@ -2,6 +2,7 @@ import json
 import os
 import shlex
 import subprocess
+import threading
 from concurrent.futures import ThreadPoolExecutor
 
 from flask import Blueprint, current_app, flash, redirect, render_template, request, url_for
@@ -135,6 +136,63 @@ def _collect_instance(instance):
     return [{"name": a["name"], "size_bytes": a.get("size_bytes", 0)} for a in raw]
 
 
+def _fetch_ssh_file(destination, data_dir, remote_filename):
+    remote_path = shlex.quote(f"{destination.remote_path}/{remote_filename}")
+    result = subprocess.run(
+        _ssh_base(destination, data_dir) + [f"cat {remote_path}"],
+        capture_output=True, timeout=3600,
+    )
+    if result.returncode != 0:
+        raise RuntimeError(result.stderr.decode(errors="replace").strip() or "Connexion SSH échouée")
+    return result.stdout
+
+
+def _do_pull(app, loc_type, archive_name, dest_id=None, instance_id=None):
+    """Rapatrie une archive nommée (SSH ou instance) vers le backup_dir local."""
+    with app.app_context():
+        backup_dir = app.config["YUNOHOST_BACKUP_DIR"]
+        data_dir = app.config["DATA_DIR"]
+        try:
+            if loc_type == "ssh":
+                dest = db.session.get(Destination, dest_id)
+                label = dest.host
+                tar_bytes = _fetch_ssh_file(dest, data_dir, archive_name + ".tar")
+                try:
+                    info_bytes = _fetch_ssh_file(dest, data_dir, archive_name + ".info.json")
+                except Exception:
+                    info_bytes = None
+
+            elif loc_type == "instance":
+                inst = db.session.get(RemoteInstance, instance_id)
+                label = inst.name
+                from federation.client import FederationClient
+                client = FederationClient(inst)
+                tar_bytes = client.download_archive(archive_name)
+                info_bytes = client.download_info_json(archive_name)
+
+            else:
+                return
+
+            tmp_tar = f"/tmp/backupmanager_pull_{archive_name}.tar"
+            with open(tmp_tar, "wb") as f:
+                f.write(tar_bytes)
+            subprocess.run(["sudo", "rsync", tmp_tar,
+                            os.path.join(backup_dir, archive_name + ".tar")], check=True)
+            os.unlink(tmp_tar)
+
+            if info_bytes:
+                tmp_info = f"/tmp/backupmanager_pull_{archive_name}.info.json"
+                with open(tmp_info, "wb") as f:
+                    f.write(info_bytes)
+                subprocess.run(["sudo", "rsync", tmp_info,
+                                os.path.join(backup_dir, archive_name + ".info.json")], check=True)
+                os.unlink(tmp_info)
+
+            app.logger.info(f"Pull {archive_name} ← {label} OK")
+        except Exception as exc:
+            app.logger.error(f"Pull {archive_name} échoué : {exc}")
+
+
 # ---------------------------------------------------------------------------
 # Routes
 # ---------------------------------------------------------------------------
@@ -253,6 +311,19 @@ def overview_delete():
     return redirect(url_for("overview.overview"))
 
 
+@bp.route("/overview/pull", methods=["POST"])
+def overview_pull():
+    name = request.form["name"]
+    loc_type = request.form["loc_type"]
+    dest_id = request.form.get("dest_id", type=int)
+    instance_id = request.form.get("instance_id", type=int)
+
+    app = current_app._get_current_object()
+    threading.Thread(target=_do_pull, args=(app, loc_type, name, dest_id, instance_id), daemon=True).start()
+    flash(f"Rapatriement de « {name} » démarré en arrière-plan.", "success")
+    return redirect(url_for("overview.overview"))
+
+
 @bp.route("/overview/retention", methods=["POST"])
 def overview_retention():
     job_id = int(request.form["job_id"])

+ 28 - 24
sources/templates/archives_overview.html

@@ -1,6 +1,31 @@
 {% extends "base.html" %}
 {% block title %}Vue globale des archives{% endblock %}
 
+{% macro archive_actions(arch, loc) %}
+<div class="flex items-center justify-end gap-1">
+  {% if loc.type == "local" %}
+  <a href="{{ url_for('jobs.archive_restore', archive_name=arch.name) }}" class="btn-secondary btn-sm">↩ Restaurer</a>
+  {% else %}
+  <form method="post" action="{{ url_for('overview.overview_pull') }}"
+        onsubmit="return confirm('Rapatrier « {{ arch.name }} » en local ?')">
+    <input type="hidden" name="name" value="{{ arch.name }}">
+    <input type="hidden" name="loc_type" value="{{ loc.type }}">
+    {% if loc.type == "ssh" %}<input type="hidden" name="dest_id" value="{{ loc.dest_id }}">{% endif %}
+    {% if loc.type == "instance" %}<input type="hidden" name="instance_id" value="{{ loc.instance_id }}">{% endif %}
+    <button type="submit" class="btn-ghost btn-sm">← Rapatrier</button>
+  </form>
+  {% endif %}
+  <form method="post" action="{{ url_for('overview.overview_delete') }}"
+        onsubmit="return confirm('Supprimer « {{ arch.name }} » ?')">
+    <input type="hidden" name="name" value="{{ arch.name }}">
+    <input type="hidden" name="loc_type" value="{{ loc.type }}">
+    {% if loc.type == "ssh" %}<input type="hidden" name="dest_id" value="{{ loc.dest_id }}">{% endif %}
+    {% if loc.type == "instance" %}<input type="hidden" name="instance_id" value="{{ loc.instance_id }}">{% endif %}
+    <button type="submit" class="btn-danger btn-icon-sm">✕</button>
+  </form>
+</div>
+{% endmacro %}
+
 {% block content %}
 <div class="space-y-6">
 
@@ -121,14 +146,7 @@
                   <td class="px-4 py-2.5 text-right text-gray-500 tabular-nums">{{ arch.size_human }}</td>
                   <td class="px-4 py-2.5 text-right text-gray-500 tabular-nums">{{ arch.date_str }}</td>
                   <td class="px-4 py-2.5 text-right">
-                    <form method="post" action="{{ url_for('overview.overview_delete') }}"
-                          onsubmit="return confirm('Supprimer « {{ arch.name }} » ?')">
-                      <input type="hidden" name="name" value="{{ arch.name }}">
-                      <input type="hidden" name="loc_type" value="{{ loc.type }}">
-                      {% if loc.type == "ssh" %}<input type="hidden" name="dest_id" value="{{ loc.dest_id }}">{% endif %}
-                      {% if loc.type == "instance" %}<input type="hidden" name="instance_id" value="{{ loc.instance_id }}">{% endif %}
-                      <button type="submit" class="btn-danger btn-icon-sm">✕</button>
-                    </form>
+                    {{ archive_actions(arch, loc) }}
                   </td>
                 </tr>
                 {% endfor %}
@@ -233,14 +251,7 @@
                     <td class="px-4 py-2.5 text-right text-gray-500 tabular-nums">{{ arch.size_human }}</td>
                     <td class="px-4 py-2.5 text-right text-gray-500 tabular-nums">{{ arch.date_str }}</td>
                     <td class="px-4 py-2.5 text-right">
-                      <form method="post" action="{{ url_for('overview.overview_delete') }}"
-                            onsubmit="return confirm('Supprimer « {{ arch.name }} » ?')">
-                        <input type="hidden" name="name" value="{{ arch.name }}">
-                        <input type="hidden" name="loc_type" value="{{ loc.type }}">
-                        {% if loc.type == "ssh" %}<input type="hidden" name="dest_id" value="{{ loc.dest_id }}">{% endif %}
-                        {% if loc.type == "instance" %}<input type="hidden" name="instance_id" value="{{ loc.instance_id }}">{% endif %}
-                        <button type="submit" class="btn-danger btn-icon-sm">✕</button>
-                      </form>
+                      {{ archive_actions(arch, loc) }}
                     </td>
                   </tr>
                   {% endfor %}
@@ -268,14 +279,7 @@
                     <td class="px-4 py-2.5 text-right text-gray-500 tabular-nums">{{ arch.size_human }}</td>
                     <td class="px-4 py-2.5 text-right text-gray-500 tabular-nums">{{ arch.date_str }}</td>
                     <td class="px-4 py-2.5 text-right">
-                      <form method="post" action="{{ url_for('overview.overview_delete') }}"
-                            onsubmit="return confirm('Supprimer « {{ arch.name }} » ?')">
-                        <input type="hidden" name="name" value="{{ arch.name }}">
-                        <input type="hidden" name="loc_type" value="{{ loc.type }}">
-                        {% if loc.type == "ssh" %}<input type="hidden" name="dest_id" value="{{ loc.dest_id }}">{% endif %}
-                        {% if loc.type == "instance" %}<input type="hidden" name="instance_id" value="{{ loc.instance_id }}">{% endif %}
-                        <button type="submit" class="btn-danger btn-icon-sm">✕</button>
-                      </form>
+                      {{ archive_actions(arch, loc) }}
                     </td>
                   </tr>
                   {% endfor %}