overview.py 11 KB

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