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:
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user