From 62bb38ecffa4702ad9fbf5a0975ab6aa7c270473 Mon Sep 17 00:00:00 2001 From: Folzo Date: Sun, 2 Aug 2026 01:08:55 +0200 Subject: [PATCH] jean-router: dashboard HTML de diagnostic temps reel - /dashboard : page HTML avec statut, slot, stats, actions - /api/dashboard/status : JSON etat complet (Jean, Ollama, slot, VRAM) - /api/dashboard/logs/stream : SSE logs en temps reel - /api/dashboard/logs : derniers logs JSON - /api/dashboard/actions/refresh : force rafraichissement cache - /api/dashboard/actions/restart-jean : restart Jean via WoL - Tracking des requetes (total, jean, ollama, erreurs) - Log buffer en memoire avec QueueHandler --- jean-router/app.py | 366 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 366 insertions(+) diff --git a/jean-router/app.py b/jean-router/app.py index 1ff74c5..42067ee 100644 --- a/jean-router/app.py +++ b/jean-router/app.py @@ -10,6 +10,9 @@ import time import logging import threading import socket +import queue +import json as json_mod +from collections import deque from typing import Optional import requests @@ -43,6 +46,29 @@ log = logging.getLogger(__name__) app = Flask(__name__) +# --- Log buffer pour le dashboard SSE --- +log_queue = queue.Queue() +log_buffer = deque(maxlen=5000) + +class QueueHandler(logging.Handler): + def emit(self, record): + msg = self.format(record) + log_buffer.append(msg) + try: + log_queue.put_nowait(msg) + except queue.Full: + pass + +queue_handler = QueueHandler() +queue_handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")) +queue_handler.setLevel(logging.DEBUG) +logging.getLogger().addHandler(queue_handler) +logging.getLogger().setLevel(logging.DEBUG) + +# --- Compteur de requêtes --- +request_stats = {"total": 0, "jean": 0, "ollama": 0, "errors": 0, "last_requests": deque(maxlen=50)} +stats_lock = threading.Lock() + presets_cache = {"data": [], "timestamp": 0} ollama_cache = {"data": [], "timestamp": 0} ollama_detail_cache = {} @@ -827,6 +853,14 @@ def chat_completions(): data = request.get_json() model_id = data.get("model") + with stats_lock: + request_stats["total"] += 1 + request_stats["last_requests"].append({ + "model": model_id, + "time": time.strftime("%H:%M:%S"), + "max_tokens": data.get("max_tokens", 0), + }) + if not model_id: return jsonify({"error": "model parameter required"}), 400 @@ -874,6 +908,8 @@ def chat_completions(): upstream_url = f"http://{JEAN_PROXY_HOST}:{JEAN_PROXY_PORT}/v1/chat/completions" log.info(f"[Router] → Jean ({model_id})") + with stats_lock: + request_stats["jean"] += 1 elif ENABLE_OLLAMA: wait_for_jean(trigger_wol=True) ollama_models = get_ollama_models() @@ -887,6 +923,8 @@ def chat_completions(): upstream_url = f"http://{OLLAMA_PROXY_HOST}:{OLLAMA_PROXY_PORT}/v1/chat/completions" log.info(f"[Router] → Ollama ({model_id})") + with stats_lock: + request_stats["ollama"] += 1 else: return jsonify({"error": f"Unknown model: {model_id}"}), 404 else: @@ -906,6 +944,8 @@ def chat_completions(): ) except Exception as e: log.error(f"[Router] Erreur upstream: {e}") + with stats_lock: + request_stats["errors"] += 1 if ENABLE_WOL: log.info("[Router] Tentative WoL + retry apres echec upstream...") wait_for_jean(trigger_wol=True) @@ -934,6 +974,332 @@ def chat_completions(): ) +# ═══════════════════════════════════════════════ +# DASHBOARD — Diagnostic & monitoring +# ═══════════════════════════════════════════════ + +@app.route("/dashboard") +def dashboard(): + """Page HTML de diagnostic""" + return Response(DASHBOARD_HTML, content_type="text/html; charset=utf-8") + + +@app.route("/api/dashboard/status") +def dashboard_status(): + """Etat complet en JSON""" + jean_ok = jean_alive() + olla_ok = False + try: + r = requests.get(f"http://{OLLAMA_DIRECT_HOST}:{OLLAMA_DIRECT_PORT}/api/version", timeout=2) + olla_ok = r.ok + except Exception: + pass + + try: + r = requests.get(f"http://{JEAN_WEB_HOST}:{JEAN_WEB_PORT}/api/presets", timeout=1) + presets_raw = r.json() if r.ok else [] + except Exception: + presets_raw = [] + + slot_info = None + try: + r = requests.get(f"http://{JEAN_WEB_HOST}:{JEAN_WEB_PORT}/slots", timeout=1) + if r.ok: + slots = r.json() + if slots: + s = slots[0] + slot_info = { + "processing": s.get("is_processing", False), + "id_task": s.get("id_task"), + "n_prompt_tokens": s.get("n_prompt_tokens", 0), + "n_decoded": s.get("next_token", [{}])[0].get("n_decoded", 0) if s.get("next_token") else 0, + } + except Exception: + pass + + active = get_active_preset() + with stats_lock: + stats = dict(request_stats) + + return jsonify({ + "jean": { + "alive": jean_ok, + "host": f"{JEAN_WEB_HOST}:{JEAN_WEB_PORT}", + "proxy": f"{JEAN_PROXY_HOST}:{JEAN_PROXY_PORT}", + "active_preset": active["id"] if active else (last_active_preset or "none"), + "presets": [{"id": p["id"], "name": p.get("name", p["id"]), "reasoning": p.get("reasoning")} for p in presets_raw], + "slot": slot_info, + "vram_backend": vram_state["active_backend"], + }, + "ollama": { + "alive": olla_ok, + "host": f"{OLLAMA_DIRECT_HOST}:{OLLAMA_DIRECT_PORT}", + "proxy": f"{OLLAMA_PROXY_HOST}:{OLLAMA_PROXY_PORT}", + }, + "wol": { + "enabled": ENABLE_WOL, + "mac": TARGET_MAC, + "timeout": WAKE_TIMEOUT, + }, + "cache": { + "ttl": CACHE_TTL, + "presets_age": int(time.time() - presets_cache["timestamp"]), + "ollama_age": int(time.time() - ollama_cache["timestamp"]), + }, + "stats": stats, + }) + + +@app.route("/api/dashboard/logs") +def dashboard_logs(): + """Derniers logs en JSON""" + limit = request.args.get("limit", 200, type=int) + return jsonify(list(log_buffer)[-limit:]) + + +@app.route("/api/dashboard/logs/stream") +def dashboard_logs_stream(): + """Stream SSE des logs en temps reel""" + def generate(): + yield "data: " + json_mod.dumps({"type": "init", "logs": list(log_buffer)[-100:]}) + "\n\n" + while True: + try: + msg = log_queue.get(timeout=5) + yield "data: " + json_mod.dumps({"type": "log", "message": msg}) + "\n\n" + except queue.Empty: + yield "data: " + json_mod.dumps({"type": "ping"}) + "\n\n" + return Response( + stream_with_context(generate()), + content_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + +@app.route("/api/dashboard/actions/refresh", methods=["POST"]) +def dashboard_refresh(): + """Force le rafraichissement du cache des modeles""" + global presets_cache, ollama_cache + presets_cache["timestamp"] = 0 + ollama_cache["timestamp"] = 0 + jean_alive_cache["timestamp"] = 0 + log.info("[Dashboard] Rafraichissement force du cache") + return jsonify({"ok": True}) + + +@app.route("/api/dashboard/actions/restart-jean", methods=["POST"]) +def dashboard_restart_jean(): + """Redemarre Jean""" + if not ENABLE_WOL: + return jsonify({"ok": False, "error": "WoL non active"}), 400 + try: + stop_jean() + start_jean() + log.info("[Dashboard] Jean redemarre via stop/start") + return jsonify({"ok": True}) + except Exception as e: + return jsonify({"ok": False, "error": str(e)}), 500 + + +DASHBOARD_HTML = r""" + + + + +Jean Router — Dashboard + + + +

🧠 Jean Router — Dashboard

+
+
+
+

🔌 Statut

+
Jean-
+
Ollama-
+
VRAM Backend-
+
Preset actif-
+
+
+

🎰 Slot Jean

+
Status-
+
Tokens prompt-
+
Tokens decoded-
+
+
+
+

📊 Stats

+
+
Total0
+
Jean0
+
Ollama0
+
Erreurs0
+
+
Cache -
+
+
+

⚡ Actions

+ + + +
+ WoL: - + Cache: - +
+
+
+

🧩 Modèles Jean

+
-
+
+
+
+

📜 Logs (auto-scroll)

+
+
+ + + +""" + + @app.route("/", defaults={"path": ""}, methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]) @app.route("/", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]) def proxy(path):