Bladeren bron

feat: Phase 3C+3D+3E — dashboard réseau, run distant, push/pull archives HTTP chunked

Cédric Hansen 21 uur geleden
bovenliggende
commit
9628da1038
5 gewijzigde bestanden met toevoegingen van 409 en 1 verwijderingen
  1. BIN
      icon/flux_krea_00003_.png
  2. 207 1
      sources/app.py
  3. 26 0
      sources/federation/client.py
  4. 1 0
      sources/templates/base.html
  5. 175 0
      sources/templates/dashboard_network.html

BIN
icon/flux_krea_00003_.png


+ 207 - 1
sources/app.py

@@ -758,18 +758,54 @@ def api_upload_finish(upload_id):
         ["sudo", "rsync", tmp_archive, dest_path],
         capture_output=True, text=True,
     )
-    shutil.rmtree(tmp_dir, ignore_errors=True)
 
     if result.returncode != 0:
         upload.status = "error"
         db.session.commit()
+        shutil.rmtree(tmp_dir, ignore_errors=True)
         return jsonify({"error": result.stderr.strip()}), 500
 
+    # .info.json optionnel transmis dans le body JSON
+    data = request.get_json(silent=True) or {}
+    info_json_str = data.get("info_json")
+    if info_json_str:
+        archive_base = upload.filename[:-4] if upload.filename.endswith(".tar") else upload.filename
+        tmp_info = os.path.join(tmp_dir, archive_base + ".info.json")
+        with open(tmp_info, "w") as f:
+            f.write(info_json_str)
+        subprocess.run(
+            ["sudo", "rsync", tmp_info,
+             os.path.join(backup_dir, archive_base + ".info.json")],
+            capture_output=True,
+        )
+
+    shutil.rmtree(tmp_dir, ignore_errors=True)
     upload.status = "complete"
     db.session.commit()
     return jsonify({"status": "complete", "filename": upload.filename})
 
 
+@app.route("/api/v1/archives/<name>/download")
+def api_archive_download(name):
+    """Téléchargement direct d'une archive via sudo cat (pour pull inter-instances)."""
+    backup_dir = app.config["YUNOHOST_BACKUP_DIR"]
+    archive_path = os.path.join(backup_dir, name + ".tar")
+    from jobs.utils import sudo_exists
+    if not sudo_exists(archive_path):
+        return jsonify({"error": "archive introuvable"}), 404
+
+    result = subprocess.run(["sudo", "cat", archive_path], capture_output=True, timeout=3600)
+    if result.returncode != 0:
+        return jsonify({"error": "lecture échouée"}), 500
+
+    from flask import Response
+    return Response(
+        result.stdout,
+        mimetype="application/octet-stream",
+        headers={"Content-Disposition": f'attachment; filename="{name}.tar"'},
+    )
+
+
 @app.route("/api/v1/archives/upload/<upload_id>", methods=["DELETE"])
 def api_upload_cancel(upload_id):
     upload = db.get_or_404(Upload, upload_id)
@@ -841,6 +877,176 @@ def remote_instance_sync(inst_id):
     return redirect(url_for("remote_instances_list"))
 
 
+@app.route("/network")
+def dashboard_network():
+    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("dashboard_network.html",
+                           local_jobs_data=local_jobs_data,
+                           instances=instances,
+                           instances_for_push=instances)
+
+
+@app.route("/network/sync-all", methods=["POST"])
+def network_sync_all():
+    from federation.client import sync_instance
+    instances = RemoteInstance.query.all()
+    errors = []
+    for inst in instances:
+        try:
+            sync_instance(inst)
+        except Exception as exc:
+            errors.append(f"{inst.name}: {exc}")
+    if errors:
+        flash("Synchronisation partielle — " + " | ".join(errors), "error")
+    else:
+        flash(f"{len(instances)} instance(s) synchronisée(s).", "success")
+    return redirect(url_for("dashboard_network"))
+
+
+@app.route("/remote-instances/<int:inst_id>/run-job/<int:job_id>", methods=["POST"])
+def remote_job_run(inst_id, job_id):
+    inst = db.get_or_404(RemoteInstance, inst_id)
+    from federation.client import FederationClient
+    try:
+        FederationClient(inst).run_job(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("dashboard_network"))
+
+
+@app.route("/archives/<path:archive_name>/push/<int:inst_id>", methods=["POST"])
+def archive_push(archive_name, inst_id):
+    inst = db.get_or_404(RemoteInstance, inst_id)
+    threading.Thread(target=_do_push_archive, args=(archive_name, inst.id), daemon=True).start()
+    flash(f"Envoi de « {archive_name} » vers « {inst.name} » démarré en arrière-plan.", "success")
+    return redirect(request.referrer or url_for("index"))
+
+
+@app.route("/remote-instances/<int:inst_id>/pull/<path:archive_name>", methods=["POST"])
+def archive_pull(inst_id, archive_name):
+    inst = db.get_or_404(RemoteInstance, inst_id)
+    threading.Thread(target=_do_pull_archive, args=(archive_name, inst.id), daemon=True).start()
+    flash(f"Rapatriement de « {archive_name} » depuis « {inst.name} » démarré.", "success")
+    return redirect(url_for("dashboard_network"))
+
+
+def _do_push_archive(archive_name, inst_id):
+    """Pousse une archive locale vers une instance distante via HTTP chunked."""
+    import hashlib as _hashlib
+    from federation.client import FederationClient
+    from jobs.utils import sudo_exists
+
+    with app.app_context():
+        inst = db.session.get(RemoteInstance, inst_id)
+        backup_dir = app.config["YUNOHOST_BACKUP_DIR"]
+        archive_path = os.path.join(backup_dir, archive_name + ".tar")
+
+        tmp_path = None
+        try:
+            # Copie vers /tmp accessible par l'app
+            tmp_path = f"/tmp/backupmanager_push_{archive_name}.tar"
+            result = subprocess.run(
+                ["sudo", "rsync", archive_path, tmp_path],
+                capture_output=True, text=True,
+            )
+            if result.returncode != 0:
+                raise RuntimeError(f"Copie locale échouée : {result.stderr.strip()}")
+
+            total_size = os.path.getsize(tmp_path)
+            sha256 = _hashlib.sha256()
+            chunk_size = 50 * 1024 * 1024
+            with open(tmp_path, "rb") as f:
+                while True:
+                    data = f.read(65536)
+                    if not data:
+                        break
+                    sha256.update(data)
+            checksum = sha256.hexdigest()
+
+            client = FederationClient(inst)
+            upload_info = client.upload_start(archive_name + ".tar", total_size, checksum, chunk_size)
+            upload_id = upload_info["upload_id"]
+
+            with open(tmp_path, "rb") as f:
+                n = 0
+                while True:
+                    data = f.read(chunk_size)
+                    if not data:
+                        break
+                    client.upload_chunk(upload_id, n, data)
+                    n += 1
+
+            # Finish + transmettre le .info.json si présent
+            info_json_content = None
+            info_path = os.path.join(backup_dir, archive_name + ".info.json")
+            if sudo_exists(info_path):
+                r = subprocess.run(["sudo", "cat", info_path], capture_output=True)
+                if r.returncode == 0:
+                    info_json_content = r.stdout.decode("utf-8", errors="replace")
+
+            client.upload_finish_with_info(upload_id, info_json_content)
+            app.logger.info(f"Push {archive_name} → {inst.name} OK")
+
+        except Exception as exc:
+            app.logger.error(f"Push {archive_name} → {inst.name} échoué : {exc}")
+        finally:
+            if tmp_path and os.path.exists(tmp_path):
+                os.unlink(tmp_path)
+
+
+def _do_pull_archive(archive_name, inst_id):
+    """Rapatrie une archive depuis une instance distante via HTTP chunked."""
+    import hashlib as _hashlib
+    from federation.client import FederationClient
+
+    with app.app_context():
+        inst = db.session.get(RemoteInstance, inst_id)
+        backup_dir = app.config["YUNOHOST_BACKUP_DIR"]
+        try:
+            client = FederationClient(inst)
+            # Télécharge l'archive chunk par chunk
+            archive_bytes = client.download_archive(archive_name)
+            tmp_path = f"/tmp/backupmanager_pull_{archive_name}.tar"
+            with open(tmp_path, "wb") as f:
+                f.write(archive_bytes)
+            dest = os.path.join(backup_dir, archive_name + ".tar")
+            subprocess.run(["sudo", "rsync", tmp_path, dest], check=True)
+            os.unlink(tmp_path)
+            app.logger.info(f"Pull {archive_name} ← {inst.name} OK")
+        except Exception as exc:
+            app.logger.error(f"Pull {archive_name} ← {inst.name} échoué : {exc}")
+
+
+class _JobRow:
+    """DTO pour le dashboard réseau (local et distant)."""
+    def __init__(self, job_id, name, type, last_run_at, last_status,
+                 last_archive_name, last_size_bytes):
+        self.job_id = job_id
+        self.name = name
+        self.type = type
+        self.last_run_at = last_run_at
+        self.last_status = last_status
+        self.last_archive_name = last_archive_name
+        self.last_size_bytes = last_size_bytes
+
+    @property
+    def size_human(self):
+        from db import _size_human
+        return _size_human(self.last_size_bytes)
+
+
 def _save_remote_instance(inst):
     f = request.form
     name = f.get("name", "").strip()

+ 26 - 0
sources/federation/client.py

@@ -75,6 +75,32 @@ class FederationClient:
     def upload_finish(self, upload_id):
         return self._post(f"/api/v1/archives/upload/{upload_id}/finish")
 
+    def upload_finish_with_info(self, upload_id, info_json_str=None):
+        payload = {}
+        if info_json_str:
+            payload["info_json"] = info_json_str
+        r = requests.post(
+            f"{self.base}/api/v1/archives/upload/{upload_id}/finish",
+            headers=self.headers,
+            json=payload if payload else None,
+            timeout=self.timeout,
+            verify=True,
+        )
+        r.raise_for_status()
+        return r.json()
+
+    def download_archive(self, archive_name):
+        """Télécharge une archive distante en mémoire (archives de taille raisonnable)."""
+        r = requests.get(
+            f"{self.base}/api/v1/archives/{archive_name}/download",
+            headers=self.headers,
+            timeout=3600,
+            stream=True,
+            verify=True,
+        )
+        r.raise_for_status()
+        return r.content
+
     def upload_cancel(self, upload_id):
         r = requests.delete(
             f"{self.base}/api/v1/archives/upload/{upload_id}",

+ 1 - 0
sources/templates/base.html

@@ -20,6 +20,7 @@
       </div>
       <div class="flex items-center gap-4 text-sm">
         <a href="{{ url_for('index') }}" class="text-gray-300 hover:text-white transition">Dashboard</a>
+        <a href="{{ url_for('dashboard_network') }}" class="text-gray-300 hover:text-white transition">Réseau</a>
         <a href="{{ url_for('remote_instances_list') }}" class="text-gray-300 hover:text-white transition">Instances</a>
         <a href="{{ url_for('destinations_list') }}" class="text-gray-300 hover:text-white transition">Destinations</a>
         <a href="{{ url_for('settings') }}" class="text-gray-300 hover:text-white transition">Paramètres</a>

+ 175 - 0
sources/templates/dashboard_network.html

@@ -0,0 +1,175 @@
+{% extends "base.html" %}
+{% block title %}Vue réseau{% endblock %}
+
+{% block content %}
+
+<div class="flex items-center justify-between mb-6">
+  <h1 class="text-xl font-bold text-gray-900">Vue réseau</h1>
+  <div class="flex gap-2">
+    <a href="{{ url_for('index') }}"
+       class="text-sm text-gray-500 hover:text-gray-700 px-3 py-1.5 rounded border border-gray-200 bg-white transition">
+      Vue locale
+    </a>
+    <form method="post" action="{{ url_for('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>
+    </form>
+  </div>
+</div>
+
+{% 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('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>
+      </form>
+      <a href="{{ url_for('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>
+    </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('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('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>
+              </form>
+              {% elif not inst_id and row.job_id %}
+              <form method="post"
+                    action="{{ url_for('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>
+              </form>
+              {% endif %}
+              {% if inst_id and row.last_archive_name and instances_for_push %}
+              <div class="relative group">
+                <button type="button"
+                        class="text-gray-400 hover:text-gray-700 text-xs px-2 py-1 rounded hover:bg-gray-100 transition">
+                  Récupérer ↓
+                </button>
+                <div class="hidden group-hover:block absolute right-0 top-6 bg-white border border-gray-200 rounded-lg shadow-lg z-10 min-w-max">
+                  <p class="text-xs text-gray-400 px-3 pt-2 pb-1">Tirer vers cette instance</p>
+                  <form method="post"
+                        action="{{ url_for('archive_pull', inst_id=inst_id, archive_name=row.last_archive_name) }}">
+                    <button type="submit"
+                            class="block w-full text-left text-xs px-3 py-2 hover:bg-gray-50">
+                      ← Rapatrier l'archive
+                    </button>
+                  </form>
+                </div>
+              </div>
+              {% endif %}
+            </div>
+          </td>
+        </tr>
+        {% endfor %}
+      </tbody>
+    </table>
+  </div>
+  {% endif %}
+</div>
+{% endmacro %}
+
+{# ── Instance locale ─────────────────────────────────────────────── #}
+{{ instance_section(instance_name, instance_url, local_jobs_data) }}
+
+{# ── Instances distantes ──────────────────────────────────────────── #}
+{% 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('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 %}
+
+{% endblock %}