overview.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280
  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. # find -printf n'est pas disponible sur tous les systèmes (BusyBox, BSD…)
  71. # On utilise cd + find + stat avec fallback BSD pour la portabilité
  72. remote_cmd = (
  73. f"cd {remote_dir} && find . -maxdepth 1 -name '*.tar' -type f 2>/dev/null"
  74. " | sed 's|^\\./||'"
  75. " | while IFS= read -r f; do"
  76. " s=$(stat -c%s \"$f\" 2>/dev/null || stat -f%z \"$f\" 2>/dev/null || echo 0);"
  77. " printf '%s\\t%s\\n' \"$f\" \"$s\";"
  78. " done; true"
  79. )
  80. result = subprocess.run(
  81. _ssh_base(destination, data_dir) + [remote_cmd],
  82. capture_output=True, text=True, timeout=20,
  83. )
  84. if result.returncode != 0:
  85. raise RuntimeError(result.stderr.strip() or "Connexion SSH échouée")
  86. archives = []
  87. for line in result.stdout.splitlines():
  88. parts = line.strip().split("\t")
  89. if len(parts) == 2 and parts[0].endswith(".tar"):
  90. try:
  91. archives.append({"name": parts[0][:-4], "size_bytes": int(parts[1])})
  92. except ValueError:
  93. pass
  94. return archives
  95. def _collect_instance(instance):
  96. from federation.client import FederationClient
  97. raw = FederationClient(instance).get_archives()
  98. return [{"name": a["name"], "size_bytes": a.get("size_bytes", 0)} for a in raw]
  99. # ---------------------------------------------------------------------------
  100. # Routes
  101. # ---------------------------------------------------------------------------
  102. @bp.route("/overview")
  103. def overview():
  104. backup_dir = current_app.config["YUNOHOST_BACKUP_DIR"]
  105. data_dir = current_app.config["DATA_DIR"]
  106. instance = current_app.config["INSTANCE_NAME"]
  107. jobs = Job.query.order_by(Job.name).all()
  108. locations = []
  109. # --- Local ---
  110. try:
  111. archives = _collect_local(backup_dir)
  112. groups, orphaned = _group_archives(archives, jobs, instance)
  113. locations.append({
  114. "type": "local", "label": "Local",
  115. "error": None, "groups": groups, "orphaned": orphaned,
  116. })
  117. except Exception as exc:
  118. locations.append({
  119. "type": "local", "label": "Local",
  120. "error": str(exc), "groups": [], "orphaned": [],
  121. })
  122. # --- SSH ---
  123. for dest in Destination.query.filter_by(enabled=True).order_by(Destination.name).all():
  124. try:
  125. archives = _collect_ssh(dest, data_dir)
  126. groups, orphaned = _group_archives(archives, jobs, instance)
  127. locations.append({
  128. "type": "ssh", "label": f"SSH → {dest.remote_str}",
  129. "dest_id": dest.id, "error": None,
  130. "groups": groups, "orphaned": orphaned,
  131. })
  132. except Exception as exc:
  133. locations.append({
  134. "type": "ssh", "label": f"SSH → {dest.remote_str}",
  135. "dest_id": dest.id, "error": str(exc),
  136. "groups": [], "orphaned": [],
  137. })
  138. # --- Instances fédérées ---
  139. for inst in RemoteInstance.query.order_by(RemoteInstance.name).all():
  140. try:
  141. archives = _collect_instance(inst)
  142. groups, orphaned = _group_archives(archives, jobs, instance)
  143. locations.append({
  144. "type": "instance", "label": f"{inst.name}",
  145. "instance_id": inst.id, "instance_url": inst.url,
  146. "error": None, "groups": groups, "orphaned": orphaned,
  147. })
  148. except Exception as exc:
  149. locations.append({
  150. "type": "instance", "label": f"{inst.name}",
  151. "instance_id": inst.id, "instance_url": inst.url,
  152. "error": str(exc), "groups": [], "orphaned": [],
  153. })
  154. # --- Vue par job : pivot de locations → jobs ---
  155. jobs_view = []
  156. for job in jobs:
  157. jlocs = []
  158. for loc in locations:
  159. group = next((g for g in loc.get("groups", []) if g["job"].id == job.id), None)
  160. jlocs.append({
  161. "type": loc["type"],
  162. "label": loc["label"],
  163. "dest_id": loc.get("dest_id"),
  164. "instance_id": loc.get("instance_id"),
  165. "error": loc["error"],
  166. "archives": group["archives"] if group else [],
  167. })
  168. jobs_view.append({
  169. "job": job,
  170. "retention_label": _retention_label(job),
  171. "locations": jlocs,
  172. })
  173. return render_template("archives_overview.html", locations=locations, jobs_view=jobs_view)
  174. @bp.route("/overview/delete", methods=["POST"])
  175. def overview_delete():
  176. name = request.form["name"]
  177. loc_type = request.form["loc_type"]
  178. backup_dir = current_app.config["YUNOHOST_BACKUP_DIR"]
  179. data_dir = current_app.config["DATA_DIR"]
  180. try:
  181. if loc_type == "local":
  182. from jobs.utils import sudo_rm_archive
  183. sudo_rm_archive(name, backup_dir)
  184. flash(f"Archive « {name} » supprimée localement.", "success")
  185. elif loc_type == "ssh":
  186. dest = db.get_or_404(Destination, int(request.form["dest_id"]))
  187. tar_q = shlex.quote(f"{dest.remote_path}/{name}.tar")
  188. info_q = shlex.quote(f"{dest.remote_path}/{name}.info.json")
  189. r = subprocess.run(
  190. _ssh_base(dest, data_dir) + [f"rm -f {tar_q} {info_q}"],
  191. capture_output=True, timeout=20,
  192. )
  193. if r.returncode != 0:
  194. raise RuntimeError(r.stderr.decode().strip())
  195. flash(f"Archive « {name} » supprimée sur {dest.host}.", "success")
  196. elif loc_type == "instance":
  197. inst = db.get_or_404(RemoteInstance, int(request.form["instance_id"]))
  198. from federation.client import FederationClient
  199. FederationClient(inst).delete_archive(name)
  200. flash(f"Archive « {name} » supprimée sur {inst.name}.", "success")
  201. except Exception as exc:
  202. flash(f"Erreur lors de la suppression : {exc}", "error")
  203. return redirect(url_for("overview.overview"))
  204. @bp.route("/overview/retention", methods=["POST"])
  205. def overview_retention():
  206. job_id = int(request.form["job_id"])
  207. loc_type = request.form["loc_type"]
  208. job = db.get_or_404(Job, job_id)
  209. backup_dir = current_app.config["YUNOHOST_BACKUP_DIR"]
  210. data_dir = current_app.config["DATA_DIR"]
  211. try:
  212. if loc_type == "local":
  213. from retention import apply_retention
  214. deleted, failed = apply_retention(job, "", backup_dir)
  215. msg = f"Rétention locale — {len(deleted)} archive(s) supprimée(s)"
  216. if failed:
  217. msg += f", {len(failed)} échec(s) : {', '.join(failed)}"
  218. flash(msg, "success" if not failed else "warning")
  219. elif loc_type == "ssh":
  220. dest = db.get_or_404(Destination, int(request.form["dest_id"]))
  221. from retention import apply_ssh_retention
  222. deleted = apply_ssh_retention(job, dest, data_dir)
  223. flash(
  224. f"Rétention SSH — {len(deleted)} archive(s) supprimée(s) sur {dest.host}.",
  225. "success",
  226. )
  227. elif loc_type == "instance":
  228. inst = db.get_or_404(RemoteInstance, int(request.form["instance_id"]))
  229. from federation.client import FederationClient
  230. from retention import apply_remote_retention
  231. deleted = apply_remote_retention(job, FederationClient(inst))
  232. flash(
  233. f"Rétention distante — {len(deleted)} archive(s) supprimée(s) sur {inst.name}.",
  234. "success",
  235. )
  236. except Exception as exc:
  237. flash(f"Erreur lors de la rétention : {exc}", "error")
  238. return redirect(url_for("overview.overview"))