Kaynağa Gözat

feat: navbar sticky, planification manuelle, actions groupées, archives rapides, filtre apps

- Navbar + barre d'activité sticky (wrapper z-50 commun)
- Planification optionnelle : checkbox dans le formulaire, label "Manuel" dans le dashboard, cron_expr nullable, APScheduler skip si None
- Actions groupées sur les jobs : checkboxes + barre Lancer/Activer/Désactiver/Supprimer via /jobs/bulk
- En-tête manquant sur le tableau des serveurs fédérés
- Page Archives : de 3N appels subprocess à 1 sudo find -printf + 2 requêtes DB
- Formulaire ynh_app : exclut les apps déjà couvertes par un job existant
- Logo mis à jour (icon/, static/icon.png, doc/LOGO.png)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Cédric Hansen 1 ay önce
ebeveyn
işleme
81fc1d6e8f

BIN
doc/LOGO.png


BIN
icon/icon_logo.png


+ 80 - 26
sources/blueprints/jobs.py

@@ -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"

+ 1 - 1
sources/db.py

@@ -46,7 +46,7 @@ class Job(db.Model):
     name = db.Column(db.Text, nullable=False)
     type = db.Column(db.Text, nullable=False)  # ynh_app|ynh_system|custom_dir|mysql|postgresql
     config_json = db.Column(db.Text)
-    cron_expr = db.Column(db.Text, nullable=False)
+    cron_expr = db.Column(db.Text, nullable=True)  # None = déclenchement manuel uniquement
     retention_mode = db.Column(db.Text, nullable=False)  # count|daily|gfs
     retention_value = db.Column(db.Integer, nullable=False)
     enabled = db.Column(db.Boolean, default=True)

+ 6 - 3
sources/helpers.py

@@ -20,8 +20,8 @@ def read_archive_info(archive_name, backup_dir):
     return info
 
 
-def get_ynh_apps():
-    """Retourne la liste des apps YunoHost installées."""
+def get_ynh_apps(exclude_app_ids=None):
+    """Retourne la liste des apps YunoHost installées, en excluant celles déjà utilisées."""
     try:
         import json
         result = subprocess.run(
@@ -29,7 +29,10 @@ def get_ynh_apps():
             capture_output=True, text=True, timeout=15,
         )
         if result.returncode == 0:
-            return json.loads(result.stdout).get("apps", [])
+            apps = json.loads(result.stdout).get("apps", [])
+            if exclude_app_ids:
+                apps = [a for a in apps if a.get("id") not in exclude_app_ids]
+            return apps
     except Exception:
         pass
     return []

+ 21 - 0
sources/jobs/utils.py

@@ -72,6 +72,27 @@ def sudo_read_backup_info(archive_path):
     return {}
 
 
+def batch_list_archives(backup_dir):
+    """Retourne {name: {size_bytes, mtime}} pour tous les .tar — UN seul appel sudo find."""
+    result = subprocess.run(
+        ["sudo", "find", backup_dir, "-maxdepth", "1", "-name", "*.tar",
+         "-printf", "%f\t%s\t%T@\n"],
+        capture_output=True, text=True,
+    )
+    stats = {}
+    for line in result.stdout.splitlines():
+        parts = line.strip().split("\t")
+        if len(parts) == 3:
+            fname, size_str, mtime_str = parts
+            if fname.endswith(".tar"):
+                name = fname[:-4]
+                try:
+                    stats[name] = {"size_bytes": int(size_str), "mtime": float(mtime_str)}
+                except ValueError:
+                    pass
+    return stats
+
+
 def sudo_listdir(directory):
     """Liste les fichiers d'un répertoire via sudo find si non accessible directement."""
     try:

+ 26 - 5
sources/scheduler.py

@@ -12,9 +12,22 @@ scheduler = BackgroundScheduler(
 def init_scheduler(flask_app):
     global _flask_app
     _flask_app = flask_app
+    # Nettoyage immédiat au démarrage : tout run "running" en DB est forcément
+    # un vestige d'un process précédent (app redémarrée pendant un backup).
+    with flask_app.app_context():
+        from db import db, Run
+        import datetime as _dt
+        stale = Run.query.filter_by(status="running").all()
+        now = _dt.datetime.utcnow()
+        for run in stale:
+            run.status = "error"
+            run.log_text = (run.log_text or "") + "\n[interrompu] Run interrompu par un redémarrage de l'application."
+            run.finished_at = now
+        if stale:
+            db.session.commit()
     if not scheduler.running:
         scheduler.start()
-    # Nettoyage des runs bloqués à "running" (app redémarrée pendant un backup)
+    # Filet de sécurité : marque en erreur tout run resté bloqué > 6h
     scheduler.add_job(
         func=_cleanup_stuck_runs,
         trigger="interval",
@@ -25,18 +38,24 @@ def init_scheduler(flask_app):
 
 
 def _cleanup_stuck_runs():
-    from datetime import timedelta
+    import datetime as _dt
     with _flask_app.app_context():
         from db import db, Run
-        cutoff = __import__("datetime").datetime.utcnow() - timedelta(hours=6)
+        cutoff = _dt.datetime.utcnow() - _dt.timedelta(hours=6)
         stuck = Run.query.filter(
             Run.status == "running",
             Run.started_at < cutoff,
         ).all()
+        now = _dt.datetime.utcnow()
         for run in stuck:
+            duration = now - run.started_at
+            hours = int(duration.total_seconds() // 3600)
+            minutes = int((duration.total_seconds() % 3600) // 60)
             run.status = "error"
-            run.log_text = (run.log_text or "") + "\n[timeout] Run marqué en erreur par le nettoyage automatique."
-            run.finished_at = __import__("datetime").datetime.utcnow()
+            run.log_text = (run.log_text or "") + (
+                f"\n[timeout] Run bloqué depuis {hours}h{minutes:02d} — marqué en erreur par le nettoyage automatique."
+            )
+            run.finished_at = now
         if stuck:
             db.session.commit()
 
@@ -50,6 +69,8 @@ def _execute_job(job_id):
 def schedule_job(job):
     import logging
     job_key = f"job_{job.id}"
+    if not job.cron_expr:
+        return  # job manuel uniquement, pas de planification APScheduler
     try:
         trigger = CronTrigger.from_crontab(job.cron_expr)
     except Exception:

BIN
sources/static/icon.png


+ 5 - 3
sources/templates/base.html

@@ -26,6 +26,7 @@
 </head>
 <body class="h-full flex flex-col">
 
+  <div class="sticky top-0 z-50">
   <nav class="bg-gray-900 text-white shadow-lg">
     <div class="max-w-7xl mx-auto px-6 py-3 flex items-center justify-between">
       <div class="flex items-center gap-3">
@@ -54,13 +55,13 @@
     </div>
   </nav>
 
-  <!-- Barre d'activité sticky -->
+  <!-- Barre d'activité (dans le bloc sticky avec la navbar) -->
   <div id="activity-bar"
-       class="hidden sticky top-0 z-40 bg-gray-900 border-b border-blue-700 text-white text-xs px-6 py-2 flex items-center justify-center gap-3 shadow">
+       class="hidden bg-gray-900 border-b border-blue-700 text-white text-xs px-6 py-2 flex items-center justify-center gap-3 shadow">
     <span class="inline-block w-2 h-2 rounded-full bg-blue-400 animate-pulse shrink-0"></span>
     <span class="text-blue-300 font-medium shrink-0">En cours</span>
     <div id="activity-list" class="flex gap-4 flex-wrap text-gray-200"></div>
-  </div>
+  </div><!-- fin sticky -->
 
   <main class="flex-1 max-w-7xl mx-auto w-full px-6 py-8">
     {% with messages = get_flashed_messages(with_categories=true) %}
@@ -85,6 +86,7 @@
     Backup Manager — instance <strong>{{ instance_name }}</strong>
   </footer>
 
+  {% block scripts %}{% endblock %}
   <script>
   (function() {
     const bar = document.getElementById('activity-bar');

+ 82 - 1
sources/templates/dashboard_local.html

@@ -43,6 +43,14 @@
   <div class="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
     <h2 class="text-base font-semibold text-gray-800">Jobs de sauvegarde</h2>
   </div>
+  <!-- Barre actions groupées (masquée par défaut) -->
+  <div id="bulk-bar" class="hidden bg-blue-50 border-b border-blue-200 px-6 py-2 flex items-center gap-3 text-sm">
+    <span class="text-blue-700 font-medium"><span id="bulk-count">0</span> sélectionné(s)</span>
+    <button type="button" onclick="bulkAction('run')" class="btn-primary btn-sm">▶ Lancer</button>
+    <button type="button" onclick="bulkAction('enable')" class="btn-secondary btn-sm">Activer</button>
+    <button type="button" onclick="bulkAction('disable')" class="btn-secondary btn-sm">Désactiver</button>
+    <button type="button" onclick="bulkAction('delete', 'Supprimer les jobs sélectionnés et leur historique ?')" class="btn-danger btn-sm">✕ Supprimer</button>
+  </div>
 
   {% if not jobs %}
     <div class="px-6 py-12 text-center text-gray-400">
@@ -56,6 +64,9 @@
       <table class="w-full text-sm">
         <thead>
           <tr class="text-xs text-gray-500 uppercase tracking-wide bg-gray-50">
+            <th class="px-4 py-3 w-8">
+              <input type="checkbox" id="select-all" class="rounded border-gray-300 text-blue-600">
+            </th>
             <th class="px-6 py-3 text-left font-medium">Nom</th>
             <th class="px-6 py-3 text-left font-medium">Type</th>
             <th class="px-6 py-3 text-left font-medium">Planification</th>
@@ -70,13 +81,19 @@
           {% for job in jobs %}
             {% set run = last_runs.get(job.id) %}
             <tr class="hover:bg-gray-50 {% if not job.enabled %}opacity-50{% endif %}">
+              <td class="px-4 py-4">
+                <input type="checkbox" class="job-checkbox rounded border-gray-300 text-blue-600" value="{{ job.id }}">
+              </td>
               <td class="px-6 py-4 font-medium text-gray-900">{{ job.name }}</td>
               <td class="px-6 py-4">
                 <span class="bg-gray-100 text-gray-600 text-xs px-2 py-0.5 rounded font-mono">
                   {{ job.type }}
                 </span>
               </td>
-              <td class="px-6 py-4 font-mono text-xs text-gray-600">{{ job.cron_expr }}</td>
+              <td class="px-6 py-4 font-mono text-xs text-gray-600">
+                {% if job.cron_expr %}{{ job.cron_expr }}
+                {% else %}<span class="bg-gray-100 text-gray-500 text-xs px-2 py-0.5 rounded font-sans">Manuel</span>{% endif %}
+              </td>
               <td class="px-6 py-4">
                 {% if job.destination %}
                   <span class="bg-violet-50 text-violet-700 text-xs px-2 py-0.5 rounded font-medium">{{ job.destination.name }}</span>
@@ -175,6 +192,16 @@
     {% else %}
     <div class="overflow-x-auto">
       <table class="w-full text-sm">
+        <thead>
+          <tr class="text-xs text-gray-500 uppercase tracking-wide bg-gray-50">
+            <th class="px-6 py-3 text-left font-medium">Nom</th>
+            <th class="px-6 py-3 text-left font-medium">Type</th>
+            <th class="px-6 py-3 text-left font-medium">Dernière exéc.</th>
+            <th class="px-6 py-3 text-left font-medium">Statut</th>
+            <th class="px-6 py-3 text-left font-medium">Taille</th>
+            <th class="px-6 py-3 text-right font-medium">Actions</th>
+          </tr>
+        </thead>
         <tbody class="divide-y divide-gray-100">
           {% for row in inst.remote_runs %}
           <tr class="hover:bg-gray-50">
@@ -228,3 +255,57 @@
 {% endif %}
 
 {% endblock %}
+
+{% block scripts %}
+<script>
+(function() {
+  const selectAll = document.getElementById('select-all');
+  const checkboxes = () => [...document.querySelectorAll('.job-checkbox')];
+  const bulkBar = document.getElementById('bulk-bar');
+  const bulkCount = document.getElementById('bulk-count');
+
+  function updateBar() {
+    const checked = checkboxes().filter(c => c.checked);
+    bulkCount.textContent = checked.length;
+    if (checked.length > 0) bulkBar.classList.remove('hidden');
+    else bulkBar.classList.add('hidden');
+  }
+
+  if (selectAll) {
+    selectAll.addEventListener('change', function() {
+      checkboxes().forEach(c => c.checked = this.checked);
+      updateBar();
+    });
+  }
+
+  document.addEventListener('change', function(e) {
+    if (!e.target.classList.contains('job-checkbox')) return;
+    const all = checkboxes();
+    if (selectAll) {
+      selectAll.checked = all.every(c => c.checked);
+      selectAll.indeterminate = all.some(c => c.checked) && !all.every(c => c.checked);
+    }
+    updateBar();
+  });
+
+  window.bulkAction = function(action, confirmMsg) {
+    const checked = checkboxes().filter(c => c.checked);
+    if (!checked.length) return;
+    if (confirmMsg && !confirm(confirmMsg)) return;
+    const form = document.createElement('form');
+    form.method = 'POST';
+    form.action = '{{ url_for("jobs.jobs_bulk") }}';
+    const ai = document.createElement('input');
+    ai.type = 'hidden'; ai.name = 'action'; ai.value = action;
+    form.appendChild(ai);
+    checked.forEach(c => {
+      const inp = document.createElement('input');
+      inp.type = 'hidden'; inp.name = 'job_ids'; inp.value = c.value;
+      form.appendChild(inp);
+    });
+    document.body.appendChild(form);
+    form.submit();
+  };
+})();
+</script>
+{% endblock %}

+ 28 - 3
sources/templates/job_form.html

@@ -216,12 +216,19 @@
     </div>
 
     {# ── Planification ── #}
+    {% set has_cron = job and job.cron_expr %}
     <div class="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
       <h2 class="text-sm font-semibold text-gray-700 uppercase tracking-wide">Planification</h2>
-      <div>
+      <div class="flex items-center gap-3">
+        <input type="checkbox" id="scheduled" name="scheduled" value="1"
+               {% if not job or has_cron %}checked{% endif %}
+               class="rounded border-gray-300 text-blue-600">
+        <label for="scheduled" class="text-sm font-medium text-gray-700">Planification automatique</label>
+      </div>
+      <div id="cron-field" {% if job and not has_cron %}class="hidden"{% endif %}>
         <label class="block text-sm font-medium text-gray-700 mb-1">Expression cron</label>
-        <input type="text" name="cron_expr" required
-               value="{{ job.cron_expr if job else '0 3 * * *' }}"
+        <input type="text" name="cron_expr" id="cron_expr"
+               value="{{ job.cron_expr if has_cron else '0 3 * * *' }}"
                placeholder="0 3 * * *"
                class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500">
         <p class="text-xs text-gray-400 mt-1">
@@ -368,5 +375,23 @@
   document.querySelector('[name=retention_mode]').addEventListener('change', updateRetentionHelp);
   showTypeConfig();
   updateRetentionHelp();
+
+  // Toggle planification cron
+  const scheduledChk = document.getElementById('scheduled');
+  const cronField = document.getElementById('cron-field');
+  const cronInput = document.getElementById('cron_expr');
+  function toggleCron() {
+    if (scheduledChk.checked) {
+      cronField.classList.remove('hidden');
+      cronInput.disabled = false;
+      cronInput.required = true;
+    } else {
+      cronField.classList.add('hidden');
+      cronInput.disabled = true;
+      cronInput.required = false;
+    }
+  }
+  scheduledChk.addEventListener('change', toggleCron);
+  toggleCron();
 </script>
 {% endblock %}