spb/qc-election
Public
Python 66.6%
HTML 24.8%
CSS 4.9%
JavaScript 3.6%
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="icon" type="image/svg+xml" href="/assets/img/favicon.svg">15<link rel="icon" type="image/png" sizes="64x64" href="/assets/img/favicon-64.png">16<link rel="apple-touch-icon" href="/assets/img/apple-touch-icon.png">17<meta property="og:site_name" content="QC Élection Forecast">18<meta property="og:type" content="website">19<meta property="og:title" content="Administration — QC Élection Forecast">20<meta property="og:description" content="Prévision probabiliste de l'élection générale québécoise du 5 octobre 2026 : 127 circonscriptions, modèle bayésien calibré, sources traçables.">21<meta property="og:image" content="https://www.qc-election.com/assets/img/og.png">22<meta property="og:image:width" content="1200">23<meta property="og:image:height" content="630">24<meta property="og:locale" content="fr_CA">25<meta name="twitter:card" content="summary_large_image">26<meta name="twitter:title" content="Administration — QC Élection Forecast">27<meta name="twitter:image" content="https://www.qc-election.com/assets/img/og.png">28<meta name="theme-color" content="#0a0d14">29<link rel="stylesheet" href="/assets/style.css">30</head>31<body>32<header class="site"></header>33<div class="wrap">34 <div class="hero">35 <h1>Administration</h1>36 <p class="sub">Gestion des données, du pipeline et des runs du modèle.</p>37 </div>3839 <div class="card" style="max-width:520px">40 <h3>Authentification</h3>41 <div class="filters">42 <input type="password" id="token" placeholder="Jeton d'administration" style="flex:1">43 <button class="primary" id="login">Connecter</button>44 </div>45 <p class="note" id="auth-status">Non connecté.</p>46 </div>4748 <div id="panel" style="display:none">49 <h2 class="section">Actions</h2>50 <div class="filters">51 <button id="act-refresh">Rafraîchir les données + relancer le forecast</button>52 <button id="act-forecast">Relancer le forecast seul</button>53 <button id="act-backtest">Relancer le backtest 2022</button>54 <span id="act-status" style="color:var(--muted)"></span>55 </div>5657 <div class="grid two">58 <div class="card">59 <h3>Ajouter un sondage</h3>60 <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:10px">61 <input id="np-pollster" type="text" placeholder="Maison (ex. Léger)">62 <input id="np-end" type="date" title="Fin de terrain">63 <input id="np-n" type="number" placeholder="Taille n">64 <select id="np-mode"><option value="web">web</option><option value="phone">téléphone</option>65 <option value="ivr">IVR</option><option value="sms">SMS</option>66 <option value="mixed">mixte</option><option value="unknown">inconnu</option></select>67 <input id="np-url" type="text" placeholder="URL source" style="grid-column:1/3">68 </div>69 <div id="np-shares" style="display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-top:10px"></div>70 <div style="margin-top:12px"><button class="primary" id="np-save">Enregistrer</button>71 <span id="np-status" style="color:var(--muted);font-size:.85rem"></span></div>72 </div>73 <div class="card">74 <h3>Exclure / réintégrer un sondage</h3>75 <div class="filters">76 <input id="ex-id" type="number" placeholder="ID sondage" style="width:110px">77 <input id="ex-reason" type="text" placeholder="Raison" style="flex:1">78 <button id="ex-do">Exclure</button>79 <button id="ex-undo">Réintégrer</button>80 </div>81 <p class="note">Les ID sont visibles via l'API <code>/api/polls</code>.</p>82 <h3 style="margin-top:18px">Sources de données</h3>83 <table class="data" id="sources"></table>84 </div>85 </div>8687 <h2 class="section">Derniers runs du modèle</h2>88 <div class="card" style="overflow-x:auto"><table class="data" id="runs"></table></div>8990 <h2 class="section">Journal du pipeline</h2>91 <div class="card" style="overflow-x:auto"><table class="data" id="logs"></table></div>92 </div>93</div>94<footer class="site"></footer>9596<script src="/assets/app.js"></script>97<script>98(async () => {99 const meta = await initShell("/admin");100 const parties = Object.keys(meta.parties);101 document.getElementById("np-shares").innerHTML = parties.map(p =>102 `<label style="font-size:.8rem;color:var(--muted)">${p}103 <input id="np-${p}" type="number" step="0.1" min="0" max="70" style="width:100%"></label>`).join("");104105 let TOKEN = sessionStorage.getItem("qce-admin") || "";106 const H = () => ({ "Authorization": `Bearer ${TOKEN}`, "Content-Type": "application/json" });107108 async function connect() {109 try {110 await getJSON(`${API}/admin/runs?limit=1`, { headers: H() });111 document.getElementById("auth-status").textContent = "Connecté.";112 document.getElementById("panel").style.display = "block";113 sessionStorage.setItem("qce-admin", TOKEN);114 loadTables();115 } catch {116 document.getElementById("auth-status").textContent = "Jeton refusé.";117 document.getElementById("panel").style.display = "none";118 }119 }120 document.getElementById("login").onclick = () => {121 TOKEN = document.getElementById("token").value.trim(); connect();122 };123 if (TOKEN) connect();124125 async function loadTables() {126 const runs = await getJSON(`${API}/admin/runs`, { headers: H() });127 document.getElementById("runs").innerHTML =128 `<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>` +129 runs.map(r => `<tr><td>${r.id}</td><td>${r.run_at.slice(0, 16).replace("T", " ")}</td>130 <td>${r.as_of}</td><td>${r.is_backtest ? (r.label || "backtest") : "forecast"}</td>131 <td>${r.n_polls}</td><td>${r.n_sims.toLocaleString("fr-CA")}</td><td>${r.model_version}</td></tr>`).join("");132 const logs = await getJSON(`${API}/admin/logs`, { headers: H() });133 document.getElementById("logs").innerHTML =134 `<tr><th>Date</th><th>Étape</th><th>Statut</th><th>Message</th></tr>` +135 logs.map(l => `<tr><td>${l.at.slice(0, 16).replace("T", " ")}</td><td>${l.step}</td>136 <td style="color:${l.status === "ok" ? "var(--good)" : "var(--danger)"}">${l.status}</td>137 <td style="color:var(--muted);max-width:520px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${l.message}</td></tr>`).join("");138 const srcs = await getJSON(`${API}/admin/sources`, { headers: H() });139 document.getElementById("sources").innerHTML =140 `<tr><th>Source</th><th>Dernier accès</th><th>Statut</th></tr>` +141 srcs.map(s => `<tr><td>${s.name}</td>142 <td>${s.last_fetch ? s.last_fetch.slice(0, 16).replace("T", " ") : "—"}</td>143 <td style="color:var(--muted)">${s.last_status || "—"}</td></tr>`).join("");144 }145146 async function act(url, label) {147 document.getElementById("act-status").textContent = label + "…";148 try {149 const r = await getJSON(url, { method: "POST", headers: H() });150 document.getElementById("act-status").textContent = JSON.stringify(r);151 setTimeout(loadTables, 1500);152 } catch (e) { document.getElementById("act-status").textContent = "erreur : " + e.message; }153 }154 document.getElementById("act-refresh").onclick = () => act(`${API}/admin/refresh`, "pipeline");155 document.getElementById("act-forecast").onclick = () => act(`${API}/admin/forecast`, "forecast");156 document.getElementById("act-backtest").onclick = () => act(`${API}/admin/backtest`, "backtest");157158 document.getElementById("np-save").onclick = async () => {159 const shares = {};160 parties.forEach(p => {161 const v = parseFloat(document.getElementById(`np-${p}`).value);162 if (!isNaN(v)) shares[p] = v;163 });164 const body = {165 pollster: document.getElementById("np-pollster").value.trim(),166 field_end: document.getElementById("np-end").value,167 sample_size: parseInt(document.getElementById("np-n").value) || null,168 mode: document.getElementById("np-mode").value,169 source_url: document.getElementById("np-url").value || null,170 shares,171 };172 try {173 const r = await getJSON(`${API}/admin/poll`, { method: "POST", headers: H(), body: JSON.stringify(body) });174 document.getElementById("np-status").textContent = `Créé (id ${r.id}). Relancez le forecast.`;175 } catch (e) { document.getElementById("np-status").textContent = "erreur : " + e.message; }176 };177178 async function patchPoll(excluded) {179 const id = document.getElementById("ex-id").value;180 if (!id) return;181 try {182 await getJSON(`${API}/admin/poll/${id}`, { method: "PATCH", headers: H(),183 body: JSON.stringify({ excluded, exclusion_reason: document.getElementById("ex-reason").value || null }) });184 document.getElementById("act-status").textContent = `Sondage ${id} ${excluded ? "exclu" : "réintégré"}.`;185 } catch (e) { document.getElementById("act-status").textContent = "erreur : " + e.message; }186 }187 document.getElementById("ex-do").onclick = () => patchPoll(true);188 document.getElementById("ex-undo").onclick = () => patchPoll(false);189})();190</script>191</body>192</html>193