"use strict"; const state = { username: null, view: "dashboard", bands: { page: 1, pageSize: 50, q: "", location_q: "", country: "", status: "", genre: "", enriched: "", has_lat: "", has_location: "", sort: "ma_id", dir: "asc", total: 0 }, runs: { page: 1, pageSize: 50, run_type: "", status: "", total: 0 }, logs: { page: 1, pageSize: 100, level: "", autoRefresh: true }, audit: { page: 1, pageSize: 50 }, logsTimer: null, queueTimer: null, geoTimer: null, cmdLogTimer: null, monitorTimer: null, monitorLogs: [], monitorLastId: 0, monitorPaused: false, monitorInterval: 5000, }; // ------------------------------------------------------------------ // 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.logsTimer) { clearInterval(state.logsTimer); state.logsTimer = null; } if (state.queueTimer) { clearInterval(state.queueTimer); state.queueTimer = null; } if (state.geoTimer) { clearInterval(state.geoTimer); state.geoTimer = null; } if (state.cmdLogTimer) { clearInterval(state.cmdLogTimer); state.cmdLogTimer = null; } if (state.monitorTimer) { clearInterval(state.monitorTimer); state.monitorTimer = 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 // ------------------------------------------------------------------ const VIEWS = ["dashboard", "bands", "queue", "runs", "monitor", "logs", "geocoding", "conflicts", "jobs", "checkpoints", "audit"]; 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.logsTimer) { clearInterval(state.logsTimer); state.logsTimer = null; } if (state.queueTimer) { clearInterval(state.queueTimer); state.queueTimer = null; } if (state.geoTimer) { clearInterval(state.geoTimer); state.geoTimer = null; } if (state.cmdLogTimer) { clearInterval(state.cmdLogTimer); state.cmdLogTimer = null; } if (state.monitorTimer) { clearInterval(state.monitorTimer); state.monitorTimer = null; } const renderers = { dashboard: renderDashboard, bands: renderBands, conflicts: renderConflicts, queue: renderQueue, runs: renderRuns, monitor: renderMonitor, logs: renderLogs, geocoding: renderGeocoding, jobs: renderJobs, checkpoints: renderCheckpoints, audit: renderAudit, }; 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"); // ------------------------------------------------------------------ // Dashboard // ------------------------------------------------------------------ // 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; // Retire les parenthèses et leur contenu (ex: "(early)", "(later)") const cleaned = genre.replace(/\([^)]*\)/g, ""); // Découpe sur / ; , 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); } async function renderDashboard() { 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 crawlés (nouveau système)", t.total - t.crawled_by_current_system, "warn")} ${statCard("Jamais enrichis (band_page absent)", t.never_enriched, "err")} ${statCard("Stale (>30j)", t.stale, "warn")}

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)}
`; } } 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
`; } // ------------------------------------------------------------------ // Queue // ------------------------------------------------------------------ async function renderQueue() { if (!document.getElementById("q-body")) { content().innerHTML = `

Derniers runs d'enrichissement

Auto-refresh 15s
IDStatutDébutFinVusEnrichisProgressionErreur
`; document.getElementById("q-cleanup-btn").addEventListener("click", async () => { const btn = document.getElementById("q-cleanup-btn"); btn.disabled = true; try { const r = await api("/admin/api/crawl-runs/cleanup", { method: "POST", body: JSON.stringify({ older_than_minutes: 30 }) }); alert(`${r.cleaned} run(s) annulé(s).`); await loadQueue(); } catch (e) { alert(`Erreur: ${e.message}`); } finally { btn.disabled = false; } }); } await loadQueue(); if (state.queueTimer) clearInterval(state.queueTimer); state.queueTimer = setInterval(loadQueue, 15000); } async function loadQueue() { try { const r = await api("/admin/api/queue"); const b = r.breakdown; const statsEl = document.getElementById("q-stats"); if (statsEl) statsEl.innerHTML = ` ${statCard("Nouveaux (priorité 1)", b.new_bands, "err")} ${statCard("Modifiés depuis enrich (p.2)", b.modified_since_enrich, "warn")} ${statCard("Legacy à ré-enrichir (p.3)", b.legacy_pending, "warn")} ${statCard("Stale >30j (p.4)", b.stale, "ok")} `; const tbody = document.getElementById("q-body"); if (tbody) tbody.innerHTML = r.recent_enrich_runs.map((x) => { const isRunning = x.status === "running"; const elapsed = isRunning ? Math.round((Date.now() - new Date(x.started_at)) / 60000) : null; const prog = isRunning && x.bands_seen > 0 ? `${x.bands_seen} vus, ${x.bands_enriched} enrichis${elapsed ? `, ${elapsed}min` : ""}` : isRunning ? `en attente… (${elapsed}min)` : "—"; return ` ${x.id} ${statusBadge(x.status)} ${fmtDate(x.started_at)} ${fmtDate(x.finished_at)} ${x.bands_seen} ${x.bands_enriched} ${prog} ${esc(x.error || "")} `; }).join("") || `Aucun run`; } catch (e) { const tbody = document.getElementById("q-body"); if (tbody) tbody.innerHTML = `Erreur : ${esc(e.message)}`; } } function statusBadge(status) { const cls = status === "done" ? "ok" : status === "error" ? "err" : status === "running" ? "warn" : "muted"; return `${esc(status)}`; } // ------------------------------------------------------------------ // Bands // ------------------------------------------------------------------ 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.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); 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)}
`; } } async function openBandModal(maId) { let band; try { const r = await api(`/admin/api/bands/${maId}`); band = r.item; } catch (e) { alert(`Erreur : ${e.message}`); return; } 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); 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; } }); } // ------------------------------------------------------------------ // Crawl runs // ------------------------------------------------------------------ async function renderRuns() { const s = state.runs; content().innerHTML = `
Chargement…
`; document.getElementById("r-apply").addEventListener("click", () => { s.run_type = document.getElementById("r-type").value; s.status = document.getElementById("r-status").value; s.page = 1; loadRuns(); }); await loadRuns(); } async function loadRuns() { const s = state.runs; const params = new URLSearchParams({ page: s.page, pageSize: s.pageSize }); if (s.run_type) params.set("run_type", s.run_type); if (s.status) params.set("status", s.status); try { const r = await api(`/admin/api/crawl-runs?${params}`); s.total = r.total; document.getElementById("r-table").innerHTML = ` ${r.items.map((x) => ` `).join("") || ``}
IDTypePaysStatutDébutFinVusNouveauxMAJEnrichisErreur
${x.id} ${esc(x.run_type)} ${esc((x.countries || []).join(", "))} ${statusBadge(x.status)} ${fmtDate(x.started_at)} ${fmtDate(x.finished_at)} ${x.bands_seen} ${x.bands_new} ${x.bands_updated} ${x.bands_enriched} ${esc(x.error || "")}
Aucun run
`; const totalPages = Math.max(1, Math.ceil(s.total / s.pageSize)); document.getElementById("r-pager").innerHTML = ` Page ${s.page} / ${totalPages} (${s.total} runs) `; document.getElementById("r-prev")?.addEventListener("click", () => { s.page--; loadRuns(); }); document.getElementById("r-next")?.addEventListener("click", () => { s.page++; loadRuns(); }); } catch (e) { document.getElementById("r-table").innerHTML = `
Erreur : ${esc(e.message)}
`; } } // ------------------------------------------------------------------ // Live Monitor // ------------------------------------------------------------------ async function renderMonitor() { state.monitorLogs = []; state.monitorLastId = 0; state.monitorPaused = false; content().innerHTML = `
Connexion…

Journal en direct

`; document.getElementById("mon-pause-btn").addEventListener("click", () => { state.monitorPaused = !state.monitorPaused; const btn = document.getElementById("mon-pause-btn"); btn.textContent = state.monitorPaused ? "▶ Reprendre" : "⏸ Pause"; btn.style.borderColor = state.monitorPaused ? "rgba(198,26,26,0.5)" : ""; }); document.getElementById("mon-clear-btn").addEventListener("click", () => { state.monitorLogs = []; state.monitorLastId = 0; renderMonitorStream(); }); document.getElementById("mon-level").addEventListener("input", renderMonitorStream); document.getElementById("mon-search").addEventListener("input", renderMonitorStream); document.getElementById("mon-interval").addEventListener("change", (e) => { state.monitorInterval = Number(e.target.value) || 5000; if (state.monitorTimer) clearInterval(state.monitorTimer); state.monitorTimer = setInterval(tickMonitor, state.monitorInterval); }); await tickMonitor(); state.monitorTimer = setInterval(tickMonitor, state.monitorInterval); } async function tickMonitor() { if (state.monitorPaused) return; const dot = document.getElementById("mon-dot"); const tsEl = document.getElementById("mon-ts"); try { const [live, logs] = await Promise.all([ api("/admin/api/live"), api(`/admin/api/logs?pageSize=100${state.monitorLastId ? `&min_id=${state.monitorLastId}` : ""}`), ]); renderMonitorStatus(live); if (logs.items.length) { const maxId = Math.max(...logs.items.map(x => x.id)); if (maxId > state.monitorLastId) state.monitorLastId = maxId; state.monitorLogs = [...logs.items, ...state.monitorLogs].slice(0, 500); renderMonitorStream(logs.items.length); } else if (!state.monitorLogs.length) { renderMonitorStream(0); } if (dot) { dot.style.background = "var(--ok)"; dot.classList.add("live"); } if (tsEl) tsEl.textContent = `Actualisé à ${new Date().toLocaleTimeString("fr-FR")}`; } catch (e) { if (dot) { dot.style.background = "var(--err)"; dot.classList.remove("live"); } if (tsEl) tsEl.textContent = `Erreur : ${e.message}`; } } function renderMonitorStatus(live) { const el = document.getElementById("mon-status"); if (!el) return; // --- Crawl runs --- 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éocodeur --- const gTotal = live.geo_queue.reduce((s, x) => s + x.n, 0); const gDone = live.geo_queue.find(x => x.status === "done")?.n || 0; const gQ = live.geo_queue.find(x => x.status === "queued")?.n || 0; const gErr = live.geo_queue.find(x => x.status === "error")?.n || 0; const gProc = live.geo_queue.find(x => x.status === "processing")?.n || 0; const gPct = gTotal ? Math.round((gDone / gTotal) * 100) : 0; const geoHtml = `
${gPct}% — ${gDone.toLocaleString("fr-FR")} / ${gTotal.toLocaleString("fr-FR")}
${gQ} en attente ${gProc ? `⚡ ${gProc} en cours` : ""} ${gErr ? `✗ ${gErr} erreurs` : ""}
${live.geo_processing.map(p => `
${esc(p.name)} (${esc(p.country || "?")}) ${p.tries > 1 ? `essai ${p.tries}` : ""}
`).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
`; el.innerHTML = `

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

${runsHtml}

Géocodeur

${geoHtml}

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

${jobsHtml}
`; } function renderMonitorStream(newCount = 0) { const stream = document.getElementById("mon-stream"); if (!stream) return; const level = document.getElementById("mon-level")?.value || ""; const search = (document.getElementById("mon-search")?.value || "").toLowerCase(); const filtered = state.monitorLogs.filter(x => { if (level && x.level !== level) return false; if (search && !x.message.toLowerCase().includes(search)) return false; return true; }); const countEl = document.getElementById("mon-count"); if (countEl) { countEl.textContent = `${filtered.length} entrée${filtered.length !== 1 ? "s" : ""}${state.monitorLogs.length !== filtered.length ? ` / ${state.monitorLogs.length} total` : ""}`; } const wasAtTop = stream.scrollTop < 40; stream.innerHTML = filtered.map((x, i) => { const lvlCls = x.level === "error" ? "err" : x.level === "warning" ? "warn" : "muted"; return `
${fmtDate(x.created_at)} ${esc(x.level)} ${esc(x.message)}${x.ma_id ? ` #${x.ma_id}` : ""}
`; }).join("") || `
Aucun log${level || search ? " (filtres actifs)" : ""}
`; if (wasAtTop || newCount > 0) stream.scrollTop = 0; } async function adminCancelRun(id) { if (!confirm(`Annuler le run #${id} ?`)) return; try { await api(`/admin/api/crawl-runs/${id}/cancel`, { method: "POST", body: "{}" }); await tickMonitor(); } 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 tickMonitor(); } catch (e) { alert(`Erreur : ${e.message}`); } } // ------------------------------------------------------------------ // Logs (historique) // ------------------------------------------------------------------ async function renderLogs() { const s = state.logs; content().innerHTML = `
Chargement…
`; document.getElementById("l-apply").addEventListener("click", () => { s.level = document.getElementById("l-level").value; loadLogs(); }); document.getElementById("l-auto").addEventListener("change", (e) => { s.autoRefresh = e.target.checked; setupLogsTimer(); }); await loadLogs(); setupLogsTimer(); } function setupLogsTimer() { if (state.logsTimer) { clearInterval(state.logsTimer); state.logsTimer = null; } if (state.logs.autoRefresh) { state.logsTimer = setInterval(loadLogs, 10000); } } async function loadLogs() { const s = state.logs; const params = new URLSearchParams({ page: 1, pageSize: s.pageSize }); if (s.level) params.set("level", s.level); try { const r = await api(`/admin/api/logs?${params}`); document.getElementById("l-list").innerHTML = r.items.map((x) => `
${fmtDate(x.created_at)} ${esc(x.level)} ${esc(x.message)}${x.ma_id ? ` (ma_id=${x.ma_id})` : ""}
`).join("") || `
Aucun log
`; } catch (e) { document.getElementById("l-list").innerHTML = `
Erreur : ${esc(e.message)}
`; } } // ------------------------------------------------------------------ // Conflits (champs verrouillés ≠ valeur crawler) // ------------------------------------------------------------------ async function renderConflicts() { content().innerHTML = `
Chargement…
`; try { const r = await api("/admin/api/conflicts?page=1&pageSize=100"); if (!r.items.length) { content().innerHTML = `
Aucun conflit — toutes les éditions manuelles sont cohérentes avec les données Metal Archives.
`; return; } content().innerHTML = `

Ces bands ont des champs édités manuellement (🔒) pour lesquels le crawler a trouvé une valeur différente. Choisissez champ par champ ce que vous voulez conserver.

${r.items.map(conflictCard).join("")}
`; document.getElementById("conflict-list").addEventListener("click", async (e) => { const btn = e.target.closest("[data-action]"); if (!btn) return; const { action, maId, field } = btn.dataset; btn.disabled = true; try { await api(`/admin/api/bands/${maId}/resolve-conflict`, { method: "POST", body: JSON.stringify({ field, action }), }); renderConflicts(); } catch (err) { alert(err.message); btn.disabled = false; } }); } catch (e) { content().innerHTML = `
Erreur : ${esc(e.message)}
`; } } function conflictCard(b) { const pending = b.crawler_pending || {}; const locked = b.locked_fields || {}; const rows = Object.entries(pending).map(([field, crawlerVal]) => { const myVal = b[field] ?? "—"; return ` ${esc(field)} ${esc(String(myVal))} ${esc(String(crawlerVal))} `; }).join(""); return `

${esc(b.name)} #${b.ma_id} · ${esc(b.country||"")}

${rows}
ChampMa valeur (verrouillée)Valeur Metal ArchivesAction
`; } // ------------------------------------------------------------------ // Géocodage // ------------------------------------------------------------------ async function renderGeocoding() { if (!document.getElementById("geo-stats")) { content().innerHTML = `
Pipeline géocodage
Auto-refresh 30s

Nouveau pipeline — band_locations

Ancien pipeline — geocode_queue

20 derniers géocodages (bands)

Chargement…
`; // Nouveau pipeline — actions const makeLocAction = (btnId, fbId, url, body, confirmMsg) => { 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 = `✓ ${r.count} entrée(s) modifiée(s).`; fb.style.color = "var(--ok)"; await loadGeocoding(); } catch (e) { fb.textContent = `Erreur: ${e.message}`; fb.style.color = "var(--err)"; } finally { btn.disabled = false; } }); }; makeLocAction("loc-reset-errors", "loc-feedback", "/admin/api/locations/reset-errors", {}, null); makeLocAction("loc-reset-llm", "loc-feedback", "/admin/api/locations/reset-llm", {}, "Remettre en queue les entrées llm_needed et manual ?"); makeLocAction("loc-requeue-all", "loc-feedback", "/admin/api/locations/requeue-all", { include_done: true }, "Re-queue TOUTES les band_locations (erreurs + LLM + manual + done) ?"); // Ancien pipeline — actions document.getElementById("geo-reset-errors").addEventListener("click", async () => { const btn = document.getElementById("geo-reset-errors"); btn.disabled = true; const fb = document.getElementById("geo-feedback"); fb.textContent = "Réinitialisation…"; fb.style.color = "var(--muted)"; try { const r = await api("/admin/api/geocoding/reset-errors", { method: "POST", body: JSON.stringify({}) }); fb.textContent = `✓ ${r.count} entrée(s) remise(s) en queue.`; fb.style.color = "var(--ok)"; await loadGeocoding(); } catch (e) { fb.textContent = `Erreur: ${e.message}`; fb.style.color = "var(--err)"; } finally { btn.disabled = false; } }); document.getElementById("geo-requeue-done").addEventListener("click", async () => { if (!confirm("Réinitialiser TOUTES les entrées geocode_queue (erreurs + fait) ?")) return; const btn = document.getElementById("geo-requeue-done"); btn.disabled = true; const fb = document.getElementById("geo-feedback"); fb.textContent = "Réinitialisation…"; fb.style.color = "var(--muted)"; try { const r = await api("/admin/api/geocoding/requeue-all", { method: "POST", body: JSON.stringify({ include_done: true }) }); fb.textContent = `✓ ${r.count} entrée(s) re-queueée(s).`; fb.style.color = "var(--ok)"; await loadGeocoding(); } catch (e) { fb.textContent = `Erreur: ${e.message}`; fb.style.color = "var(--err)"; } finally { btn.disabled = false; } }); } await loadGeocoding(); if (state.geoTimer) clearInterval(state.geoTimer); state.geoTimer = setInterval(async () => { await loadGeocoding(); const el = document.getElementById("geo-refresh-status"); if (el) el.textContent = `Actualisé ${new Date().toLocaleTimeString("fr-FR")}`; }, 30000); } const LOC_STATUS_LABELS = { queued: "En queue", processing: "En cours", done: "Done", error: "Erreur", llm_needed: "LLM needed", manual: "Manuel", country_only: "Pays (centroïde)", skipped: "Ignoré", }; async function loadGeocoding() { try { const r = await api("/admin/api/geocoding"); // Nouveau pipeline — band_locations const locs = r.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 llmCost = parseFloat(r.llm_cache?.total_cost_usd || 0).toFixed(4); const locStatsEl = document.getElementById("loc-stats"); if (locStatsEl) locStatsEl.innerHTML = ` ${statCard("Total locations", locTotal)} ${statCard("Géocodées", locDone, "ok")} ${statCard("LLM needed", locLlm, locLlm ? "warn" : "")} ${statCard("Manuel", locManual, locManual ? "warn" : "")} ${statCard("Erreurs", locErr, locErr ? "err" : "")} ${statCard("LLM cache", r.llm_cache?.n || 0)} ${statCard("Coût LLM (USD)", "$" + llmCost)} `; const locBar = document.getElementById("loc-bar"); if (locBar) locBar.style.width = locPct + "%"; const locPctEl = document.getElementById("loc-pct"); if (locPctEl) locPctEl.textContent = `${locPct}% — ${locDone.toLocaleString("fr-FR")} / ${locTotal.toLocaleString("fr-FR")} — statuts: ${locs.map((x) => `${LOC_STATUS_LABELS[x.status] || x.status} ${x.n}`).join(", ")}`; // Ancien pipeline — geocode_queue const total = r.queue.reduce((s, x) => s + x.n, 0); const done = r.queue.find((x) => x.status === "done")?.n || 0; const queued = r.queue.find((x) => x.status === "queued")?.n || 0; const errored = r.queue.find((x) => x.status === "error")?.n || 0; const processing = r.queue.find((x) => x.status === "processing")?.n || 0; const pct = total ? Math.round((done / total) * 100) : 0; const statsEl = document.getElementById("geo-stats"); if (statsEl) statsEl.innerHTML = ` ${statCard("Total queue", total)} ${statCard("Géocodés", done, "ok")} ${statCard("En attente", queued, queued ? "warn" : "")} ${statCard("En cours", processing, processing ? "ok" : "")} ${statCard("Erreurs", errored, errored ? "err" : "")} ${statCard("Cache Geoapify", r.cache_size)} `; const bar = document.getElementById("geo-bar"); if (bar) bar.style.width = pct + "%"; const pctEl = document.getElementById("geo-pct"); if (pctEl) pctEl.textContent = `${pct}% — ${done.toLocaleString("fr-FR")} / ${total.toLocaleString("fr-FR")}${processing ? ` (${processing} en cours)` : ""}`; const recentWrap = document.getElementById("geo-recent-wrap"); if (recentWrap) recentWrap.innerHTML = ` ${r.recent.map((x) => ``).join("") || ``}
MA IDNomPaysProviderQueryGéocodé leErreur
${x.ma_id} ${esc(x.name)} ${esc(x.country || "")} ${esc(x.geocode_provider || "")} ${esc(x.geocode_query || "")} ${fmtDate(x.geocoded_at)} ${esc(x.geocode_error || "")}
Aucun
`; } catch (e) { const pctEl = document.getElementById("geo-pct"); if (pctEl) pctEl.textContent = `Erreur : ${esc(e.message)}`; } } // ------------------------------------------------------------------ // Centre de commandes (Jobs + Géocodeur + Maintenance + Live log) // ------------------------------------------------------------------ async function renderJobs() { content().innerHTML = `

🕷 Crawler

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

🗺 Géocodeur

Le géocodeur tourne en continu — ces actions modifient la queue directement.

🧹 Maintenance

Historique des jobs déclenchés

Chargement…

Journal en direct (20 dernières lignes — auto-refresh 10s)

Chargement…
`; // Crawler jobs 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.`; fb.style.color = "var(--ok)"; await Promise.all([loadJobsList(), loadCmdLogTail()]); } catch (e) { fb.textContent = `Erreur: ${e.message}`; fb.style.color = "var(--err)"; } finally { btn.disabled = false; } }); }); // Geocoder reset errors document.getElementById("cmd-reset-errors").addEventListener("click", async () => { const btn = document.getElementById("cmd-reset-errors"); btn.disabled = true; const fb = document.getElementById("geo-cmd-feedback"); fb.textContent = "Réinitialisation…"; fb.style.color = "var(--muted)"; try { const r = await api("/admin/api/geocoding/reset-errors", { method: "POST", body: JSON.stringify({}) }); fb.textContent = `✓ ${r.count} entrée(s) remise(s) en queue.`; fb.style.color = "var(--ok)"; await loadCmdLogTail(); } catch (e) { fb.textContent = `Erreur: ${e.message}`; fb.style.color = "var(--err)"; } finally { btn.disabled = false; } }); // Geocoder requeue all document.getElementById("cmd-requeue-all").addEventListener("click", async () => { if (!confirm("Réinitialiser TOUTES les entrées (erreurs + déjà géocodées) ? Ceci relance le géocodage depuis zéro.")) return; const btn = document.getElementById("cmd-requeue-all"); btn.disabled = true; const fb = document.getElementById("geo-cmd-feedback"); fb.textContent = "Réinitialisation…"; fb.style.color = "var(--muted)"; try { const r = await api("/admin/api/geocoding/requeue-all", { method: "POST", body: JSON.stringify({ include_done: true }) }); fb.textContent = `✓ ${r.count} entrée(s) re-queueée(s).`; fb.style.color = "var(--ok)"; await loadCmdLogTail(); } catch (e) { fb.textContent = `Erreur: ${e.message}`; fb.style.color = "var(--err)"; } finally { btn.disabled = false; } }); // Cleanup 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)"; await loadCmdLogTail(); } catch (e) { fb.textContent = `Erreur: ${e.message}`; fb.style.color = "var(--err)"; } finally { btn.disabled = false; } }); await Promise.all([loadJobsList(), loadCmdLogTail()]); if (state.cmdLogTimer) clearInterval(state.cmdLogTimer); state.cmdLogTimer = setInterval(async () => { await loadCmdLogTail(); const ts = document.getElementById("cmd-log-ts"); if (ts) ts.textContent = `Actualisé à ${new Date().toLocaleTimeString("fr-FR")}`; }, 10000); } async function loadJobsList() { const el = document.getElementById("jobs-list"); if (!el) return; try { const r = await api("/admin/api/job-triggers"); el.innerHTML = `
${r.items.map((x) => ``).join("") || ``}
IDTypeStatutDemandé parCréé leDémarréTerminéErreur
${x.id} ${esc(x.job_type)} ${statusBadge(x.status)} ${esc(x.requested_by || "—")} ${fmtDate(x.created_at)} ${fmtDate(x.started_at)} ${fmtDate(x.finished_at)} ${esc(x.error || "")}
Aucun job
`; } catch (e) { el.innerHTML = `
Erreur : ${esc(e.message)}
`; } } async function loadCmdLogTail() { const el = document.getElementById("cmd-log-tail"); if (!el) return; try { const r = await api("/admin/api/logs?pageSize=20&page=1"); el.innerHTML = ` ${r.items.map((x) => ``).join("") || ``}
DateNiveauMessage
${fmtDate(x.created_at)} ${esc(x.level)} ${esc(x.message)}
Aucun log
`; } catch (e) { el.innerHTML = `
Erreur : ${esc(e.message)}
`; } } // ------------------------------------------------------------------ // Checkpoints // ------------------------------------------------------------------ async function renderCheckpoints() { content().innerHTML = `
Chargement…
`; try { const r = await api("/admin/api/crawl-checkpoints"); content().innerHTML = `
${r.items.map((x) => ``).join("") || ``}
CléValeurMis à jour
${esc(x.key)}${esc(x.value || "—")}${fmtDate(x.updated_at)}
Aucun checkpoint
`; } catch (e) { content().innerHTML = `
Erreur : ${esc(e.message)}
`; } } // ------------------------------------------------------------------ // Audit log // ------------------------------------------------------------------ async function renderAudit() { content().innerHTML = `
Chargement…
`; try { const r = await api(`/admin/api/audit-log?page=1&pageSize=${state.audit.pageSize}`); content().innerHTML = `
${r.items.map((x) => ` `).join("") || ``}
DateAdminActionTableID cible
${fmtDate(x.created_at)} ${esc(x.admin_username)} ${esc(x.action)} ${esc(x.target_table)} ${esc(x.target_id)}
Aucune action
`; } catch (e) { content().innerHTML = `
Erreur : ${esc(e.message)}
`; } } // ------------------------------------------------------------------ // Boot // ------------------------------------------------------------------ checkSession();