overview.py 11 KB

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