瀏覽代碼

feat: page vue globale des archives (local + SSH + instances)

Nouvelle page /overview : archives groupées par destination puis par job,
avec suppression manuelle et déclenchement de la rétention par destination.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Cedric Hansen 2 周之前
父節點
當前提交
af64a44b6e
共有 4 個文件被更改,包括 432 次插入0 次删除
  1. 2 0
      sources/app.py
  2. 252 0
      sources/blueprints/overview.py
  3. 174 0
      sources/templates/archives_overview.html
  4. 4 0
      sources/templates/base.html

+ 2 - 0
sources/app.py

@@ -43,12 +43,14 @@ from blueprints.destinations import bp as bp_dest
 from blueprints.network import bp as bp_network
 from blueprints.settings import bp as bp_cfg
 from blueprints.api import bp as bp_api
+from blueprints.overview import bp as bp_overview
 
 app.register_blueprint(bp_jobs)
 app.register_blueprint(bp_dest)
 app.register_blueprint(bp_network)
 app.register_blueprint(bp_cfg)
 app.register_blueprint(bp_api)
+app.register_blueprint(bp_overview)
 
 # --- Context processor -------------------------------------------------------
 

+ 252 - 0
sources/blueprints/overview.py

@@ -0,0 +1,252 @@
+import json
+import os
+import shlex
+import subprocess
+
+from flask import Blueprint, current_app, flash, redirect, render_template, request, url_for
+
+from db import db, Job, Destination, RemoteInstance, _size_human
+
+bp = Blueprint("overview", __name__)
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def _retention_label(job):
+    if job.retention_mode == "count":
+        return f"{job.retention_value} dernières"
+    if job.retention_mode == "daily":
+        return f"1/j sur {job.retention_value} j"
+    if job.retention_mode == "gfs":
+        cfg = json.loads(job.retention_gfs_config or "{}") if job.retention_gfs_config else {}
+        d = cfg.get("daily", 7)
+        w = cfg.get("weekly", 4)
+        m = cfg.get("monthly", 12)
+        return f"GFS {d}j/{w}s/{m}m"
+    return "—"
+
+
+def _group_archives(archive_list, jobs, instance):
+    """
+    archive_list : [{"name": "jerry_nextcloud_20260716", "size_bytes": N}]
+    Retourne (groups, orphaned) où chaque group est associé à un job.
+    """
+    from retention import _job_archive_prefix, _extract_date
+
+    used = set()
+    groups = []
+    for job in jobs:
+        prefix = _job_archive_prefix(job, instance)
+        matching = [a for a in archive_list if a["name"].startswith(prefix)]
+        matching.sort(key=lambda a: _extract_date(a["name"] + ".tar"))
+        for a in matching:
+            used.add(a["name"])
+            d = _extract_date(a["name"] + ".tar")
+            a["date_str"] = d.strftime("%d %b %Y") if d.year > 1 else "—"
+            a["size_human"] = _size_human(a["size_bytes"])
+        groups.append({
+            "job": job,
+            "retention_label": _retention_label(job),
+            "archives": matching,
+        })
+
+    orphaned = []
+    for a in archive_list:
+        if a["name"] not in used:
+            from retention import _extract_date
+            d = _extract_date(a["name"] + ".tar")
+            a["date_str"] = d.strftime("%d %b %Y") if d.year > 1 else "—"
+            a["size_human"] = _size_human(a["size_bytes"])
+            orphaned.append(a)
+
+    return groups, orphaned
+
+
+def _collect_local(backup_dir):
+    from jobs.utils import batch_list_archives
+    stats = batch_list_archives(backup_dir)
+    return [{"name": name, "size_bytes": info["size_bytes"]} for name, info in stats.items()]
+
+
+def _ssh_base(destination, data_dir):
+    key_path = os.path.join(data_dir, "keys", destination.key_name)
+    return [
+        "ssh", "-i", key_path,
+        "-p", str(destination.port),
+        "-o", "StrictHostKeyChecking=accept-new",
+        "-o", "BatchMode=yes",
+        "-o", "ConnectTimeout=15",
+        f"{destination.user}@{destination.host}",
+    ]
+
+
+def _collect_ssh(destination, data_dir):
+    remote_dir = shlex.quote(destination.remote_path)
+    result = subprocess.run(
+        _ssh_base(destination, data_dir) + [
+            f"find {remote_dir} -maxdepth 1 -name '*.tar' -printf '%f\\t%s\\n' 2>/dev/null || true"
+        ],
+        capture_output=True, text=True, timeout=20,
+    )
+    if result.returncode != 0:
+        raise RuntimeError(result.stderr.strip() or "Connexion SSH échouée")
+    archives = []
+    for line in result.stdout.splitlines():
+        parts = line.strip().split("\t")
+        if len(parts) == 2 and parts[0].endswith(".tar"):
+            try:
+                archives.append({"name": parts[0][:-4], "size_bytes": int(parts[1])})
+            except ValueError:
+                pass
+    return archives
+
+
+def _collect_instance(instance):
+    from federation.client import FederationClient
+    raw = FederationClient(instance).get_archives()
+    return [{"name": a["name"], "size_bytes": a.get("size_bytes", 0)} for a in raw]
+
+
+# ---------------------------------------------------------------------------
+# Routes
+# ---------------------------------------------------------------------------
+
+@bp.route("/overview")
+def overview():
+    backup_dir = current_app.config["YUNOHOST_BACKUP_DIR"]
+    data_dir = current_app.config["DATA_DIR"]
+    instance = current_app.config["INSTANCE_NAME"]
+    jobs = Job.query.order_by(Job.name).all()
+
+    locations = []
+
+    # --- Local ---
+    try:
+        archives = _collect_local(backup_dir)
+        groups, orphaned = _group_archives(archives, jobs, instance)
+        locations.append({
+            "type": "local", "label": "Local",
+            "error": None, "groups": groups, "orphaned": orphaned,
+        })
+    except Exception as exc:
+        locations.append({
+            "type": "local", "label": "Local",
+            "error": str(exc), "groups": [], "orphaned": [],
+        })
+
+    # --- SSH ---
+    for dest in Destination.query.filter_by(enabled=True).order_by(Destination.name).all():
+        try:
+            archives = _collect_ssh(dest, data_dir)
+            groups, orphaned = _group_archives(archives, jobs, instance)
+            locations.append({
+                "type": "ssh", "label": f"SSH → {dest.remote_str}",
+                "dest_id": dest.id, "error": None,
+                "groups": groups, "orphaned": orphaned,
+            })
+        except Exception as exc:
+            locations.append({
+                "type": "ssh", "label": f"SSH → {dest.remote_str}",
+                "dest_id": dest.id, "error": str(exc),
+                "groups": [], "orphaned": [],
+            })
+
+    # --- Instances fédérées ---
+    for inst in RemoteInstance.query.order_by(RemoteInstance.name).all():
+        try:
+            archives = _collect_instance(inst)
+            groups, orphaned = _group_archives(archives, jobs, instance)
+            locations.append({
+                "type": "instance", "label": f"{inst.name}",
+                "instance_id": inst.id, "instance_url": inst.url,
+                "error": None, "groups": groups, "orphaned": orphaned,
+            })
+        except Exception as exc:
+            locations.append({
+                "type": "instance", "label": f"{inst.name}",
+                "instance_id": inst.id, "instance_url": inst.url,
+                "error": str(exc), "groups": [], "orphaned": [],
+            })
+
+    return render_template("archives_overview.html", locations=locations)
+
+
+@bp.route("/overview/delete", methods=["POST"])
+def overview_delete():
+    name = request.form["name"]
+    loc_type = request.form["loc_type"]
+    backup_dir = current_app.config["YUNOHOST_BACKUP_DIR"]
+    data_dir = current_app.config["DATA_DIR"]
+
+    try:
+        if loc_type == "local":
+            from jobs.utils import sudo_rm_archive
+            sudo_rm_archive(name, backup_dir)
+            flash(f"Archive « {name} » supprimée localement.", "success")
+
+        elif loc_type == "ssh":
+            dest = db.get_or_404(Destination, int(request.form["dest_id"]))
+            tar_q = shlex.quote(f"{dest.remote_path}/{name}.tar")
+            info_q = shlex.quote(f"{dest.remote_path}/{name}.info.json")
+            r = subprocess.run(
+                _ssh_base(dest, data_dir) + [f"rm -f {tar_q} {info_q}"],
+                capture_output=True, timeout=20,
+            )
+            if r.returncode != 0:
+                raise RuntimeError(r.stderr.decode().strip())
+            flash(f"Archive « {name} » supprimée sur {dest.host}.", "success")
+
+        elif loc_type == "instance":
+            inst = db.get_or_404(RemoteInstance, int(request.form["instance_id"]))
+            from federation.client import FederationClient
+            FederationClient(inst).delete_archive(name)
+            flash(f"Archive « {name} » supprimée sur {inst.name}.", "success")
+
+    except Exception as exc:
+        flash(f"Erreur lors de la suppression : {exc}", "error")
+
+    return redirect(url_for("overview.overview"))
+
+
+@bp.route("/overview/retention", methods=["POST"])
+def overview_retention():
+    job_id = int(request.form["job_id"])
+    loc_type = request.form["loc_type"]
+    job = db.get_or_404(Job, job_id)
+    backup_dir = current_app.config["YUNOHOST_BACKUP_DIR"]
+    data_dir = current_app.config["DATA_DIR"]
+
+    try:
+        if loc_type == "local":
+            from retention import apply_retention
+            deleted, failed = apply_retention(job, "", backup_dir)
+            msg = f"Rétention locale — {len(deleted)} archive(s) supprimée(s)"
+            if failed:
+                msg += f", {len(failed)} échec(s) : {', '.join(failed)}"
+            flash(msg, "success" if not failed else "warning")
+
+        elif loc_type == "ssh":
+            dest = db.get_or_404(Destination, int(request.form["dest_id"]))
+            from retention import apply_ssh_retention
+            deleted = apply_ssh_retention(job, dest, data_dir)
+            flash(
+                f"Rétention SSH — {len(deleted)} archive(s) supprimée(s) sur {dest.host}.",
+                "success",
+            )
+
+        elif loc_type == "instance":
+            inst = db.get_or_404(RemoteInstance, int(request.form["instance_id"]))
+            from federation.client import FederationClient
+            from retention import apply_remote_retention
+            deleted = apply_remote_retention(job, FederationClient(inst))
+            flash(
+                f"Rétention distante — {len(deleted)} archive(s) supprimée(s) sur {inst.name}.",
+                "success",
+            )
+
+    except Exception as exc:
+        flash(f"Erreur lors de la rétention : {exc}", "error")
+
+    return redirect(url_for("overview.overview"))

+ 174 - 0
sources/templates/archives_overview.html

@@ -0,0 +1,174 @@
+{% extends "base.html" %}
+{% block title %}Vue globale des archives{% endblock %}
+
+{% block content %}
+<div class="space-y-8">
+
+  <div class="flex items-center justify-between">
+    <div>
+      <h1 class="text-2xl font-bold text-gray-900">Vue globale des archives</h1>
+      <p class="text-sm text-gray-500 mt-1">Toutes les destinations — local, SSH et instances fédérées</p>
+    </div>
+    <a href="{{ url_for('overview.overview') }}" class="btn-ghost btn-sm">
+      ↻ Rafraîchir
+    </a>
+  </div>
+
+  {% for loc in locations %}
+  <div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
+
+    {# En-tête de la section #}
+    <div class="px-6 py-4 bg-gray-50 border-b border-gray-200 flex items-center gap-3">
+      {% if loc.type == "local" %}
+        <span class="inline-flex items-center gap-1 bg-blue-100 text-blue-700 text-xs font-semibold px-2.5 py-1 rounded-full">
+          Local
+        </span>
+      {% elif loc.type == "ssh" %}
+        <span class="inline-flex items-center gap-1 bg-slate-100 text-slate-700 text-xs font-semibold px-2.5 py-1 rounded-full">
+          SSH
+        </span>
+      {% else %}
+        <span class="inline-flex items-center gap-1 bg-purple-100 text-purple-700 text-xs font-semibold px-2.5 py-1 rounded-full">
+          Instance
+        </span>
+      {% endif %}
+      <span class="font-semibold text-gray-800">{{ loc.label }}</span>
+      {% if loc.type == "instance" and loc.instance_url is defined %}
+        <span class="text-xs text-gray-400">{{ loc.instance_url }}</span>
+      {% endif %}
+    </div>
+
+    {# Erreur de connexion #}
+    {% if loc.error %}
+    <div class="px-6 py-5 flex items-start gap-3 text-sm text-red-700 bg-red-50">
+      <span class="text-base shrink-0">⚠</span>
+      <span>{{ loc.error }}</span>
+    </div>
+
+    {% else %}
+
+      {# Groupes par job #}
+      {% set ns = namespace(has_content=false) %}
+      {% for group in loc.groups %}
+        {% if group.archives %}
+          {% set ns.has_content = true %}
+        {% endif %}
+      {% endfor %}
+      {% if loc.orphaned %}{% set ns.has_content = true %}{% endif %}
+
+      {% if not ns.has_content %}
+      <div class="px-6 py-8 text-center text-sm text-gray-400">
+        Aucune archive sur cette destination.
+      </div>
+      {% else %}
+
+      <div class="divide-y divide-gray-100">
+
+        {% for group in loc.groups %}
+        {% if group.archives %}
+        <div class="px-6 py-4">
+
+          {# En-tête du groupe #}
+          <div class="flex items-center justify-between mb-3">
+            <div class="flex items-center gap-2">
+              <span class="font-medium text-gray-800">{{ group.job.name }}</span>
+              <span class="text-xs bg-gray-100 text-gray-500 px-2 py-0.5 rounded-full">
+                {{ group.job.type }}
+              </span>
+              <span class="text-xs bg-green-50 text-green-700 border border-green-200 px-2 py-0.5 rounded-full">
+                rétention : {{ group.retention_label }}
+              </span>
+            </div>
+            <form method="post" action="{{ url_for('overview.overview_retention') }}"
+                  onsubmit="return confirm('Appliquer la rétention pour « {{ group.job.name }} » sur cette destination ?')">
+              <input type="hidden" name="job_id" value="{{ group.job.id }}">
+              <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-secondary btn-sm">
+                ↺ Appliquer la rétention
+              </button>
+            </form>
+          </div>
+
+          {# Table des archives #}
+          <div class="rounded-lg border border-gray-100 overflow-hidden">
+            <table class="w-full text-sm">
+              <thead class="bg-gray-50 text-xs text-gray-500 uppercase tracking-wide">
+                <tr>
+                  <th class="text-left px-4 py-2 font-medium">Archive</th>
+                  <th class="text-right px-4 py-2 font-medium">Taille</th>
+                  <th class="text-right px-4 py-2 font-medium">Date</th>
+                  <th class="px-4 py-2"></th>
+                </tr>
+              </thead>
+              <tbody class="divide-y divide-gray-50">
+                {% for arch in group.archives %}
+                <tr class="hover:bg-gray-50 transition">
+                  <td class="px-4 py-2.5 font-mono text-xs text-gray-700">{{ arch.name }}</td>
+                  <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>
+                  </td>
+                </tr>
+                {% endfor %}
+              </tbody>
+            </table>
+          </div>
+
+        </div>
+        {% endif %}
+        {% endfor %}
+
+        {# Archives orphelines (ne correspondent à aucun job) #}
+        {% if loc.orphaned %}
+        <div class="px-6 py-4">
+          <div class="flex items-center gap-2 mb-3">
+            <span class="font-medium text-gray-500">Sans job associé</span>
+            <span class="text-xs bg-yellow-50 text-yellow-700 border border-yellow-200 px-2 py-0.5 rounded-full">
+              {{ loc.orphaned | length }} archive(s)
+            </span>
+          </div>
+          <div class="rounded-lg border border-gray-100 overflow-hidden">
+            <table class="w-full text-sm">
+              <tbody class="divide-y divide-gray-50">
+                {% for arch in loc.orphaned %}
+                <tr class="hover:bg-gray-50 transition">
+                  <td class="px-4 py-2.5 font-mono text-xs text-gray-700">{{ arch.name }}</td>
+                  <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>
+                  </td>
+                </tr>
+                {% endfor %}
+              </tbody>
+            </table>
+          </div>
+        </div>
+        {% endif %}
+
+      </div>
+      {% endif %}
+    {% endif %}
+
+  </div>
+  {% endfor %}
+
+</div>
+{% endblock %}

+ 4 - 0
sources/templates/base.html

@@ -43,6 +43,10 @@
            class="text-gray-300 hover:text-white transition {% if request.endpoint == 'jobs.archives' %}text-white font-semibold{% endif %}">
           Archives
         </a>
+        <a href="{{ url_for('overview.overview') }}"
+           class="text-gray-300 hover:text-white transition {% if request.endpoint and request.endpoint.startswith('overview.') %}text-white font-semibold{% endif %}">
+          Vue globale
+        </a>
         <a href="{{ url_for('cfg.settings') }}"
            class="text-gray-300 hover:text-white transition {% if request.endpoint and request.endpoint.startswith('cfg.') %}text-white font-semibold{% endif %}">
           Paramètres