diff --git a/apps/admin/site/app.js b/apps/admin/site/app.js index 0f2be69..1699548 100644 --- a/apps/admin/site/app.js +++ b/apps/admin/site/app.js @@ -3,19 +3,10 @@ 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, + 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, }; // ------------------------------------------------------------------ @@ -44,11 +35,7 @@ async function api(path, opts = {}) { 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; } + if (state.overviewTimer) { clearInterval(state.overviewTimer); state.overviewTimer = null; } } function showApp() { @@ -102,9 +89,9 @@ document.getElementById("logout-btn").addEventListener("click", async () => { }); // ------------------------------------------------------------------ -// Router +// Router — 5 onglets : Vue d'ensemble, Groupes, Activité, Actions, LLM // ------------------------------------------------------------------ -const VIEWS = ["dashboard", "bands", "queue", "runs", "monitor", "logs", "geocoding", "llm", "conflicts", "jobs", "checkpoints", "audit"]; +const VIEWS = ["dashboard", "bands", "activity", "actions", "llm"]; function router() { const hash = (location.hash || "#/dashboard").replace("#/", ""); @@ -113,24 +100,13 @@ function router() { 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; } + if (state.overviewTimer) { clearInterval(state.overviewTimer); state.overviewTimer = null; } const renderers = { - dashboard: renderDashboard, + dashboard: renderOverview, bands: renderBands, - conflicts: renderConflicts, - queue: renderQueue, - runs: renderRuns, - monitor: renderMonitor, - logs: renderLogs, - geocoding: renderGeocoding, + activity: renderActivity, + actions: renderActions, llm: renderLLM, - jobs: renderJobs, - checkpoints: renderCheckpoints, - audit: renderAudit, }; renderers[view](); } @@ -146,17 +122,25 @@ function fmtDate(s) { } const content = () => document.getElementById("content"); -// ------------------------------------------------------------------ -// Dashboard -// ------------------------------------------------------------------ +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; - // 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(); @@ -169,7 +153,10 @@ function splitGenreElements(genreRows) { .sort((a, b) => b.n - a.n); } -async function renderDashboard() { +// ------------------------------------------------------------------ +// 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"); @@ -186,10 +173,21 @@ async function renderDashboard() { ${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")} + +
+
En direct
+ Auto-refresh 15s +
+
+
+
+
+
+
+

Par statut

@@ -208,98 +206,131 @@ async function renderDashboard() { `; } catch (e) { content().innerHTML = `
Erreur : ${esc(e.message)}
`; + return; } + + await loadOverviewLive(); + if (state.overviewTimer) clearInterval(state.overviewTimer); + state.overviewTimer = setInterval(loadOverviewLive, 15000); } -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
`; } +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"), + ]); -// ------------------------------------------------------------------ -// Queue -// ------------------------------------------------------------------ -async function renderQueue() { - if (!document.getElementById("q-body")) { - content().innerHTML = ` -
-
-
-

Derniers runs d'enrichissement

-
- Auto-refresh 15s - -
-
-
- - -
IDStatutDébutFinVusEnrichisProgressionErreur
+ // --- 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)
`; - 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")} + 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 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`; + const ts = document.getElementById("ov-live-ts"); + if (ts) ts.textContent = `Actualisé à ${new Date().toLocaleTimeString("fr-FR")}`; } catch (e) { - const tbody = document.getElementById("q-body"); - if (tbody) tbody.innerHTML = `Erreur : ${esc(e.message)}`; + const ts = document.getElementById("ov-live-ts"); + if (ts) ts.textContent = `Erreur : ${e.message}`; } } -function statusBadge(status) { - const cls = status === "done" ? "ok" : status === "error" ? "err" : status === "running" ? "warn" : "muted"; - return `${esc(status)}`; +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}`); } } // ------------------------------------------------------------------ -// Bands +// Groupes (Bands + Conflits fondus) // ------------------------------------------------------------------ async function renderBands() { const s = state.bands; @@ -326,6 +357,9 @@ async function renderBands() { +
Chargement…
@@ -341,6 +375,7 @@ async function renderBands() { 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(); }); @@ -375,6 +410,7 @@ async function loadBands() { 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}`); @@ -429,6 +465,24 @@ async function loadBands() { } } +// 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 { @@ -450,6 +504,19 @@ async function openBandModal(maId) { })(); 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) : "—"; @@ -529,6 +596,7 @@ async function openBandModal(maId) { + ${conflictHtml} ${provenanceHtml}