|
@@ -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"))
|