feat(ui): réécriture front « clinique-apaisé » — shell d'app (phase 2)
Refonte UX complète (page blanche assumée sur l'UI uniquement) : - web/styles.css : design system clair (tokens couleurs/espacements, cartes, tables, badges, boutons, toasts, spinner, écran de déverrouillage). - web/index.html : shell minimal (charge css + js). - web/app.js : shell d'application — top-bar (contexte tablette + état coffre), nav latérale par sections (Flotte · Voix · Mises à jour · Audit + stubs Profils/ Conversations/Config/RAG « à venir »), écran de déverrouillage propre, toasts + états de chargement, sélecteur de tablette global. - Sections Flotte (vue parc + détail device), Voix (catalogue global + chargées/ chargeables + déploiement + ré-enrôlement + autosync), Updates (OTA parc), Audit. - Sans build (vanilla JS servi par StaticFiles), wrappable pywebview. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2731b37b20
commit
8d4ab9a07f
|
|
@ -0,0 +1,224 @@
|
||||||
|
"use strict";
|
||||||
|
/* Kazeia-central — shell d'application (clinique-apaisé, sans build). */
|
||||||
|
|
||||||
|
const S = { initialized: false, unlocked: false, devices: [], selected: null, section: "flotte" };
|
||||||
|
|
||||||
|
// ---- helpers --------------------------------------------------------------
|
||||||
|
const $ = (s, r = document) => r.querySelector(s);
|
||||||
|
function elFrom(html) { const t = document.createElement("template"); t.innerHTML = html.trim(); return t.content.firstChild; }
|
||||||
|
async function api(path, opts) {
|
||||||
|
const r = await fetch("/api" + path, opts);
|
||||||
|
if (!r.ok) { let d = r.status; try { d = (await r.json()).detail || d; } catch {} const e = new Error(d); e.status = r.status; throw e; }
|
||||||
|
return r.status === 204 ? null : r.json();
|
||||||
|
}
|
||||||
|
function toast(msg, kind = "info", ms = 3800) {
|
||||||
|
const t = elFrom(`<div class="toast ${kind}">${msg}</div>`); $("#toasts").appendChild(t);
|
||||||
|
setTimeout(() => t.remove(), ms);
|
||||||
|
}
|
||||||
|
const esc = (s) => (s == null ? "" : String(s)).replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
||||||
|
const badge = (txt, cls = "") => `<span class="badge ${cls}">${esc(txt)}</span>`;
|
||||||
|
const dot = (cls) => `<span class="dot ${cls}"></span>`;
|
||||||
|
const fmtTs = (ms) => ms ? new Date(ms).toLocaleString("fr-FR") : "—";
|
||||||
|
const scopeBadge = (s) => badge(s || "?", { exclusive: "warn", global: "ok", pending: "" }[s] || "");
|
||||||
|
function deviceLabel(serial) { const d = S.devices.find(x => x.serial === serial); return d && d.label ? d.label : serial; }
|
||||||
|
async function withBusy(btn, fn) { const o = btn.textContent; btn.disabled = true; btn.innerHTML = `<span class="spinner"></span>`; try { return await fn(); } finally { btn.disabled = false; btn.textContent = o; } }
|
||||||
|
|
||||||
|
// ---- sections -------------------------------------------------------------
|
||||||
|
const SECTIONS = [
|
||||||
|
{ id: "flotte", ic: "🛰", label: "Flotte", render: renderFlotte },
|
||||||
|
{ id: "voix", ic: "🎙", label: "Voix", render: renderVoix },
|
||||||
|
{ id: "updates", ic: "⬆", label: "Mises à jour", render: renderUpdates },
|
||||||
|
{ id: "audit", ic: "📋", label: "Audit", render: renderAudit },
|
||||||
|
{ sep: "Parité admin (à venir)" },
|
||||||
|
{ id: "profils", ic: "👤", label: "Profils", render: () => stub("Profils & fiche patient", "Fiche clinique chiffrée côté Kazeia-central (identité, contact, clinique) + langue par patient. La fiche est constructible maintenant ; la propagation de la langue passe par l'app patiente (PROFILE_LANGUAGE_SPEC livrée).") },
|
||||||
|
{ id: "conversations", ic: "💬", label: "Conversations", render: () => stub("Conversations cliniques", "Rapatriement chiffré des conversations + export PDF/JSON + purge + collecteur de fond. Dépend de conversations_export côté app patiente.") },
|
||||||
|
{ id: "config", ic: "⚙", label: "Config & presets", render: () => stub("Config & presets", "Édition de la config sampling + presets et push. Dépend des méthodes call() base64-JSON côté app patiente (PROVIDER_RPC_SPEC).") },
|
||||||
|
{ id: "rag", ic: "📚", label: "RAG", render: () => stub("Corpus RAG", "Authoring du corpus + test retrieval + publication OTA. Dépend de rag_upsert_json côté app patiente.") },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---- boot + shell ---------------------------------------------------------
|
||||||
|
boot();
|
||||||
|
async function boot() {
|
||||||
|
try { const st = await api("/lock-status"); S.initialized = st.initialized; S.unlocked = st.unlocked; }
|
||||||
|
catch (e) { $("#app").innerHTML = `<div class="gate"><div class="panel"><div class="logo">Kazeia<span class="dot">·</span>central</div><p class="err">serveur injoignable : ${esc(e.message)}</p></div></div>`; return; }
|
||||||
|
if (!S.unlocked) return renderGate();
|
||||||
|
await renderShell();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGate() {
|
||||||
|
const first = !S.initialized;
|
||||||
|
$("#app").innerHTML = `<div class="gate"><div class="panel">
|
||||||
|
<div class="logo">Kazeia<span class="dot">·</span>central</div>
|
||||||
|
<p>${first ? "Choisis un mot de passe opérateur (première fois)" : "Déverrouille le coffre opérateur"}</p>
|
||||||
|
<input id="pw" type="password" placeholder="mot de passe" autofocus>
|
||||||
|
<button class="btn-primary" id="gobtn">${first ? "Initialiser" : "Déverrouiller"}</button>
|
||||||
|
<div id="gerr" class="err" style="margin-top:10px"></div>
|
||||||
|
</div></div>`;
|
||||||
|
const go = async () => {
|
||||||
|
try { await api("/unlock", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ password: $("#pw").value }) }); S.unlocked = true; await renderShell(); }
|
||||||
|
catch (e) { $("#gerr").textContent = e.message; }
|
||||||
|
};
|
||||||
|
$("#gobtn").onclick = go;
|
||||||
|
$("#pw").onkeydown = (e) => { if (e.key === "Enter") go(); };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderShell() {
|
||||||
|
$("#app").innerHTML = `
|
||||||
|
<div class="topbar">
|
||||||
|
<span class="brand">Kazeia<span class="dot">·</span>central</span>
|
||||||
|
<span class="ctx">Tablette : <select id="devsel"></select></span>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<span class="lockpill on" id="lockpill">🔓 coffre ouvert</span>
|
||||||
|
<button class="btn-sm btn-ghost" onclick="lockStore()">verrouiller</button>
|
||||||
|
</div>
|
||||||
|
<nav class="sidebar" id="sidebar"></nav>
|
||||||
|
<main><div id="content"></div></main>`;
|
||||||
|
$("#sidebar").innerHTML = SECTIONS.map(s => s.sep
|
||||||
|
? `<div class="nav-label">${s.sep}</div>`
|
||||||
|
: `<div class="nav-item" data-id="${s.id}"><span class="ic">${s.ic}</span>${s.label}</div>`).join("");
|
||||||
|
$("#sidebar").querySelectorAll(".nav-item").forEach(n => n.onclick = () => { S.section = n.dataset.id; renderSection(); });
|
||||||
|
await loadDevices();
|
||||||
|
renderSection();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDevices() {
|
||||||
|
try { S.devices = await api("/devices"); } catch (e) { S.devices = []; toast("erreur adb : " + e.message, "err"); }
|
||||||
|
if (S.devices.length && !S.selected) S.selected = S.devices[0].serial;
|
||||||
|
const sel = $("#devsel");
|
||||||
|
if (sel) {
|
||||||
|
sel.innerHTML = `<option value="">— aucune —</option>` + S.devices.map(d =>
|
||||||
|
`<option value="${d.serial}" ${d.serial === S.selected ? "selected" : ""}>${esc(d.label || d.serial)} ${d.state !== "device" ? "(" + d.state + ")" : ""}</option>`).join("");
|
||||||
|
sel.onchange = () => { S.selected = sel.value || null; renderSection(); };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSection() {
|
||||||
|
document.querySelectorAll(".nav-item").forEach(n => n.classList.toggle("active", n.dataset.id === S.section));
|
||||||
|
const sec = SECTIONS.find(s => s.id === S.section) || SECTIONS[0];
|
||||||
|
const c = $("#content"); c.innerHTML = `<div class="empty"><span class="spinner"></span></div>`;
|
||||||
|
Promise.resolve(sec.render(c)).catch(e => { c.innerHTML = `<div class="card"><div class="err">${esc(e.message)}</div></div>`; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageHead(title, sub, actions = "") {
|
||||||
|
return `<div class="page-head"><div><h1>${title}</h1>${sub ? `<div class="sub">${sub}</div>` : ""}</div><div class="actions">${actions}</div></div>`;
|
||||||
|
}
|
||||||
|
function stub(title, desc) {
|
||||||
|
return `${pageHead(title)}<div class="card"><div class="empty"><div class="big">🚧</div><p>${esc(desc)}</p><p class="muted">Section prévue par l'architecture — non encore implémentée.</p></div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- FLOTTE ---------------------------------------------------------------
|
||||||
|
async function renderFlotte(c) {
|
||||||
|
const rep = await api("/fleet/overview").catch(e => ({ error: e.message }));
|
||||||
|
const rows = Array.isArray(rep) ? rep : [];
|
||||||
|
const head = pageHead("Flotte", `${S.devices.length} tablette(s)`,
|
||||||
|
`<button class="btn-sm" onclick="renderSection()">↻ Rafraîchir</button>`);
|
||||||
|
const table = rep.error ? `<div class="err">${esc(rep.error)}</div>` : rows.length ? `<table>
|
||||||
|
<tr><th>tablette</th><th>état</th><th>RAM</th><th>MAJ</th><th>RAG</th><th>profils</th><th>sessions</th><th>crashes</th></tr>
|
||||||
|
${rows.map(r => { const o = r.result || {}; const sel = r.serial === S.selected;
|
||||||
|
return `<tr style="cursor:pointer;${sel ? "background:var(--primary-soft)" : ""}" onclick="selectDevice('${r.serial}')">
|
||||||
|
<td><b>${esc(r.label || r.serial)}</b><div class="muted mono">${esc(r.serial)}</div></td>
|
||||||
|
${r.ok === false ? `<td colspan="7" class="err">${esc(r.error)}</td>` : `
|
||||||
|
<td>${dot(o.online ? "ok" : "off")} ${o.online ? "en ligne" : "hors-ligne"}</td>
|
||||||
|
<td>${o.mem_available_mb ?? "—"} Mo</td>
|
||||||
|
<td>${esc(o.update_phase || "—")}${o.app_update_available ? " ⬆" : ""}</td>
|
||||||
|
<td>${dot(o.rag_ready ? "ok" : "idle")} ${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("")}
|
||||||
|
</table>` : `<div class="empty">aucune tablette branchée</div>`;
|
||||||
|
c.innerHTML = head + `<div class="card full">${table}</div><div id="devdetail"></div>`;
|
||||||
|
if (S.selected) renderDeviceDetail($("#devdetail"), S.selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectDevice(serial) { S.selected = serial; const sel = $("#devsel"); if (sel) sel.value = serial; renderSection(); }
|
||||||
|
|
||||||
|
async function renderDeviceDetail(host, serial) {
|
||||||
|
host.innerHTML = `<div class="page-head" style="margin-top:22px"><h1 style="font-size:16px">Détail · ${esc(deviceLabel(serial))}</h1></div><div class="grid" id="ddgrid"><div class="empty"><span class="spinner"></span></div></div>`;
|
||||||
|
const get = (p, fn) => api(`/devices/${serial}/${p}`).then(fn).catch(e => `<div class="card"><h3>${p}</h3><div class="err">${esc(e.message)}</div></div>`);
|
||||||
|
const cards = await Promise.all([
|
||||||
|
get("state", s => card("État runtime", kv("pid", s.pid) + kv("uptime (s)", s.uptime_seconds) + kv("PSS (Mo)", s.pss_mb) + kv("RAM dispo (Mo)", s.mem_available_mb))),
|
||||||
|
get("updates", u => card("Mises à jour", kv("phase", u.phase) + kv("MAJ dispo", u.app_update_available ? "oui" : "non") + kv("version", u.app_version_name || "—"))),
|
||||||
|
get("rag/status", r => card("RAG", kv("état", (r.ready ? "prêt" : "non prêt")) + kv("modèle", r.model || "—") + kv("docs", r.doc_count ?? 0))),
|
||||||
|
get("profiles", ps => card("Profils (" + ps.length + ")", ps.length ? `<table><tr><th>nom</th><th>voix</th><th>PIN</th><th>tours</th></tr>${ps.map(p => `<tr><td>${esc(p.display_name || p.id)}</td><td>${esc(p.voice_id || "—")}</td><td>${p.has_pin ? "🔒" : "—"}</td><td>${p.turn_count ?? 0}</td></tr>`).join("")}</table>` : `<span class="muted">aucun</span>`, "full")),
|
||||||
|
get("conversations", cs => card("Conversations (" + cs.length + ")", cs.length ? `<table><tr><th>profil</th><th>session</th><th>tours</th><th>début</th></tr>${cs.map(x => `<tr><td>${esc(x.profile_id)}</td><td class="mono">${esc(x.session_id)}</td><td>${x.turn_count ?? 0}</td><td>${fmtTs(x.started_at)}</td></tr>`).join("")}</table>` : `<span class="muted">aucune session</span>`, "full")),
|
||||||
|
get("crashes", cr => card("Crashes (" + cr.length + ")", cr.length ? cr.map(x => kv(x.component || "?", x.message || "")).join("") : `<span class="muted">aucun</span>`)),
|
||||||
|
]);
|
||||||
|
$("#ddgrid").innerHTML = cards.join("");
|
||||||
|
}
|
||||||
|
const card = (title, body, cls = "") => `<div class="card ${cls}"><h3>${title}</h3>${body}</div>`;
|
||||||
|
const kv = (k, v) => `<div class="kv"><span class="k">${k}</span><span class="v">${v ?? "—"}</span></div>`;
|
||||||
|
|
||||||
|
// ---- MISES À JOUR ---------------------------------------------------------
|
||||||
|
async function renderUpdates(c) {
|
||||||
|
const rep = await api("/fleet/overview").catch(e => ({ error: e.message }));
|
||||||
|
const rows = Array.isArray(rep) ? rep : [];
|
||||||
|
c.innerHTML = pageHead("Mises à jour", "OTA piloté par tablette",
|
||||||
|
`<button class="btn-sm" id="chk">Vérifier le parc</button> <button class="btn-sm btn-primary" id="inst">Installer le parc</button>`)
|
||||||
|
+ `<div class="card full">${rep.error ? `<div class="err">${esc(rep.error)}</div>` : `<table><tr><th>tablette</th><th>phase</th><th>MAJ dispo</th><th>version</th></tr>
|
||||||
|
${rows.map(r => { const o = r.result || {}; return `<tr><td><b>${esc(r.label || r.serial)}</b></td><td>${esc(o.update_phase || "—")}</td><td>${o.app_update_available ? badge("disponible", "warn") : "—"}</td><td>${esc(o.app_version_name || "—")}</td></tr>`; }).join("")}</table>`}</div>`;
|
||||||
|
$("#chk").onclick = (e) => withBusy(e.target, async () => { const r = await api("/fleet/update-check", { method: "POST" }); toast(`${r.filter(x => x.ok).length}/${r.length} ont accepté la vérification`, "ok"); });
|
||||||
|
$("#inst").onclick = (e) => { if (!confirm("Lancer l'installation OTA sur tout le parc ?")) return; withBusy(e.target, async () => { const r = await api("/fleet/update-install", { method: "POST" }); toast(`installation lancée sur ${r.filter(x => x.ok).length}/${r.length}`, "ok"); }); };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- AUDIT ----------------------------------------------------------------
|
||||||
|
async function renderAudit(c) {
|
||||||
|
const log = await api("/audit").catch(e => ({ error: e.message }));
|
||||||
|
const rows = Array.isArray(log) ? log : [];
|
||||||
|
c.innerHTML = pageHead("Audit", "piste d'accès opérateur (§8)") + `<div class="card full">${log.error ? `<div class="err">${esc(log.error)}</div>` : rows.length ? `<table><tr><th>date</th><th>acteur</th><th>action</th><th>cible</th></tr>
|
||||||
|
${rows.map(a => `<tr><td>${fmtTs(a.ts)}</td><td>${esc(a.actor || "—")}</td><td>${badge(a.action, "primary")}</td><td class="mono">${esc(a.target || "—")}</td></tr>`).join("")}</table>` : `<div class="empty">aucune entrée</div>`}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- VOIX -----------------------------------------------------------------
|
||||||
|
async function renderVoix(c) {
|
||||||
|
const serial = S.selected;
|
||||||
|
const reqs = [api("/voices/catalog").catch(e => ({ error: e.message })), api("/voices/autosync").catch(e => ({ error: e.message }))];
|
||||||
|
if (serial) reqs.push(api(`/voices/${serial}`).catch(e => ({ error: e.message })), api(`/voices/${serial}/admin`).catch(e => ({ error: e.message })));
|
||||||
|
const [cat, auto, tablet, admin] = await Promise.all(reqs);
|
||||||
|
|
||||||
|
const cv = Array.isArray(cat) ? cat : [];
|
||||||
|
const catCard = card(`Catalogue voix — flotte (${cv.length})`, cat.error ? `<div class="err">${esc(cat.error)}</div>` :
|
||||||
|
cv.length ? `<table><tr><th>voix</th><th>portée</th><th>verrou</th><th>déployée sur</th><th></th></tr>
|
||||||
|
${cv.map(v => `<tr><td><b>${esc(v.name || v.voice_id)}</b><div class="muted mono">${esc(v.voice_id)}</div></td>
|
||||||
|
<td>${scopeBadge(v.scope)}</td>
|
||||||
|
<td>${v.locked_profile_id ? "🔒 " + esc(v.owner_name || v.locked_profile_id) : `<span class="muted">généraliste</span>`}</td>
|
||||||
|
<td class="muted">${(v.deployed_serials || []).map(deviceLabel).map(esc).join(", ") || "—"}</td>
|
||||||
|
<td style="white-space:nowrap">${v.locked_profile_id ? `<button class="btn-sm" onclick="unlockVoice('${v.voice_id}')">déverrouiller</button>` : `<button class="btn-sm" onclick="lockVoicePrompt('${v.voice_id}')">verrouiller</button>`}
|
||||||
|
${v.has_wav ? `<button class="btn-sm" onclick="reenrollVoice(this,'${v.voice_id}')">ré-enrôler</button>` : ""}</td></tr>`).join("")}
|
||||||
|
</table>` : `<div class="empty">catalogue vide — enregistre des voix via l'app admin puis synchronise</div>`, "full");
|
||||||
|
|
||||||
|
const autoCard = card("Déclencheur automatique", auto.error ? `<div class="err">${esc(auto.error)}</div>` :
|
||||||
|
`<div class="kv"><span class="k">état</span><span class="v">${dot(auto.enabled ? "ok" : "idle")} ${auto.enabled ? "armé" : "désarmé"}</span></div>
|
||||||
|
<div style="display:flex;gap:10px;align-items:center;margin-top:10px">
|
||||||
|
<button onclick="toggleAutosync(${!auto.enabled})">${auto.enabled ? "Désarmer" : "Armer"}</button>
|
||||||
|
<label class="chk"><input type="checkbox" id="auto_del" ${auto.delete_source ? "checked" : ""}> supprimer le WAV device après</label></div>`);
|
||||||
|
|
||||||
|
let cards = catCard + autoCard;
|
||||||
|
if (!serial) {
|
||||||
|
cards += card("Par tablette", `<div class="empty">Choisis une tablette (en haut) pour voir ce qui y est chargé et les voix chargeables dessus.</div>`, "full");
|
||||||
|
} else {
|
||||||
|
const av = Array.isArray(admin) ? admin : [];
|
||||||
|
const adminCard = card(`Voix admin à enrôler — ${esc(deviceLabel(serial))} (${av.length})`, admin.error ? `<div class="err">${esc(admin.error)}</div>` :
|
||||||
|
(av.length ? `<table><tr><th>nom</th><th>portée</th><th>propriétaire</th><th>statut</th></tr>${av.map(v => `<tr><td><b>${esc(v.name || v.voice_id)}</b></td><td>${scopeBadge(v.scope)}</td><td>${esc(v.owner_name || v.owner_profile_id || "—")}</td><td>${esc(v.status)}</td></tr>`).join("")}</table>` : `<div class="empty">aucune voix admin en attente</div>`)
|
||||||
|
+ `<div style="display:flex;gap:10px;align-items:center;margin-top:12px"><button class="btn-primary" onclick="syncAdmin(this,'${serial}')">Synchroniser (enrôler + déployer)</button><label class="chk"><input type="checkbox" id="sync_del"> supprimer le WAV device après</label></div>`, "full");
|
||||||
|
|
||||||
|
const loaded = (tablet && tablet.deployed) || [], loadable = (tablet && tablet.deployable) || [];
|
||||||
|
const tabCard = card(`${esc(deviceLabel(serial))} — chargées (${loaded.length}) · chargeables (${loadable.filter(v => v.deployable).length})`, tablet && tablet.error ? `<div class="err">${esc(tablet.error)}</div>` :
|
||||||
|
`<table><tr><th>voix</th><th>portée</th><th>état</th><th></th></tr>
|
||||||
|
${loaded.map(v => `<tr><td>${dot("ok")} ${esc(v.name || v.voice_id)}${v.in_catalog === false ? ` <span class="muted">(hors catalogue)</span>` : ""}</td><td>${v.scope ? scopeBadge(v.scope) : "—"}</td><td>chargée</td><td>${v.in_catalog !== false ? `<button class="btn-sm" onclick="undeployVoice(this,'${serial}','${v.voice_id}')">retirer</button>` : ""}</td></tr>`).join("")}
|
||||||
|
${loadable.map(v => `<tr><td class="muted">${esc(v.name || v.voice_id)}</td><td>${scopeBadge(v.scope)}</td><td class="muted">${v.deployable ? "chargeable" : esc(v.reason || "—")}</td><td>${v.deployable ? `<button class="btn-sm btn-primary" onclick="deployVoice(this,'${serial}','${v.voice_id}')">charger</button>` : ""}</td></tr>`).join("")}
|
||||||
|
</table>`, "full");
|
||||||
|
cards += adminCard + tabCard;
|
||||||
|
}
|
||||||
|
c.innerHTML = pageHead("Voix", "catalogue de flotte · enrôlement OmniVoice") + `<div class="grid">${cards}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- actions voix ---------------------------------------------------------
|
||||||
|
async function toggleAutosync(on) { try { await api("/voices/autosync", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ enabled: on, delete_source: $("#auto_del") && $("#auto_del").checked || false }) }); toast(on ? "auto-enrôlement armé" : "désarmé", "ok"); renderSection(); } catch (e) { toast(e.message, "err"); } }
|
||||||
|
async function syncAdmin(btn, serial) { const del = $("#sync_del") && $("#sync_del").checked || false; await withBusy(btn, async () => { try { const r = await api(`/voices/${serial}/admin/sync`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ delete_source: del }) }); toast(`${r.filter(x => x.ok && x.deployed).length}/${r.length} voix déployée(s)`, "ok"); renderSection(); } catch (e) { toast(e.message, "err"); } }); }
|
||||||
|
async function deployVoice(btn, serial, vid) { await withBusy(btn, async () => { try { const r = await api(`/voices/${serial}/deploy`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ voice_id: vid }) }); r.deployed ? toast("voix chargée", "ok") : toast("non chargée : " + (r.reason || ""), "err"); renderSection(); } catch (e) { toast(e.message, "err"); } }); }
|
||||||
|
async function undeployVoice(btn, serial, vid) { await withBusy(btn, async () => { try { await api(`/voices/${serial}/undeploy`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ voice_id: vid }) }); toast("voix retirée", "ok"); renderSection(); } catch (e) { toast(e.message, "err"); } }); }
|
||||||
|
async function reenrollVoice(btn, vid) { if (!confirm(`Ré-enrôler « ${vid} » (fix §7) et re-pousser sur ses tablettes ?`)) return; await withBusy(btn, async () => { try { const r = await api(`/voices/catalog/${vid}/reenroll`, { method: "POST" }); toast("ré-enrôlé · re-déployé : " + ((r.redeployed || []).join(", ") || "—"), "ok"); renderSection(); } catch (e) { toast(e.message, "err"); } }); }
|
||||||
|
async function lockVoicePrompt(vid) { const p = prompt(`Verrouiller « ${vid} » sur quel profil patient ? (profile_id)`); if (!p) return; try { await api(`/voices/catalog/${vid}/lock`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ profile_id: p }) }); toast("voix verrouillée", "ok"); renderSection(); } catch (e) { toast(e.message, "err"); } }
|
||||||
|
async function unlockVoice(vid) { try { await api(`/voices/catalog/${vid}/unlock`, { method: "POST" }); toast("voix déverrouillée", "ok"); renderSection(); } catch (e) { toast(e.message, "err"); } }
|
||||||
|
|
||||||
|
// ---- store ----------------------------------------------------------------
|
||||||
|
async function lockStore() { try { await api("/lock", { method: "POST" }); S.unlocked = false; renderGate(); } catch (e) { toast(e.message, "err"); } }
|
||||||
|
|
@ -3,405 +3,12 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Kazeia-central — console de flotte</title>
|
<title>Kazeia-central</title>
|
||||||
<style>
|
<link rel="stylesheet" href="/styles.css">
|
||||||
: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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<div id="app"></div>
|
||||||
<h1>Kazeia-central</h1>
|
<div id="toasts"></div>
|
||||||
<span class="sub" id="sub">console de flotte</span>
|
<script src="/app.js"></script>
|
||||||
<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 : catalogue GLOBAL (flotte) + déploiement par tablette ----
|
|
||||||
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 (!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; // peut être null → vue catalogue seule
|
|
||||||
const d = $("detail");
|
|
||||||
d.innerHTML = `<div class="muted">chargement du catalogue voix…</div>`;
|
|
||||||
const reqs = [api("/voices/catalog").catch(e=>({error:e.message})),
|
|
||||||
api("/voices/autosync").catch(e=>({error:e.message}))];
|
|
||||||
if (serial) reqs.push(api(`/voices/${serial}`).catch(e=>({error:e.message})),
|
|
||||||
api(`/voices/${serial}/admin`).catch(e=>({error:e.message})));
|
|
||||||
const [cat, auto, tablet, admin] = await Promise.all(reqs);
|
|
||||||
|
|
||||||
// 1) catalogue global (toutes tablettes) — visible SANS sélectionner de tablette
|
|
||||||
const cv = Array.isArray(cat) ? cat : [];
|
|
||||||
const catCard = card(`Catalogue voix — flotte (${cv.length})`,
|
|
||||||
cat.error ? `<div class="err">${cat.error}</div>` :
|
|
||||||
(cv.length ? `<table>
|
|
||||||
<tr><th>voix</th><th>portée</th><th>verrou</th><th>déployée sur</th><th></th></tr>
|
|
||||||
${cv.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.locked_profile_id?`🔒 ${v.owner_name||v.locked_profile_id}`:"<span class='muted'>généraliste</span>"}</td>
|
|
||||||
<td class="muted">${(v.deployed_serials||[]).length?(v.deployed_serials).join(", "):"—"}</td>
|
|
||||||
<td>${v.locked_profile_id
|
|
||||||
? `<button onclick="unlockVoice('${v.voice_id}')">déverrouiller</button>`
|
|
||||||
: `<button onclick="lockVoice('${v.voice_id}')">verrouiller…</button>`}
|
|
||||||
${v.has_wav?`<button onclick="reenrollVoice('${v.voice_id}')" title="régénère le .ovsp (fix §7) + re-pousse">ré-enrôler</button>`:""}</td>
|
|
||||||
</tr>`).join("")}
|
|
||||||
</table>` : `<span class="muted">catalogue vide — enregistre des voix via l'app admin puis synchronise</span>`), "full");
|
|
||||||
|
|
||||||
// 2) 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>${aStat?`<div class="muted" style="font-size:12px;margin-top:6px">Dernière sync ${serial} : <b>${aStat.state}</b>${aStat.enrolled!=null?` (${aStat.enrolled}/${aStat.total})`:""}</div>`:""}`);
|
|
||||||
|
|
||||||
let cards = catCard + autoCard;
|
|
||||||
|
|
||||||
if (!serial) {
|
|
||||||
cards += card("Par tablette", `<span class="muted">Sélectionne une tablette (colonne de gauche), puis reclique 🎙 Voix : tu verras ce qui y est chargé et les voix chargeables dessus.</span>`, "full");
|
|
||||||
} else {
|
|
||||||
// 3) voix admin à enrôler sur cette tablette
|
|
||||||
const av = Array.isArray(admin) ? admin : [];
|
|
||||||
const adminCard = card(`Voix admin à enrôler — ${serial} (${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></tr>
|
|
||||||
${av.map(v=>`<tr><td><b>${v.name||v.voice_id}</b></td><td>${scopeBadge(v.scope)}</td><td>${v.owner_name||v.owner_profile_id||"—"}</td><td>${v.status}</td></tr>`).join("")}
|
|
||||||
</table>` : `<span class="muted">aucune voix admin en attente</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");
|
|
||||||
|
|
||||||
// 4) tablette : voix chargées + chargeables
|
|
||||||
const loaded = (tablet && tablet.deployed) || [], loadable = (tablet && tablet.deployable) || [];
|
|
||||||
const tabCard = card(`Tablette ${serial} — chargées (${loaded.length}) · chargeables (${loadable.filter(v=>v.deployable).length})`,
|
|
||||||
tablet.error ? `<div class="err">${tablet.error}</div>` :
|
|
||||||
`<table><tr><th>voix</th><th>portée</th><th>état</th><th></th></tr>
|
|
||||||
${loaded.map(v=>`<tr><td>${dot(true)}${v.name||v.voice_id}${v.in_catalog===false?" <span class='muted'>(hors catalogue)</span>":""}</td>
|
|
||||||
<td>${v.scope?scopeBadge(v.scope):"—"}</td><td>chargée</td>
|
|
||||||
<td>${v.in_catalog!==false?`<button onclick="undeployVoice('${serial}','${v.voice_id}')">retirer</button>`:""}</td></tr>`).join("")}
|
|
||||||
${loadable.map(v=>`<tr><td class="muted">${v.name||v.voice_id}</td><td>${scopeBadge(v.scope)}</td>
|
|
||||||
<td class="muted">${v.deployable?"chargeable":(v.reason||"—")}</td>
|
|
||||||
<td>${v.deployable?`<button onclick="deployVoice('${serial}','${v.voice_id}')">charger</button>`:""}</td></tr>`).join("")}
|
|
||||||
</table>`, "full");
|
|
||||||
|
|
||||||
cards += adminCard + tabCard;
|
|
||||||
}
|
|
||||||
d.innerHTML = `<div class="grid">${cards}</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})});
|
|
||||||
msg.textContent = `${rep.filter(r=>r.ok && r.deployed).length}/${rep.length} voix déployée(s).`;
|
|
||||||
await voiceView();
|
|
||||||
} catch (e) { msg.textContent = "erreur : " + e.message; btn.disabled = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deployVoice(serial, vid) {
|
|
||||||
const m = document.createElement("div"); m.className = "muted";
|
|
||||||
m.textContent = `déploiement de ${vid} sur ${serial}… (1ʳᵉ fois : enrôlement ~1 min)`;
|
|
||||||
$("detail").prepend(m);
|
|
||||||
try {
|
|
||||||
const r = await api(`/voices/${serial}/deploy`, {method:"POST",
|
|
||||||
headers:{"content-type":"application/json"}, body: JSON.stringify({voice_id:vid})});
|
|
||||||
if (!r.deployed) alert("non chargée : " + (r.reason || ""));
|
|
||||||
await voiceView();
|
|
||||||
} catch (e) { alert("erreur : " + e.message); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function undeployVoice(serial, vid) {
|
|
||||||
await api(`/voices/${serial}/undeploy`, {method:"POST",
|
|
||||||
headers:{"content-type":"application/json"}, body: JSON.stringify({voice_id:vid})});
|
|
||||||
voiceView();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function lockVoice(vid) {
|
|
||||||
const pid = prompt(`Verrouiller « ${vid} » sur quel profil patient ? (profile_id)`);
|
|
||||||
if (!pid) return;
|
|
||||||
await api(`/voices/catalog/${vid}/lock`, {method:"POST",
|
|
||||||
headers:{"content-type":"application/json"}, body: JSON.stringify({profile_id:pid})});
|
|
||||||
voiceView();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function unlockVoice(vid) {
|
|
||||||
await api(`/voices/catalog/${vid}/unlock`, {method:"POST"});
|
|
||||||
voiceView();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function reenrollVoice(vid) {
|
|
||||||
if (!confirm(`Ré-enrôler « ${vid} » (fix §7) et re-pousser sur ses tablettes ? (~1 min)`)) return;
|
|
||||||
const m = document.createElement("div"); m.className = "muted";
|
|
||||||
m.textContent = `ré-enrôlement de ${vid}… (~1 min, ne ferme pas)`; $("detail").prepend(m);
|
|
||||||
try {
|
|
||||||
const r = await api(`/voices/catalog/${vid}/reenroll`, {method:"POST"});
|
|
||||||
alert(`ré-enrôlé.\nref_text : ${r.ref_text || "?"}\nre-déployé : ${(r.redeployed || []).join(", ") || "—"}`);
|
|
||||||
await voiceView();
|
|
||||||
} catch (e) { alert("erreur : " + e.message); }
|
|
||||||
}
|
|
||||||
|
|
||||||
(async () => { await refreshLock(); await loadDevices(); })();
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,147 @@
|
||||||
|
/* Kazeia-central — design system « clinique-apaisé » (clair, sans build). */
|
||||||
|
:root {
|
||||||
|
--bg: #eef2f6; /* fond app, gris-bleu très clair */
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-2: #f4f7fa;
|
||||||
|
--line: #e2e8f0;
|
||||||
|
--line-strong: #cdd7e2;
|
||||||
|
--txt: #25323f;
|
||||||
|
--muted: #64748b;
|
||||||
|
--primary: #3b7dd8; /* bleu calme */
|
||||||
|
--primary-d: #2f68bb;
|
||||||
|
--primary-soft: #e8f0fb;
|
||||||
|
--ok: #2e9e6b;
|
||||||
|
--ok-soft: #e4f4ec;
|
||||||
|
--warn: #c98a1b;
|
||||||
|
--warn-soft: #fbf1dc;
|
||||||
|
--danger: #d2544f;
|
||||||
|
--danger-soft: #fbe9e8;
|
||||||
|
--radius: 12px;
|
||||||
|
--radius-sm: 8px;
|
||||||
|
--shadow: 0 1px 2px rgba(20,40,70,.06), 0 4px 16px rgba(20,40,70,.06);
|
||||||
|
--shadow-sm: 0 1px 2px rgba(20,40,70,.08);
|
||||||
|
--side: 230px;
|
||||||
|
--top: 58px;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { height: 100%; }
|
||||||
|
body {
|
||||||
|
margin: 0; background: var(--bg); color: var(--txt);
|
||||||
|
font: 14px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
a { color: var(--primary); text-decoration: none; }
|
||||||
|
h1,h2,h3 { margin: 0; font-weight: 650; }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
.mono { font-family: ui-monospace, "SF Mono", Menlo, monospace; }
|
||||||
|
|
||||||
|
/* ---- shell ---- */
|
||||||
|
.topbar {
|
||||||
|
position: fixed; top: 0; left: 0; right: 0; height: var(--top); z-index: 20;
|
||||||
|
display: flex; align-items: center; gap: 14px; padding: 0 18px;
|
||||||
|
background: var(--surface); border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.brand { font-weight: 700; font-size: 15px; letter-spacing: .2px; }
|
||||||
|
.brand .dot { color: var(--primary); }
|
||||||
|
.topbar .ctx { color: var(--muted); font-size: 13px; display: flex; align-items: center; gap: 8px; }
|
||||||
|
.topbar .spacer { flex: 1; }
|
||||||
|
.lockpill { display: inline-flex; align-items: center; gap: 6px; font-size: 12px;
|
||||||
|
padding: 4px 10px; border-radius: 999px; border: 1px solid var(--line-strong); background: var(--surface-2); }
|
||||||
|
.lockpill.on { color: var(--ok); border-color: var(--ok); background: var(--ok-soft); }
|
||||||
|
.lockpill.off { color: var(--warn); border-color: var(--warn); background: var(--warn-soft); }
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: fixed; top: var(--top); left: 0; bottom: 0; width: var(--side);
|
||||||
|
background: var(--surface); border-right: 1px solid var(--line);
|
||||||
|
padding: 14px 12px; overflow: auto;
|
||||||
|
}
|
||||||
|
.nav-item {
|
||||||
|
display: flex; align-items: center; gap: 11px; padding: 9px 12px; border-radius: var(--radius-sm);
|
||||||
|
color: var(--txt); cursor: pointer; font-weight: 500; margin-bottom: 2px; user-select: none;
|
||||||
|
}
|
||||||
|
.nav-item .ic { width: 18px; text-align: center; opacity: .85; }
|
||||||
|
.nav-item:hover { background: var(--surface-2); }
|
||||||
|
.nav-item.active { background: var(--primary-soft); color: var(--primary-d); font-weight: 650; }
|
||||||
|
.nav-sep { height: 1px; background: var(--line); margin: 10px 6px; }
|
||||||
|
.nav-label { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted);
|
||||||
|
padding: 6px 12px 4px; }
|
||||||
|
|
||||||
|
main { margin: var(--top) 0 0 var(--side); padding: 22px 26px; min-height: calc(100vh - var(--top)); }
|
||||||
|
.page-head { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
|
||||||
|
.page-head h1 { font-size: 19px; }
|
||||||
|
.page-head .sub { color: var(--muted); font-size: 13px; }
|
||||||
|
.page-head .actions { margin-left: auto; display: flex; gap: 8px; }
|
||||||
|
|
||||||
|
/* ---- composants ---- */
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
|
||||||
|
.card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow); padding: 16px 18px; }
|
||||||
|
.card.full { grid-column: 1 / -1; }
|
||||||
|
.card > h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted);
|
||||||
|
margin-bottom: 12px; font-weight: 650; }
|
||||||
|
.kv { display: flex; justify-content: space-between; gap: 12px; padding: 5px 0; border-bottom: 1px solid var(--surface-2); }
|
||||||
|
.kv:last-child { border-bottom: 0; }
|
||||||
|
.kv .k { color: var(--muted); } .kv .v { font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
button, .btn { font: inherit; cursor: pointer; border-radius: var(--radius-sm); padding: 7px 13px;
|
||||||
|
border: 1px solid var(--line-strong); background: var(--surface); color: var(--txt);
|
||||||
|
box-shadow: var(--shadow-sm); transition: .12s; }
|
||||||
|
button:hover, .btn:hover { border-color: var(--primary); color: var(--primary-d); }
|
||||||
|
.btn-primary { background: var(--primary); border-color: var(--primary); color: #fff; }
|
||||||
|
.btn-primary:hover { background: var(--primary-d); color: #fff; }
|
||||||
|
.btn-sm { padding: 4px 9px; font-size: 12.5px; }
|
||||||
|
.btn-ghost { background: transparent; border-color: transparent; box-shadow: none; }
|
||||||
|
button:disabled { opacity: .5; cursor: default; }
|
||||||
|
input, select { font: inherit; padding: 8px 11px; border: 1px solid var(--line-strong);
|
||||||
|
border-radius: var(--radius-sm); background: var(--surface); color: var(--txt); }
|
||||||
|
input:focus, select:focus { outline: 2px solid var(--primary-soft); border-color: var(--primary); }
|
||||||
|
label.chk { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font-size: 13px; }
|
||||||
|
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
|
||||||
|
th, td { text-align: left; padding: 9px 10px; border-bottom: 1px solid var(--line); vertical-align: middle; }
|
||||||
|
th { color: var(--muted); font-weight: 600; font-size: 12px; }
|
||||||
|
tr:last-child td { border-bottom: 0; }
|
||||||
|
tbody tr:hover { background: var(--surface-2); }
|
||||||
|
|
||||||
|
.badge { display: inline-block; padding: 2px 9px; border-radius: 999px; font-size: 11.5px; font-weight: 600;
|
||||||
|
border: 1px solid var(--line-strong); color: var(--muted); background: var(--surface-2); }
|
||||||
|
.badge.ok { color: var(--ok); border-color: var(--ok); background: var(--ok-soft); }
|
||||||
|
.badge.warn { color: var(--warn); border-color: var(--warn); background: var(--warn-soft); }
|
||||||
|
.badge.danger { color: var(--danger); border-color: var(--danger); background: var(--danger-soft); }
|
||||||
|
.badge.primary { color: var(--primary-d); border-color: var(--primary); background: var(--primary-soft); }
|
||||||
|
|
||||||
|
.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
|
||||||
|
.dot.ok { background: var(--ok); } .dot.off { background: var(--danger); } .dot.idle { background: var(--muted); }
|
||||||
|
|
||||||
|
.empty { text-align: center; color: var(--muted); padding: 34px 16px; }
|
||||||
|
.empty .big { font-size: 30px; opacity: .5; }
|
||||||
|
.err { color: var(--danger); font-size: 13px; }
|
||||||
|
|
||||||
|
.list-tablet { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.tablet-row { display: flex; align-items: center; gap: 10px; padding: 11px 13px; border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-sm); background: var(--surface); cursor: pointer; box-shadow: var(--shadow-sm); }
|
||||||
|
.tablet-row:hover { border-color: var(--primary); }
|
||||||
|
.tablet-row.sel { border-color: var(--primary); background: var(--primary-soft); }
|
||||||
|
.tablet-row .name { font-weight: 600; } .tablet-row .meta { color: var(--muted); font-size: 12px; }
|
||||||
|
|
||||||
|
.spinner { width: 16px; height: 16px; border: 2px solid var(--line-strong); border-top-color: var(--primary);
|
||||||
|
border-radius: 50%; display: inline-block; animation: spin .7s linear infinite; vertical-align: -3px; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* ---- toasts ---- */
|
||||||
|
#toasts { position: fixed; right: 18px; bottom: 18px; z-index: 50; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.toast { background: var(--surface); border: 1px solid var(--line-strong); border-left: 4px solid var(--primary);
|
||||||
|
border-radius: var(--radius-sm); box-shadow: var(--shadow); padding: 11px 15px; max-width: 360px; font-size: 13.5px;
|
||||||
|
animation: slideup .18s ease; }
|
||||||
|
.toast.ok { border-left-color: var(--ok); } .toast.err { border-left-color: var(--danger); }
|
||||||
|
@keyframes slideup { from { transform: translateY(8px); opacity: 0; } }
|
||||||
|
|
||||||
|
/* ---- écran de déverrouillage ---- */
|
||||||
|
.gate { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; background: var(--bg); }
|
||||||
|
.gate .panel { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow); padding: 30px 32px; width: 360px; text-align: center; }
|
||||||
|
.gate .panel .logo { font-size: 22px; font-weight: 700; margin-bottom: 4px; }
|
||||||
|
.gate .panel .logo .dot { color: var(--primary); }
|
||||||
|
.gate .panel p { color: var(--muted); font-size: 13px; margin: 4px 0 18px; }
|
||||||
|
.gate .panel input { width: 100%; margin-bottom: 10px; }
|
||||||
|
.gate .panel .btn-primary { width: 100%; }
|
||||||
Loading…
Reference in New Issue