Procházet zdrojové kódy

feat: refonte UI — onglets Fédération/Paramètres, barre d'activité, navigateur d'archives, boutons unifiés

- Dashboard : colonne "Transfert" (destination rsync ou Local), suppression "Prochaine exéc."
- Boutons unifiés : 4 classes CSS (btn-primary/secondary/ghost/danger × btn-sm/md/icon-sm) appliquées à tous les templates
- Navigation : "Réseau" + "Instances" → lien unique "Fédération" avec 2 onglets (Vue réseau | Instances) ; "Archives" ajoutée en premier niveau ; "Destinations" retirée (déplacée dans Paramètres)
- Page Fédération : nouveau template federation.html avec onglets JS persistants via URL ?tab=
- Paramètres : onglets "Destinations" (1er) + "Configuration", bouton contextuel "+ Nouvelle destination"
- Barre d'activité sticky : bandeau sous le header, polling /api/v1/running toutes les 5 s, disparaît quand idle
- API : endpoint GET /api/v1/running retournant les runs locaux en status=running
- Navigateur d'archives : page /archives listant tous les .tar avec filtres (nom/statut/type), actions Restaurer / Pousser (dropdown instances) / Télécharger / Supprimer
- Route /archives/<name>/download-web pour téléchargement browser via sudo rsync + stream
- Route /archives/<name>/delete pour suppression via sudo rm
- api_token injecté dans le context processor global pour usage dans les templates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Cédric Hansen před 1 měsícem
rodič
revize
4de605277e

+ 2 - 0
sources/app.py

@@ -56,6 +56,8 @@ app.register_blueprint(bp_api)
 def _inject_globals():
     return {
         "instance_name": app.config.get("INSTANCE_NAME", ""),
+        "instance_url": app.config.get("INSTANCE_URL", ""),
+        "api_token": app.config.get("API_TOKEN", ""),
         "now": datetime.utcnow(),
     }
 

+ 16 - 0
sources/blueprints/api.py

@@ -38,6 +38,22 @@ def api_health():
     return jsonify({"status": "ok", "instance": current_app.config.get("INSTANCE_NAME")})
 
 
+@bp.route("/running")
+def api_running():
+    """Retourne les activités en cours (runs locaux status=running)."""
+    running_runs = Run.query.filter_by(status="running").order_by(Run.started_at.asc()).all()
+    items = []
+    for r in running_runs:
+        job = db.session.get(Job, r.job_id)
+        items.append({
+            "kind": "backup" if not r.archive_name or "restore" not in (r.archive_name or "") else "restore",
+            "name": job.name if job else f"job#{r.job_id}",
+            "started_at": r.started_at.isoformat() if r.started_at else None,
+            "size_human": r.size_human if r.size_bytes else None,
+        })
+    return jsonify({"running": items})
+
+
 @bp.route("/jobs")
 def api_jobs():
     jobs = Job.query.all()

+ 2 - 2
sources/blueprints/destinations.py

@@ -43,7 +43,7 @@ def destination_delete(dest_id):
     db.session.delete(dest)
     db.session.commit()
     flash(f"Destination « {dest.name} » supprimée.", "success")
-    return redirect(url_for("dest.destinations_list"))
+    return redirect(url_for("cfg.settings") + "?tab=destinations")
 
 
 @bp.route("/destinations/<int:dest_id>/test", methods=["POST"])
@@ -52,7 +52,7 @@ def destination_test(dest_id):
     from jobs.transfer import test_connection
     ok, msg = test_connection(dest, current_app.config["DATA_DIR"])
     flash(msg, "success" if ok else "error")
-    return redirect(url_for("dest.destinations_list"))
+    return redirect(url_for("cfg.settings") + "?tab=destinations")
 
 
 @bp.route("/archives/<path:archive_name>/transfer", methods=["POST"])

+ 93 - 0
sources/blueprints/jobs.py

@@ -94,6 +94,99 @@ def job_history(job_id):
     return render_template("job_history.html", job=job, runs=runs)
 
 
+# --- Navigateur d'archives ----------------------------------------------------
+
+@bp.route("/archives")
+def archives():
+    import os, glob
+    backup_dir = current_app.config["YUNOHOST_BACKUP_DIR"]
+    tars = sorted(
+        glob.glob(os.path.join(backup_dir, "*.tar")),
+        key=os.path.getmtime,
+        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
+
+        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
+
+        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,
+            "job_name": job.name if job else "—",
+            "job_id": job.id if job else None,
+            "last_status": run.status if run else None,
+            "run_at": run.started_at if run else None,
+            "size_bytes": size_bytes,
+            "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()
+    return render_template("archives.html", items=items, instances=instances)
+
+
+@bp.route("/archives/<path:archive_name>/download")
+def archive_download(archive_name):
+    import os, subprocess
+    from flask import Response, stream_with_context
+    backup_dir = current_app.config["YUNOHOST_BACKUP_DIR"]
+    archive_path = os.path.join(backup_dir, archive_name + ".tar")
+    tmp_path = f"/tmp/backupmanager_webdl_{archive_name}.tar"
+    try:
+        r = subprocess.run(["sudo", "rsync", archive_path, tmp_path],
+                           capture_output=True, text=True, timeout=3600)
+        if r.returncode != 0:
+            flash(f"Téléchargement impossible : {r.stderr.strip()}", "error")
+            return redirect(url_for("jobs.archives"))
+
+        def _stream():
+            try:
+                with open(tmp_path, "rb") as f:
+                    while True:
+                        chunk = f.read(1024 * 1024)
+                        if not chunk:
+                            break
+                        yield chunk
+            finally:
+                subprocess.run(["sudo", "rm", "-f", tmp_path], capture_output=True)
+
+        return Response(
+            stream_with_context(_stream()),
+            mimetype="application/octet-stream",
+            headers={"Content-Disposition": f'attachment; filename="{archive_name}.tar"'},
+        )
+    except Exception as exc:
+        subprocess.run(["sudo", "rm", "-f", tmp_path], capture_output=True)
+        flash(f"Erreur : {exc}", "error")
+        return redirect(url_for("jobs.archives"))
+
+
+@bp.route("/archives/<path:archive_name>/delete", methods=["POST"])
+def archive_delete(archive_name):
+    import os, subprocess
+    backup_dir = current_app.config["YUNOHOST_BACKUP_DIR"]
+    tar_path = os.path.join(backup_dir, archive_name + ".tar")
+    info_path = os.path.join(backup_dir, archive_name + ".info.json")
+    subprocess.run(["sudo", "rm", "-f", tar_path, info_path], capture_output=True)
+    flash(f"Archive « {archive_name} » supprimée.", "success")
+    return redirect(url_for("jobs.archives"))
+
+
 # --- Restauration -------------------------------------------------------------
 
 @bp.route("/archives/<path:archive_name>/restore", methods=["GET", "POST"])

+ 29 - 8
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.remote_instances_list"))
+    return redirect(url_for("network.federation") + "?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.remote_instances_list"))
+    return redirect(url_for("network.federation") + "?tab=instances")
 
 
 @bp.route("/remote-instances/<int:inst_id>/sync", methods=["POST"])
@@ -77,10 +77,31 @@ def remote_instance_sync(inst_id):
         flash(f"Instance « {inst.name} » synchronisée.", "success")
     except Exception as exc:
         flash(f"Synchronisation échouée pour « {inst.name} » : {exc}", "error")
-    return redirect(url_for("network.remote_instances_list"))
+    return redirect(url_for("network.federation"))
 
 
-# --- Dashboard réseau ---------------------------------------------------------
+# --- Fédération (vue unifiée) ------------------------------------------------
+
+@bp.route("/federation")
+def federation():
+    local_jobs = Job.query.order_by(Job.name).all()
+    local_jobs_data = []
+    for job in local_jobs:
+        run = Run.query.filter_by(job_id=job.id).order_by(Run.started_at.desc()).first()
+        local_jobs_data.append(_JobRow(
+            job_id=job.id, name=job.name, type=job.type,
+            last_run_at=run.started_at if run else None,
+            last_status=run.status if run else None,
+            last_archive_name=run.archive_name if run else None,
+            last_size_bytes=run.size_bytes if run else None,
+        ))
+    instances = RemoteInstance.query.order_by(RemoteInstance.name).all()
+    return render_template("federation.html",
+                           local_jobs_data=local_jobs_data,
+                           instances=instances)
+
+
+# --- Dashboard réseau (legacy redirect) --------------------------------------
 
 @bp.route("/network")
 def dashboard_network():
@@ -116,7 +137,7 @@ def network_sync_all():
         flash("Synchronisation partielle — " + " | ".join(errors), "error")
     else:
         flash(f"{len(instances)} instance(s) synchronisée(s).", "success")
-    return redirect(url_for("network.dashboard_network"))
+    return redirect(url_for("network.federation"))
 
 
 # --- Contrôle distant ---------------------------------------------------------
@@ -130,7 +151,7 @@ def remote_job_run(inst_id, job_id):
         flash(f"Job déclenché sur « {inst.name} ».", "success")
     except Exception as exc:
         flash(f"Impossible de lancer le job sur « {inst.name} » : {exc}", "error")
-    return redirect(url_for("network.dashboard_network"))
+    return redirect(url_for("network.federation"))
 
 
 # --- Push / Pull archives -----------------------------------------------------
@@ -150,7 +171,7 @@ def archive_pull_latest(inst_id, remote_job_id):
     app = current_app._get_current_object()
     threading.Thread(target=_do_pull_latest, args=(app, inst.id, remote_job_id), daemon=True).start()
     flash(f"Rapatriement depuis « {inst.name} » démarré en arrière-plan.", "success")
-    return redirect(url_for("network.dashboard_network"))
+    return redirect(url_for("network.federation"))
 
 
 def _do_push_archive(app, archive_name, inst_id):
@@ -291,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.remote_instances_list"))
+    return redirect(url_for("network.federation") + "?tab=instances")
 
 
 # --- DTO dashboard réseau -----------------------------------------------------

+ 3 - 1
sources/blueprints/settings.py

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

+ 174 - 0
sources/templates/archives.html

@@ -0,0 +1,174 @@
+{% extends "base.html" %}
+{% block title %}Archives{% endblock %}
+
+{% block content %}
+
+<div class="flex items-center justify-between mb-6">
+  <h1 class="text-xl font-bold text-gray-900">Archives</h1>
+  <span class="text-sm text-gray-400">{{ items | length }} archive{{ 's' if items | length != 1 }}</span>
+</div>
+
+{% if not items %}
+  <div class="bg-white rounded-xl border border-gray-200 px-6 py-12 text-center text-gray-400">
+    <p class="text-lg">Aucune archive sur ce serveur.</p>
+    <p class="text-sm mt-2">Les archives apparaissent ici après l'exécution d'un job de sauvegarde.</p>
+  </div>
+{% else %}
+
+{# ── Filtres ─────────────────────────────────────────────────────── #}
+<div class="bg-white rounded-xl border border-gray-200 p-4 mb-4 flex flex-wrap gap-3 items-center">
+  <input id="filter-search" type="text" placeholder="Filtrer par nom ou job…"
+         oninput="applyFilters()"
+         class="border border-gray-200 rounded px-3 py-1.5 text-sm flex-1 min-w-40 focus:outline-none focus:ring-2 focus:ring-blue-400">
+  <select id="filter-status" onchange="applyFilters()"
+          class="border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400">
+    <option value="">Tous les statuts</option>
+    <option value="success">✓ succès</option>
+    <option value="error">✗ erreur</option>
+    <option value="running">⟳ en cours</option>
+    <option value="">— sans run</option>
+  </select>
+  <select id="filter-type" onchange="applyFilters()"
+          class="border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400">
+    <option value="">Tous les types</option>
+    {% for t in items | map(attribute='type') | unique | sort %}
+      {% if t %}<option value="{{ t }}">{{ t }}</option>{% endif %}
+    {% endfor %}
+  </select>
+</div>
+
+<div class="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
+  <div class="overflow-x-auto">
+    <table class="w-full text-sm" id="archives-table">
+      <thead>
+        <tr class="text-xs text-gray-500 uppercase tracking-wide bg-gray-50">
+          <th class="px-6 py-3 text-left font-medium">Archive</th>
+          <th class="px-6 py-3 text-left font-medium">Type</th>
+          <th class="px-6 py-3 text-left font-medium">Job</th>
+          <th class="px-6 py-3 text-left font-medium">Date</th>
+          <th class="px-6 py-3 text-left font-medium">Statut</th>
+          <th class="px-6 py-3 text-right 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 item in items %}
+        <tr class="hover:bg-gray-50 archive-row"
+            data-name="{{ item.name | lower }}"
+            data-job="{{ item.job_name | lower }}"
+            data-status="{{ item.last_status or '' }}"
+            data-type="{{ item.type or '' }}">
+          <td class="px-6 py-3 font-mono text-xs text-gray-700 max-w-xs truncate" title="{{ item.name }}">
+            {{ item.name }}
+          </td>
+          <td class="px-6 py-3">
+            {% if item.type %}
+              <span class="bg-gray-100 text-gray-600 text-xs px-2 py-0.5 rounded font-mono">{{ item.type }}</span>
+            {% else %}
+              <span class="text-gray-300 text-xs">—</span>
+            {% endif %}
+          </td>
+          <td class="px-6 py-3 text-xs text-gray-600">
+            {% if item.job_id %}
+              <a href="{{ url_for('jobs.job_history', job_id=item.job_id) }}"
+                 class="hover:text-blue-600 hover:underline">{{ item.job_name }}</a>
+            {% else %}
+              {{ item.job_name }}
+            {% endif %}
+          </td>
+          <td class="px-6 py-3 text-xs text-gray-500 whitespace-nowrap">
+            {% if item.run_at %}{{ item.run_at.strftime('%d/%m/%Y %H:%M') }}
+            {% else %}<span class="text-gray-300">—</span>{% endif %}
+          </td>
+          <td class="px-6 py-3">
+            {% if item.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 item.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 item.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 text-right font-mono">{{ item.size_human }}</td>
+          <td class="px-6 py-3 text-right">
+            <div class="flex items-center justify-end gap-1.5">
+              <a href="{{ url_for('jobs.archive_restore', archive_name=item.name) }}"
+                 class="btn-secondary btn-sm">↩ Restaurer</a>
+
+              {% if instances %}
+              <div class="relative group">
+                <button type="button"
+                        class="btn-secondary btn-sm"
+                        onclick="togglePush(this)">↑ Pousser</button>
+                <div class="push-menu hidden absolute right-0 top-full mt-1 z-20 bg-white border border-gray-200 rounded-lg shadow-lg min-w-40 py-1">
+                  {% for inst in instances %}
+                  <form method="post"
+                        action="{{ url_for('network.archive_push', archive_name=item.name, inst_id=inst.id) }}"
+                        onsubmit="return confirm('Pousser « {{ item.name }} » vers « {{ inst.name }} » ?')">
+                    <button type="submit"
+                            class="w-full text-left px-4 py-2 text-xs hover:bg-gray-50 text-gray-700">
+                      {{ inst.name }}
+                    </button>
+                  </form>
+                  {% endfor %}
+                </div>
+              </div>
+              {% endif %}
+
+              <a href="{{ url_for('jobs.archive_download', archive_name=item.name) }}"
+                 class="btn-ghost btn-icon-sm" title="Télécharger ({{ item.size_human }})"
+                 onclick="return confirm('Télécharger l\'archive « {{ item.name }} » ({{ item.size_human }}) ?')">↓</a>
+
+              <form method="post"
+                    action="{{ url_for('jobs.archive_delete', archive_name=item.name) }}"
+                    onsubmit="return confirm('Supprimer définitivement l\'archive « {{ item.name }} » ?')">
+                <button type="submit" class="btn-danger btn-icon-sm">✕</button>
+              </form>
+            </div>
+          </td>
+        </tr>
+        {% endfor %}
+      </tbody>
+    </table>
+  </div>
+  <div id="empty-filter" class="hidden px-6 py-8 text-center text-gray-400 text-sm">
+    Aucune archive ne correspond aux filtres.
+  </div>
+</div>
+
+<script>
+function applyFilters() {
+  const search = document.getElementById('filter-search').value.toLowerCase();
+  const status = document.getElementById('filter-status').value;
+  const type   = document.getElementById('filter-type').value;
+  let visible = 0;
+  document.querySelectorAll('.archive-row').forEach(function(row) {
+    const matchSearch = !search || row.dataset.name.includes(search) || row.dataset.job.includes(search);
+    const matchStatus = !status || row.dataset.status === status;
+    const matchType   = !type   || row.dataset.type === type;
+    const show = matchSearch && matchStatus && matchType;
+    row.classList.toggle('hidden', !show);
+    if (show) visible++;
+  });
+  document.getElementById('empty-filter').classList.toggle('hidden', visible > 0);
+}
+
+function togglePush(btn) {
+  const menu = btn.nextElementSibling;
+  const allMenus = document.querySelectorAll('.push-menu');
+  allMenus.forEach(function(m) { if (m !== menu) m.classList.add('hidden'); });
+  menu.classList.toggle('hidden');
+}
+
+document.addEventListener('click', function(e) {
+  if (!e.target.closest('.group')) {
+    document.querySelectorAll('.push-menu').forEach(function(m) { m.classList.add('hidden'); });
+  }
+});
+
+</script>
+
+{% endif %}
+{% endblock %}

+ 79 - 5
sources/templates/base.html

@@ -5,6 +5,22 @@
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <title>{% block title %}Backup Manager{% endblock %} — {{ instance_name }}</title>
   <script src="https://cdn.tailwindcss.com"></script>
+  <script type="text/tailwindcss">
+    @layer components {
+      /* Bouton principal (créer, lancer, enregistrer) */
+      .btn-primary   { @apply inline-flex items-center gap-1.5 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition; }
+      /* Bouton secondaire (tester, éditer, sync, historique) */
+      .btn-secondary { @apply inline-flex items-center gap-1.5 bg-white hover:bg-gray-50 text-gray-700 border border-gray-200 font-medium rounded transition; }
+      /* Bouton icône / action légère */
+      .btn-ghost     { @apply inline-flex items-center gap-1 text-gray-500 hover:text-gray-800 hover:bg-gray-100 rounded transition; }
+      /* Bouton destructeur */
+      .btn-danger    { @apply inline-flex items-center gap-1 text-red-400 hover:text-red-600 hover:bg-red-50 rounded transition; }
+      /* Tailles */
+      .btn-sm  { @apply text-xs px-3 py-1.5; }
+      .btn-md  { @apply text-sm px-5 py-2; }
+      .btn-icon-sm { @apply text-xs px-2 py-1; }
+    }
+  </script>
 </head>
 <body class="h-full flex flex-col">
 
@@ -19,11 +35,22 @@
         <span class="bg-blue-600 text-xs font-medium px-2 py-0.5 rounded">{{ instance_name }}</span>
       </div>
       <div class="flex items-center gap-4 text-sm">
-        <a href="{{ url_for('jobs.index') }}" class="text-gray-300 hover:text-white transition">Dashboard</a>
-        <a href="{{ url_for('network.dashboard_network') }}" class="text-gray-300 hover:text-white transition">Réseau</a>
-        <a href="{{ url_for('network.remote_instances_list') }}" class="text-gray-300 hover:text-white transition">Instances</a>
-        <a href="{{ url_for('dest.destinations_list') }}" class="text-gray-300 hover:text-white transition">Destinations</a>
-        <a href="{{ url_for('cfg.settings') }}" class="text-gray-300 hover:text-white transition">Paramètres</a>
+        <a href="{{ url_for('jobs.index') }}"
+           class="text-gray-300 hover:text-white transition {% if request.endpoint and request.endpoint.startswith('jobs.') and request.endpoint != 'jobs.job_new' %}text-white font-semibold{% endif %}">
+          Dashboard
+        </a>
+        <a href="{{ url_for('jobs.archives') }}"
+           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
+        </a>
         <a href="{{ url_for('jobs.job_new') }}"
            class="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1.5 rounded font-medium transition">
           + Nouveau job
@@ -32,6 +59,14 @@
     </div>
   </nav>
 
+  <!-- Barre d'activité sticky -->
+  <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 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 flex-1 text-gray-200"></div>
+  </div>
+
   <main class="flex-1 max-w-7xl mx-auto w-full px-6 py-8">
     {% with messages = get_flashed_messages(with_categories=true) %}
       {% if messages %}
@@ -55,5 +90,44 @@
     Backup Manager — instance <strong>{{ instance_name }}</strong>
   </footer>
 
+  <script>
+  (function() {
+    const bar = document.getElementById('activity-bar');
+    const list = document.getElementById('activity-list');
+    const LABELS = { backup: 'Sauvegarde', restore: 'Restauration', push: 'Push', pull: 'Pull' };
+
+    function elapsed(isoStr) {
+      const sec = Math.round((Date.now() - new Date(isoStr).getTime()) / 1000);
+      if (sec < 60) return sec + 's';
+      return Math.floor(sec / 60) + 'min' + (sec % 60 ? (sec % 60) + 's' : '');
+    }
+
+    function render(items) {
+      if (!items || items.length === 0) {
+        bar.classList.add('hidden');
+        return;
+      }
+      bar.classList.remove('hidden');
+      list.innerHTML = items.map(it => {
+        const label = LABELS[it.kind] || it.kind;
+        const since = it.started_at ? ' · ' + elapsed(it.started_at) : '';
+        const size = it.size_human ? ' · ' + it.size_human : '';
+        return `<span class="bg-gray-800 rounded px-2 py-0.5">${label} <strong>${it.name}</strong>${since}${size}</span>`;
+      }).join('');
+    }
+
+    function poll() {
+      fetch('{{ url_for("api.api_running") }}', {
+        headers: { 'X-BackupManager-Key': '{{ api_token }}' }
+      })
+        .then(r => r.ok ? r.json() : null)
+        .then(data => render(data && data.running ? data.running : []))
+        .catch(() => {});
+    }
+
+    poll();
+    setInterval(poll, 5000);
+  })();
+  </script>
 </body>
 </html>

+ 10 - 22
sources/templates/dashboard_local.html

@@ -59,7 +59,7 @@
             <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>
-            <th class="px-6 py-3 text-left font-medium">Prochaine exéc.</th>
+            <th class="px-6 py-3 text-left font-medium">Transfert</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>
@@ -77,12 +77,11 @@
                 </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 text-xs text-gray-500">
-                {% set next = job.next_run() %}
-                {% if next and job.enabled %}
-                  {{ next.strftime('%d/%m %H:%M') }}
+              <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>
                 {% else %}
-                  <span class="text-gray-300">—</span>
+                  <span class="bg-gray-100 text-gray-500 text-xs px-2 py-0.5 rounded">Local</span>
                 {% endif %}
               </td>
               <td class="px-6 py-4 text-xs text-gray-500">
@@ -112,32 +111,21 @@
                 <div class="flex items-center justify-end gap-2">
                   <form method="post" action="{{ url_for('jobs.job_run_now', job_id=job.id) }}"
                         onsubmit="return confirm('Lancer « {{ job.name }} » maintenant ?')">
-                    <button type="submit"
-                      class="bg-blue-50 hover:bg-blue-100 text-blue-700 text-xs font-medium px-2.5 py-1 rounded transition">
-                      ▶ Lancer
-                    </button>
+                    <button type="submit" class="btn-primary btn-sm">▶ Lancer</button>
                   </form>
                   <a href="{{ url_for('jobs.job_history', job_id=job.id) }}"
-                     class="text-gray-400 hover:text-gray-700 text-xs px-2 py-1 rounded hover:bg-gray-100 transition">
-                    Historique
-                  </a>
+                     class="btn-secondary btn-sm">Historique</a>
                   <a href="{{ url_for('jobs.job_edit', job_id=job.id) }}"
-                     class="text-gray-400 hover:text-gray-700 text-xs px-2 py-1 rounded hover:bg-gray-100 transition">
-                    Éditer
-                  </a>
+                     class="btn-secondary btn-sm">Éditer</a>
                   <form method="post" action="{{ url_for('jobs.job_toggle', job_id=job.id) }}">
-                    <button type="submit"
-                      class="text-gray-400 hover:text-gray-700 text-xs px-2 py-1 rounded hover:bg-gray-100 transition"
+                    <button type="submit" class="btn-ghost btn-icon-sm"
                       title="{{ 'Désactiver' if job.enabled else 'Activer' }}">
                       {{ '⏸' if job.enabled else '▶' }}
                     </button>
                   </form>
                   <form method="post" action="{{ url_for('jobs.job_delete', job_id=job.id) }}"
                         onsubmit="return confirm('Supprimer définitivement « {{ job.name }} » et son historique ?')">
-                    <button type="submit"
-                      class="text-red-300 hover:text-red-600 text-xs px-2 py-1 rounded hover:bg-red-50 transition">
-                      ✕
-                    </button>
+                    <button type="submit" class="btn-danger btn-icon-sm">✕</button>
                   </form>
                 </div>
               </td>

+ 6 - 23
sources/templates/dashboard_network.html

@@ -11,10 +11,7 @@
       Vue locale
     </a>
     <form method="post" action="{{ url_for('network.network_sync_all') }}">
-      <button type="submit"
-              class="text-sm bg-blue-600 hover:bg-blue-700 text-white px-3 py-1.5 rounded transition">
-        Synchroniser tout
-      </button>
+      <button type="submit" class="btn-primary btn-sm">Synchroniser tout</button>
     </form>
   </div>
 </div>
@@ -42,15 +39,10 @@
     {% if inst_id %}
     <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="text-xs bg-gray-50 hover:bg-gray-100 text-gray-600 px-3 py-1.5 rounded border border-gray-200 transition">
-          Sync
-        </button>
+        <button type="submit" class="btn-secondary btn-sm">Sync</button>
       </form>
       <a href="{{ url_for('network.remote_instance_edit', inst_id=inst_id) }}"
-         class="text-xs bg-gray-50 hover:bg-gray-100 text-gray-600 px-3 py-1.5 rounded border border-gray-200 transition">
-        Éditer
-      </a>
+         class="btn-secondary btn-sm">Éditer</a>
     </div>
     {% endif %}
   </div>
@@ -110,29 +102,20 @@
               <form method="post"
                     action="{{ url_for('network.remote_job_run', inst_id=inst_id, job_id=row.job_id) }}"
                     onsubmit="return confirm('Lancer « {{ row.name }} » sur {{ inst_name }} ?')">
-                <button type="submit"
-                  class="bg-blue-50 hover:bg-blue-100 text-blue-700 text-xs font-medium px-2.5 py-1 rounded transition">
-                  ▶ Lancer
-                </button>
+                <button type="submit" class="btn-primary btn-sm">▶ Lancer</button>
               </form>
               {% elif not inst_id and row.job_id %}
               <form method="post"
                     action="{{ url_for('jobs.job_run_now', job_id=row.job_id) }}"
                     onsubmit="return confirm('Lancer « {{ row.name }} » maintenant ?')">
-                <button type="submit"
-                  class="bg-blue-50 hover:bg-blue-100 text-blue-700 text-xs font-medium px-2.5 py-1 rounded transition">
-                  ▶ Lancer
-                </button>
+                <button type="submit" class="btn-primary btn-sm">▶ Lancer</button>
               </form>
               {% endif %}
               {% if inst_id and row.job_id %}
               <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.name }} » depuis {{ inst_name }} ?')">
-                <button type="submit"
-                  class="text-gray-400 hover:text-gray-700 text-xs px-2 py-1 rounded hover:bg-gray-100 transition">
-                  ← Rapatrier
-                </button>
+                <button type="submit" class="btn-ghost btn-sm">← Rapatrier</button>
               </form>
               {% endif %}
             </div>

+ 3 - 7
sources/templates/destination_form.html

@@ -4,7 +4,7 @@
 {% block content %}
 <div class="max-w-xl">
   <div class="mb-6">
-    <a href="{{ url_for('dest.destinations_list') }}" class="text-gray-400 hover:text-gray-600 text-sm">← Destinations</a>
+    <a href="{{ url_for('cfg.settings') + '?tab=destinations' }}" class="text-gray-400 hover:text-gray-600 text-sm">← Destinations</a>
   </div>
   <h1 class="text-xl font-bold text-gray-900 mb-6">
     {{ 'Éditer « ' + dest.name + ' »' if dest else 'Nouvelle destination rsync SSH' }}
@@ -88,14 +88,10 @@
     {% endif %}
 
     <div class="flex gap-3">
-      <button type="submit"
-              class="bg-blue-600 hover:bg-blue-700 text-white px-5 py-2 rounded-lg font-medium text-sm transition">
+      <button type="submit" class="btn-primary btn-md">
         {{ 'Enregistrer' if dest else 'Créer la destination' }}
       </button>
-      <a href="{{ url_for('dest.destinations_list') }}"
-         class="bg-white hover:bg-gray-50 text-gray-700 border border-gray-300 px-5 py-2 rounded-lg font-medium text-sm transition">
-        Annuler
-      </a>
+      <a href="{{ url_for('cfg.settings') + '?tab=destinations' }}" class="btn-secondary btn-md">Annuler</a>
     </div>
   </form>
 </div>

+ 3 - 11
sources/templates/destinations.html

@@ -44,21 +44,13 @@
 
         <div class="flex items-center gap-2 shrink-0">
           <form method="post" action="{{ url_for('dest.destination_test', dest_id=dest.id) }}">
-            <button type="submit"
-              class="bg-gray-50 hover:bg-gray-100 text-gray-700 text-xs px-3 py-1.5 rounded border border-gray-200 transition">
-              Tester
-            </button>
+            <button type="submit" class="btn-secondary btn-sm">Tester</button>
           </form>
           <a href="{{ url_for('dest.destination_edit', dest_id=dest.id) }}"
-             class="bg-gray-50 hover:bg-gray-100 text-gray-700 text-xs px-3 py-1.5 rounded border border-gray-200 transition">
-            Éditer
-          </a>
+             class="btn-secondary btn-sm">Éditer</a>
           <form method="post" action="{{ url_for('dest.destination_delete', dest_id=dest.id) }}"
                 onsubmit="return confirm('Supprimer la destination « {{ dest.name }} » ?')">
-            <button type="submit"
-              class="text-red-300 hover:text-red-600 text-xs px-2 py-1.5 rounded hover:bg-red-50 transition">
-              ✕
-            </button>
+            <button type="submit" class="btn-danger btn-icon-sm">✕</button>
           </form>
         </div>
       </div>

+ 271 - 0
sources/templates/federation.html

@@ -0,0 +1,271 @@
+{% extends "base.html" %}
+{% block title %}Fédération{% endblock %}
+
+{% block content %}
+
+<div class="flex items-center justify-between mb-6">
+  <h1 class="text-xl font-bold text-gray-900">Fédération</h1>
+  <div class="flex gap-2">
+    <a href="{{ url_for('network.remote_instance_new') }}" class="btn-secondary btn-sm">+ Ajouter une instance</a>
+    <form method="post" action="{{ url_for('network.network_sync_all') }}">
+      <button type="submit" class="btn-primary btn-sm">Synchroniser tout</button>
+    </form>
+  </div>
+</div>
+
+{# ── Onglets ─────────────────────────────────────────────────────── #}
+<div class="flex border-b border-gray-200 mb-6 gap-1">
+  <button id="tab-btn-reseau" onclick="setTab('reseau')"
+          class="tab-btn px-4 py-2 text-sm font-medium border-b-2 border-blue-600 text-blue-600 -mb-px">
+    Vue réseau
+  </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 ({{ instances | length }})
+  </button>
+</div>
+
+{# ══════════════════ PANNEAU : VUE RÉSEAU ══════════════════════════ #}
+<div id="pane-reseau">
+
+{% macro instance_section(inst_name, inst_url, jobs_data, inst_id=None, inst_status=None, last_seen=None) %}
+<div class="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden mb-6">
+  <div class="px-6 py-4 border-b border-gray-100 flex items-center justify-between gap-4">
+    <div class="flex items-center gap-3 min-w-0">
+      <h2 class="text-base font-semibold text-gray-800">{{ inst_name }}</h2>
+      {% if inst_id %}
+        {% set sc = {'online':'bg-green-100 text-green-700','error':'bg-red-100 text-red-700','offline':'bg-gray-100 text-gray-500'} %}
+        <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 last_seen %}
+          <span class="text-xs text-gray-400 hidden sm:inline">sync {{ last_seen.strftime('%d/%m %H:%M') }}</span>
+        {% endif %}
+      {% else %}
+        <span class="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full font-medium">local</span>
+      {% endif %}
+      {% if inst_url %}
+        <span class="text-xs font-mono text-gray-400 truncate hidden md:inline">{{ inst_url }}</span>
+      {% endif %}
+    </div>
+    {% if inst_id %}
+    <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>
+      <a href="{{ url_for('network.remote_instance_edit', inst_id=inst_id) }}"
+         class="btn-secondary btn-sm">Éditer</a>
+    </div>
+    {% endif %}
+  </div>
+
+  {% if not jobs_data %}
+    <div class="px-6 py-8 text-center text-gray-400 text-sm">
+      {% if inst_id %}
+        Aucune donnée — cliquez sur "Sync" pour récupérer l'état de cette instance.
+      {% else %}
+        Aucun job configuré.
+        <a href="{{ url_for('jobs.job_new') }}" class="text-blue-600 hover:underline ml-1">Créer un job →</a>
+      {% endif %}
+    </div>
+  {% 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">Job</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 jobs_data %}
+        <tr class="hover:bg-gray-50">
+          <td class="px-6 py-3 font-medium text-gray-900">{{ row.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.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_human is defined %}{{ row.last_size_human }}
+            {% elif row.size_human is defined %}{{ row.size_human }}
+            {% else %}—{% endif %}
+          </td>
+          <td class="px-6 py-3 text-right">
+            <div class="flex items-center justify-end gap-2">
+              {% if inst_id and 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.name }} » sur {{ inst_name }} ?')">
+                <button type="submit" class="btn-primary btn-sm">▶ Lancer</button>
+              </form>
+              {% elif not inst_id and row.job_id %}
+              <form method="post"
+                    action="{{ url_for('jobs.job_run_now', job_id=row.job_id) }}"
+                    onsubmit="return confirm('Lancer « {{ row.name }} » maintenant ?')">
+                <button type="submit" class="btn-primary btn-sm">▶ Lancer</button>
+              </form>
+              {% endif %}
+              {% if inst_id and row.job_id %}
+              <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.name }} » depuis {{ inst_name }} ?')">
+                <button type="submit" class="btn-ghost btn-sm">← Rapatrier</button>
+              </form>
+              {% endif %}
+            </div>
+          </td>
+        </tr>
+        {% endfor %}
+      </tbody>
+    </table>
+  </div>
+  {% endif %}
+</div>
+{% endmacro %}
+
+{{ instance_section(instance_name, instance_url, local_jobs_data) }}
+
+{% if not instances %}
+  <div class="bg-white rounded-xl border border-gray-200 px-6 py-10 text-center text-gray-400">
+    <p>Aucune instance distante enregistrée.</p>
+    <a href="{{ url_for('network.remote_instance_new') }}" class="mt-2 inline-block text-blue-600 hover:underline text-sm">
+      Ajouter une instance →
+    </a>
+  </div>
+{% else %}
+  {% for inst in instances %}
+    {{ instance_section(inst.name, inst.url_display, inst.remote_runs,
+                        inst_id=inst.id, inst_status=inst.status, last_seen=inst.last_seen) }}
+  {% endfor %}
+{% endif %}
+
+</div>{# /pane-reseau #}
+
+{# ══════════════════ 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-2 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 %}
+          <div class="mt-3 border-t border-gray-100 pt-3">
+            <p class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">
+              Jobs ({{ inst.remote_runs | length }})
+            </p>
+            <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
+              {% for rr in inst.remote_runs %}
+              {% set run_color = {
+                'success': 'text-green-600',
+                'error':   'text-red-600',
+                'running': 'text-blue-600',
+              }.get(rr.last_status, 'text-gray-400') %}
+              <div class="text-xs bg-gray-50 rounded-lg px-3 py-2 space-y-0.5">
+                <p class="font-medium text-gray-800 truncate">{{ rr.job_name }}</p>
+                <p class="text-gray-400">{{ rr.job_type }}</p>
+                {% if rr.last_run_at %}
+                  <p class="{{ run_color }} font-medium">
+                    {{ rr.last_status }} — {{ rr.last_run_at.strftime('%d/%m %H:%M') }}
+                  </p>
+                {% else %}
+                  <p class="text-gray-300">jamais exécuté</p>
+                {% endif %}
+                {% if rr.last_size_bytes %}
+                  <p class="text-gray-400">{{ rr.last_size_human }}</p>
+                {% endif %}
+              </div>
+              {% endfor %}
+            </div>
+          </div>
+          {% endif %}
+        </div>
+
+        <div class="flex items-center gap-2 shrink-0 flex-wrap justify-end">
+          <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">Synchroniser</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 #}
+
+<script>
+function setTab(name) {
+  ['reseau', 'instances'].forEach(function(t) {
+    const pane = document.getElementById('pane-' + t);
+    const btn  = document.getElementById('tab-btn-' + t);
+    const active = t === name;
+    pane.classList.toggle('hidden', !active);
+    btn.classList.toggle('border-blue-600', active);
+    btn.classList.toggle('text-blue-600', active);
+    btn.classList.toggle('border-transparent', !active);
+    btn.classList.toggle('text-gray-500', !active);
+  });
+  history.replaceState(null, '', location.pathname + '?tab=' + name);
+}
+// Restore tab from URL
+const urlTab = new URLSearchParams(location.search).get('tab');
+if (urlTab === 'instances') setTab('instances');
+</script>
+{% endblock %}

+ 2 - 6
sources/templates/job_form.html

@@ -294,14 +294,10 @@
 
     {# ── Actions ── #}
     <div class="flex gap-3">
-      <button type="submit"
-              class="bg-blue-600 hover:bg-blue-700 text-white px-5 py-2 rounded-lg font-medium text-sm transition">
+      <button type="submit" class="btn-primary btn-md">
         {{ 'Enregistrer' if job else 'Créer le job' }}
       </button>
-      <a href="{{ url_for('jobs.index') }}"
-         class="bg-white hover:bg-gray-50 text-gray-700 border border-gray-300 px-5 py-2 rounded-lg font-medium text-sm transition">
-        Annuler
-      </a>
+      <a href="{{ url_for('jobs.index') }}" class="btn-secondary btn-md">Annuler</a>
     </div>
   </form>
 </div>

+ 2 - 6
sources/templates/remote_instance_form.html

@@ -49,14 +49,10 @@
     </div>
 
     <div class="flex gap-3">
-      <button type="submit"
-              class="bg-blue-600 hover:bg-blue-700 text-white px-5 py-2 rounded-lg font-medium text-sm transition">
+      <button type="submit" class="btn-primary btn-md">
         {{ 'Enregistrer' if inst else 'Ajouter l\'instance' }}
       </button>
-      <a href="{{ url_for('network.remote_instances_list') }}"
-         class="bg-white hover:bg-gray-50 text-gray-700 border border-gray-300 px-5 py-2 rounded-lg font-medium text-sm transition">
-        Annuler
-      </a>
+      <a href="{{ url_for('network.federation') }}?tab=instances" class="btn-secondary btn-md">Annuler</a>
     </div>
 
   </form>

+ 4 - 15
sources/templates/remote_instances.html

@@ -79,27 +79,16 @@
 
         <div class="flex items-center gap-2 shrink-0 flex-wrap justify-end">
           <form method="post" action="{{ url_for('network.remote_instance_test', inst_id=inst.id) }}">
-            <button type="submit"
-              class="bg-gray-50 hover:bg-gray-100 text-gray-700 text-xs px-3 py-1.5 rounded border border-gray-200 transition">
-              Tester
-            </button>
+            <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="bg-gray-50 hover:bg-gray-100 text-gray-700 text-xs px-3 py-1.5 rounded border border-gray-200 transition">
-              Synchroniser
-            </button>
+            <button type="submit" class="btn-secondary btn-sm">Synchroniser</button>
           </form>
           <a href="{{ url_for('network.remote_instance_edit', inst_id=inst.id) }}"
-             class="bg-gray-50 hover:bg-gray-100 text-gray-700 text-xs px-3 py-1.5 rounded border border-gray-200 transition">
-            Éditer
-          </a>
+             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="text-red-300 hover:text-red-600 text-xs px-2 py-1.5 rounded hover:bg-red-50 transition">
-              ✕
-            </button>
+            <button type="submit" class="btn-danger btn-icon-sm">✕</button>
           </form>
         </div>
 

+ 2 - 5
sources/templates/restore_confirm.html

@@ -92,13 +92,10 @@
 
     <form method="post" class="flex gap-3">
       <button type="submit"
-              class="bg-red-600 hover:bg-red-700 text-white px-5 py-2 rounded-lg font-medium text-sm transition">
+              class="inline-flex items-center gap-1.5 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition btn-md">
         Confirmer la restauration
       </button>
-      <a href="{{ url_for('jobs.index') }}"
-         class="bg-white hover:bg-gray-50 text-gray-700 border border-gray-300 px-5 py-2 rounded-lg font-medium text-sm transition">
-        Annuler
-      </a>
+      <a href="{{ url_for('jobs.index') }}" class="btn-secondary btn-md">Annuler</a>
     </form>
   </div>
 </div>

+ 105 - 12
sources/templates/settings.html

@@ -2,8 +2,81 @@
 {% block title %}Paramètres{% endblock %}
 
 {% block content %}
-<div class="max-w-xl">
-  <h1 class="text-xl font-bold text-gray-900 mb-6">Paramètres</h1>
+<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>
+
+{# ── Onglets ─────────────────────────────────────────────────────── #}
+<div class="flex border-b border-gray-200 mb-6 gap-1">
+  <button id="tab-btn-destinations" onclick="setTab('destinations')"
+          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-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
+  </button>
+</div>
+
+{# ══════════════════ PANNEAU : DESTINATIONS ═══════════════════════ #}
+<div id="pane-destinations">
+
+{% if not destinations %}
+  <div class="bg-white rounded-xl border border-gray-200 px-6 py-12 text-center text-gray-400">
+    <p class="text-lg">Aucune destination configurée.</p>
+    <p class="text-sm mt-2">Les archives sont conservées localement uniquement.</p>
+    <a href="{{ url_for('dest.destination_new') }}" class="mt-4 inline-block text-blue-600 hover:underline text-sm">
+      Configurer une première destination →
+    </a>
+  </div>
+{% else %}
+  <div class="space-y-4">
+    {% for dest in destinations %}
+    <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">
+          <div class="flex items-center gap-2">
+            <span class="font-semibold text-gray-900">{{ dest.name }}</span>
+            {% if dest.enabled %}
+              <span class="bg-green-100 text-green-700 text-xs px-2 py-0.5 rounded-full font-medium">actif</span>
+            {% else %}
+              <span class="bg-gray-100 text-gray-500 text-xs px-2 py-0.5 rounded-full font-medium">inactif</span>
+            {% endif %}
+          </div>
+          <p class="text-sm font-mono text-gray-600">
+            {{ dest.user }}@{{ dest.host }}:{{ dest.port }} → {{ dest.remote_path }}
+          </p>
+          {% if dest.jobs %}
+            <p class="text-xs text-gray-400">
+              Utilisée par : {{ dest.jobs | map(attribute='name') | join(', ') }}
+            </p>
+          {% endif %}
+        </div>
+
+        <div class="flex items-center gap-2 shrink-0">
+          <form method="post" action="{{ url_for('dest.destination_test', dest_id=dest.id) }}">
+            <button type="submit" class="btn-secondary btn-sm">Tester</button>
+          </form>
+          <a href="{{ url_for('dest.destination_edit', dest_id=dest.id) }}"
+             class="btn-secondary btn-sm">Éditer</a>
+          <form method="post" action="{{ url_for('dest.destination_delete', dest_id=dest.id) }}"
+                onsubmit="return confirm('Supprimer la destination « {{ dest.name }} » ?')">
+            <button type="submit" class="btn-danger btn-icon-sm">✕</button>
+          </form>
+        </div>
+      </div>
+    </div>
+    {% endfor %}
+  </div>
+{% endif %}
+
+</div>{# /pane-destinations #}
+
+{# ══════════════════ PANNEAU : CONFIGURATION ══════════════════════ #}
+<div id="pane-config" class="hidden max-w-xl">
 
   <div class="bg-white rounded-xl border border-gray-200 p-6 space-y-3 mb-6">
     <h2 class="text-sm font-semibold text-gray-700 uppercase tracking-wide">API — Token d'accès</h2>
@@ -14,9 +87,7 @@
       <input type="text" id="api-token" readonly value="{{ api_token }}"
              class="flex-1 border border-gray-200 bg-gray-50 rounded-lg px-3 py-2 text-sm font-mono text-gray-700 focus:outline-none select-all">
       <button type="button" onclick="copyToken()"
-              class="shrink-0 bg-gray-100 hover:bg-gray-200 text-gray-700 text-xs px-3 py-2 rounded-lg border border-gray-200 transition">
-        Copier
-      </button>
+              class="btn-secondary btn-sm shrink-0">Copier</button>
     </div>
     {% if instance_url %}
     <p class="text-xs text-gray-400">
@@ -93,7 +164,7 @@
     </div>
 
     <div class="bg-white rounded-xl border border-gray-200 p-6 space-y-3">
-      <h2 class="text-sm font-semibold text-gray-700 uppercase tracking-wide">Déclenchement</h2>
+      <h2 class="text-sm font-semibold text-gray-700 uppercase tracking-wide">Notifications</h2>
 
       <label class="flex items-center gap-2">
         <input type="checkbox" name="notify_on_error" value="1"
@@ -111,21 +182,37 @@
     </div>
 
     <div class="flex gap-3 flex-wrap">
-      <button type="submit" name="action" value="save"
-              class="bg-blue-600 hover:bg-blue-700 text-white px-5 py-2 rounded-lg font-medium text-sm transition">
+      <button type="submit" name="action" value="save" class="btn-primary btn-md">
         Enregistrer
       </button>
-
-      <button type="submit" name="action" value="test_smtp"
-              class="bg-white hover:bg-gray-50 text-gray-700 border border-gray-300 px-5 py-2 rounded-lg font-medium text-sm transition">
+      <button type="submit" name="action" value="test_smtp" class="btn-secondary btn-md">
         Envoyer un email de test
       </button>
     </div>
 
   </form>
-</div>
+</div>{# /pane-config #}
 
 <script>
+function setTab(name) {
+  ['destinations', 'config'].forEach(function(t) {
+    const pane = document.getElementById('pane-' + t);
+    const btn  = document.getElementById('tab-btn-' + t);
+    const active = t === name;
+    pane.classList.toggle('hidden', !active);
+    btn.classList.toggle('border-blue-600', active);
+    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');
+    }
+  });
+  history.replaceState(null, '', location.pathname + '?tab=' + name);
+}
+
 function copyToken() {
   const input = document.getElementById('api-token');
   navigator.clipboard.writeText(input.value).then(() => {
@@ -134,5 +221,11 @@ function copyToken() {
     setTimeout(() => btn.textContent = 'Copier', 2000);
   });
 }
+
+// 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');
 </script>
 {% endblock %}