"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: "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 }, timer: null, }; // ------------------------------------------------------------------ // API // ------------------------------------------------------------------ async function api(path, opts = {}) { const res = await fetch(path, { credentials: "include", headers: { "Content-Type": "application/json", ...(opts.headers || {}) }, ...opts, }); if (res.status === 401) { showLogin(); throw new Error("unauthorized"); } const body = await res.json().catch(() => ({})); 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; } } /** * Jeton de rendu. * * Les vues chargent leurs données de façon asynchrone puis arment un * auto-refresh. Sans ce jeton, quitter une vue PENDANT son chargement laissait * le minuteur s'armer après coup : router() avait déjà fait son clearTimer(), * et le Pilotage continuait d'interroger quatre endpoints toutes les 15 s * depuis un autre onglet, indéfiniment. */ let renderToken = 0; const isStale = (token) => token !== renderToken; // ------------------------------------------------------------------ // Auth // ------------------------------------------------------------------ function showLogin() { document.getElementById("login-screen").classList.remove("hidden"); document.getElementById("app").classList.add("hidden"); clearTimer(); } function showApp() { document.getElementById("login-screen").classList.add("hidden"); document.getElementById("app").classList.remove("hidden"); document.getElementById("whoami").textContent = state.username || ""; } async function checkSession() { try { const r = await api("/admin/auth/me"); state.username = r.username; showApp(); router(); } catch { showLogin(); } } document.getElementById("login-form").addEventListener("submit", async (e) => { e.preventDefault(); 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", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }), }); const body = await res.json().catch(() => ({})); if (!res.ok || body.ok === false) { errEl.textContent = body.error || "Échec de connexion"; return; } state.username = body.username; document.getElementById("login-password").value = ""; showApp(); router(); } catch { errEl.textContent = "Erreur réseau"; } finally { btn.disabled = false; } }); document.getElementById("logout-btn").addEventListener("click", async () => { try { await fetch("/admin/auth/logout", { method: "POST", credentials: "include" }); } catch {} state.username = null; showLogin(); }); // ------------------------------------------------------------------ // Routeur // ------------------------------------------------------------------ const VIEWS = ["pilotage", "bands", "locations", "activity", "llm"]; const VIEW_ALIASES = { "": "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) { // 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 : "pilotage"; } function currentViewFromLocation() { 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; const token = ++renderToken; document.querySelectorAll(".nav a").forEach((a) => { const active = a.dataset.view === view; a.classList.toggle("active", active); if (active) a.setAttribute("aria-current", "page"); else a.removeAttribute("aria-current"); }); clearTimer(); RENDERERS[view](token); } window.addEventListener("hashchange", router); // ================================================================== // PILOTAGE — « est-ce que ça tourne ? » // ================================================================== async function renderPilotage(token = renderToken) { 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.

`; renderTriggers(); wireDangerZone(); await loadPilotage(); if (isStale(token)) return; // l'utilisateur a changé de vue pendant le chargement clearTimer(); state.timer = setInterval(loadPilotage, 15000); } 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; } }); } 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 ?", }, }; 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; } }); } /** * État des services de fond (crawler, geocoder…). * * Ils ne sont pas exposés par Traefik : sans ce battement de cœur, un crawler * dont FlareSolverr est injoignable reste invisible, et le seul symptôme est * l'absence de données nouvelles. Un contrôle trop ancien vaut une panne : * un service arrêté cesse simplement de mettre sa ligne à jour. */ function servicesCard(services) { if (!services.length) { return healthCard("Services", "—", "aucun battement de cœur reçu", "warn"); } const ko = services.filter((s) => !s.ok || s.stale); const detail = ko.length ? ko.map((s) => `${esc(s.service)} : ${s.stale ? "silencieux" : esc(s.error || "dégradé")}`).join(" · ") : services.map((s) => esc(s.service)).join(" · "); return healthCard("Services", `${services.length - ko.length}/${services.length}`, detail, ko.length ? "err" : "ok"); } function healthCard(label, value, sub, tone = "") { return `
${esc(label)}
${value}
${sub}
`; } async function loadPilotage() { try { const [stats, live, geo, queue] = await Promise.all([ api("/admin/api/stats"), api("/admin/api/live"), api("/admin/api/geocoding"), api("/admin/api/queue"), ]); 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); // La vue a pu être remplacée pendant les requêtes. const healthEl = document.getElementById("p-health"); if (!healthEl) return; // ---- Bandeau de santé ---- healthEl.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`), servicesCard(live.services || []), ].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" }], }); } for (const svc of (live.services || []).filter((x) => !x.ok || x.stale)) { alerts.push({ tone: "err", text: svc.stale ? `Service « ${svc.service} » silencieux depuis plus de 2 h — arrêté ou bloqué` : `Service « ${svc.service} » dégradé : ${svc.error || "sonde en échec"}`, actions: [{ label: "Voir le journal", href: "#/activity" }], }); } 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
${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")} ${(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("")}` : ""} `; 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 note = document.getElementById("p-refresh"); if (note) note.textContent = `Actualisé à ${new Date().toLocaleTimeString("fr-FR")}`; } catch (e) { const note = document.getElementById("p-refresh"); if (note) note.textContent = `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 renderBands() { const s = state.bands; content().innerHTML = `

Groupes

${BAND_CHIPS.map((c) => ``).join("")}
Chargement…
`; 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: "ID" }, { key: "name", label: "Nom" }, { key: "country", label: "Pays" }, { key: "status", label: "Statut" }, { key: "genre", label: "Genre" }, { key: "location_text", label: "Lieu" }, { key: "formed_year", label: "Année" }, { 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 }); 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; document.getElementById("b-count").textContent = `${fmtNum(r.total)} groupe(s)`; document.getElementById("b-table").innerHTML = ` ${BAND_COLUMNS.map((c) => ``).join("")} ${r.items.map((b) => ` `).join("") || ``}
${b.ma_id} ${esc(b.name)} ${esc(b.country || "")} ${esc(b.status || "")} ${esc(b.genre || "")} ${esc(b.location_text || "")} ${b.formed_year || ""} ${b.enriched ? 'enrichi' : 'à enrichir'} ${b.lat == null ? '📍?' : ""} ${Object.keys(b.locked_fields || {}).length ? '🔒' : ""} ${fmtDate(b.updated_at)}
Aucun résultat
`; 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"; else { s.sort = key; s.dir = "asc"; } loadBands(); }); }); // Les lignes sont activables au clavier (role=button + tabindex), pas // seulement à la souris. tableEl.querySelectorAll("tr[data-ma-id]").forEach((tr) => { const open = () => openBandModal(Number(tr.dataset.maId)); tr.addEventListener("click", open); tr.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); open(); } }); }); renderPager("b-pager", s, loadBands); } catch (e) { document.getElementById("b-table").innerHTML = errorBox(e); } } /** 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(); }; } // ================================================================== // 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" }, ]; async function renderLocations() { const s = state.locations; // Les alertes du Pilotage pointent sur un statut précis (#/locations?status=error). // Ce paramètre est CONSOMMÉ : on le retire de l'URL une fois appliqué, sinon // chaque rendu suivant le réappliquerait et annulerait les clics sur les // chips — arrivé depuis une alerte, le filtre devenait impossible à changer. // replaceState ne déclenche pas hashchange : pas de boucle de rendu. const [hashBase, hashQuery] = location.hash.split("?"); if (hashQuery) { const fromHash = new URLSearchParams(hashQuery); if (fromHash.has("status")) { s.status = fromHash.get("status"); s.page = 1; } history.replaceState(null, "", hashBase || "#/locations"); } content().innerHTML = `

Localisations

Une ligne par lieu à géocoder. C'est ici qu'on débloque un lieu précis, sans relancer des milliers d'appels facturés.

${LOCATION_FILTERS.map((f) => ``).join("")}
Chargement…
`; const apply = () => { s.q = document.getElementById("l-q").value.trim(); s.country = document.getElementById("l-country").value.trim(); s.page = 1; loadLocations(); }; document.getElementById("l-search").addEventListener("click", apply); document.getElementById("l-q").addEventListener("keydown", (e) => { if (e.key === "Enter") apply(); }); document.querySelector(".chips").addEventListener("click", (e) => { const chip = e.target.closest("[data-status]"); if (!chip) return; s.status = chip.dataset.status; s.page = 1; renderLocations(); }); await loadLocations(); } async function loadLocations() { const s = state.locations; const params = new URLSearchParams({ page: s.page, pageSize: s.pageSize }); if (s.status) params.set("status", s.status); if (s.q) params.set("q", s.q); if (s.country) params.set("country", s.country); try { const r = await api(`/admin/api/locations?${params}`); s.total = r.total; document.getElementById("l-count").textContent = `${fmtNum(r.total)} localisation(s)`; document.getElementById("l-table").innerHTML = ` ${r.items.map((l) => ` `).join("") || ``}
GroupeLieu brutStatutCoordonnées EssaisDernière erreurActions
${esc(l.country || "")}${l.step_label ? ` · ${esc(l.step_label)}` : ""}
${esc(l.location_raw)} ${statusBadge(l.geocode_status)} ${l.lat != null && l.lon != null ? `${Number(l.lat).toFixed(4)}, ${Number(l.lon).toFixed(4)}` : "—"}
${esc(l.geocode_provider || "")}${l.geocode_confidence != null ? ` · ${Number(l.geocode_confidence).toFixed(2)}` : ""}
${l.geocode_tries_geo || 0} géo · ${l.geocode_tries_llm || 0} LLM ${esc(l.geocode_error || "—")}
Aucune localisation pour ce filtre
`; document.getElementById("l-table").onclick = (e) => { const band = e.target.closest("[data-open-band]"); if (band) return openBandModal(Number(band.dataset.openBand)); const rq = e.target.closest("[data-requeue]"); if (rq) return requeueLocation(Number(rq.dataset.requeue), rq); const sc = e.target.closest("[data-setcoords]"); if (sc) { const id = Number(sc.dataset.setcoords); return openCoordsModal(r.items.find((x) => x.id === id)); } }; renderPager("l-pager", s, loadLocations); } catch (e) { document.getElementById("l-table").innerHTML = errorBox(e); } } async function requeueLocation(id, btn) { btn.disabled = true; try { await api(`/admin/api/locations/${id}/requeue`, { method: "POST", body: "{}" }); await loadLocations(); } catch (e) { alert(e.message); btn.disabled = false; } } function openCoordsModal(loc) { if (!loc) return; const body = `

Saisir les coordonnées à la main pour ${esc(loc.location_raw)} (${esc(loc.band_name || "#" + loc.ma_id)}). Le lieu sera marqué comme résolu par « admin » et ne repassera plus par le géocodeur.

`; const modal = openModal(`Coordonnées — ${esc(loc.location_raw)}`, body); modal.querySelector("#c-save").addEventListener("click", async () => { const errEl = modal.querySelector("#c-error"); errEl.textContent = ""; try { await api(`/admin/api/locations/${loc.id}`, { method: "PATCH", body: JSON.stringify({ lat: modal.querySelector("#c-lat").value, lon: modal.querySelector("#c-lon").value, }), }); closeModal(modal); await loadLocations(); } catch (e) { errEl.textContent = e.message; } }); } // ================================================================== // Modales — ouverture, fermeture, piège à focus // ================================================================== let lastFocused = null; function openModal(title, bodyHtml, extraClass = "") { lastFocused = document.activeElement; const backdrop = document.createElement("div"); backdrop.className = "modal-backdrop"; backdrop.innerHTML = ` `; document.body.appendChild(backdrop); backdrop.addEventListener("click", (e) => { if (e.target === backdrop || e.target.closest("[data-close]")) closeModal(backdrop); }); // Échap ferme, Tab reste enfermé dans la modale : sans ça le focus repartait // derrière le voile, invisible. backdrop.addEventListener("keydown", (e) => { if (e.key === "Escape") { closeModal(backdrop); return; } if (e.key !== "Tab") return; const focusables = backdrop.querySelectorAll( 'button, [href], input, select, textarea, summary, [tabindex]:not([tabindex="-1"])'); if (!focusables.length) return; const first = focusables[0]; const last = focusables[focusables.length - 1]; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } }); const target = backdrop.querySelector("input, button:not(.modal-close)"); (target || backdrop.querySelector(".modal-close")).focus(); return backdrop; } function closeModal(backdrop) { backdrop.remove(); if (lastFocused && document.contains(lastFocused)) lastFocused.focus(); } // ================================================================== // Modal groupe (édition + provenance) // ================================================================== function conflictRowsHtml(band) { const pending = band.crawler_pending || {}; return Object.entries(pending).map(([field, crawlerVal]) => ` ${esc(field)} ${esc(String(band[field] ?? "—"))} ${esc(String(crawlerVal))} `).join(""); } 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(e.message); return; } 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 conflictRows = conflictRowsHtml(band); const fields = [ ["name", "Nom", "text"], ["country", "Pays", "text"], ["status", "Statut", "text"], ["genre", "Genre", "text"], ["formed_year", "Année de formation", "number"], ["location_text", "Localisation (texte)", "text"], ["themes", "Thèmes", "text"], ["lat", "Latitude", "number"], ["lon", "Longitude", "number"], ]; const body = ` ${conflictRows ? ` ` : ""} `; const modal = openModal(`${band.name} — #${band.ma_id}`, body, "modal-wide"); const conflictBody = modal.querySelector("#m-conflict-body"); conflictBody?.addEventListener("click", async (e) => { const btn = e.target.closest("[data-conflict-action]"); if (!btn) return; btn.disabled = true; try { await api(`/admin/api/bands/${maId}/resolve-conflict`, { method: "POST", body: JSON.stringify({ field: btn.dataset.field, action: btn.dataset.conflictAction }), }); closeModal(modal); openBandModal(maId); } catch (err) { alert(err.message); btn.disabled = false; } }); modal.querySelector("#m-save").addEventListener("click", async () => { const errEl = modal.querySelector("#m-error"); errEl.textContent = ""; const val = (k) => modal.querySelector(`#m-${k}`).value.trim(); const num = (k) => (val(k) === "" ? null : Number(val(k))); const payload = { name: val("name") || null, country: val("country") || null, status: val("status") || null, genre: val("genre") || null, location_text: val("location_text") || null, themes: val("themes") || null, formed_year: num("formed_year"), lat: num("lat"), lon: num("lon"), }; try { await api(`/admin/api/bands/${maId}`, { method: "PATCH", body: JSON.stringify(payload) }); closeModal(modal); if (state.view === "bands") loadBands(); else if (state.view === "locations") loadLocations(); } catch (e) { errEl.textContent = e.message; } }); } // ================================================================== // JOURNAL — « que s'est-il passé ? » // ================================================================== async function renderActivity(token = renderToken) { const s = state.activity; content().innerHTML = `

Journal

Actualisation auto toutes les 15 s
Chargement…
`; document.querySelector(".chips").addEventListener("click", (e) => { const t = e.target.closest("[data-atype]"); const st = e.target.closest("[data-astatus]"); if (t) { s.type = t.dataset.atype; s.page = 1; renderActivity(); } else if (st) { s.status = s.status === st.dataset.astatus ? "" : st.dataset.astatus; s.page = 1; renderActivity(); } }); await loadActivity(); if (isStale(token)) return; clearTimer(); state.timer = setInterval(async () => { await loadActivity(); const el = document.getElementById("a-refresh"); if (el) el.textContent = `Actualisé à ${new Date().toLocaleTimeString("fr-FR")}`; }, 15000); } function activitySummary(row) { const sum = row?.summary || {}; if (row?.type === "run") { const parts = []; if (sum.bands_seen != null) parts.push(`${fmtNum(sum.bands_seen)} vus`); if (sum.bands_new) parts.push(`${fmtNum(sum.bands_new)} nouveaux`); if (sum.bands_updated) parts.push(`${fmtNum(sum.bands_updated)} MAJ`); if (sum.bands_enriched) parts.push(`${fmtNum(sum.bands_enriched)} enrichis`); if (sum.error) parts.push(`erreur : ${sum.error}`); return parts.join(" · ") || "—"; } return `${esc(sum.target_table || "")} #${sum.target_id ?? ""}`; } async function loadActivity() { const s = state.activity; const params = new URLSearchParams({ page: s.page, pageSize: s.pageSize }); if (s.type) params.set("type", s.type); if (s.status) params.set("status", s.status); try { const r = await api(`/admin/api/activity?${params}`); s.total = r.total; const tableEl = document.getElementById("a-table"); if (!tableEl) return; tableEl.innerHTML = ` ${r.items.map((x, i) => ` `).join("") || ``}
DateTypeDétailStatutAuteurRésumé
${fmtDate(x.ts)} ${x.type === "run" ? "Traitement" : "Admin"} ${esc(x.subtype)} ${statusBadge(x.status)} ${esc(x.actor || "—")} ${activitySummary(x)}
Aucune activité
`; document.getElementById("a-table").querySelectorAll("tr[data-idx]").forEach((tr) => { const row = r.items[Number(tr.dataset.idx)]; const open = () => openActivityModal(row); tr.addEventListener("click", open); tr.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); open(); } }); }); renderPager("a-pager", s, loadActivity); } catch (e) { document.getElementById("a-table").innerHTML = errorBox(e); } } async function openActivityModal(row) { const sum = row?.summary || {}; const isRun = row?.type === "run"; const body = isRun ? `
${statusBadge(row.status)} · démarré ${fmtDate(row.ts)} · terminé ${fmtDate(row.finished_at)} ${sum.countries ? ` · pays : ${esc((sum.countries || []).join(", "))}` : ""}

${activitySummary(row)}

${row.status === "running" ? `` : ""}

Journal détaillé

Chargement…
` : `
${fmtDate(row.ts)} · ${esc(row.actor || "")} · ${esc(row.subtype || "")}

Cible : ${esc(sum.target_table || "")} #${sum.target_id ?? ""}

Avant

${esc(JSON.stringify(sum.before, null, 2))}

Après

${esc(JSON.stringify(sum.after, null, 2))}
`; const modal = openModal( isRun ? `Traitement #${row.id} — ${esc(row.subtype || "")}` : `Action admin #${row.id}`, body, "modal-wide"); modal.querySelector("#am-cancel")?.addEventListener("click", async (e) => { if (!confirm(`Demander l'arrêt du traitement #${row.id} ?`)) return; e.target.disabled = true; try { await api(`/admin/api/crawl-runs/${row.id}/cancel`, { method: "POST", body: "{}" }); closeModal(modal); loadActivity(); } catch (err) { alert(err.message); } }); if (isRun) { try { const logs = await api(`/admin/api/logs?run_id=${row.id}&pageSize=200`); const el = modal.querySelector("#act-logs"); if (el) { el.innerHTML = logs.items.map((x) => `
${fmtDate(x.created_at)} ${esc(x.level)} ${esc(x.message)}
`).join("") || emptyRow("Aucun log pour ce traitement"); } } catch (e) { const el = modal.querySelector("#act-logs"); if (el) el.innerHTML = errorBox(e); } } } // ================================================================== // LLM & COÛTS // ================================================================== async function renderLLM() { content().innerHTML = `

LLM & coûts

Chargement…

Chaque appel Groq est mis en cache (modèle + pays + lieu) et rattaché au groupe qui l'a déclenché. Déplier une ligne montre le prompt exact et la réponse brute.

Chargement…
`; document.getElementById("llm-apply").addEventListener("click", () => { state.llm.page = 1; loadLLM(); }); document.getElementById("llm-q").addEventListener("keydown", (e) => { if (e.key === "Enter") { state.llm.page = 1; loadLLM(); } }); document.getElementById("llm-list").addEventListener("click", (e) => { const band = e.target.closest("[data-open-band]"); if (band) openBandModal(Number(band.dataset.openBand)); }); loadLLMSummary(); await loadLLM(); } async function loadLLMSummary() { try { const geo = await api("/admin/api/geocoding"); const c = geo.llm_cache || {}; const models = geo.llm_models || []; document.getElementById("llm-summary").innerHTML = [ healthCard("Coût total", `$${Number(c.total_cost_usd || 0).toFixed(4)}`, "cumul Groq"), healthCard("Appels", fmtNum(c.n), `${fmtNum(c.n_null)} sans résultat`), healthCard("Cache géocodage", fmtNum(geo.cache_size), "lieux déjà résolus"), ...models.slice(0, 2).map((m) => healthCard(m.model, fmtNum(m.n), `$${Number(m.cost || 0).toFixed(4)}`)), ].join(""); } catch (e) { document.getElementById("llm-summary").innerHTML = errorBox(e); } } async function loadLLM() { const q = document.getElementById("llm-q")?.value.trim() || ""; const model = document.getElementById("llm-model")?.value || ""; const onlyNull = document.getElementById("llm-null")?.checked ? "1" : ""; const page = state.llm.page || 1; const params = new URLSearchParams({ page: String(page), pageSize: "50" }); if (q) params.set("q", q); if (model) params.set("model", model); if (onlyNull) params.set("only_null", "1"); try { const r = await api(`/admin/api/llm?${params}`); document.getElementById("llm-status").textContent = `${fmtNum(r.total)} appel(s)`; document.getElementById("llm-list").innerHTML = r.items.length ? ` ${r.items.map((c) => ` `).join("")}
DateGroupeModèleLieuRésultatTokensCoût
${fmtDate(c.created_at)} ${c.ma_id ? `` : "—"} ${esc(c.model)} ${esc(c.location_raw || "—")}${c.country ? ` [${esc(c.country)}]` : ""} ${c.is_null ? 'aucun' : `${esc(c.parsed_city || "?")} (${esc(c.parsed_country || "?")})`} ${(c.tokens_in || 0)}+${(c.tokens_out || 0)} ${c.cost_usd != null ? `$${Number(c.cost_usd).toFixed(6)}` : "—"}
Prompt et réponse
PROMPT:\n${esc(c.prompt || "")}\n\nRÉPONSE:\n${esc(c.response || "")}
` : emptyRow("Aucun appel LLM"); const s = { page, pageSize: 50, total: r.total }; renderPager("llm-pager", s, () => { state.llm.page = s.page; loadLLM(); }); } catch (e) { document.getElementById("llm-list").innerHTML = errorBox(e); } } // ------------------------------------------------------------------ checkSession();