diff --git a/.githooks/pre-push b/.githooks/pre-push index 146d2ba..4fe8bad 100644 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -11,10 +11,12 @@ # set -e -echo "▶ pre-push : porte de qualité (~6 s)…" +echo "▶ pre-push : porte de qualité + parcours e2e (~30 s)…" start=$(date +%s) +# `check` d'abord (6 s) : la majorité des régressions y tombent, autant échouer +# vite. Les parcours Playwright (~22 s) ensuite, seulement si le reste est vert. if ! npm run --silent check; then cat >&2 <<'MSG' @@ -29,4 +31,18 @@ MSG exit 1 fi +if ! npm run --silent test:e2e; then + cat >&2 <<'MSG' + +✗ Les parcours e2e ont échoué — push interrompu. + + Rejouer en mode interactif pour comprendre : + npm run test:e2e:ui + + Sur un clone neuf, récupérer d'abord le navigateur : + npm run test:e2e:install +MSG + exit 1 +fi + echo "✓ pre-push : tout est vert ($(( $(date +%s) - start ))s)" diff --git a/.gitignore b/.gitignore index 280c680..6efe934 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,7 @@ CLAUDE.md # Caches d outillage (lint, tsc) node_modules/.cache/ + +# Artefacts Playwright +test-results/ +playwright-report/ diff --git a/README-CI.md b/README-CI.md index 284859c..d3c7417 100644 --- a/README-CI.md +++ b/README-CI.md @@ -7,10 +7,15 @@ Coolify redéploie sur webhook à **chaque** push (`dev` → dev.metalfrom.eu, **locale**, dans le hook `pre-push`. ``` -git push ──▶ [hook pre-push : npm run check] ──▶ Forgejo ──▶ webhook ──▶ Coolify redeploy - ↑ 6 s, bloquant +git push ──▶ [pre-push : npm run check + test:e2e] ──▶ Forgejo ──▶ webhook ──▶ Coolify redeploy + ↑ 6 s + 22 s, bloquant ``` +Deux granularités volontaires : `check` (6 s) tourne pendant qu'on code, les +parcours Playwright (22 s) seulement au moment de pousser. Mettre l'e2e dans +`check` ferait passer la boucle de 6 à 22 s pour un gain marginal — la plupart +des régressions tombent déjà dans les tests unitaires. + ## Installation (une fois par clone) ```bash @@ -27,7 +32,9 @@ pip install -r requirements-dev.txt | `npm run check:sequential` | Idem, en série (pour isoler un échec) | 14 s | — | | `npm run test:mutation` | Mutation, logique pure (352 mutants) | 33 s | ~5 s | | `npm run test:mutation:full` | Mutation, toute l'API (1511 mutants) | 3 min 40 | **12 s** | -| `npm run check:full` | `check` + audits de dépendances + mutation complète | — | ~40 s | +| `npm run test:e2e` | 53 parcours Playwright (dashboard admin) | 22 s | — | +| `npm run test:e2e:install` | Récupère Chromium (clone neuf) | — | — | +| `npm run check:full` | `check` + e2e + audits + mutation complète | — | ~1 min | | `npm run check:clean` | Purge les caches ESLint / tsc | — | — | ### Où passe le temps @@ -77,6 +84,8 @@ Le plancher de ~3 min à froid est celui de Stryker sur 1511 mutants, assumé. | **Frontend** | `apps/web/test/pure.test.js` | Filtrage, tri, échappement HTML | | **Méta** | `apps/api/test/harness.test.js` | Vérifie le faux pool lui-même | | **Python** | `apps/geocoder/tests/`, `apps/crawler/tests/` | Parseur de localisations, annulation | +| **Parcours (e2e)** | `apps/admin/test/e2e/admin.spec.js` | Playwright sur la VRAIE app Fastify + faux pool : connexion, recherche, édition, déblocage d'une localisation, annulation d'un traitement | +| **a11y des vues rendues** | `apps/admin/test/e2e/a11y.spec.js` | axe-core dans un vrai navigateur — contraste inclus, ce que jsdom ne sait pas calculer | | **Mutation** | `stryker*.config.json` | 82 % (logique pure) / 65 % (API complète) | ## Principe des tests @@ -110,10 +119,28 @@ dit que « cette ligne a été exécutée ». Les seuils d'échec (70 % et 55 %) sont des cliquets anti-régression, pas des objectifs. +## Les parcours e2e + +`apps/admin/test/e2e/server.mjs` reproduit la topologie de production : un +serveur HTTP sert les fichiers statiques et proxifie `/admin/*` vers la VRAIE +application Fastify (comme `apps/admin/nginx.conf`), avec un faux pool à la +place de Postgres. + +Sont donc réellement exercés : le routage Fastify, les cookies, le JWT, bcrypt, +la validation des entrées et la génération SQL. Ne le sont pas : la validité du +SQL pour Postgres et le comportement de PostGIS — affaire de tests +d'intégration, pas de tests d'interface. + +Démarrage ~1 s, aucun conteneur. Une session est enregistrée une fois +(`auth.setup.js`) et partagée : sans ça, chacun des 53 scénarios repayait un +aller-retour de connexion complet. 4 workers est l'optimum mesuré (8 et 12 sont +plus lents — contention au démarrage des navigateurs). + ### Ce qui n'est pas couvert -- Règles a11y exigeant un moteur de rendu (contraste, cibles tactiles) : jsdom - ne calcule pas de styles. À compléter par un audit Lighthouse manuel. +- Règles a11y exigeant un moteur de rendu sur le **site public** : le test jsdom + ne calcule pas les styles. Le dashboard admin, lui, est couvert par axe dans + un vrai navigateur. - `migrate.js` : s'exécute au démarrage du conteneur et appelle `process.exit`. - Crawler et workers de géocodage : seuls le parseur et l'annulation sont couverts, le reste est de l'I/O réseau et base. diff --git a/apps/admin/site/app.js b/apps/admin/site/app.js index fb1f702..ab59bb0 100644 --- a/apps/admin/site/app.js +++ b/apps/admin/site/app.js @@ -1,16 +1,36 @@ "use strict"; +/** + * Dashboard admin metalfrom.eu. + * + * Organisation : un onglet = une question que se pose l'admin, et non une table + * de la base. Chaque chiffre problématique porte son action à côté de lui — + * l'ancienne séparation « je constate ici / j'agis dans un autre onglet » était + * la principale source de confusion. + * + * Pilotage → est-ce que ça tourne ? + * Groupes → trouver et corriger un groupe + * Localisations → qu'est-ce qui coince dans le géocodage ? + * Journal → que s'est-il passé ? + * LLM & coûts → combien ça coûte ? + */ + const state = { username: null, - view: "dashboard", - bands: { page: 1, pageSize: 50, q: "", location_q: "", country: "", status: "", genre: "", enriched: "", has_lat: "", has_location: "", has_conflict: "", sort: "ma_id", dir: "asc", total: 0 }, + view: "pilotage", + bands: { + page: 1, pageSize: 50, q: "", location_q: "", country: "", status: "", genre: "", + themes_q: "", enriched: "", has_lat: "", has_location: "", has_conflict: "", + sort: "ma_id", dir: "asc", total: 0, advanced: false, + }, + locations: { page: 1, pageSize: 50, status: "error,llm_needed", q: "", country: "", total: 0 }, activity: { page: 1, pageSize: 50, type: "", status: "", total: 0 }, llm: { page: 1 }, - overviewTimer: null, + timer: null, }; // ------------------------------------------------------------------ -// API helper +// API // ------------------------------------------------------------------ async function api(path, opts = {}) { const res = await fetch(path, { @@ -23,21 +43,69 @@ async function api(path, opts = {}) { throw new Error("unauthorized"); } const body = await res.json().catch(() => ({})); - if (!res.ok || body.ok === false) { - throw new Error(body.error || `HTTP ${res.status}`); - } + if (!res.ok || body.ok === false) throw new Error(body.error || `HTTP ${res.status}`); return body; } +// ------------------------------------------------------------------ +// Utilitaires de rendu +// ------------------------------------------------------------------ +function esc(s) { + return String(s ?? "").replace(/[&<>"']/g, (c) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); +} +function fmtDate(s) { + if (!s) return "—"; + return new Date(s).toLocaleString("fr-FR", { + day: "2-digit", month: "2-digit", year: "2-digit", hour: "2-digit", minute: "2-digit", + }); +} +function fmtNum(n) { + return Number(n || 0).toLocaleString("fr-FR"); +} +/** Durée écoulée, lisible ("3 min", "2 h 10"). */ +function since(ts) { + if (!ts) return "—"; + const min = Math.max(0, Math.round((Date.now() - new Date(ts)) / 60000)); + if (min < 60) return `${min} min`; + const h = Math.floor(min / 60); + return `${h} h ${String(min % 60).padStart(2, "0")}`; +} +const content = () => document.getElementById("content"); + +const STATUS_TONE = { + done: "ok", country_only: "ok", error: "err", running: "warn", + cancelled: "muted", queued: "muted", processing: "warn", + llm_needed: "warn", manual: "warn", +}; +/** Les valeurs stockées sont techniques ; l'admin lit du français. */ +const STATUS_LABEL = { + done: "résolu", country_only: "pays seul", error: "erreur", + running: "en cours", cancelled: "annulé", queued: "en file", + processing: "traitement", llm_needed: "attente LLM", manual: "manuel", +}; +function statusBadge(status) { + return `${esc(STATUS_LABEL[status] || status)}`; +} +function emptyRow(msg = "Aucune donnée") { return `
${esc(msg)}
`; } + +/** Bloc d'erreur uniforme, avec le message réel de l'API. */ +function errorBox(e) { + return `
⚠ ${esc(e.message)}
`; +} + +function clearTimer() { + if (state.timer) { clearInterval(state.timer); state.timer = null; } +} + // ------------------------------------------------------------------ // Auth // ------------------------------------------------------------------ function showLogin() { document.getElementById("login-screen").classList.remove("hidden"); document.getElementById("app").classList.add("hidden"); - if (state.overviewTimer) { clearInterval(state.overviewTimer); state.overviewTimer = null; } + clearTimer(); } - function showApp() { document.getElementById("login-screen").classList.add("hidden"); document.getElementById("app").classList.remove("hidden"); @@ -60,7 +128,9 @@ document.getElementById("login-form").addEventListener("submit", async (e) => { const username = document.getElementById("login-username").value.trim(); const password = document.getElementById("login-password").value; const errEl = document.getElementById("login-error"); + const btn = e.target.querySelector('button[type="submit"]'); errEl.textContent = ""; + btn.disabled = true; try { const res = await fetch("/admin/auth/login", { method: "POST", @@ -79,6 +149,8 @@ document.getElementById("login-form").addEventListener("submit", async (e) => { router(); } catch { errEl.textContent = "Erreur réseau"; + } finally { + btn.disabled = false; } }); @@ -89,399 +161,469 @@ document.getElementById("logout-btn").addEventListener("click", async () => { }); // ------------------------------------------------------------------ -// Router — 5 onglets : Vue d'ensemble, Groupes, Activité, Actions, LLM +// Routeur // ------------------------------------------------------------------ -const VIEWS = ["dashboard", "bands", "activity", "actions", "llm"]; +const VIEWS = ["pilotage", "bands", "locations", "activity", "llm"]; const VIEW_ALIASES = { - "": "dashboard", - dashboard: "dashboard", - overview: "dashboard", - bands: "bands", - band: "bands", - activity: "activity", - activities: "activity", - activite: "activity", - activites: "activity", - actions: "actions", - action: "actions", - llm: "llm", + "": "pilotage", pilotage: "pilotage", dashboard: "pilotage", overview: "pilotage", + bands: "bands", band: "bands", groupes: "bands", + locations: "locations", localisations: "locations", geocoding: "locations", + activity: "activity", activite: "activity", journal: "activity", + llm: "llm", couts: "llm", + // L'onglet « Actions » a été fondu dans Pilotage : on redirige les favoris. + actions: "pilotage", }; function normalizeView(raw) { - const key = String(raw || "").trim().toLowerCase().replace(/^\/+|\/+$/g, ""); + // La query string doit être retirée AVANT la recherche d'alias : les liens du + // Pilotage pointent vers "#/locations?status=error", qui sinon ne + // correspondrait à aucune vue et retomberait sur Pilotage. + const key = String(raw || "").split("?")[0] + .trim().toLowerCase().replace(/^\/+|\/+$/g, ""); const normalized = VIEW_ALIASES[key]; - return VIEWS.includes(normalized) ? normalized : "dashboard"; + return VIEWS.includes(normalized) ? normalized : "pilotage"; } function currentViewFromLocation() { - const hashView = normalizeView((location.hash || "").replace(/^#\/?/, "")); - if (location.hash) return hashView; - - const pathParts = location.pathname.split("/").filter(Boolean); - return normalizeView(pathParts[0] || ""); + if (location.hash) return normalizeView((location.hash || "").replace(/^#\/?/, "")); + return normalizeView(location.pathname.split("/").filter(Boolean)[0] || ""); } +const RENDERERS = { + pilotage: renderPilotage, + bands: renderBands, + locations: renderLocations, + activity: renderActivity, + llm: renderLLM, +}; + function router() { const view = currentViewFromLocation(); state.view = view; document.querySelectorAll(".nav a").forEach((a) => { - a.classList.toggle("active", a.dataset.view === view); + const active = a.dataset.view === view; + a.classList.toggle("active", active); + if (active) a.setAttribute("aria-current", "page"); + else a.removeAttribute("aria-current"); }); - if (state.overviewTimer) { clearInterval(state.overviewTimer); state.overviewTimer = null; } - const renderers = { - dashboard: renderOverview, - bands: renderBands, - activity: renderActivity, - actions: renderActions, - llm: renderLLM, - }; - renderers[view](); + clearTimer(); + RENDERERS[view](); } window.addEventListener("hashchange", router); -function esc(s) { - return String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); -} -function fmtDate(s) { - if (!s) return "—"; - const d = new Date(s); - return d.toLocaleString("fr-FR", { day: "2-digit", month: "2-digit", year: "2-digit", hour: "2-digit", minute: "2-digit" }); -} -const content = () => document.getElementById("content"); +// ================================================================== +// PILOTAGE — « est-ce que ça tourne ? » +// ================================================================== +async function renderPilotage() { + content().innerHTML = ` +
+

Pilotage

+ Actualisation auto toutes les 15 s +
+
Chargement…
+
+
+
Chargement…
+
+
+
+ ⚠️ Opérations destructives +

+ À n'utiliser que si le géocodage est corrompu. Ces actions relancent des + milliers d'appels Geoapify et Groq, qui sont facturés. +

+
+ + + +
+
+
+ `; -function statCard(label, value, cls = "") { - return `
${esc(label)}
${Number(value).toLocaleString("fr-FR")}
`; -} -function barRow(label, count, max) { - const pct = Math.max(2, Math.round((count / max) * 100)); - return `
${esc(label)}
${count}
`; -} -function emptyRow() { return `
Aucune donnée
`; } -function statusBadge(status) { - const cls = status === "done" ? "ok" : status === "error" ? "err" : status === "running" ? "warn" : "muted"; - return `${esc(status)}`; + renderTriggers(); + wireDangerZone(); + await loadPilotage(); + clearTimer(); + state.timer = setInterval(loadPilotage, 15000); } -// Découpe un genre complexe ("Doom/Death Metal; Gothic/Progressive") en mots-clés -function splitGenreElements(genreRows) { - const counts = new Map(); - for (const { genre, total } of genreRows) { - if (!genre) continue; - const cleaned = genre.replace(/\([^)]*\)/g, ""); - const tokens = cleaned.split(/[/;,]+/); - for (const tok of tokens) { - const kw = tok.trim(); - if (kw.length < 3) continue; - counts.set(kw, (counts.get(kw) || 0) + total); +function renderTriggers() { + document.getElementById("p-triggers").innerHTML = ` +

Lancer un traitement

+

Le crawler consomme la demande à son prochain cycle (~1 min).

+
+ + + + +
+
+ `; + + document.getElementById("p-triggers").addEventListener("click", async (e) => { + const btn = e.target.closest("[data-job]"); + if (!btn) return; + const job_type = btn.dataset.job; + if (job_type === "full_crawl" && + !confirm("Lancer un crawl complet de l'Europe ?\n\nPlusieurs heures, et beaucoup de requêtes vers Metal Archives.")) return; + const fb = document.getElementById("p-job-feedback"); + btn.disabled = true; + fb.textContent = "Envoi…"; fb.className = "feedback"; + try { + const r = await api("/admin/api/job-triggers", { method: "POST", body: JSON.stringify({ job_type }) }); + fb.textContent = `✓ Demande #${r.id} enregistrée — suivi dans Journal.`; + fb.className = "feedback ok"; + await loadPilotage(); + } catch (err) { + fb.textContent = `✗ ${err.message}`; + fb.className = "feedback err"; + } finally { + btn.disabled = false; } - } - return [...counts.entries()] - .map(([kw, n]) => ({ kw, n })) - .sort((a, b) => b.n - a.n); + }); } -// ------------------------------------------------------------------ -// Vue d'ensemble — stats bands + genre/pays + live (crawl/jobs/géocodage) -// ------------------------------------------------------------------ -async function renderOverview() { - content().innerHTML = `
Chargement…
`; - try { - const r = await api("/admin/api/stats"); - const t = r.totals; - const maxStatus = Math.max(1, ...r.by_status.map((x) => x.total)); - const maxCountry = Math.max(1, ...r.by_country.map((x) => x.total)); - const elements = splitGenreElements(r.by_genre); - const maxEl = Math.max(1, ...elements.map((x) => x.n)); +const DANGER_ACTIONS = { + "reset-all": { + url: "/admin/api/locations/reset-all", + confirm: "RÉINITIALISER TOUT LE GÉOCODAGE ?\n\nToutes les localisations repassent en file et perdent leurs coordonnées. Le cache est réutilisé quand c'est possible, mais cela reste coûteux.", + }, + "purge-nominatim": { + url: "/admin/api/geocode-cache/purge-nominatim", + confirm: "Supprimer les entrées de cache de l'ancien pipeline Nominatim ?\n\nCes lieux seront re-géocodés via Geoapify — appels facturés.", + }, + "requeue-all": { + url: "/admin/api/locations/requeue-all", + body: { include_done: true }, + confirm: "Remettre TOUTES les localisations en file, y compris celles déjà résolues ?", + }, +}; - content().innerHTML = ` -
- ${statCard("Total bands", t.total)} - ${statCard("Enrichis", t.enriched, "ok")} - ${statCard("Non enrichis", t.not_enriched, "warn")} - ${statCard("Géocodés", t.geocoded, "ok")} - ${statCard("Sans localisation", t.not_geocoded, "warn")} - ${statCard("Jamais enrichis (band_page absent)", t.never_enriched, "err")} - ${statCard("Stale (>30j)", t.stale, "warn")} -
- -
-
En direct
- Auto-refresh 15s -
-
-
-
-
-
-
- -
-
-

Par statut

- ${r.by_status.map((x) => barRow(x.status, x.total, maxStatus)).join("") || emptyRow()} -
-
-

Par pays (${r.by_country.length})

- ${r.by_country.map((x) => barRow(x.country, x.total, maxCountry)).join("") || emptyRow()} -
-
-

Éléments de genre (${elements.length} mots-clés)

-

Chaque genre complexe (ex: "Doom/Death Metal; Gothic") est découpé en mots-clés.

- ${elements.map((x) => barRow(x.kw, x.n, maxEl)).join("") || emptyRow()} -
-
- `; - } catch (e) { - content().innerHTML = `
Erreur : ${esc(e.message)}
`; - return; - } - - await loadOverviewLive(); - if (state.overviewTimer) clearInterval(state.overviewTimer); - state.overviewTimer = setInterval(loadOverviewLive, 15000); +function wireDangerZone() { + document.getElementById("p-danger").addEventListener("click", async (e) => { + const btn = e.target.closest("[data-danger]"); + if (!btn) return; + const action = DANGER_ACTIONS[btn.dataset.danger]; + if (!action || !confirm(action.confirm)) return; + const fb = document.getElementById("p-danger-feedback"); + btn.disabled = true; + fb.textContent = "En cours…"; fb.className = "feedback"; + try { + const r = await api(action.url, { method: "POST", body: JSON.stringify(action.body || {}) }); + fb.textContent = `✓ ${fmtNum(r.count)} entrée(s) modifiée(s).`; + fb.className = "feedback ok"; + await loadPilotage(); + } catch (err) { + fb.textContent = `✗ ${err.message}`; + fb.className = "feedback err"; + } finally { + btn.disabled = false; + } + }); } -async function loadOverviewLive() { - const el = document.getElementById("ov-live"); - if (!el) return; +function healthCard(label, value, sub, tone = "") { + return `
+
${esc(label)}
+
${value}
+
${sub}
+
`; +} + +async function loadPilotage() { try { - const [live, geo, queue] = await Promise.all([ + const [stats, live, geo, queue] = await Promise.all([ + api("/admin/api/stats"), api("/admin/api/live"), api("/admin/api/geocoding"), api("/admin/api/queue"), ]); - // --- Crawl en cours --- - const runsHtml = live.active_runs.length - ? live.active_runs.map(r => { - const elapsed = Math.round((Date.now() - new Date(r.started_at)) / 60000); - // L'annulation est coopérative : le crawler ne s'arrête qu'au prochain - // point de contrôle. Tant qu'il n'a pas basculé le statut à - // 'cancelled', on affiche l'état intermédiaire au lieu de laisser - // croire que c'est déjà fait. - const pendingCancel = r.cancel_requested === true; - return `
-
- ▶ ${esc(r.run_type)} -
- ${elapsed}min · ${r.bands_seen} vus · ${r.bands_new} nouveaux · ${r.bands_enriched} enrichis -
- ${pendingCancel ? `
⏳ Arrêt demandé${r.cancel_requested_by ? ` par ${esc(r.cancel_requested_by)}` : ""} — le crawler s'arrêtera au prochain lot
` : ""} - ${r.error ? `
${esc(r.error)}
` : ""} + const t = stats.totals; + const locs = geo.locations || []; + const n = (s) => locs.find((x) => x.status === s)?.n || 0; + const locTotal = locs.reduce((sum, x) => sum + x.n, 0); + const locDone = n("done") + n("country_only"); + const locPct = locTotal ? Math.round((locDone / locTotal) * 100) : 0; + const errors = n("error"); + const llmNeeded = n("llm_needed"); + const queued = n("queued") + n("processing"); + const cost = Number(geo.llm_cache?.total_cost_usd || 0); + + // ---- Bandeau de santé ---- + document.getElementById("p-health").innerHTML = [ + healthCard("Groupes", fmtNum(t.total), `${fmtNum(t.enriched)} enrichis`), + healthCard("Géocodage", `${locPct} %`, + `${fmtNum(locDone)} / ${fmtNum(locTotal)} lieux`, + locPct >= 95 ? "ok" : locPct >= 70 ? "warn" : "err"), + healthCard("En file", fmtNum(queued), + queued ? "traitement en cours" : "rien à traiter", + queued ? "warn" : "ok"), + healthCard("Bloqués", fmtNum(errors + llmNeeded), + `${fmtNum(errors)} erreurs · ${fmtNum(llmNeeded)} LLM`, + errors + llmNeeded === 0 ? "ok" : "err"), + healthCard("Coût LLM", `$${cost.toFixed(2)}`, `${fmtNum(geo.llm_cache?.n || 0)} appels`), + ].join(""); + + // ---- Alertes actionnables ---- + // Le principe de cette vue : aucun chiffre problématique sans le bouton qui + // le résout juste à côté. + const alerts = []; + if (errors > 0) { + alerts.push({ + tone: "err", + text: `${fmtNum(errors)} localisation(s) en erreur de géocodage`, + actions: [ + { label: "Examiner", href: "#/locations?status=error" }, + { label: "Tout remettre en file", post: "/admin/api/locations/reset-errors" }, + ], + }); + } + if (llmNeeded > 0) { + alerts.push({ + tone: "warn", + text: `${fmtNum(llmNeeded)} lieu(x) en attente de désambiguïsation LLM`, + actions: [{ label: "Examiner", href: "#/locations?status=llm_needed" }], + }); + } + if (t.never_enriched > 0) { + alerts.push({ + tone: "warn", + text: `${fmtNum(t.never_enriched)} groupe(s) jamais enrichis`, + actions: [{ label: "Lancer l'enrichissement", job: "enrich" }], + }); + } + if (queue.breakdown?.stale > 0) { + alerts.push({ + tone: "muted", + text: `${fmtNum(queue.breakdown.stale)} groupe(s) non revus depuis plus de 30 jours`, + actions: [{ label: "Crawl incrémental", job: "incremental" }], + }); + } + const stuck = (live.active_runs || []).filter( + (r) => Date.now() - new Date(r.started_at) > 30 * 60000 && !r.cancel_requested); + if (stuck.length) { + alerts.push({ + tone: "err", + text: `${stuck.length} run(s) actifs depuis plus de 30 min — possiblement bloqués`, + actions: [{ label: "Nettoyer les runs bloqués", post: "/admin/api/crawl-runs/cleanup", body: { older_than_minutes: 30 } }], + }); + } + + document.getElementById("p-alerts").innerHTML = alerts.length + ? `
${alerts.map((a, idx) => ` +
+ ${esc(a.text)} + + ${a.actions.map((act, j) => act.href + ? `${esc(act.label)}` + : `` + ).join("")} + +
`).join("")}
` + : `
✓ Rien ne bloque : le pipeline est sain.
`; + + document.getElementById("p-alerts").onclick = async (e) => { + const btn = e.target.closest("[data-alert]"); + if (!btn) return; + const act = alerts[Number(btn.dataset.alert)].actions[Number(btn.dataset.act)]; + btn.disabled = true; + try { + if (act.job) { + await api("/admin/api/job-triggers", { method: "POST", body: JSON.stringify({ job_type: act.job }) }); + } else { + await api(act.post, { method: "POST", body: JSON.stringify(act.body || {}) }); + } + await loadPilotage(); + } catch (err) { + alert(err.message); + btn.disabled = false; + } + }; + + // ---- Runs actifs ---- + const runs = live.active_runs || []; + document.getElementById("p-runs").innerHTML = ` +

Traitements en cours (${runs.length})

+ ${runs.length ? runs.map((r) => { + const pending = r.cancel_requested === true; + return `
+
+ ▶ ${esc(r.run_type)} +
+ ${since(r.started_at)} · ${fmtNum(r.bands_seen)} vus · ${fmtNum(r.bands_new)} nouveaux · ${fmtNum(r.bands_enriched)} enrichis
- ${pendingCancel - ? `` - : ``} -
`; - }).join("") - : `
Aucun run actif
`; + ${pending ? `
⏳ Arrêt demandé${r.cancel_requested_by ? ` par ${esc(r.cancel_requested_by)}` : ""} — s'arrêtera au prochain lot
` : ""} +
+ ${pending + ? `` + : ``} +
`; + }).join("") : emptyRow("Aucun traitement en cours")} - // --- Géocodage (band_locations) --- - const locs = geo.locations || []; - const locTotal = locs.reduce((s, x) => s + x.n, 0); - const locDone = (locs.find(x => x.status === "done")?.n || 0) + (locs.find(x => x.status === "country_only")?.n || 0); - const locLlm = locs.find(x => x.status === "llm_needed")?.n || 0; - const locManual = locs.find(x => x.status === "manual")?.n || 0; - const locErr = locs.find(x => x.status === "error")?.n || 0; - const locPct = locTotal ? Math.round((locDone / locTotal) * 100) : 0; - const provTxt = (geo.providers || []).map(p => `${esc(p.provider)}: ${p.n.toLocaleString("fr-FR")}${p.avg_conf != null ? ` (conf~${p.avg_conf})` : ""}`).join(" · "); - const geoHtml = ` -
-
-
-
${locPct}% — ${locDone.toLocaleString("fr-FR")} / ${locTotal.toLocaleString("fr-FR")}
-
- ${locLlm ? `⚡ ${locLlm} LLM needed` : ""} - ${locManual ? `${locManual} manuel` : ""} - ${locErr ? `✗ ${locErr} erreurs` : ""} -
-
${provTxt || "—"}
+ ${(live.pending_jobs || []).length ? ` +

En attente (${live.pending_jobs.length})

+ ${live.pending_jobs.map((j) => `
+
+ ${esc(j.job_type)} +
demandé par ${esc(j.requested_by || "?")} · il y a ${since(j.created_at)}
+
+ +
`).join("")}` : ""} `; - // --- Jobs en attente --- - const jobsHtml = live.pending_jobs.length - ? live.pending_jobs.map(j => { - const age = Math.round((Date.now() - new Date(j.created_at)) / 60000); - return `
-
- ${esc(j.job_type)} -
par ${esc(j.requested_by || "?")} · ${age}min
-
- -
`; - }).join("") - : `
Aucun job en attente
`; + document.getElementById("p-runs").onclick = async (e) => { + const runBtn = e.target.closest("[data-cancel-run]"); + const jobBtn = e.target.closest("[data-cancel-job]"); + if (!runBtn && !jobBtn) return; + const id = Number((runBtn || jobBtn).dataset.cancelRun || (runBtn || jobBtn).dataset.cancelJob); + const url = runBtn ? `/admin/api/crawl-runs/${id}/cancel` : `/admin/api/job-triggers/${id}/cancel`; + const msg = runBtn + ? `Demander l'arrêt du traitement #${id} ?\n\nIl s'arrêtera à son prochain point de contrôle.` + : `Retirer la demande #${id} de la file ?`; + if (!confirm(msg)) return; + (runBtn || jobBtn).disabled = true; + try { await api(url, { method: "POST", body: "{}" }); await loadPilotage(); } + catch (err) { alert(err.message); await loadPilotage(); } + }; - const b = queue.breakdown; - const queueHtml = ` -
- ${b.new_bands} nouveaux (priorité 1) - ${b.modified_since_enrich} modifiés depuis enrich (p.2) - ${b.legacy_pending} legacy à ré-enrichir (p.3) - ${b.stale} stale >30j (p.4) -
- `; - - el.innerHTML = ` -
-

Crawl en cours (${live.active_runs.length})

- ${runsHtml} -
-
-

Géocodage

- ${geoHtml} -
-
-

Jobs en attente (${live.pending_jobs.length})

- ${jobsHtml} -
-
-

File d'enrichissement

- ${queueHtml} -
- `; - const ts = document.getElementById("ov-live-ts"); - if (ts) ts.textContent = `Actualisé à ${new Date().toLocaleTimeString("fr-FR")}`; + const note = document.getElementById("p-refresh"); + if (note) note.textContent = `Actualisé à ${new Date().toLocaleTimeString("fr-FR")}`; } catch (e) { - const ts = document.getElementById("ov-live-ts"); - if (ts) ts.textContent = `Erreur : ${e.message}`; + const note = document.getElementById("p-refresh"); + if (note) note.textContent = `Erreur : ${e.message}`; } } -async function adminCancelRun(id) { - if (!confirm(`Demander l'arrêt du run #${id} ?\n\nLe crawler s'arrêtera à son prochain point de contrôle (quelques secondes à une minute selon le job).`)) return; - try { - await api(`/admin/api/crawl-runs/${id}/cancel`, { method: "POST", body: "{}" }); - await loadOverviewLive(); - } catch (e) { alert(`Erreur : ${e.message}`); } -} +// ================================================================== +// GROUPES — « trouver et corriger » +// ================================================================== +const BAND_CHIPS = [ + { key: "has_conflict", value: "true", label: "🔒 Conflits crawler" }, + { key: "has_lat", value: "false", label: "Sans coordonnées" }, + { key: "has_location", value: "false", label: "Sans lieu renseigné" }, + { key: "enriched", value: "false", label: "Non enrichis" }, +]; -async function adminCancelJob(id) { - if (!confirm(`Annuler le job trigger #${id} ?`)) return; - try { - await api(`/admin/api/job-triggers/${id}/cancel`, { method: "POST", body: "{}" }); - await loadOverviewLive(); - } catch (e) { alert(`Erreur : ${e.message}`); } -} - -// Ces deux handlers sont appelés depuis des attributs onclick= générés dans -// les templates ci-dessus : l'exposition sur `window` est explicite pour que le -// couplage reste visible (et que le linter ne les prenne pas pour du code mort). -window.adminCancelRun = adminCancelRun; -window.adminCancelJob = adminCancelJob; - -// ------------------------------------------------------------------ -// Groupes (Bands + Conflits fondus) -// ------------------------------------------------------------------ async function renderBands() { const s = state.bands; content().innerHTML = ` -
- - - - - - - - - - - +

Groupes

+ + + +
+ ${BAND_CHIPS.map((c) => ``).join("")} + +
+ +
+ + + + + + +
+
Chargement…
`; - document.getElementById("b-apply").addEventListener("click", () => { - s.q = document.getElementById("b-q").value.trim(); - s.location_q = document.getElementById("b-location_q").value.trim(); - s.country = document.getElementById("b-country").value.trim(); - s.status = document.getElementById("b-status").value.trim(); - s.genre = document.getElementById("b-genre").value.trim(); - s.enriched = document.getElementById("b-enriched").value; - s.themes_q = document.getElementById("b-themes_q").value.trim(); - s.has_lat = document.getElementById("b-has_lat").value; - s.has_location = document.getElementById("b-has_location").value; - s.has_conflict = document.getElementById("b-has_conflict").checked ? "true" : ""; + + const applySearch = () => { s.q = document.getElementById("b-q").value.trim(); s.page = 1; loadBands(); }; + document.getElementById("b-search").addEventListener("click", applySearch); + document.getElementById("b-q").addEventListener("keydown", (e) => { if (e.key === "Enter") applySearch(); }); + + document.getElementById("b-advanced-toggle").addEventListener("click", (e) => { + s.advanced = !s.advanced; + document.getElementById("b-advanced").classList.toggle("hidden", !s.advanced); + e.target.setAttribute("aria-expanded", String(s.advanced)); + }); + + document.querySelector(".chips").addEventListener("click", (e) => { + const chip = e.target.closest("[data-chip]"); + if (chip) { + const { chip: key, val } = chip.dataset; + s[key] = s[key] === val ? "" : val; + s.page = 1; + renderBands(); + return; + } + if (e.target.id === "b-clear") { + for (const c of BAND_CHIPS) s[c.key] = ""; + s.q = s.country = s.status = s.genre = s.location_q = s.themes_q = ""; + s.page = 1; + renderBands(); + } + }); + + document.getElementById("b-apply")?.addEventListener("click", () => { + for (const f of ["country", "status", "genre", "location_q", "themes_q"]) { + s[f] = document.getElementById(`b-${f}`).value.trim(); + } s.page = 1; loadBands(); }); + await loadBands(); } const BAND_COLUMNS = [ - { key: "ma_id", label: "MA ID" }, + { key: "ma_id", label: "ID" }, { key: "name", label: "Nom" }, { key: "country", label: "Pays" }, { key: "status", label: "Statut" }, { key: "genre", label: "Genre" }, - { key: "themes", label: "Thèmes" }, { key: "location_text", label: "Lieu" }, { key: "formed_year", label: "Année" }, - { key: "enriched", label: "Enrichi" }, - { key: "crawled_at", label: "Crawlé le" }, - { key: "updated_at", label: "Modifié le" }, + { key: "enriched", label: "État" }, + { key: "updated_at", label: "Modifié" }, ]; async function loadBands() { const s = state.bands; - const params = new URLSearchParams({ - page: s.page, pageSize: s.pageSize, sort: s.sort, dir: s.dir, - }); - if (s.q) params.set("q", s.q); - if (s.location_q) params.set("location_q", s.location_q); - if (s.country) params.set("country", s.country); - if (s.status) params.set("status", s.status); - if (s.genre) params.set("genre", s.genre); - if (s.enriched) params.set("enriched", s.enriched); - if (s.themes_q) params.set("themes_q", s.themes_q); - if (s.has_lat) params.set("has_lat", s.has_lat); - if (s.has_location) params.set("has_location", s.has_location); - if (s.has_conflict) params.set("has_conflict", s.has_conflict); + const params = new URLSearchParams({ page: s.page, pageSize: s.pageSize, sort: s.sort, dir: s.dir }); + for (const f of ["q", "location_q", "country", "status", "genre", "themes_q", "enriched", "has_lat", "has_location", "has_conflict"]) { + if (s[f]) params.set(f, s[f]); + } try { const r = await api(`/admin/api/bands?${params}`); s.total = r.total; - const tableEl = document.getElementById("b-table"); - tableEl.innerHTML = ` + document.getElementById("b-count").textContent = `${fmtNum(r.total)} groupe(s)`; + + document.getElementById("b-table").innerHTML = ` - ${BAND_COLUMNS.map((c) => ``).join("")} + ${BAND_COLUMNS.map((c) => ``).join("")} ${r.items.map((b) => ` - + - - - + + - - + - - `).join("") || ``} + `).join("") || ``} -
${c.label}${s.sort === c.key ? (s.dir === "asc" ? " ▲" : " ▼") : ""}
${b.ma_id} ${esc(b.name)} ${esc(b.country || "")} ${esc(b.status || "")}${esc(b.genre || "")}${esc(b.themes || "")}${esc(b.location_text || "")}${esc(b.genre || "")}${esc(b.location_text || "")} ${b.formed_year || ""}${b.enriched ? 'oui' : 'non'}${Object.keys(b.locked_fields||{}).length ? ' 🔒' : ""}${fmtDate(b.crawled_at)} + ${b.enriched ? 'enrichi' : 'à enrichir'} + ${b.lat == null ? '📍?' : ""} + ${Object.keys(b.locked_fields || {}).length ? '🔒' : ""} + ${fmtDate(b.updated_at)}
Aucun résultat
Aucun résultat
- `; - tableEl.querySelectorAll("th[data-sort]").forEach((th) => { + `; + + const tableEl = document.getElementById("b-table"); + tableEl.querySelectorAll("[data-sort]").forEach((th) => { th.addEventListener("click", () => { const key = th.dataset.sort; if (s.sort === key) s.dir = s.dir === "asc" ? "desc" : "asc"; @@ -489,260 +631,443 @@ async function loadBands() { loadBands(); }); }); + // Les lignes sont activables au clavier (role=button + tabindex), pas + // seulement à la souris. tableEl.querySelectorAll("tr[data-ma-id]").forEach((tr) => { - tr.addEventListener("click", () => openBandModal(Number(tr.dataset.maId))); + const open = () => openBandModal(Number(tr.dataset.maId)); + tr.addEventListener("click", open); + tr.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { e.preventDefault(); open(); } + }); }); - const totalPages = Math.max(1, Math.ceil(s.total / s.pageSize)); - document.getElementById("b-pager").innerHTML = ` - - Page ${s.page} / ${totalPages} (${s.total.toLocaleString("fr-FR")} bands) - - `; - document.getElementById("b-prev")?.addEventListener("click", () => { s.page--; loadBands(); }); - document.getElementById("b-next")?.addEventListener("click", () => { s.page++; loadBands(); }); + renderPager("b-pager", s, loadBands); } catch (e) { - document.getElementById("b-table").innerHTML = `
Erreur : ${esc(e.message)}
`; + document.getElementById("b-table").innerHTML = errorBox(e); } } -// Lignes de conflit champ-par-champ (crawler_pending vs valeur actuelle), -// réutilisées par le modal band. -function conflictRowsHtml(band) { - const pending = band.crawler_pending || {}; - return Object.entries(pending).map(([field, crawlerVal]) => { - const myVal = band[field] ?? "—"; - return ` - ${esc(field)} - ${esc(String(myVal))} - ${esc(String(crawlerVal))} - - - - - `; - }).join(""); +/** Pagination partagée par les trois listes. */ +function renderPager(elId, s, reload) { + const totalPages = Math.max(1, Math.ceil(s.total / s.pageSize)); + const el = document.getElementById(elId); + if (!el) return; + el.innerHTML = ` + + Page ${s.page} / ${totalPages} — ${fmtNum(s.total)} résultat(s) + `; + el.onclick = (e) => { + const btn = e.target.closest("[data-page]"); + if (!btn || btn.disabled) return; + s.page += btn.dataset.page === "next" ? 1 : -1; + reload(); + }; } -async function openBandModal(maId) { - let band, locations = [], llmCalls = []; - try { - const r = await api(`/admin/api/bands/${maId}`); - band = r.item; - locations = r.locations || []; - llmCalls = r.llm || []; - } catch (e) { - alert(`Erreur : ${e.message}`); - return; - } - // ---- Provenance & debug (lecture seule) ---- - const lockedFields = (() => { - const lf = band.locked_fields; - if (!lf) return []; - if (Array.isArray(lf)) return lf; - if (typeof lf === "object") return Object.keys(lf).filter(k => lf[k]); - try { const p = JSON.parse(lf); return Array.isArray(p) ? p : Object.keys(p); } catch { return []; } - })(); - const srcRow = (label, val) => `
${esc(label)}${val}
`; +// ================================================================== +// LOCALISATIONS — « qu'est-ce qui coince dans le géocodage ? » +// ================================================================== +const LOCATION_FILTERS = [ + { value: "error,llm_needed", label: "À débloquer" }, + { value: "error", label: "En erreur" }, + { value: "llm_needed", label: "Attente LLM" }, + { value: "queued,processing", label: "En file" }, + { value: "manual", label: "Manuel" }, + { value: "done,country_only", label: "Résolues" }, + { value: "", label: "Toutes" }, +]; - const conflictRows = conflictRowsHtml(band); - const conflictHtml = conflictRows ? ` -
- ⚠️ Conflits en attente -

- Champs verrouillés manuellement pour lesquels le crawler a trouvé une valeur différente. -

-
- - ${conflictRows} -
ChampMa valeur (verrouillée)Valeur Metal ArchivesAction
-
` : ""; +async function renderLocations() { + const s = state.locations; + // Permet aux alertes du Pilotage de pointer directement sur un statut. + const fromHash = new URLSearchParams((location.hash.split("?")[1] || "")); + if (fromHash.get("status") !== null) { s.status = fromHash.get("status"); s.page = 1; } - const locRowsHtml = locations.length ? locations.map(l => { - const coords = (l.lat != null && l.lon != null) ? `${Number(l.lat).toFixed(3)}, ${Number(l.lon).toFixed(3)}` : "—"; - const conf = l.geocode_confidence != null ? Number(l.geocode_confidence).toFixed(2) : "—"; - const tries = `${l.geocode_tries_geo || 0}g/${l.geocode_tries_llm || 0}l`; - const err = l.geocode_error ? `
${esc(l.geocode_error)}
` : ""; - return ` - ${l.step_order}${l.step_label ? ` (${esc(l.step_label)})` : ""} - ${esc(l.location_raw)}${err} - ${esc(l.geocode_status)} - ${esc(l.geocode_provider || "—")} - ${conf} - ${coords} - ${tries} - ${esc(l.geocode_query || "—")} - `; - }).join("") : `Aucune localisation dans band_locations`; - - const llmHtml = llmCalls.length ? llmCalls.map(c => { - const res = c.is_null ? `city=null` : `${esc(c.parsed_city || "?")} (${esc(c.parsed_country || "?")})`; - const cost = c.cost_usd != null ? `$${Number(c.cost_usd).toFixed(6)}` : "—"; - return `
- ${esc(c.model)} · ${esc(c.location_raw || "")} → ${res} · ${c.tokens_in || 0}+${c.tokens_out || 0}tok · ${cost} · ${fmtDate(c.created_at)} -
PROMPT:\n${esc(c.prompt || "")}\n\nRESPONSE:\n${esc(c.response || "")}
-
`; - }).join("") : `
Aucun appel LLM enregistré pour ce groupe.
`; - - const provenanceHtml = ` -
- 🔎 Provenance & géocodage (lecture seule) - -
-
Metal Archives (crawl)
- ${srcRow("enrichi", band.enriched ? "oui" : "non")} - ${srcRow("crawled_at", fmtDate(band.crawled_at))} - ${srcRow("MA créé / modifié", `${fmtDate(band.ma_created_at)} / ${fmtDate(band.ma_modified_at)}`)} - ${srcRow("champs verrouillés (manuel)", lockedFields.length ? esc(lockedFields.join(", ")) : "—")} -
- -
-
Géocodage (point du groupe)
- ${srcRow("provider", esc(band.geocode_provider || "—"))} - ${srcRow("query", esc(band.geocode_query || "—"))} - ${srcRow("geocoded_at", fmtDate(band.geocoded_at))} - ${band.geocode_error ? srcRow("erreur", `${esc(band.geocode_error)}`) : ""} -
- -
-
Localisations (band_locations)
-
- - - - - ${locRowsHtml} -
StepBrutStatutProviderConfCoordsEssaisQuery
-
-
- -
-
Appels LLM (Groq)
- ${llmHtml} -
-
`; - - const backdrop = document.createElement("div"); - backdrop.className = "modal-backdrop"; - backdrop.innerHTML = ` -