SPB Git forge

spb/qc-election

Public
20commits 1branches 0releases
4.9 MBsize
maindefault branch
20 days agolast push
Python 66.6% HTML 24.8% CSS 4.9% JavaScript 3.6%
9.4 KB · 193 lines html
Raw Blame History
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