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
This commit is contained in:
Folzo
2026-08-02 01:08:55 +02:00
parent 06285b8d4f
commit 62bb38ecff
+366
View File
@@ -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"""<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Jean Router — Dashboard</title>
<style>
:root{--bg:#0d1117;--card:#161b22;--border:#30363d;--text:#c9d1d9;--green:#3fb950;--red:#f85149;--yellow:#d2991d;--blue:#58a6ff;--purple:#bc8cff;--muted:#8b949e}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--text);font:14px/1.5 SFMono,Consolas,monospace;padding:16px}
h1{font-size:18px;color:var(--purple);margin-bottom:12px}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:12px;margin-bottom:16px}
.card{background:var(--card);border:1px solid var(--border);border-radius:8px;padding:14px}
.card h2{font-size:14px;color:var(--blue);margin-bottom:8px;border-bottom:1px solid var(--border);padding-bottom:6px}
.status-dot{display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:6px}
.on{background:var(--green);box-shadow:0 0 6px var(--green)}
.off{background:var(--red);box-shadow:0 0 6px var(--red)}
.warn{background:var(--yellow);box-shadow:0 0 6px var(--yellow)}
.row{display:flex;justify-content:space-between;padding:2px 0;font-size:13px}
.row .label{color:var(--muted)}
.row .value{font-weight:600}
.badge{padding:2px 8px;border-radius:4px;font-size:12px;display:inline-block;margin:2px}
.badge-green{background:#1a3a1a;color:var(--green);border:1px solid var(--green)}
.badge-red{background:#3a1a1a;color:var(--red);border:1px solid var(--red)}
.badge-blue{background:#1a2a3a;color:var(--blue);border:1px solid var(--blue)}
#logs{grid-column:1/-1;height:400px;overflow-y:auto;font-size:12px;line-height:1.6;border:1px solid var(--border);border-radius:6px;padding:10px;background:#0a0e13}
#logs .error{color:var(--red)}
#logs .warn{color:var(--yellow)}
#logs .info{color:var(--muted)}
#logs .debug{color:#484f58}
#logs .highlight{color:var(--green);font-weight:700}
.btn{background:var(--card);color:var(--blue);border:1px solid var(--border);padding:6px 14px;border-radius:6px;cursor:pointer;font-size:12px;margin:3px}
.btn:hover{background:#1f2937;border-color:var(--blue)}
.btn-danger{color:var(--red);border-color:var(--red)}
.btn-danger:hover{background:#3a1a1a}
.slot-bar{height:8px;background:#21262d;border-radius:4px;overflow:hidden;margin:6px 0}
.slot-fill{height:100%;background:var(--blue);border-radius:4px;transition:width .3s}
.slot-fill.warn{background:var(--yellow)}
.slot-fill.danger{background:var(--red)}
.stats-grid{display:grid;grid-template-columns:1fr 1fr;gap:4px}
#toast{position:fixed;top:16px;right:16px;background:var(--green);color:#000;padding:10px 18px;border-radius:8px;font-weight:700;opacity:0;transition:opacity .3s;z-index:100}
#toast.show{opacity:1}
</style>
</head>
<body>
<h1>🧠 Jean Router — Dashboard</h1>
<div id="toast"></div>
<div class="grid">
<div class="card">
<h2>🔌 Statut</h2>
<div class="row"><span class="label">Jean</span><span id="jean-status">-</span></div>
<div class="row"><span class="label">Ollama</span><span id="ollama-status">-</span></div>
<div class="row"><span class="label">VRAM Backend</span><span id="vram-status">-</span></div>
<div class="row"><span class="label">Preset actif</span><span id="active-preset">-</span></div>
</div>
<div class="card">
<h2>🎰 Slot Jean</h2>
<div class="row"><span class="label">Status</span><span id="slot-status">-</span></div>
<div class="row"><span class="label">Tokens prompt</span><span id="slot-prompt">-</span></div>
<div class="row"><span class="label">Tokens decoded</span><span id="slot-decoded">-</span></div>
<div class="slot-bar"><div class="slot-fill" id="slot-bar" style="width:0%"></div></div>
</div>
<div class="card">
<h2>📊 Stats</h2>
<div class="stats-grid">
<div class="row"><span class="label">Total</span><span id="stat-total" class="value">0</span></div>
<div class="row"><span class="label">Jean</span><span id="stat-jean" class="value">0</span></div>
<div class="row"><span class="label">Ollama</span><span id="stat-ollama" class="value">0</span></div>
<div class="row"><span class="label">Erreurs</span><span id="stat-errors" class="value">0</span></div>
</div>
<div style="margin-top:10px"><span class="label">Cache</span> <span id="cache-age" style="font-size:11px;color:var(--muted)">-</span></div>
</div>
<div class="card">
<h2>⚡ Actions</h2>
<button class="btn" onclick="action('refresh')">🔄 Refresh cache</button>
<button class="btn btn-danger" onclick="action('restart-jean')" title="Stop + Start Jean">🔁 Restart Jean</button>
<button class="btn" onclick="location.reload()">↻ Reload page</button>
<div style="margin-top:8px">
<span class="badge badge-blue" id="wol-badge">WoL: -</span>
<span class="badge badge-green" id="cache-badge">Cache: -</span>
</div>
</div>
<div class="card" style="grid-column:1/-1">
<h2>🧩 Modèles Jean</h2>
<div id="preset-list">-</div>
</div>
</div>
<div class="card">
<h2>📜 Logs <span style="color:var(--muted);font-weight:400;font-size:12px">(auto-scroll)</span></h2>
<div id="logs"></div>
</div>
<script>
const $ = (s) => document.querySelector(s);
let autoScroll = true;
function fmt(n) { return n > 1000 ? Math.round(n/1000)+'k' : n; }
async function fetchStatus() {
try {
const r = await fetch('/api/dashboard/status');
const d = await r.json();
// Status dots
$('#jean-status').innerHTML = (d.jean.alive ? '<span class="status-dot on"></span>' : '<span class="status-dot off"></span>') + 'Jean : ' + d.jean.host;
$('#ollama-status').innerHTML = (d.ollama.alive ? '<span class="status-dot on"></span>' : '<span class="status-dot off"></span>') + 'Ollama : ' + d.ollama.host;
$('#vram-status').innerHTML = (d.jean.vram_backend === 'jean' ? '<span class="badge badge-blue">JEAN</span>' : '<span class="badge badge-green">OLLAMA</span>');
$('#active-preset').innerHTML = '<span class="badge badge-green">' + d.jean.active_preset + '</span>';
// Slot
if (d.jean.slot) {
const s = d.jean.slot;
$('#slot-status').innerHTML = s.processing ? '<span class="status-dot warn"></span> PROCESSING' : '<span class="status-dot on"></span> IDLE';
$('#slot-prompt').innerText = fmt(s.n_prompt_tokens) + ' tokens';
$('#slot-decoded').innerText = fmt(s.n_decoded) + ' tokens';
const pct = Math.min(100, Math.round((s.n_decoded || 0) / 256000 * 100));
const bar = $('#slot-bar');
bar.style.width = pct + '%';
bar.className = 'slot-fill' + (pct > 80 ? ' danger' : pct > 50 ? ' warn' : '');
} else {
$('#slot-status').innerHTML = '<span class="status-dot off"></span> N/A';
$('#slot-prompt').innerText = '-';
$('#slot-decoded').innerText = '-';
$('#slot-bar').style.width = '0%';
}
// Stats
$('#stat-total').innerText = d.stats.total;
$('#stat-jean').innerText = d.stats.jean;
$('#stat-ollama').innerText = d.stats.ollama;
$('#stat-errors').innerText = d.stats.errors;
$('#cache-age').innerText = 'Presets: ' + d.cache.presets_age + 's | Ollama: ' + d.cache.ollama_age + 's';
$('#wol-badge').innerText = 'WoL: ' + (d.wol.enabled ? 'ON ' + d.wol.mac : 'OFF');
$('#cache-badge').innerText = 'Cache TTL: ' + d.cache.ttl + 's';
// Presets
$('#preset-list').innerHTML = d.jean.presets.map(p =>
'<span class="badge badge-blue">' + p.name +
(p.reasoning === 'on' ? ' 🧠' : '') + '</span>'
).join(' ') || '<span style="color:var(--muted)">Aucun</span>';
} catch(e) {
console.error('Status fetch error:', e);
}
}
function appendLog(msg) {
const div = document.createElement('div');
div.textContent = msg;
if (msg.includes('[ERROR]') || msg.includes('[Erreur]')) div.className = 'error';
else if (msg.includes('[WARNING]')) div.className = 'warn';
else if (msg.includes('highlight')) div.className = 'highlight';
else div.className = 'info';
$('#logs').appendChild(div);
if (autoScroll) {
$('#logs').scrollTop = $('#logs').scrollHeight;
}
}
$('#logs').addEventListener('scroll', () => {
const el = $('#logs');
autoScroll = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
});
// SSE Log stream
const evtSource = new EventSource('/api/dashboard/logs/stream');
evtSource.onmessage = function(e) {
const d = JSON.parse(e.data);
if (d.type === 'init') {
d.logs.forEach(msg => appendLog(msg));
} else if (d.type === 'log') {
appendLog(d.message);
}
};
evtSource.onerror = function() { console.log('SSE reconnect...'); };
// Initial load
fetchStatus();
// Buttons
async function action(name) {
try {
const r = await fetch('/api/dashboard/actions/' + name, {method:'POST'});
const j = await r.json();
const toast = $('#toast');
toast.textContent = j.ok ? '✓ ' + name + ' OK' : '✗ ' + (j.error || 'failed');
toast.className = 'show';
setTimeout(() => toast.className = '', 2000);
if (name === 'refresh') fetchStatus();
} catch(e) {
alert('Action failed: ' + e);
}
}
// Poll status every 3s
setInterval(fetchStatus, 3000);
</script>
</body>
</html>"""
@app.route("/", defaults={"path": ""}, methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"])
@app.route("/<path:path>", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"])
def proxy(path):