408 lines
21 KiB
HTML
408 lines
21 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 : 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>
|
|
</html>
|