"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, }; // ------------------------------------------------------------------ // 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; } } 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", "logs", "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; } const renderers = { dashboard: renderDashboard, bands: renderBands, queue: renderQueue, runs: renderRuns, logs: renderLogs, 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 // ------------------------------------------------------------------ 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 maxGenre = Math.max(1, ...r.by_genre.map((x) => x.total)); 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()}

Par genre (${r.by_genre.length})

${r.by_genre.map((x) => barRow(x.genre, x.total, maxGenre)).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.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: "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.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.location_text || "")} ${b.formed_year || ""} ${b.enriched ? 'oui' : 'non'} ${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)}
`; } } // ------------------------------------------------------------------ // Logs // ------------------------------------------------------------------ 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)}
`; } } // ------------------------------------------------------------------ // 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();