spb/qc-election
Public
1<!DOCTYPE html>2<!--3 QC Élection Forecast — Plateforme de prévision électorale du Québec 20264 Auteur : Simon-Pierre Boucher5 Contact : contact@spboucher.ai6 https://www.qc-election.com7-->8<html lang="fr">9<head>10<script>document.documentElement.dataset.theme=localStorage.getItem("qce-theme")||"dark";</script>11<meta charset="utf-8">12<meta name="viewport" content="width=device-width, initial-scale=1">13<title>Administration — QC Élection Forecast</title>14<link rel="stylesheet" href="/assets/style.css">15</head>16<body>17<header class="site"></header>18<div class="wrap">19 <div class="hero">20 <h1>Administration</h1>21 <p class="sub">Gestion des données, du pipeline et des runs du modèle.</p>22 </div>2324 <div class="card" style="max-width:520px">25 <h3>Authentification</h3>26 <div class="filters">27 <input type="password" id="token" placeholder="Jeton d'administration" style="flex:1">28 <button class="primary" id="login">Connecter</button>29 </div>30 <p class="note" id="auth-status">Non connecté.</p>31 </div>3233 <div id="panel" style="display:none">34 <h2 class="section">Actions</h2>35 <div class="filters">36 <button id="act-refresh">Rafraîchir les données + relancer le forecast</button>37 <button id="act-forecast">Relancer le forecast seul</button>38 <button id="act-backtest">Relancer le backtest 2022</button>39 <span id="act-status" style="color:var(--muted)"></span>40 </div>4142 <div class="grid two">43 <div class="card">44 <h3>Ajouter un sondage</h3>45 <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:10px">46 <input id="np-pollster" type="text" placeholder="Maison (ex. Léger)">47 <input id="np-end" type="date" title="Fin de terrain">48 <input id="np-n" type="number" placeholder="Taille n">49 <select id="np-mode"><option value="web">web</option><option value="phone">téléphone</option>50 <option value="ivr">IVR</option><option value="sms">SMS</option>51 <option value="mixed">mixte</option><option value="unknown">inconnu</option></select>52 <input id="np-url" type="text" placeholder="URL source" style="grid-column:1/3">53 </div>54 <div id="np-shares" style="display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-top:10px"></div>55 <div style="margin-top:12px"><button class="primary" id="np-save">Enregistrer</button>56 <span id="np-status" style="color:var(--muted);font-size:.85rem"></span></div>57 </div>58 <div class="card">59 <h3>Exclure / réintégrer un sondage</h3>60 <div class="filters">61 <input id="ex-id" type="number" placeholder="ID sondage" style="width:110px">62 <input id="ex-reason" type="text" placeholder="Raison" style="flex:1">63 <button id="ex-do">Exclure</button>64 <button id="ex-undo">Réintégrer</button>65 </div>66 <p class="note">Les ID sont visibles via l'API <code>/api/polls</code>.</p>67 <h3 style="margin-top:18px">Sources de données</h3>68 <table class="data" id="sources"></table>69 </div>70 </div>7172 <h2 class="section">Derniers runs du modèle</h2>73 <div class="card" style="overflow-x:auto"><table class="data" id="runs"></table></div>7475 <h2 class="section">Journal du pipeline</h2>76 <div class="card" style="overflow-x:auto"><table class="data" id="logs"></table></div>77 </div>78</div>79<footer class="site"></footer>8081<script src="/assets/app.js"></script>82<script>83(async () => {84 const meta = await initShell("/admin");85 const parties = Object.keys(meta.parties);86 document.getElementById("np-shares").innerHTML = parties.map(p =>87 `<label style="font-size:.8rem;color:var(--muted)">${p}88 <input id="np-${p}" type="number" step="0.1" min="0" max="70" style="width:100%"></label>`).join("");8990 let TOKEN = sessionStorage.getItem("qce-admin") || "";91 const H = () => ({ "Authorization": `Bearer ${TOKEN}`, "Content-Type": "application/json" });9293 async function connect() {94 try {95 await getJSON(`${API}/admin/runs?limit=1`, { headers: H() });96 document.getElementById("auth-status").textContent = "Connecté.";97 document.getElementById("panel").style.display = "block";98 sessionStorage.setItem("qce-admin", TOKEN);99 loadTables();100 } catch {101 document.getElementById("auth-status").textContent = "Jeton refusé.";102 document.getElementById("panel").style.display = "none";103 }104 }105 document.getElementById("login").onclick = () => {106 TOKEN = document.getElementById("token").value.trim(); connect();107 };108 if (TOKEN) connect();109110 async function loadTables() {111 const runs = await getJSON(`${API}/admin/runs`, { headers: H() });112 document.getElementById("runs").innerHTML =113 `<tr><th>ID</th><th>Exécuté</th><th>Vu au</th><th>Type</th><th>Sondages</th><th>Simulations</th><th>Version</th></tr>` +114 runs.map(r => `<tr><td>${r.id}</td><td>${r.run_at.slice(0, 16).replace("T", " ")}</td>115 <td>${r.as_of}</td><td>${r.is_backtest ? (r.label || "backtest") : "forecast"}</td>116 <td>${r.n_polls}</td><td>${r.n_sims.toLocaleString("fr-CA")}</td><td>${r.model_version}</td></tr>`).join("");117 const logs = await getJSON(`${API}/admin/logs`, { headers: H() });118 document.getElementById("logs").innerHTML =119 `<tr><th>Date</th><th>Étape</th><th>Statut</th><th>Message</th></tr>` +120 logs.map(l => `<tr><td>${l.at.slice(0, 16).replace("T", " ")}</td><td>${l.step}</td>121 <td style="color:${l.status === "ok" ? "var(--good)" : "var(--plq)"}">${l.status}</td>122 <td style="color:var(--muted);max-width:520px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${l.message}</td></tr>`).join("");123 const srcs = await getJSON(`${API}/admin/sources`, { headers: H() });124 document.getElementById("sources").innerHTML =125 `<tr><th>Source</th><th>Dernier accès</th><th>Statut</th></tr>` +126 srcs.map(s => `<tr><td>${s.name}</td>127 <td>${s.last_fetch ? s.last_fetch.slice(0, 16).replace("T", " ") : "—"}</td>128 <td style="color:var(--muted)">${s.last_status || "—"}</td></tr>`).join("");129 }130131 async function act(url, label) {132 document.getElementById("act-status").textContent = label + "…";133 try {134 const r = await getJSON(url, { method: "POST", headers: H() });135 document.getElementById("act-status").textContent = JSON.stringify(r);136 setTimeout(loadTables, 1500);137 } catch (e) { document.getElementById("act-status").textContent = "erreur : " + e.message; }138 }139 document.getElementById("act-refresh").onclick = () => act(`${API}/admin/refresh`, "pipeline");140 document.getElementById("act-forecast").onclick = () => act(`${API}/admin/forecast`, "forecast");141 document.getElementById("act-backtest").onclick = () => act(`${API}/admin/backtest`, "backtest");142143 document.getElementById("np-save").onclick = async () => {144 const shares = {};145 parties.forEach(p => {146 const v = parseFloat(document.getElementById(`np-${p}`).value);147 if (!isNaN(v)) shares[p] = v;148 });149 const body = {150 pollster: document.getElementById("np-pollster").value.trim(),151 field_end: document.getElementById("np-end").value,152 sample_size: parseInt(document.getElementById("np-n").value) || null,153 mode: document.getElementById("np-mode").value,154 source_url: document.getElementById("np-url").value || null,155 shares,156 };157 try {158 const r = await getJSON(`${API}/admin/poll`, { method: "POST", headers: H(), body: JSON.stringify(body) });159 document.getElementById("np-status").textContent = `Créé (id ${r.id}). Relancez le forecast.`;160 } catch (e) { document.getElementById("np-status").textContent = "erreur : " + e.message; }161 };162163 async function patchPoll(excluded) {164 const id = document.getElementById("ex-id").value;165 if (!id) return;166 try {167 await getJSON(`${API}/admin/poll/${id}`, { method: "PATCH", headers: H(),168 body: JSON.stringify({ excluded, exclusion_reason: document.getElementById("ex-reason").value || null }) });169 document.getElementById("act-status").textContent = `Sondage ${id} ${excluded ? "exclu" : "réintégré"}.`;170 } catch (e) { document.getElementById("act-status").textContent = "erreur : " + e.message; }171 }172 document.getElementById("ex-do").onclick = () => patchPoll(true);173 document.getElementById("ex-undo").onclick = () => patchPoll(false);174})();175</script>176</body>177</html>178