Kazeia-central/kazeia_central/web/index.html

370 lines
18 KiB
HTML

<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Kazeia-central — console de flotte</title>
<style>
:root {
--bg:#0f1115; --panel:#171a21; --panel2:#1e222b; --line:#2a2f3a;
--txt:#e6e8ec; --muted:#8b93a3; --accent:#7aa2f7; --ok:#7ee787;
--warn:#e3b341; --err:#f7768e;
}
* { box-sizing:border-box; }
body { margin:0; font:14px/1.5 system-ui,Segoe UI,Roboto,sans-serif;
background:var(--bg); color:var(--txt); }
header { padding:14px 20px; border-bottom:1px solid var(--line);
display:flex; align-items:center; gap:12px; }
header h1 { font-size:16px; margin:0; font-weight:600; }
header .sub { color:var(--muted); font-size:12px; }
header .right { margin-left:auto; display:flex; align-items:center; gap:10px; }
.layout { display:flex; height:calc(100vh - 53px); }
aside { width:310px; border-right:1px solid var(--line); overflow:auto; padding:10px; }
main { flex:1; overflow:auto; padding:18px 22px; }
.dev { padding:10px 12px; border:1px solid var(--line); border-radius:8px;
margin-bottom:8px; cursor:pointer; background:var(--panel); }
.dev:hover { border-color:var(--accent); }
.dev.sel { border-color:var(--accent); background:var(--panel2); }
.dev .name { font-weight:600; display:flex; align-items:center; gap:6px; }
.dev .serial { font-family:ui-monospace,monospace; color:var(--muted); font-size:12px; }
.dev .meta { color:var(--muted); font-size:12px; margin-top:2px; }
.edit { margin-left:auto; opacity:.5; font-size:12px; padding:1px 6px; }
.dev:hover .edit { opacity:1; }
.badge { display:inline-block; padding:1px 7px; border-radius:10px; font-size:11px;
border:1px solid var(--line); }
.badge.device { color:var(--ok); border-color:var(--ok); }
.badge.offline,.badge.unauthorized { color:var(--err); border-color:var(--err); }
button { background:var(--panel2); color:var(--txt); border:1px solid var(--line);
padding:6px 12px; border-radius:7px; cursor:pointer; font-size:13px; }
button:hover { border-color:var(--accent); }
input { background:var(--bg); color:var(--txt); border:1px solid var(--line);
padding:6px 9px; border-radius:7px; font-size:13px; }
#vault { background:var(--panel); border:1px solid var(--line); border-radius:8px;
padding:10px; margin-bottom:10px; font-size:13px; }
#vault.locked { border-color:var(--warn); }
#vault .row { display:flex; gap:6px; margin-top:7px; }
#vault input { flex:1; }
.lockchip { font-size:12px; padding:2px 9px; border-radius:10px; border:1px solid var(--line); }
.lockchip.on { color:var(--ok); border-color:var(--ok); }
.lockchip.off { color:var(--warn); border-color:var(--warn); }
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(260px,1fr)); gap:14px; }
.card { background:var(--panel); border:1px solid var(--line); border-radius:10px; padding:14px 16px; }
.card h3 { margin:0 0 10px; font-size:13px; color:var(--muted); text-transform:uppercase;
letter-spacing:.04em; font-weight:600; }
.kv { display:flex; justify-content:space-between; gap:10px; padding:3px 0;
border-bottom:1px dashed var(--line); }
.kv:last-child { border-bottom:0; }
.kv .k { color:var(--muted); } .kv .v { font-family:ui-monospace,monospace; }
table { width:100%; border-collapse:collapse; font-size:13px; }
th,td { text-align:left; padding:6px 8px; border-bottom:1px solid var(--line); }
th { color:var(--muted); font-weight:600; font-size:12px; }
.full { grid-column:1/-1; }
.err { color:var(--err); font-size:12px; }
.muted { color:var(--muted); }
.dot { width:7px; height:7px; border-radius:50%; display:inline-block; margin-right:6px; }
.dot.on { background:var(--ok); } .dot.off { background:var(--err); }
#placeholder { color:var(--muted); margin-top:40px; text-align:center; }
</style>
</head>
<body>
<header>
<h1>Kazeia-central</h1>
<span class="sub" id="sub">console de flotte</span>
<div class="right">
<span id="lockchip"></span>
<button onclick="voiceView()">🎙 Voix</button>
<button onclick="fleetView()">▦ Vue parc</button>
<button onclick="loadDevices()">↻ Parc</button>
</div>
</header>
<div class="layout">
<aside>
<div id="vault"></div>
<div id="devices" class="muted">chargement…</div>
</aside>
<main><div id="detail"><div id="placeholder">← sélectionne une tablette</div></div></main>
</div>
<script>
const $ = (id) => document.getElementById(id);
let selected = null, unlocked = false;
async function api(path, opts) {
const r = await fetch("/api" + path, opts);
if (!r.ok) {
let detail = r.status;
try { detail = (await r.json()).detail || detail; } catch {}
const e = new Error(detail); e.status = r.status; throw e;
}
return r.status === 204 ? null : r.json();
}
// ---- coffre opérateur ----------------------------------------------------
async function refreshLock() {
const st = await api("/lock-status");
unlocked = st.unlocked;
$("lockchip").className = "lockchip " + (unlocked ? "on" : "off");
$("lockchip").textContent = unlocked ? "🔓 store ouvert" : "🔒 store verrouillé";
const v = $("vault");
if (unlocked) {
v.className = "";
v.innerHTML = `<b>Store déverrouillé</b> — labels patients actifs.
<div class="row"><button onclick="lockStore()">Verrouiller</button></div>`;
} else {
v.className = "locked";
v.innerHTML = `<b>${st.initialized ? "Déverrouiller" : "Initialiser"} le store</b>
<div class="muted" style="font-size:12px">${st.initialized
? "mot de passe opérateur" : "choisis un mot de passe opérateur (1ʳᵉ fois)"}</div>
<div class="row">
<input id="pw" type="password" placeholder="mot de passe"
onkeydown="if(event.key==='Enter')doUnlock()">
<button onclick="doUnlock()">OK</button>
</div><div id="vaulterr" class="err"></div>`;
}
}
async function doUnlock() {
$("vaulterr").textContent = "";
try {
await api("/unlock", {method:"POST", headers:{"content-type":"application/json"},
body: JSON.stringify({password: $("pw").value})});
await refreshLock();
await loadDevices();
} catch (e) { $("vaulterr").textContent = e.message; }
}
async function lockStore() {
await api("/lock", {method:"POST"});
await refreshLock();
await loadDevices();
}
async function setLabel(serial, current, ev) {
ev.stopPropagation();
const label = prompt(`Nom du patient pour ${serial} :`, current || "");
if (label === null) return;
await api(`/devices/${serial}/label`, {method:"PUT",
headers:{"content-type":"application/json"}, body: JSON.stringify({label})});
await loadDevices();
}
// ---- parc ----------------------------------------------------------------
async function loadDevices() {
const box = $("devices");
try {
const devs = await api("/devices");
$("sub").textContent = devs.length + " tablette(s) découverte(s)";
if (!devs.length) { box.innerHTML = '<div class="muted">aucune tablette branchée</div>'; return; }
box.innerHTML = "";
for (const d of devs) {
const label = d.label || null;
const el = document.createElement("div");
el.className = "dev" + (d.serial === selected ? " sel" : "");
el.innerHTML = `
<div class="name">${label ? `👤 ${label}` : `<span class="serial">${d.serial}</span>`}
${unlocked ? `<button class="edit" title="nommer">✎</button>` : ""}</div>
${label ? `<div class="serial">${d.serial}</div>` : ""}
<div class="meta">${d.props?.model || "?"} · <span class="badge ${d.state}">${d.state}</span></div>`;
el.onclick = () => selectDevice(d.serial);
const edit = el.querySelector(".edit");
if (edit) edit.onclick = (ev) => setLabel(d.serial, label, ev);
box.appendChild(el);
}
} catch (e) {
box.innerHTML = `<div class="err">erreur : ${e.message}</div>`;
}
}
// ---- vue parc (fan-out) --------------------------------------------------
async function fleetView() {
selected = null; loadDevices();
const d = $("detail");
d.innerHTML = `<div class="muted">interrogation du parc…</div>`;
try {
const rows = await api("/fleet/overview");
const yn = (v) => v == null ? "—" : (v ? "oui" : "non");
const body = rows.map(r => {
const o = r.result || {};
const who = r.label ? `👤 ${r.label}` : `<span class="serial">${r.serial}</span>`;
if (!r.ok) return `<tr><td>${who}</td><td colspan="9" class="err">${r.error}</td></tr>`;
return `<tr>
<td>${who}</td>
<td>${dot(o.online)}${o.online?"en ligne":"hors-ligne"}</td>
<td>${o.uptime_s ?? "—"}</td>
<td>${o.mem_available_mb ?? "—"}</td>
<td>${o.update_phase ?? "—"}${o.app_update_available?" ⬆":""}</td>
<td>${o.rag_ready?dot(true):dot(false)}${o.rag_docs ?? 0}</td>
<td>${o.profiles ?? 0}</td>
<td>${o.sessions ?? 0}</td>
<td>${o.crashes ? `<span class="err">${o.crashes}</span>` : 0}</td></tr>`;
}).join("");
d.innerHTML =
`<div style="margin-bottom:12px;display:flex;gap:8px;align-items:center">
<button onclick="fleetUpdateCheck()">Vérifier les MAJ (tout le parc)</button>
<span id="fleetmsg" class="muted"></span>
</div>
<table>
<tr><th>tablette</th><th>état</th><th>uptime(s)</th><th>RAM(Mo)</th><th>MAJ</th>
<th>RAG</th><th>profils</th><th>sessions</th><th>crashes</th></tr>
${body || `<tr><td colspan="9" class="muted">aucune tablette prête</td></tr>`}
</table>`;
} catch (e) { d.innerHTML = `<div class="err">vue parc : ${e.message}</div>`; }
}
async function fleetUpdateCheck() {
$("fleetmsg").textContent = "vérification en cours…";
try {
const rep = await api("/fleet/update-check", {method:"POST"});
const ok = rep.filter(r => r.ok).length;
$("fleetmsg").textContent = `${ok}/${rep.length} tablette(s) ont accepté la vérification`;
} catch (e) { $("fleetmsg").textContent = e.message; }
}
function card(title, body, cls="") { return `<div class="card ${cls}"><h3>${title}</h3>${body}</div>`; }
function kv(k, v) { return `<div class="kv"><span class="k">${k}</span><span class="v">${v ?? "—"}</span></div>`; }
function dot(on) { return `<span class="dot ${on ? "on":"off"}"></span>`; }
async function panel(serial, path, render) {
try { return render(await api(`/devices/${serial}/${path}`)); }
catch (e) { return `<div class="err">${path} : ${e.message}</div>`; }
}
async function selectDevice(serial) {
selected = serial;
loadDevices();
const d = $("detail");
d.innerHTML = `<div class="muted">interrogation de <b>${serial}</b>…</div>`;
const [state, updates, rag, profiles, convs, crashes] = await Promise.all([
panel(serial, "state", s => card("État runtime",
kv("pid", s.pid) + kv("uptime (s)", s.uptime_seconds) + kv("PSS (Mo)", s.pss_mb) +
kv("ION (Mo)", s.ion_mb) + kv("RAM dispo (Mo)", s.mem_available_mb))),
panel(serial, "updates", u => card("Mises à jour",
kv("phase", u.phase) + kv("app MAJ dispo", u.app_update_available) +
kv("version", u.app_version_name) + kv("dernier check", u.last_check))),
panel(serial, "rag/status", r => card("RAG",
`<div class="kv"><span class="k">état</span><span class="v">${dot(r.ready)}${r.ready?"prêt":"non prêt"}</span></div>` +
kv("modèle", r.model) + kv("dim", r.dim) + kv("docs", r.doc_count) + kv("chunks", r.chunk_count))),
panel(serial, "profiles", ps => card("Profils ("+ps.length+")",
`<table><tr><th>id</th><th>nom</th><th>voix</th><th>PIN</th><th>tours</th></tr>` +
ps.map(p=>`<tr><td>${p.id}</td><td>${p.display_name??"—"}</td><td>${p.voice_id??"—"}</td><td>${p.has_pin?"🔒":"—"}</td><td>${p.turn_count??0}</td></tr>`).join("") +
`</table>`, "full")),
panel(serial, "conversations", cs => card("Conversations ("+cs.length+" sessions)",
cs.length ? `<table><tr><th>profil</th><th>session</th><th>tours</th><th>début</th></tr>` +
cs.map(c=>`<tr><td>${c.profile_id}</td><td>${c.session_id}</td><td>${c.turn_count??0}</td><td>${c.started_at?new Date(c.started_at).toLocaleString():"—"}</td></tr>`).join("") +
`</table>` : `<span class="muted">aucune session</span>`, "full")),
panel(serial, "crashes", cr => card("Crashes ("+cr.length+")",
cr.length ? cr.map(c=>kv(c.component||"?", c.message||"")).join("") : `<span class="muted">aucun</span>`)),
]);
d.innerHTML = `<div class="grid">${state}${updates}${rag}${profiles}${convs}${crashes}</div>`;
}
// ---- panneau voix (enrôlement OmniVoice) ---------------------------------
const SCOPE_COLOR = { exclusive:"var(--warn)", global:"var(--ok)", pending:"var(--muted)" };
function scopeBadge(s) {
return `<span class="badge" style="color:${SCOPE_COLOR[s]||"var(--muted)"};border-color:${SCOPE_COLOR[s]||"var(--line)"}">${s||"?"}</span>`;
}
async function voiceView() {
if (!selected) { $("detail").innerHTML = `<div id="placeholder">← sélectionne d'abord une tablette</div>`; return; }
if (!unlocked) { $("detail").innerHTML = `<div class="card"><h3>Voix</h3><div class="err">Déverrouille le store (colonne de gauche) pour gérer les voix.</div></div>`; return; }
const serial = selected;
const d = $("detail");
d.innerHTML = `<div class="muted">chargement des voix de <b>${serial}</b>…</div>`;
const [auto, admin, deployed] = await Promise.all([
api("/voices/autosync").catch(e=>({error:e.message})),
api(`/voices/${serial}/admin`).catch(e=>({error:e.message})),
api(`/voices/${serial}`).catch(e=>({error:e.message})),
]);
// 1) déclencheur auto
const aStat = auto?.devices?.[serial];
const autoCard = card("Déclencheur automatique",
auto.error ? `<div class="err">${auto.error}</div>` :
`<div class="kv"><span class="k">état</span><span class="v">${dot(auto.enabled)}${auto.enabled?"armé":"désarmé"}</span></div>
<div class="row" style="display:flex;gap:8px;align-items:center;margin-top:8px">
<button onclick="toggleAutosync(${!auto.enabled})">${auto.enabled?"Désarmer":"Armer"}</button>
<label class="muted" style="font-size:12px"><input type="checkbox" id="auto_del" ${auto.delete_source?"checked":""}> supprimer le WAV device après</label>
</div>
<div class="muted" style="font-size:12px;margin-top:6px">À l'armement, toute tablette qui se (re)connecte est enrôlée automatiquement.
${aStat?`<br>Dernière sync ${serial} : <b>${aStat.state}</b>${aStat.enrolled!=null?` (${aStat.enrolled}/${aStat.total})`:""}`:""}</div>`);
// 2) voix admin à enrôler
const av = Array.isArray(admin) ? admin : [];
const adminCard = card(`Voix enregistrées — admin (${av.length})`,
admin.error ? `<div class="err">${admin.error}</div>` :
(av.length ? `<table>
<tr><th>nom</th><th>portée</th><th>propriétaire</th><th>statut</th><th>consentement</th></tr>
${av.map(v=>`<tr>
<td><b>${v.name||v.voice_id}</b><div class="serial">${v.voice_id}</div></td>
<td>${scopeBadge(v.scope)}</td>
<td>${v.owner_name||v.owner_profile_id||"—"}</td>
<td>${v.status}${v.deployed_on_device?" ✓":""}</td>
<td class="muted" title="${(v.reference_text||"").replace(/"/g,"&quot;")}">${(v.reference_text||"—").slice(0,40)}${(v.reference_text||"").length>40?"…":""}</td>
</tr>`).join("")}
</table>` : `<span class="muted">aucune voix enregistrée côté admin</span>`) +
`<div class="row" style="display:flex;gap:8px;align-items:center;margin-top:10px">
<button id="syncbtn" onclick="syncAdmin('${serial}')">Synchroniser (enrôler + déployer)</button>
<label class="muted" style="font-size:12px"><input type="checkbox" id="sync_del"> supprimer le WAV device après</label>
<span id="syncmsg" class="muted"></span>
</div>`, "full");
// 3) voix déployées (verrou)
const dv = Array.isArray(deployed) ? deployed : [];
const depCard = card(`Voix déployées sur la tablette (${dv.filter(v=>v.deployed_on_device).length})`,
deployed.error ? `<div class="err">${deployed.error}</div>` :
(dv.length ? `<table>
<tr><th>voix</th><th>statut</th><th>verrou</th><th>profils</th><th></th></tr>
${dv.map(v=>`<tr>
<td>${v.voice_id}</td>
<td>${v.deployed_on_device?dot(true)+"déployée":v.status}</td>
<td>${v.locked_profile_id?`🔒 ${v.locked_profile_id}`:"<span class='muted'>généraliste</span>"}</td>
<td class="muted">${(v.used_by_profiles||[]).join(", ")||"—"}</td>
<td>${v.locked_profile_id
? `<button onclick="unlockVoice('${serial}','${v.voice_id}')">déverrouiller</button>`
: `<button onclick="lockVoice('${serial}','${v.voice_id}')">verrouiller…</button>`}</td>
</tr>`).join("")}
</table>` : `<span class="muted">aucune voix</span>`), "full");
d.innerHTML = `<div class="grid">${autoCard}${adminCard}${depCard}</div>`;
}
async function toggleAutosync(on) {
const del = $("auto_del")?.checked || false;
await api("/voices/autosync", {method:"POST", headers:{"content-type":"application/json"},
body: JSON.stringify({enabled:on, delete_source:del})});
voiceView();
}
async function syncAdmin(serial) {
const del = $("sync_del")?.checked || false;
const btn = $("syncbtn"), msg = $("syncmsg");
btn.disabled = true; msg.textContent = "enrôlement en cours… (≈1 min par voix, ne ferme pas)";
try {
const rep = await api(`/voices/${serial}/admin/sync`, {method:"POST",
headers:{"content-type":"application/json"}, body: JSON.stringify({delete_source:del})});
const ok = rep.filter(r=>r.ok && r.deployed).length;
msg.textContent = `${ok}/${rep.length} voix déployée(s).`;
await voiceView();
} catch (e) { msg.textContent = "erreur : " + e.message; btn.disabled = false; }
}
async function lockVoice(serial, vid) {
const pid = prompt(`Verrouiller « ${vid} » sur quel profil patient ? (profile_id)`);
if (!pid) return;
await api(`/voices/${serial}/${vid}/lock`, {method:"POST",
headers:{"content-type":"application/json"}, body: JSON.stringify({profile_id:pid})});
voiceView();
}
async function unlockVoice(serial, vid) {
await api(`/voices/${serial}/${vid}/unlock`, {method:"POST"});
voiceView();
}
(async () => { await refreshLock(); await loadDevices(); })();
</script>
</body>
</html>