Quellcode durchsuchen

fix+feat: archives visibles (sudo_listdir), instances dans Paramètres, fédérés sur home

- Archives : remplacement de glob.glob/os.stat (inaccessibles) par sudo_listdir/sudo_getsize/sudo_getmtime
- Paramètres : 3e onglet "Instances" avec liste, statut, Tester/Sync/Éditer/Supprimer ; bouton contextuel "Ajouter une instance" ; redirects après CRUD pointent sur ?tab=instances
- Dashboard (home) : section "Serveurs fédérés" sous le tableau des jobs, avec jobs distants (statut, lancer, rapatrier) et bouton Sync par instance
- Navigation : lien "Fédération" retiré (remplacé par home + Paramètres/Instances)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Cédric Hansen vor 1 Monat
Ursprung
Commit
2222015748

+ 18 - 17
sources/blueprints/jobs.py

@@ -23,12 +23,15 @@ bp = Blueprint("jobs", __name__)
 
 @bp.route("/")
 def index():
+    from db import RemoteInstance
     jobs = Job.query.order_by(Job.name).all()
     last_runs = {
         j.id: Run.query.filter_by(job_id=j.id).order_by(Run.started_at.desc()).first()
         for j in jobs
     }
-    return render_template("dashboard_local.html", jobs=jobs, last_runs=last_runs)
+    instances = RemoteInstance.query.order_by(RemoteInstance.name).all()
+    return render_template("dashboard_local.html", jobs=jobs, last_runs=last_runs,
+                           instances=instances)
 
 
 # --- CRUD Jobs ----------------------------------------------------------------
@@ -98,23 +101,24 @@ def job_history(job_id):
 
 @bp.route("/archives")
 def archives():
-    import os, glob
+    import os
+    from jobs.utils import sudo_listdir, sudo_getsize, sudo_getmtime
+    from db import _size_human, RemoteInstance
+
     backup_dir = current_app.config["YUNOHOST_BACKUP_DIR"]
-    tars = sorted(
-        glob.glob(os.path.join(backup_dir, "*.tar")),
-        key=os.path.getmtime,
+    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 tar_path in tars:
-        name = os.path.basename(tar_path)[:-4]  # strip .tar
-        stat = None
-        try:
-            stat = os.stat(tar_path)
-        except OSError:
-            pass
-        size_bytes = stat.st_size if stat else None
+    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
@@ -122,7 +126,6 @@ def archives():
         info = read_archive_info(name, backup_dir)
         arch_type = info.get("type") or (job.type if job else "")
 
-        from db import _size_human
         items.append({
             "name": name,
             "type": arch_type,
@@ -134,9 +137,7 @@ def archives():
             "size_human": _size_human(size_bytes) if size_bytes else "—",
         })
 
-    instances = __import__("db", fromlist=["RemoteInstance"]).RemoteInstance.query.order_by(
-        __import__("db", fromlist=["RemoteInstance"]).RemoteInstance.name
-    ).all()
+    instances = RemoteInstance.query.order_by(RemoteInstance.name).all()
     return render_template("archives.html", items=items, instances=instances)
 
 

+ 3 - 3
sources/blueprints/network.py

@@ -47,7 +47,7 @@ def remote_instance_delete(inst_id):
     db.session.delete(inst)
     db.session.commit()
     flash(f"Instance « {inst.name} » supprimée.", "success")
-    return redirect(url_for("network.federation") + "?tab=instances")
+    return redirect(url_for("cfg.settings") + "?tab=instances")
 
 
 @bp.route("/remote-instances/<int:inst_id>/test", methods=["POST"])
@@ -65,7 +65,7 @@ def remote_instance_test(inst_id):
         inst.status = "error"
         db.session.commit()
         flash(f"Connexion échouée vers « {inst.name} » : {exc}", "error")
-    return redirect(url_for("network.federation") + "?tab=instances")
+    return redirect(url_for("cfg.settings") + "?tab=instances")
 
 
 @bp.route("/remote-instances/<int:inst_id>/sync", methods=["POST"])
@@ -312,7 +312,7 @@ def _save_remote_instance(inst):
     inst.api_key = api_key
     db.session.commit()
     flash(f"Instance « {inst.name} » enregistrée.", "success")
-    return redirect(url_for("network.federation") + "?tab=instances")
+    return redirect(url_for("cfg.settings") + "?tab=instances")
 
 
 # --- DTO dashboard réseau -----------------------------------------------------

+ 4 - 2
sources/blueprints/settings.py

@@ -65,7 +65,7 @@ def settings():
 
         return redirect(url_for("cfg.settings"))
 
-    from db import Destination
+    from db import Destination, RemoteInstance
     cfg = {k: _get_setting(k) for k in _SETTING_KEYS}
     cfg.setdefault("smtp_port", "587")
     cfg["smtp_tls"] = cfg.get("smtp_tls") or "1"
@@ -74,8 +74,10 @@ def settings():
     api_token = current_app.config.get("API_TOKEN", "")
     instance_url = current_app.config.get("INSTANCE_URL", "")
     destinations = Destination.query.order_by(Destination.name).all()
+    instances = RemoteInstance.query.order_by(RemoteInstance.name).all()
     return render_template("settings.html", cfg=cfg, api_token=api_token,
-                           instance_url=instance_url, destinations=destinations)
+                           instance_url=instance_url, destinations=destinations,
+                           instances=instances)
 
 
 @bp.route("/internal/databases/<db_type>")

+ 0 - 4
sources/templates/base.html

@@ -43,10 +43,6 @@
            class="text-gray-300 hover:text-white transition {% if request.endpoint == 'jobs.archives' %}text-white font-semibold{% endif %}">
           Archives
         </a>
-        <a href="{{ url_for('network.federation') }}"
-           class="text-gray-300 hover:text-white transition {% if request.endpoint and request.endpoint.startswith('network.') %}text-white font-semibold{% endif %}">
-          Fédération
-        </a>
         <a href="{{ url_for('cfg.settings') }}"
            class="text-gray-300 hover:text-white transition {% if request.endpoint and request.endpoint.startswith('cfg.') %}text-white font-semibold{% endif %}">
           Paramètres

+ 90 - 0
sources/templates/dashboard_local.html

@@ -137,4 +137,94 @@
   {% endif %}
 </div>
 
+{# ── Serveurs fédérés ─────────────────────────────────────────────── #}
+{% if instances %}
+<div class="mt-8">
+  <div class="flex items-center justify-between mb-4">
+    <h2 class="text-base font-semibold text-gray-700">Serveurs fédérés</h2>
+    <form method="post" action="{{ url_for('network.network_sync_all') }}">
+      <button type="submit" class="btn-secondary btn-sm">Synchroniser tout</button>
+    </form>
+  </div>
+
+  {% for inst in instances %}
+  {% set sc = {'online':'bg-green-100 text-green-700','error':'bg-red-100 text-red-700','offline':'bg-gray-100 text-gray-500'} %}
+  <div class="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden mb-4">
+    <div class="px-6 py-3 border-b border-gray-100 flex items-center justify-between gap-4">
+      <div class="flex items-center gap-3">
+        <span class="font-semibold text-gray-800 text-sm">{{ inst.name }}</span>
+        <span class="text-xs px-2 py-0.5 rounded-full font-medium {{ sc.get(inst.status, 'bg-gray-100 text-gray-400') }}">
+          {{ inst.status or 'unknown' }}
+        </span>
+        {% if inst.last_seen %}
+          <span class="text-xs text-gray-400 hidden sm:inline">sync {{ inst.last_seen.strftime('%d/%m %H:%M') }}</span>
+        {% endif %}
+        <span class="text-xs font-mono text-gray-400 truncate hidden md:inline">{{ inst.url_display }}</span>
+      </div>
+      <div class="flex gap-2 shrink-0">
+        <form method="post" action="{{ url_for('network.remote_instance_sync', inst_id=inst.id) }}">
+          <button type="submit" class="btn-secondary btn-sm">Sync</button>
+        </form>
+      </div>
+    </div>
+
+    {% if not inst.remote_runs %}
+      <div class="px-6 py-5 text-center text-gray-400 text-sm">
+        Aucune donnée — cliquez sur "Sync" pour récupérer l'état.
+      </div>
+    {% else %}
+    <div class="overflow-x-auto">
+      <table class="w-full text-sm">
+        <tbody class="divide-y divide-gray-100">
+          {% for row in inst.remote_runs %}
+          <tr class="hover:bg-gray-50">
+            <td class="px-6 py-3 font-medium text-gray-900">{{ row.job_name }}</td>
+            <td class="px-6 py-3">
+              <span class="bg-gray-100 text-gray-600 text-xs px-2 py-0.5 rounded font-mono">{{ row.job_type }}</span>
+            </td>
+            <td class="px-6 py-3 text-xs text-gray-500">
+              {% if row.last_run_at %}{{ row.last_run_at.strftime('%d/%m/%Y %H:%M') }}
+              {% else %}<span class="text-gray-300">Jamais</span>{% endif %}
+            </td>
+            <td class="px-6 py-3">
+              {% if row.last_status == 'success' %}
+                <span class="bg-green-100 text-green-700 text-xs font-medium px-2 py-0.5 rounded-full">✓ succès</span>
+              {% elif row.last_status == 'error' %}
+                <span class="bg-red-100 text-red-700 text-xs font-medium px-2 py-0.5 rounded-full">✗ erreur</span>
+              {% elif row.last_status == 'running' %}
+                <span class="bg-blue-100 text-blue-700 text-xs font-medium px-2 py-0.5 rounded-full animate-pulse">⟳ en cours</span>
+              {% else %}
+                <span class="text-gray-300 text-xs">—</span>
+              {% endif %}
+            </td>
+            <td class="px-6 py-3 text-xs text-gray-500">
+              {% if row.last_size_bytes %}{{ row.last_size_human }}{% else %}—{% endif %}
+            </td>
+            <td class="px-6 py-3 text-right">
+              <div class="flex items-center justify-end gap-2">
+                {% if row.job_id %}
+                <form method="post"
+                      action="{{ url_for('network.remote_job_run', inst_id=inst.id, job_id=row.job_id) }}"
+                      onsubmit="return confirm('Lancer « {{ row.job_name }} » sur {{ inst.name }} ?')">
+                  <button type="submit" class="btn-primary btn-sm">▶ Lancer</button>
+                </form>
+                <form method="post"
+                      action="{{ url_for('network.archive_pull_latest', inst_id=inst.id, remote_job_id=row.job_id) }}"
+                      onsubmit="return confirm('Rapatrier la dernière archive de « {{ row.job_name }} » ?')">
+                  <button type="submit" class="btn-ghost btn-sm">← Rapatrier</button>
+                </form>
+                {% endif %}
+              </div>
+            </td>
+          </tr>
+          {% endfor %}
+        </tbody>
+      </table>
+    </div>
+    {% endif %}
+  </div>
+  {% endfor %}
+</div>
+{% endif %}
+
 {% endblock %}

+ 1 - 1
sources/templates/remote_instance_form.html

@@ -52,7 +52,7 @@
       <button type="submit" class="btn-primary btn-md">
         {{ 'Enregistrer' if inst else 'Ajouter l\'instance' }}
       </button>
-      <a href="{{ url_for('network.federation') }}?tab=instances" class="btn-secondary btn-md">Annuler</a>
+      <a href="{{ url_for('cfg.settings') }}?tab=instances" class="btn-secondary btn-md">Annuler</a>
     </div>
 
   </form>

+ 83 - 12
sources/templates/settings.html

@@ -4,9 +4,14 @@
 {% block content %}
 <div class="flex items-center justify-between mb-6">
   <h1 class="text-xl font-bold text-gray-900">Paramètres</h1>
-  <a href="{{ url_for('dest.destination_new') }}" id="btn-new-dest" class="btn-primary btn-sm hidden">
-    + Nouvelle destination
-  </a>
+  <div class="flex gap-2">
+    <a href="{{ url_for('dest.destination_new') }}" id="btn-new-dest" class="btn-primary btn-sm hidden">
+      + Nouvelle destination
+    </a>
+    <a href="{{ url_for('network.remote_instance_new') }}" id="btn-new-inst" class="btn-primary btn-sm hidden">
+      + Ajouter une instance
+    </a>
+  </div>
 </div>
 
 {# ── Onglets ─────────────────────────────────────────────────────── #}
@@ -15,6 +20,10 @@
           class="tab-btn px-4 py-2 text-sm font-medium border-b-2 border-blue-600 text-blue-600 -mb-px">
     Destinations
   </button>
+  <button id="tab-btn-instances" onclick="setTab('instances')"
+          class="tab-btn px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 -mb-px">
+    Instances
+  </button>
   <button id="tab-btn-config" onclick="setTab('config')"
           class="tab-btn px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 -mb-px">
     Configuration
@@ -75,6 +84,67 @@
 
 </div>{# /pane-destinations #}
 
+{# ══════════════════ PANNEAU : INSTANCES ══════════════════════════ #}
+<div id="pane-instances" class="hidden">
+
+{% if not instances %}
+  <div class="bg-white rounded-xl border border-gray-200 px-6 py-12 text-center text-gray-400">
+    <p class="text-lg">Aucune instance distante configurée.</p>
+    <p class="text-sm mt-2">Ajoutez une instance pour voir son état et déclencher des sauvegardes à distance.</p>
+    <a href="{{ url_for('network.remote_instance_new') }}" class="mt-4 inline-block text-blue-600 hover:underline text-sm">
+      Ajouter une première instance →
+    </a>
+  </div>
+{% else %}
+  <div class="space-y-4">
+    {% for inst in instances %}
+    {% set status_color = {
+      'online':  'bg-green-100 text-green-700',
+      'error':   'bg-red-100 text-red-700',
+      'offline': 'bg-gray-100 text-gray-500',
+    }.get(inst.status, 'bg-gray-100 text-gray-400') %}
+
+    <div class="bg-white rounded-xl border border-gray-200 shadow-sm p-5">
+      <div class="flex items-start justify-between gap-4">
+        <div class="space-y-1 min-w-0 flex-1">
+          <div class="flex items-center gap-2 flex-wrap">
+            <span class="font-semibold text-gray-900">{{ inst.name }}</span>
+            <span class="text-xs px-2 py-0.5 rounded-full font-medium {{ status_color }}">
+              {{ inst.status or 'unknown' }}
+            </span>
+            {% if inst.last_seen %}
+              <span class="text-xs text-gray-400">vu {{ inst.last_seen.strftime('%d/%m %H:%M') }}</span>
+            {% endif %}
+          </div>
+          <p class="text-sm font-mono text-gray-500">{{ inst.url_display }}</p>
+          {% if inst.remote_runs %}
+            <p class="text-xs text-gray-400">
+              {{ inst.remote_runs | length }} job{{ 's' if inst.remote_runs | length != 1 }} synchronisé{{ 's' if inst.remote_runs | length != 1 }}
+            </p>
+          {% endif %}
+        </div>
+        <div class="flex items-center gap-2 shrink-0">
+          <form method="post" action="{{ url_for('network.remote_instance_test', inst_id=inst.id) }}">
+            <button type="submit" class="btn-secondary btn-sm">Tester</button>
+          </form>
+          <form method="post" action="{{ url_for('network.remote_instance_sync', inst_id=inst.id) }}">
+            <button type="submit" class="btn-secondary btn-sm">Sync</button>
+          </form>
+          <a href="{{ url_for('network.remote_instance_edit', inst_id=inst.id) }}"
+             class="btn-secondary btn-sm">Éditer</a>
+          <form method="post" action="{{ url_for('network.remote_instance_delete', inst_id=inst.id) }}"
+                onsubmit="return confirm('Supprimer l\'instance « {{ inst.name }} » ?')">
+            <button type="submit" class="btn-danger btn-icon-sm">✕</button>
+          </form>
+        </div>
+      </div>
+    </div>
+    {% endfor %}
+  </div>
+{% endif %}
+
+</div>{# /pane-instances #}
+
 {# ══════════════════ PANNEAU : CONFIGURATION ══════════════════════ #}
 <div id="pane-config" class="hidden max-w-xl">
 
@@ -194,8 +264,10 @@
 </div>{# /pane-config #}
 
 <script>
+const TAB_NAMES = ['destinations', 'instances', 'config'];
+
 function setTab(name) {
-  ['destinations', 'config'].forEach(function(t) {
+  TAB_NAMES.forEach(function(t) {
     const pane = document.getElementById('pane-' + t);
     const btn  = document.getElementById('tab-btn-' + t);
     const active = t === name;
@@ -204,12 +276,9 @@ function setTab(name) {
     btn.classList.toggle('text-blue-600', active);
     btn.classList.toggle('border-transparent', !active);
     btn.classList.toggle('text-gray-500', !active);
-    if (name === 'destinations') {
-      document.getElementById('btn-new-dest').classList.remove('hidden');
-    } else {
-      document.getElementById('btn-new-dest').classList.add('hidden');
-    }
   });
+  document.getElementById('btn-new-dest').classList.toggle('hidden', name !== 'destinations');
+  document.getElementById('btn-new-inst').classList.toggle('hidden', name !== 'instances');
   history.replaceState(null, '', location.pathname + '?tab=' + name);
 }
 
@@ -224,8 +293,10 @@ function copyToken() {
 
 // Restore tab from URL
 const urlTab = new URLSearchParams(location.search).get('tab');
-if (urlTab === 'config') setTab('config');
-// Show "+ Nouvelle destination" button by default (destinations tab is first)
-document.getElementById('btn-new-dest').classList.remove('hidden');
+if (TAB_NAMES.includes(urlTab)) {
+  setTab(urlTab);
+} else {
+  setTab('destinations');
+}
 </script>
 {% endblock %}