overview.py 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  1. import json
  2. import os
  3. import shlex
  4. import subprocess
  5. from concurrent.futures import ThreadPoolExecutor
  6. from flask import Blueprint, current_app, flash, redirect, render_template, request, url_for
  7. from db import db, Job, Destination, RemoteInstance, _size_human
  8. bp = Blueprint("overview", __name__)
  9. # ---------------------------------------------------------------------------
  10. # Helpers
  11. # ---------------------------------------------------------------------------
  12. def _retention_label(job):
  13. if job.retention_mode == "count":
  14. return f"{job.retention_value} dernières"
  15. if job.retention_mode == "daily":
  16. return f"1/j sur {job.retention_value} j"
  17. if job.retention_mode == "gfs":
  18. cfg = json.loads(job.retention_gfs_config or "{}") if job.retention_gfs_config else {}
  19. d = cfg.get("daily", 7)
  20. w = cfg.get("weekly", 4)
  21. m = cfg.get("monthly", 12)
  22. return f"GFS {d}j/{w}s/{m}m"
  23. return "—"
  24. def _group_archives(archive_list, jobs, instance):
  25. """
  26. archive_list : [{"name": "jerry_nextcloud_20260716", "size_bytes": N}]
  27. Retourne (groups, orphaned) où chaque group est associé à un job.
  28. """
  29. from retention import _job_archive_prefix, _extract_date
  30. used = set()
  31. groups = []
  32. for job in jobs:
  33. prefix = _job_archive_prefix(job, instance)
  34. matching = [a for a in archive_list if a["name"].startswith(prefix)]
  35. matching.sort(key=lambda a: _extract_date(a["name"] + ".tar"))
  36. for a in matching:
  37. used.add(a["name"])
  38. d = _extract_date(a["name"] + ".tar")
  39. a["date_str"] = d.strftime("%d %b %Y") if d.year > 1 else "—"
  40. a["size_human"] = _size_human(a["size_bytes"])
  41. groups.append({
  42. "job": job,
  43. "retention_label": _retention_label(job),
  44. "archives": matching,
  45. })
  46. orphaned = []
  47. for a in archive_list:
  48. if a["name"] not in used:
  49. from retention import _extract_date
  50. d = _extract_date(a["name"] + ".tar")
  51. a["date_str"] = d.strftime("%d %b %Y") if d.year > 1 else "—"
  52. a["size_human"] = _size_human(a["size_bytes"])
  53. orphaned.append(a)
  54. return groups, orphaned
  55. def _collect_local(backup_dir):
  56. from jobs.utils import batch_list_archives
  57. stats = batch_list_archives(backup_dir)
  58. return [{"name": name, "size_bytes": info["size_bytes"]} for name, info in stats.items()]
  59. def _ssh_base(destination, data_dir):
  60. key_path = os.path.join(data_dir, "keys", destination.key_name)
  61. return [
  62. "ssh", "-i", key_path,
  63. "-p", str(destination.port),
  64. "-o", "StrictHostKeyChecking=accept-new",
  65. "-o", "BatchMode=yes",
  66. "-o", "ConnectTimeout=15",
  67. f"{destination.user}@{destination.host}",
  68. ]
  69. def _collect_ssh(destination, data_dir):
  70. remote_dir = shlex.quote(destination.remote_path)
  71. # find -printf n'est pas disponible sur tous les systèmes (BusyBox, BSD…)
  72. # On utilise cd + find + stat avec fallback BSD pour la portabilité
  73. remote_cmd = (
  74. f"cd {remote_dir} && find . -maxdepth 1 -name '*.tar' -type f 2>/dev/null"
  75. " | sed 's|^\\./||'"
  76. " | while IFS= read -r f; do"
  77. " s=$(stat -c%s \"$f\" 2>/dev/null || stat -f%z \"$f\" 2>/dev/null || echo 0);"
  78. " printf '%s\\t%s\\n' \"$f\" \"$s\";"
  79. " done; true"
  80. )
  81. result = subprocess.run(
  82. _ssh_base(destination, data_dir) + [remote_cmd],
  83. capture_output=True, text=True, timeout=20,
  84. )
  85. if result.returncode != 0:
  86. raise RuntimeError(result.stderr.strip() or "Connexion SSH échouée")
  87. archives = []
  88. for line in result.stdout.splitlines():
  89. parts = line.strip().split("\t")
  90. if len(parts) == 2 and parts[0].endswith(".tar"):
  91. try:
  92. archives.append({"name": parts[0][:-4], "size_bytes": int(parts[1])})
  93. except ValueError:
  94. pass
  95. return archives
  96. def _collect_instance(instance):
  97. from federation.client import FederationClient
  98. raw = FederationClient(instance).get_archives()
  99. return [{"name": a["name"], "size_bytes": a.get("size_bytes", 0)} for a in raw]
  100. # ---------------------------------------------------------------------------
  101. # Routes
  102. # ---------------------------------------------------------------------------
  103. @bp.route("/overview")
  104. def overview():
  105. backup_dir = current_app.config["YUNOHOST_BACKUP_DIR"]
  106. data_dir = current_app.config["DATA_DIR"]
  107. instance = current_app.config["INSTANCE_NAME"]
  108. jobs = Job.query.order_by(Job.name).all()
  109. # Construire la liste des tâches de collecte dans l'ordre d'affichage souhaité
  110. dests = Destination.query.filter_by(enabled=True).order_by(Destination.name).all()
  111. insts = RemoteInstance.query.order_by(RemoteInstance.name).all()
  112. tasks = [
  113. ({"type": "local", "label": "Local"},
  114. lambda: _collect_local(backup_dir)),
  115. ]
  116. for dest in dests:
  117. tasks.append((
  118. {"type": "ssh", "label": f"SSH → {dest.remote_str}", "dest_id": dest.id},
  119. lambda d=dest: _collect_ssh(d, data_dir),
  120. ))
  121. for inst in insts:
  122. tasks.append((
  123. {"type": "instance", "label": inst.name,
  124. "instance_id": inst.id, "instance_url": inst.url},
  125. lambda i=inst: _collect_instance(i),
  126. ))
  127. # Collecter toutes les destinations en parallèle pour éviter que les timeouts
  128. # d'une destination lente ne bloquent les autres
  129. def _run_task(args):
  130. meta, fn = args
  131. try:
  132. return meta, fn(), None
  133. except Exception as exc:
  134. return meta, None, str(exc)
  135. with ThreadPoolExecutor(max_workers=min(len(tasks), 10)) as pool:
  136. raw_results = list(pool.map(_run_task, tasks))
  137. locations = []
  138. for meta, archives, error in raw_results:
  139. if error:
  140. locations.append({**meta, "error": error, "groups": [], "orphaned": []})
  141. else:
  142. groups, orphaned = _group_archives(archives, jobs, instance)
  143. locations.append({**meta, "error": None, "groups": groups, "orphaned": orphaned})
  144. # --- Vue par job : pivot de locations → jobs ---
  145. jobs_view = []
  146. for job in jobs:
  147. jlocs = []
  148. for loc in locations:
  149. group = next((g for g in loc.get("groups", []) if g["job"].id == job.id), None)
  150. jlocs.append({
  151. "type": loc["type"],
  152. "label": loc["label"],
  153. "dest_id": loc.get("dest_id"),
  154. "instance_id": loc.get("instance_id"),
  155. "error": loc["error"],
  156. "archives": group["archives"] if group else [],
  157. })
  158. jobs_view.append({
  159. "job": job,
  160. "retention_label": _retention_label(job),
  161. "locations": jlocs,
  162. })
  163. return render_template("archives_overview.html", locations=locations, jobs_view=jobs_view)
  164. @bp.route("/overview/delete", methods=["POST"])
  165. def overview_delete():
  166. name = request.form["name"]
  167. loc_type = request.form["loc_type"]
  168. backup_dir = current_app.config["YUNOHOST_BACKUP_DIR"]
  169. data_dir = current_app.config["DATA_DIR"]
  170. try:
  171. if loc_type == "local":
  172. from jobs.utils import sudo_rm_archive
  173. sudo_rm_archive(name, backup_dir)
  174. flash(f"Archive « {name} » supprimée localement.", "success")
  175. elif loc_type == "ssh":
  176. dest = db.get_or_404(Destination, int(request.form["dest_id"]))
  177. tar_q = shlex.quote(f"{dest.remote_path}/{name}.tar")
  178. info_q = shlex.quote(f"{dest.remote_path}/{name}.info.json")
  179. r = subprocess.run(
  180. _ssh_base(dest, data_dir) + [f"rm -f {tar_q} {info_q}"],
  181. capture_output=True, timeout=20,
  182. )
  183. if r.returncode != 0:
  184. raise RuntimeError(r.stderr.decode().strip())
  185. flash(f"Archive « {name} » supprimée sur {dest.host}.", "success")
  186. elif loc_type == "instance":
  187. inst = db.get_or_404(RemoteInstance, int(request.form["instance_id"]))
  188. from federation.client import FederationClient
  189. FederationClient(inst).delete_archive(name)
  190. flash(f"Archive « {name} » supprimée sur {inst.name}.", "success")
  191. except Exception as exc:
  192. flash(f"Erreur lors de la suppression : {exc}", "error")
  193. return redirect(url_for("overview.overview"))
  194. @bp.route("/overview/retention", methods=["POST"])
  195. def overview_retention():
  196. job_id = int(request.form["job_id"])
  197. loc_type = request.form["loc_type"]
  198. job = db.get_or_404(Job, job_id)
  199. backup_dir = current_app.config["YUNOHOST_BACKUP_DIR"]
  200. data_dir = current_app.config["DATA_DIR"]
  201. try:
  202. if loc_type == "local":
  203. from retention import apply_retention
  204. deleted, failed = apply_retention(job, "", backup_dir)
  205. msg = f"Rétention locale — {len(deleted)} archive(s) supprimée(s)"
  206. if failed:
  207. msg += f", {len(failed)} échec(s) : {', '.join(failed)}"
  208. flash(msg, "success" if not failed else "warning")
  209. elif loc_type == "ssh":
  210. dest = db.get_or_404(Destination, int(request.form["dest_id"]))
  211. from retention import apply_ssh_retention
  212. deleted = apply_ssh_retention(job, dest, data_dir)
  213. flash(
  214. f"Rétention SSH — {len(deleted)} archive(s) supprimée(s) sur {dest.host}.",
  215. "success",
  216. )
  217. elif loc_type == "instance":
  218. inst = db.get_or_404(RemoteInstance, int(request.form["instance_id"]))
  219. from federation.client import FederationClient
  220. from retention import apply_remote_retention
  221. deleted = apply_remote_retention(job, FederationClient(inst))
  222. flash(
  223. f"Rétention distante — {len(deleted)} archive(s) supprimée(s) sur {inst.name}.",
  224. "success",
  225. )
  226. except Exception as exc:
  227. flash(f"Erreur lors de la rétention : {exc}", "error")
  228. return redirect(url_for("overview.overview"))