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:
alf 2026-06-30 17:02:55 +02:00
parent 2731b37b20
commit 8d4ab9a07f
3 changed files with 376 additions and 398 deletions

224
kazeia_central/web/app.js Normal file
View File

@ -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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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"); } }

View File

@ -3,405 +3,12 @@
<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>
<title>Kazeia-central</title>
<link rel="stylesheet" href="/styles.css">
</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>
<div id="app"></div>
<div id="toasts"></div>
<script src="/app.js"></script>
</body>
</html>

View File

@ -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%; }