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) # find -printf n'est pas disponible sur tous les systèmes (BusyBox, BSD…) # On utilise cd + find + stat avec fallback BSD pour la portabilité remote_cmd = ( f"cd {remote_dir} && find . -maxdepth 1 -name '*.tar' -type f 2>/dev/null" " | sed 's|^\\./||'" " | while IFS= read -r f; do" " s=$(stat -c%s \"$f\" 2>/dev/null || stat -f%z \"$f\" 2>/dev/null || echo 0);" " printf '%s\\t%s\\n' \"$f\" \"$s\";" " done; true" ) result = subprocess.run( _ssh_base(destination, data_dir) + [remote_cmd], 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": [], }) # --- Vue par job : pivot de locations → jobs --- jobs_view = [] for job in jobs: jlocs = [] for loc in locations: group = next((g for g in loc.get("groups", []) if g["job"].id == job.id), None) jlocs.append({ "type": loc["type"], "label": loc["label"], "dest_id": loc.get("dest_id"), "instance_id": loc.get("instance_id"), "error": loc["error"], "archives": group["archives"] if group else [], }) jobs_view.append({ "job": job, "retention_label": _retention_label(job), "locations": jlocs, }) return render_template("archives_overview.html", locations=locations, jobs_view=jobs_view) @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"))