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