"use strict"; 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 }, activity: { page: 1, pageSize: 50, type: "", status: "", total: 0 }, llm: { page: 1 }, overviewTimer: null, }; // ------------------------------------------------------------------ // API helper // ------------------------------------------------------------------ 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; } // ------------------------------------------------------------------ // 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; } } 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"); errEl.textContent = ""; 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"; } }); document.getElementById("logout-btn").addEventListener("click", async () => { try { await fetch("/admin/auth/logout", { method: "POST", credentials: "include" }); } catch {} state.username = null; showLogin(); }); // ------------------------------------------------------------------ // Router — 5 onglets : Vue d'ensemble, Groupes, Activité, Actions, LLM // ------------------------------------------------------------------ const VIEWS = ["dashboard", "bands", "activity", "actions", "llm"]; function router() { const hash = (location.hash || "#/dashboard").replace("#/", ""); const view = VIEWS.includes(hash) ? hash : "dashboard"; state.view = view; document.querySelectorAll(".nav a").forEach((a) => { a.classList.toggle("active", a.dataset.view === view); }); if (state.overviewTimer) { clearInterval(state.overviewTimer); state.overviewTimer = null; } const renderers = { dashboard: renderOverview, bands: renderBands, activity: renderActivity, actions: renderActions, llm: renderLLM, }; 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"); 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)}`; } // 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); } } 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)); 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); } async function loadOverviewLive() { const el = document.getElementById("ov-live"); if (!el) return; try { const [live, geo, queue] = await Promise.all([ 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); return `
▶ ${esc(r.run_type)}
${elapsed}min · ${r.bands_seen} vus · ${r.bands_new} nouveaux · ${r.bands_enriched} enrichis
${r.error ? `
${esc(r.error)}
` : ""}
`; }).join("") : `
Aucun run actif
`; // --- 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 || "—"}
`; // --- 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
`; 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")}`; } catch (e) { const ts = document.getElementById("ov-live-ts"); if (ts) ts.textContent = `Erreur : ${e.message}`; } } async function adminCancelRun(id) { if (!confirm(`Annuler le run #${id} ?`)) return; try { await api(`/admin/api/crawl-runs/${id}/cancel`, { method: "POST", body: "{}" }); await loadOverviewLive(); } catch (e) { alert(`Erreur : ${e.message}`); } } 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}`); } } // ------------------------------------------------------------------ // Groupes (Bands + Conflits fondus) // ------------------------------------------------------------------ async function renderBands() { const s = state.bands; content().innerHTML = `
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" : ""; s.page = 1; loadBands(); }); await loadBands(); } const BAND_COLUMNS = [ { key: "ma_id", label: "MA 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" }, ]; 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); try { const r = await api(`/admin/api/bands?${params}`); s.total = r.total; const tableEl = document.getElementById("b-table"); tableEl.innerHTML = ` ${BAND_COLUMNS.map((c) => ``).join("")} ${r.items.map((b) => ` `).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 || "")} ${b.formed_year || ""} ${b.enriched ? 'oui' : 'non'}${Object.keys(b.locked_fields||{}).length ? ' 🔒' : ""} ${fmtDate(b.crawled_at)} ${fmtDate(b.updated_at)}
Aucun résultat
`; tableEl.querySelectorAll("th[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(); }); }); tableEl.querySelectorAll("tr[data-ma-id]").forEach((tr) => { tr.addEventListener("click", () => openBandModal(Number(tr.dataset.maId))); }); 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(); }); } catch (e) { document.getElementById("b-table").innerHTML = `
Erreur : ${esc(e.message)}
`; } } // 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(""); } 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}
`; 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
` : ""; 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 = ` `; document.body.appendChild(backdrop); const close = () => backdrop.remove(); backdrop.querySelector(".modal-close").addEventListener("click", close); backdrop.addEventListener("click", (e) => { if (e.target === backdrop) close(); }); backdrop.querySelector("#m-cancel").addEventListener("click", close); const conflictBody = backdrop.querySelector("#m-conflict-body"); if (conflictBody) { conflictBody.addEventListener("click", async (e) => { const btn = e.target.closest("[data-conflict-action]"); if (!btn) return; const { conflictAction, field } = btn.dataset; btn.disabled = true; try { await api(`/admin/api/bands/${maId}/resolve-conflict`, { method: "POST", body: JSON.stringify({ field, action: conflictAction }), }); close(); openBandModal(maId); } catch (err) { alert(err.message); btn.disabled = false; } }); } backdrop.querySelector("#m-save").addEventListener("click", async () => { const errEl = backdrop.querySelector("#m-error"); errEl.textContent = ""; const payload = { name: backdrop.querySelector("#m-name").value.trim() || null, country: backdrop.querySelector("#m-country").value.trim() || null, status: backdrop.querySelector("#m-status").value.trim() || null, genre: backdrop.querySelector("#m-genre").value.trim() || null, location_text: backdrop.querySelector("#m-location_text").value.trim() || null, themes: backdrop.querySelector("#m-themes").value.trim() || null, formed_year: backdrop.querySelector("#m-formed_year").value ? Number(backdrop.querySelector("#m-formed_year").value) : null, lat: backdrop.querySelector("#m-lat").value ? Number(backdrop.querySelector("#m-lat").value) : null, lon: backdrop.querySelector("#m-lon").value ? Number(backdrop.querySelector("#m-lon").value) : null, }; try { await api(`/admin/api/bands/${maId}`, { method: "PATCH", body: JSON.stringify(payload) }); close(); loadBands(); } catch (e) { errEl.textContent = e.message; } }); } // ------------------------------------------------------------------ // Activité — liste unifiée (crawl_run + admin_audit_log), cliquable → détail + logs // ------------------------------------------------------------------ async function renderActivity() { const s = state.activity; content().innerHTML = `
Auto-refresh 15s
Chargement…
`; document.getElementById("a-apply").addEventListener("click", () => { s.type = document.getElementById("a-type").value; s.status = document.getElementById("a-status").value; s.page = 1; loadActivity(); }); await loadActivity(); if (state.overviewTimer) clearInterval(state.overviewTimer); state.overviewTimer = setInterval(async () => { await loadActivity(); const el = document.getElementById("a-refresh-status"); 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(`${sum.bands_seen} vus`); if (sum.bands_new) parts.push(`${sum.bands_new} nouveaux`); if (sum.bands_updated) parts.push(`${sum.bands_updated} MAJ`); if (sum.bands_enriched) parts.push(`${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; document.getElementById("a-table").innerHTML = ` ${r.items.map((x) => ` `).join("") || ``}
DateTypeSous-typeStatutActeurRésumé
${fmtDate(x.ts)} ${x.type === "run" ? "Run" : "Admin"} ${esc(x.subtype)} ${statusBadge(x.status)} ${esc(x.actor || "—")} ${activitySummary(x)}
Aucune activité
`; document.getElementById("a-table").querySelectorAll("tr[data-id]").forEach((tr) => { tr.addEventListener("click", () => openActivityModal(tr.dataset.type, Number(tr.dataset.id), r.items.find(x => x.type === tr.dataset.type && x.id === Number(tr.dataset.id)))); }); const totalPages = Math.max(1, Math.ceil(s.total / s.pageSize)); document.getElementById("a-pager").innerHTML = ` Page ${s.page} / ${totalPages} (${s.total.toLocaleString("fr-FR")}) `; document.getElementById("a-prev")?.addEventListener("click", () => { s.page--; loadActivity(); }); document.getElementById("a-next")?.addEventListener("click", () => { s.page++; loadActivity(); }); } catch (e) { document.getElementById("a-table").innerHTML = `
Erreur : ${esc(e.message)}
`; } } async function openActivityModal(type, id, row) { const backdrop = document.createElement("div"); backdrop.className = "modal-backdrop"; let bodyHtml; if (type === "run") { const sum = row?.summary || {}; bodyHtml = `
${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" ? `` : ""}

Logs

Chargement…
`; } else { const sum = row?.summary || {}; bodyHtml = `
${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))}
`; } backdrop.innerHTML = ` `; document.body.appendChild(backdrop); const close = () => backdrop.remove(); backdrop.querySelector(".modal-close").addEventListener("click", close); backdrop.addEventListener("click", (e) => { if (e.target === backdrop) close(); }); if (type === "run") { try { const logs = await api(`/admin/api/logs?run_id=${id}&pageSize=200`); const logsEl = backdrop.querySelector("#act-logs"); if (logsEl) logsEl.innerHTML = logs.items.map(x => { const lvlCls = x.level === "error" ? "err" : x.level === "warning" ? "warn" : "muted"; return `
${fmtDate(x.created_at)} ${esc(x.level)} ${esc(x.message)}
`; }).join("") || `
Aucun log pour ce run
`; } catch (e) { const logsEl = backdrop.querySelector("#act-logs"); if (logsEl) logsEl.innerHTML = `
Erreur : ${esc(e.message)}
`; } } } // ------------------------------------------------------------------ // Actions — toutes les commandes possibles, groupées et vérifiées // ------------------------------------------------------------------ async function renderActions() { content().innerHTML = `

🕷 Crawl

Consommés par le crawler dans sa prochaine itération (~1 min).

🗺 Géocodage

L'enqueue tourne aussi automatiquement en tâche de fond (rattrapage). Statistiques dans Vue d'ensemble.

🧹 Maintenance

⚠️ Zone dangereuse — tout recommencer

À utiliser si le géocodage est corrompu. « Reset complet » remet toutes les localisations en queue et efface leurs coordonnées (cache réutilisé quand possible). « Purger cache Nominatim » supprime les vieux géocodages de l'ancien pipeline pour forcer un re-géocodage Geoapify frais.

Checkpoints crawler (lecture seule)

Chargement…
`; // Crawl document.querySelectorAll("[data-job]").forEach((btn) => { btn.addEventListener("click", async () => { const job_type = btn.dataset.job; btn.disabled = true; const fb = document.getElementById("job-feedback"); fb.textContent = "Envoi…"; fb.style.color = "var(--muted)"; try { const r = await api("/admin/api/job-triggers", { method: "POST", body: JSON.stringify({ job_type }) }); fb.textContent = `✓ Job #${r.id} créé — le crawler le consommera sous ~1 min (visible dans Activité).`; fb.style.color = "var(--ok)"; } catch (e) { fb.textContent = `Erreur: ${e.message}`; fb.style.color = "var(--err)"; } finally { btn.disabled = false; } }); }); // Géocodage — actions simples const makeLocAction = (btnId, fbId, url, body, confirmMsg, successMsg) => { document.getElementById(btnId).addEventListener("click", async () => { if (confirmMsg && !confirm(confirmMsg)) return; const btn = document.getElementById(btnId); btn.disabled = true; const fb = document.getElementById(fbId); fb.textContent = "En cours…"; fb.style.color = "var(--muted)"; try { const r = await api(url, { method: "POST", body: JSON.stringify(body || {}) }); fb.textContent = successMsg ? successMsg(r) : `✓ ${r.count} entrée(s) modifiée(s).`; fb.style.color = "var(--ok)"; } catch (e) { fb.textContent = `Erreur: ${e.message}`; fb.style.color = "var(--err)"; } finally { btn.disabled = false; } }); }; document.getElementById("loc-enqueue").addEventListener("click", async () => { const btn = document.getElementById("loc-enqueue"); btn.disabled = true; const fb = document.getElementById("geo-feedback"); fb.textContent = "Job envoyé, le daemon va démarrer l'enqueue (peut prendre quelques secondes)…"; fb.style.color = "var(--muted)"; try { await api("/admin/api/job-triggers", { method: "POST", body: JSON.stringify({ job_type: "geocoder_enqueue" }) }); fb.textContent = "✓ Job geocoder_enqueue créé — suivre sa progression dans Activité."; fb.style.color = "var(--ok)"; } catch (e) { fb.textContent = `Erreur: ${e.message}`; fb.style.color = "var(--err)"; } finally { btn.disabled = false; } }); makeLocAction("loc-reset-errors", "geo-feedback", "/admin/api/locations/reset-errors", {}, null); makeLocAction("loc-reset-llm", "geo-feedback", "/admin/api/locations/reset-llm", {}, "Remettre en queue les entrées llm_needed et manual ?"); makeLocAction("loc-requeue-all", "geo-feedback", "/admin/api/locations/requeue-all", { include_done: true }, "Re-queue TOUTES les band_locations (erreurs + LLM + manual + done) ?"); // Zone dangereuse makeLocAction("loc-reset-all", "danger-feedback", "/admin/api/locations/reset-all", {}, "⚠️ RESET COMPLET : remettre TOUTES les localisations en queue et effacer leurs coordonnées ? Le worker va tout re-géocoder (cache réutilisé quand possible)."); makeLocAction("cache-purge-nominatim", "danger-feedback", "/admin/api/geocode-cache/purge-nominatim", {}, "⚠️ Supprimer toutes les entrées de cache Nominatim (ancien pipeline) ? Ces lieux seront re-géocodés via Geoapify (coûte des appels API)."); // Maintenance document.getElementById("cmd-cleanup").addEventListener("click", async () => { const btn = document.getElementById("cmd-cleanup"); btn.disabled = true; const fb = document.getElementById("maintenance-feedback"); fb.textContent = "Nettoyage…"; fb.style.color = "var(--muted)"; try { const r = await api("/admin/api/crawl-runs/cleanup", { method: "POST", body: JSON.stringify({ older_than_minutes: 30 }) }); fb.textContent = `✓ ${r.cleaned} run(s) annulé(s).`; fb.style.color = "var(--ok)"; } catch (e) { fb.textContent = `Erreur: ${e.message}`; fb.style.color = "var(--err)"; } finally { btn.disabled = false; } }); // Checkpoints (lecture seule) try { const r = await api("/admin/api/crawl-checkpoints"); document.getElementById("checkpoints-wrap").innerHTML = ` ${r.items.map((x) => ``).join("") || ``}
CléValeurMis à jour
${esc(x.key)}${esc(x.value || "—")}${fmtDate(x.updated_at)}
Aucun checkpoint
`; } catch (e) { document.getElementById("checkpoints-wrap").innerHTML = `
Erreur : ${esc(e.message)}
`; } } // ------------------------------------------------------------------ // LLM — debug des appels Groq (désambiguïsation géocodage) // ------------------------------------------------------------------ async function renderLLM() { content().innerHTML = `

Chaque appel Groq est mis en cache (clé = modèle + pays + lieu) et relié au groupe déclencheur. Déplie une ligne pour voir le prompt exact et la réponse brute du modèle.

Chargement…
`; if (!state.llm) state.llm = { page: 1 }; document.getElementById("llm-apply").addEventListener("click", () => { state.llm.page = 1; loadLLM(); }); // Délégation attachée une seule fois sur le conteneur persistant. document.getElementById("llm-list").addEventListener("click", (e) => { const det = e.target.closest(".llm-detail"); if (det) { const row = document.querySelector(`.llm-detail-row[data-id="${det.dataset.id}"]`); if (row) row.style.display = row.style.display === "none" ? "" : "none"; return; } const bandLink = e.target.closest(".llm-band"); if (bandLink) { e.preventDefault(); openBandModal(Number(bandLink.dataset.ma)); } }); await loadLLM(); } 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.toString()}`); const st = document.getElementById("llm-status"); if (st) st.textContent = `${r.total.toLocaleString("fr-FR")} appel(s) LLM`; const rows = (r.items || []).map((c) => { const res = c.is_null ? `null` : `${esc(c.parsed_city || "?")} (${esc(c.parsed_country || "?")})`; const cost = c.cost_usd != null ? `$${Number(c.cost_usd).toFixed(6)}` : "—"; const band = c.ma_id ? `${esc(c.band_name || ("#" + c.ma_id))}` : "—"; return ` ${fmtDate(c.created_at)} ${band} ${esc(c.model)} ${esc(c.location_raw || "—")}${c.country ? ` [${esc(c.country)}]` : ""} ${res} ${(c.tokens_in || 0)}+${(c.tokens_out || 0)} ${cost}
PROMPT:\n${esc(c.prompt || "")}\n\nRESPONSE:\n${esc(c.response || "")}
`; }).join(""); document.getElementById("llm-list").innerHTML = rows ? `${rows}
DateGroupeModèleLieu (brut)ExtraitTokensCoût
` : `
Aucun appel LLM.
`; const totalPages = Math.max(1, Math.ceil(r.total / 50)); document.getElementById("llm-pager").innerHTML = ` Page ${page} / ${totalPages} `; document.getElementById("llm-prev")?.addEventListener("click", () => { state.llm.page = page - 1; loadLLM(); }); document.getElementById("llm-next")?.addEventListener("click", () => { state.llm.page = page + 1; loadLLM(); }); } catch (e) { document.getElementById("llm-list").innerHTML = `
Erreur : ${esc(e.message)}
`; } } // ------------------------------------------------------------------ // Boot // ------------------------------------------------------------------ checkSession();