2 커밋 7529a98443 ... 2f83cfd6d7

작성자 SHA1 메시지 날짜
  Cedric Hansen 2f83cfd6d7 chore: bump version 1.8~ynh1 1 개월 전
  Cedric Hansen b145b5e005 feat: ajouter boutons rapatrier/restaurer dans la vue d'ensemble des archives 1 개월 전
5개의 변경된 파일111개의 추가작업 그리고 39개의 파일을 삭제
  1. 1 1
      manifest.toml
  2. 10 13
      sources/blueprints/api.py
  3. 71 0
      sources/blueprints/overview.py
  4. 28 24
      sources/templates/archives_overview.html
  5. 1 1
      v3/apps.json

+ 1 - 1
manifest.toml

@@ -4,7 +4,7 @@ name = "Backup Manager"
 description.en = "Centralized backup manager for YunoHost"
 description.fr = "Gestionnaire de sauvegardes centralisé pour YunoHost"
 
-version = "1.7~ynh1"
+version = "1.8~ynh1"
 
 maintainers = []
 

+ 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 %}

+ 1 - 1
v3/apps.json

@@ -24,7 +24,7 @@
         "packaging_format": 2,
         "id": "backupmanager",
         "name": "Backup Manager",
-        "version": "1.7~ynh1",
+        "version": "1.8~ynh1",
         "description": {
           "en": "Centralized backup manager for YunoHost",
           "fr": "Gestionnaire de sauvegardes centralisé pour YunoHost"