|
|
@@ -36,11 +36,20 @@ def index():
|
|
|
|
|
|
# --- CRUD Jobs ----------------------------------------------------------------
|
|
|
|
|
|
+def _used_app_ids(exclude_job_id=None):
|
|
|
+ """Retourne les app_id déjà couverts par un job ynh_app existant."""
|
|
|
+ q = Job.query.filter_by(type="ynh_app")
|
|
|
+ if exclude_job_id:
|
|
|
+ q = q.filter(Job.id != exclude_job_id)
|
|
|
+ return {json.loads(j.config_json).get("app_id") for j in q.all() if j.config_json}
|
|
|
+
|
|
|
+
|
|
|
@bp.route("/jobs/new", methods=["GET", "POST"])
|
|
|
def job_new():
|
|
|
if request.method == "POST":
|
|
|
return _save_job(None)
|
|
|
- return render_template("job_form.html", job=None, ynh_apps=get_ynh_apps(),
|
|
|
+ return render_template("job_form.html", job=None,
|
|
|
+ ynh_apps=get_ynh_apps(exclude_app_ids=_used_app_ids()),
|
|
|
destinations=Destination.query.filter_by(enabled=True).all())
|
|
|
|
|
|
|
|
|
@@ -49,7 +58,8 @@ def job_edit(job_id):
|
|
|
job = db.get_or_404(Job, job_id)
|
|
|
if request.method == "POST":
|
|
|
return _save_job(job)
|
|
|
- return render_template("job_form.html", job=job, ynh_apps=get_ynh_apps(),
|
|
|
+ return render_template("job_form.html", job=job,
|
|
|
+ ynh_apps=get_ynh_apps(exclude_app_ids=_used_app_ids(exclude_job_id=job_id)),
|
|
|
destinations=Destination.query.filter_by(enabled=True).all())
|
|
|
|
|
|
|
|
|
@@ -74,6 +84,53 @@ def job_run_now(job_id):
|
|
|
return redirect(url_for("jobs.index"))
|
|
|
|
|
|
|
|
|
+@bp.route("/jobs/bulk", methods=["POST"])
|
|
|
+def jobs_bulk():
|
|
|
+ action = request.form.get("action")
|
|
|
+ job_ids = [int(jid) for jid in request.form.getlist("job_ids") if jid.isdigit()]
|
|
|
+ if not job_ids:
|
|
|
+ return redirect(url_for("jobs.index"))
|
|
|
+
|
|
|
+ from scheduler import schedule_job, remove_job, _execute_job
|
|
|
+
|
|
|
+ if action == "run":
|
|
|
+ for jid in job_ids:
|
|
|
+ job = db.session.get(Job, jid)
|
|
|
+ if job:
|
|
|
+ threading.Thread(target=_execute_job, args=(jid,), daemon=True).start()
|
|
|
+ flash(f"{len(job_ids)} job(s) lancé(s) en arrière-plan.", "info")
|
|
|
+ elif action == "enable":
|
|
|
+ for jid in job_ids:
|
|
|
+ job = db.session.get(Job, jid)
|
|
|
+ if job:
|
|
|
+ job.enabled = True
|
|
|
+ job.updated_at = datetime.utcnow()
|
|
|
+ schedule_job(job)
|
|
|
+ db.session.commit()
|
|
|
+ flash(f"{len(job_ids)} job(s) activé(s).", "success")
|
|
|
+ elif action == "disable":
|
|
|
+ for jid in job_ids:
|
|
|
+ job = db.session.get(Job, jid)
|
|
|
+ if job:
|
|
|
+ job.enabled = False
|
|
|
+ job.updated_at = datetime.utcnow()
|
|
|
+ remove_job(jid)
|
|
|
+ db.session.commit()
|
|
|
+ flash(f"{len(job_ids)} job(s) désactivé(s).", "info")
|
|
|
+ elif action == "delete":
|
|
|
+ names = []
|
|
|
+ for jid in job_ids:
|
|
|
+ job = db.session.get(Job, jid)
|
|
|
+ if job:
|
|
|
+ names.append(job.name)
|
|
|
+ remove_job(jid)
|
|
|
+ db.session.delete(job)
|
|
|
+ db.session.commit()
|
|
|
+ flash(f"{len(names)} job(s) supprimé(s).", "success")
|
|
|
+
|
|
|
+ return redirect(url_for("jobs.index"))
|
|
|
+
|
|
|
+
|
|
|
@bp.route("/jobs/<int:job_id>/toggle", methods=["POST"])
|
|
|
def job_toggle(job_id):
|
|
|
job = db.get_or_404(Job, job_id)
|
|
|
@@ -101,34 +158,30 @@ def job_history(job_id):
|
|
|
|
|
|
@bp.route("/archives")
|
|
|
def archives():
|
|
|
- import os
|
|
|
- from jobs.utils import sudo_listdir, sudo_getsize, sudo_getmtime
|
|
|
+ from jobs.utils import batch_list_archives
|
|
|
from db import _size_human, RemoteInstance
|
|
|
|
|
|
backup_dir = current_app.config["YUNOHOST_BACKUP_DIR"]
|
|
|
- all_files = sudo_listdir(backup_dir)
|
|
|
- tar_names = [f[:-4] for f in all_files if f.endswith(".tar")]
|
|
|
-
|
|
|
- # Tri par mtime décroissant
|
|
|
- tar_names.sort(
|
|
|
- key=lambda n: sudo_getmtime(os.path.join(backup_dir, n + ".tar")),
|
|
|
- reverse=True,
|
|
|
- )
|
|
|
|
|
|
- items = []
|
|
|
- for name in tar_names:
|
|
|
- tar_path = os.path.join(backup_dir, name + ".tar")
|
|
|
- size_bytes = sudo_getsize(tar_path) or None
|
|
|
-
|
|
|
- run = Run.query.filter_by(archive_name=name).order_by(Run.started_at.desc()).first()
|
|
|
- job = db.session.get(Job, run.job_id) if run else None
|
|
|
+ # UN seul appel sudo find pour toutes les tailles + mtimes
|
|
|
+ file_stats = batch_list_archives(backup_dir)
|
|
|
+ sorted_names = sorted(file_stats, key=lambda n: file_stats[n]["mtime"], reverse=True)
|
|
|
|
|
|
- info = read_archive_info(name, backup_dir)
|
|
|
- arch_type = info.get("type") or (job.type if job else "")
|
|
|
+ # Pré-charger runs et jobs en une passe DB (pas de subprocess)
|
|
|
+ runs_by_archive = {}
|
|
|
+ for run in Run.query.order_by(Run.started_at.desc()).all():
|
|
|
+ if run.archive_name and run.archive_name not in runs_by_archive:
|
|
|
+ runs_by_archive[run.archive_name] = run
|
|
|
+ jobs_by_id = {j.id: j for j in Job.query.all()}
|
|
|
|
|
|
+ items = []
|
|
|
+ for name in sorted_names:
|
|
|
+ size_bytes = file_stats[name]["size_bytes"] or None
|
|
|
+ run = runs_by_archive.get(name)
|
|
|
+ job = jobs_by_id.get(run.job_id) if run else None
|
|
|
items.append({
|
|
|
"name": name,
|
|
|
- "type": arch_type,
|
|
|
+ "type": job.type if job else "",
|
|
|
"job_name": job.name if job else "—",
|
|
|
"job_id": job.id if job else None,
|
|
|
"last_status": run.status if run else None,
|
|
|
@@ -288,7 +341,7 @@ def _save_job(job):
|
|
|
|
|
|
if not name:
|
|
|
flash("Le nom est requis.", "error")
|
|
|
- return render_template("job_form.html", job=job, ynh_apps=get_ynh_apps(),
|
|
|
+ return render_template("job_form.html", job=job, ynh_apps=get_ynh_apps(exclude_app_ids=_used_app_ids(exclude_job_id=job.id if job else None)),
|
|
|
destinations=Destination.query.filter_by(enabled=True).all())
|
|
|
|
|
|
cfg = {}
|
|
|
@@ -300,14 +353,14 @@ def _save_job(job):
|
|
|
dbname = f.get("db_database", "").strip()
|
|
|
if not dbname:
|
|
|
flash("Le nom de la base de données est requis.", "error")
|
|
|
- return render_template("job_form.html", job=job, ynh_apps=get_ynh_apps(),
|
|
|
+ return render_template("job_form.html", job=job, ynh_apps=get_ynh_apps(exclude_app_ids=_used_app_ids(exclude_job_id=job.id if job else None)),
|
|
|
destinations=Destination.query.filter_by(enabled=True).all())
|
|
|
cfg = {"database": dbname}
|
|
|
elif job_type == "custom_dir":
|
|
|
source_path = f.get("source_path", "").strip().rstrip("/")
|
|
|
if not source_path or not source_path.startswith("/"):
|
|
|
flash("Le chemin source doit être un chemin absolu (ex: /opt/monapp).", "error")
|
|
|
- return render_template("job_form.html", job=job, ynh_apps=get_ynh_apps(),
|
|
|
+ return render_template("job_form.html", job=job, ynh_apps=get_ynh_apps(exclude_app_ids=_used_app_ids(exclude_job_id=job.id if job else None)),
|
|
|
destinations=Destination.query.filter_by(enabled=True).all())
|
|
|
excludes = [e.strip() for e in f.get("excludes", "").splitlines() if e.strip()]
|
|
|
restore_cfg = {}
|
|
|
@@ -346,7 +399,8 @@ def _save_job(job):
|
|
|
job.name = name
|
|
|
job.type = job_type
|
|
|
job.config_json = json.dumps(cfg)
|
|
|
- job.cron_expr = f.get("cron_expr", "0 3 * * *").strip()
|
|
|
+ cron_raw = (f.get("cron_expr") or "").strip()
|
|
|
+ job.cron_expr = cron_raw if cron_raw else None
|
|
|
job.retention_mode = f.get("retention_mode", "count")
|
|
|
job.retention_value = int(f.get("retention_value", 7))
|
|
|
job.enabled = f.get("enabled") == "1"
|