overview.py 9.3 KB

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