L'admin mélangeait pipeline legacy et récent (onglet "Queue" pointait vers geocode_queue mort → erreur "Cannot read properties of undefined (reading 'reduce')"), dispersait les logs (Monitor/Historique/Jobs) et les actions (Géocodage/Jobs/Checkpoints) sur des onglets séparés sans lien entre eux. Nouvelle nav (5 onglets) : - Vue d'ensemble : stats bands + genre/pays (ex-Dashboard) + cartes live auto-refresh 15s (crawl en cours, géocodage, jobs en attente, file d'enrichissement) — fusion Dashboard+Monitor+Queue. - Groupes : liste bands inchangée + filtre "Conflits seulement" (nouveau has_conflict sur GET /admin/api/bands) ; le modal band affiche maintenant une section "Conflits en attente" resolvable inline (garder ma valeur / accepter Metal Archives) — remplace l'onglet Conflits séparé. - Activité : liste chronologique unique (GET /admin/api/activity, fusion crawl_run tous types + admin_audit_log), cliquable → dialog avec logs complets du run (GET /admin/api/logs?run_id=) ou diff avant/après pour une action admin. Remplace Crawl runs + Historique + Audit + la liste de jobs de l'ex-onglet Jobs. - Actions : toutes les commandes (crawl, géocodage, maintenance) sur une seule page, groupées, chaque bouton vérifié contre son endpoint réel + checkpoints en lecture seule. Remplace Jobs + les boutons de Géocodage. - LLM : inchangé. Backend - endpoints morts supprimés (audit-log, conflicts liste, crawl-runs liste — tous remplacés par /admin/api/activity ou le filtre has_conflict) ; vérifié 1:1 qu'aucun appel front ne pointe vers un endpoint disparu et qu'aucun endpoint restant n'est orphelin. - GET /admin/api/bands : nouveau filtre has_conflict. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1064 lines
51 KiB
JavaScript
1064 lines
51 KiB
JavaScript
"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 `<div class="card stat-card"><div class="label">${esc(label)}</div><div class="value ${cls}">${Number(value).toLocaleString("fr-FR")}</div></div>`;
|
|
}
|
|
function barRow(label, count, max) {
|
|
const pct = Math.max(2, Math.round((count / max) * 100));
|
|
return `<div class="bar-row"><div class="bar-label" title="${esc(label)}">${esc(label)}</div><div class="bar-track"><div class="bar-fill" style="width:${pct}%"></div></div><div class="bar-count">${count}</div></div>`;
|
|
}
|
|
function emptyRow() { return `<div class="empty">Aucune donnée</div>`; }
|
|
function statusBadge(status) {
|
|
const cls = status === "done" ? "ok" : status === "error" ? "err" : status === "running" ? "warn" : "muted";
|
|
return `<span class="badge ${cls}">${esc(status)}</span>`;
|
|
}
|
|
|
|
// 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 = `<div class="loading">Chargement…</div>`;
|
|
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 = `
|
|
<div class="grid grid-stats">
|
|
${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")}
|
|
</div>
|
|
|
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px">
|
|
<div style="font-size:13px;font-weight:700;color:var(--bone)">En direct</div>
|
|
<span id="ov-live-ts" style="font-size:11px;color:var(--muted)">Auto-refresh 15s</span>
|
|
</div>
|
|
<div class="grid grid-3" id="ov-live" style="margin-bottom:20px">
|
|
<div class="card"><div class="loading">…</div></div>
|
|
<div class="card"><div class="loading">…</div></div>
|
|
<div class="card"><div class="loading">…</div></div>
|
|
<div class="card"><div class="loading">…</div></div>
|
|
</div>
|
|
|
|
<div class="grid grid-2">
|
|
<div class="card" style="max-height:420px;overflow-y:auto">
|
|
<h2>Par statut</h2>
|
|
${r.by_status.map((x) => barRow(x.status, x.total, maxStatus)).join("") || emptyRow()}
|
|
</div>
|
|
<div class="card" style="max-height:420px;overflow-y:auto">
|
|
<h2>Par pays (${r.by_country.length})</h2>
|
|
${r.by_country.map((x) => barRow(x.country, x.total, maxCountry)).join("") || emptyRow()}
|
|
</div>
|
|
<div class="card" style="max-height:500px;overflow-y:auto">
|
|
<h2>Éléments de genre (${elements.length} mots-clés)</h2>
|
|
<p style="font-size:11px;color:var(--muted);margin:0 0 10px">Chaque genre complexe (ex: "Doom/Death Metal; Gothic") est découpé en mots-clés.</p>
|
|
${elements.map((x) => barRow(x.kw, x.n, maxEl)).join("") || emptyRow()}
|
|
</div>
|
|
</div>
|
|
`;
|
|
} catch (e) {
|
|
content().innerHTML = `<div class="empty">Erreur : ${esc(e.message)}</div>`;
|
|
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 `<div class="mon-run-row">
|
|
<div>
|
|
<span class="badge warn">▶ ${esc(r.run_type)}</span>
|
|
<div style="font-size:11px;color:var(--muted);margin-top:5px">
|
|
${elapsed}min · ${r.bands_seen} vus · ${r.bands_new} nouveaux · ${r.bands_enriched} enrichis
|
|
</div>
|
|
${r.error ? `<div style="font-size:11px;color:var(--err);margin-top:3px">${esc(r.error)}</div>` : ""}
|
|
</div>
|
|
<button class="btn btn-mini" style="border-color:rgba(198,26,26,0.4)" onclick="adminCancelRun(${r.id})">✕ Annuler</button>
|
|
</div>`;
|
|
}).join("")
|
|
: `<div class="empty" style="padding:16px 0">Aucun run actif</div>`;
|
|
|
|
// --- 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 = `
|
|
<div style="background:rgba(255,255,255,0.06);border-radius:6px;height:6px;overflow:hidden;margin-bottom:8px">
|
|
<div style="width:${locPct}%;height:100%;background:linear-gradient(90deg,var(--blood),rgba(198,26,26,0.5));transition:width 0.5s ease"></div>
|
|
</div>
|
|
<div style="font-size:13px;font-weight:700;margin-bottom:6px">${locPct}% — ${locDone.toLocaleString("fr-FR")} / ${locTotal.toLocaleString("fr-FR")}</div>
|
|
<div style="font-size:11px;color:var(--muted);display:flex;flex-wrap:wrap;gap:8px;margin-bottom:6px">
|
|
${locLlm ? `<span style="color:var(--warn)">⚡ ${locLlm} LLM needed</span>` : ""}
|
|
${locManual ? `<span style="color:var(--warn)">${locManual} manuel</span>` : ""}
|
|
${locErr ? `<span style="color:var(--err)">✗ ${locErr} erreurs</span>` : ""}
|
|
</div>
|
|
<div style="font-size:11px;color:var(--muted)">${provTxt || "—"}</div>
|
|
`;
|
|
|
|
// --- 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 `<div class="mon-run-row">
|
|
<div>
|
|
<span class="badge muted">${esc(j.job_type)}</span>
|
|
<div style="font-size:11px;color:var(--muted);margin-top:4px">par ${esc(j.requested_by || "?")} · ${age}min</div>
|
|
</div>
|
|
<button class="btn btn-mini" onclick="adminCancelJob(${j.id})">✕</button>
|
|
</div>`;
|
|
}).join("")
|
|
: `<div class="empty" style="padding:16px 0">Aucun job en attente</div>`;
|
|
|
|
const b = queue.breakdown;
|
|
const queueHtml = `
|
|
<div style="font-size:11px;color:var(--muted);display:flex;flex-direction:column;gap:4px">
|
|
<span>${b.new_bands} nouveaux (priorité 1)</span>
|
|
<span>${b.modified_since_enrich} modifiés depuis enrich (p.2)</span>
|
|
<span>${b.legacy_pending} legacy à ré-enrichir (p.3)</span>
|
|
<span>${b.stale} stale >30j (p.4)</span>
|
|
</div>
|
|
`;
|
|
|
|
el.innerHTML = `
|
|
<div class="card">
|
|
<h2 style="margin-bottom:10px">Crawl en cours <span style="font-weight:400;color:var(--muted)">(${live.active_runs.length})</span></h2>
|
|
${runsHtml}
|
|
</div>
|
|
<div class="card">
|
|
<h2 style="margin-bottom:10px">Géocodage</h2>
|
|
${geoHtml}
|
|
</div>
|
|
<div class="card">
|
|
<h2 style="margin-bottom:10px">Jobs en attente <span style="font-weight:400;color:var(--muted)">(${live.pending_jobs.length})</span></h2>
|
|
${jobsHtml}
|
|
</div>
|
|
<div class="card">
|
|
<h2 style="margin-bottom:10px">File d'enrichissement</h2>
|
|
${queueHtml}
|
|
</div>
|
|
`;
|
|
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 = `
|
|
<div class="toolbar">
|
|
<input type="text" id="b-q" placeholder="Nom / genre…" value="${esc(s.q)}" style="min-width:180px">
|
|
<input type="text" id="b-location_q" placeholder="Lieu…" value="${esc(s.location_q)}" style="width:160px">
|
|
<input type="text" id="b-country" placeholder="Pays (FR, DE…)" value="${esc(s.country)}" style="width:110px">
|
|
<input type="text" id="b-status" placeholder="Statut" value="${esc(s.status)}" style="width:130px">
|
|
<input type="text" id="b-genre" placeholder="Genre" value="${esc(s.genre)}" style="width:150px">
|
|
<select id="b-enriched">
|
|
<option value="">Enrichi: tous</option>
|
|
<option value="true" ${s.enriched === "true" ? "selected" : ""}>Enrichi ✓</option>
|
|
<option value="false" ${s.enriched === "false" ? "selected" : ""}>Non enrichi ✗</option>
|
|
</select>
|
|
<select id="b-has_lat">
|
|
<option value="">Géocodé: tous</option>
|
|
<option value="true" ${s.has_lat === "true" ? "selected" : ""}>Géocodé ✓</option>
|
|
<option value="false" ${s.has_lat === "false" ? "selected" : ""}>Sans coords ✗</option>
|
|
</select>
|
|
<input type="text" id="b-themes_q" placeholder="Thèmes…" value="${esc(s.themes_q||"")}" style="width:150px">
|
|
<select id="b-has_location">
|
|
<option value="">Lieu texte: tous</option>
|
|
<option value="true" ${s.has_location === "true" ? "selected" : ""}>Lieu renseigné ✓</option>
|
|
<option value="false" ${s.has_location === "false" ? "selected" : ""}>Lieu vide ✗</option>
|
|
</select>
|
|
<label style="display:flex;align-items:center;gap:6px;font-size:13px">
|
|
<input type="checkbox" id="b-has_conflict" ${s.has_conflict === "true" ? "checked" : ""}> Conflits seulement 🔒
|
|
</label>
|
|
<button class="btn" id="b-apply">Filtrer</button>
|
|
</div>
|
|
<div id="b-table" class="table-wrap"><div class="loading">Chargement…</div></div>
|
|
<div class="pager" id="b-pager"></div>
|
|
`;
|
|
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 = `
|
|
<table>
|
|
<thead><tr>
|
|
${BAND_COLUMNS.map((c) => `<th data-sort="${c.key}">${c.label}${s.sort === c.key ? (s.dir === "asc" ? " ▲" : " ▼") : ""}</th>`).join("")}
|
|
</tr></thead>
|
|
<tbody>
|
|
${r.items.map((b) => `
|
|
<tr class="clickable" data-ma-id="${b.ma_id}">
|
|
<td>${b.ma_id}</td>
|
|
<td>${esc(b.name)}</td>
|
|
<td>${esc(b.country || "")}</td>
|
|
<td>${esc(b.status || "")}</td>
|
|
<td>${esc(b.genre || "")}</td>
|
|
<td style="max-width:140px;overflow:hidden;text-overflow:ellipsis" title="${esc(b.themes || "")}">${esc(b.themes || "")}</td>
|
|
<td style="max-width:140px;overflow:hidden;text-overflow:ellipsis" title="${esc(b.location_text || "")}">${esc(b.location_text || "")}</td>
|
|
<td>${b.formed_year || ""}</td>
|
|
<td>${b.enriched ? '<span class="badge ok">oui</span>' : '<span class="badge warn">non</span>'}${Object.keys(b.locked_fields||{}).length ? ' <span title="Champs verrouillés manuellement" style="color:var(--blood)">🔒</span>' : ""}</td>
|
|
<td>${fmtDate(b.crawled_at)}</td>
|
|
<td>${fmtDate(b.updated_at)}</td>
|
|
</tr>
|
|
`).join("") || `<tr><td colspan="${BAND_COLUMNS.length}" class="empty">Aucun résultat</td></tr>`}
|
|
</tbody>
|
|
</table>
|
|
`;
|
|
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 = `
|
|
<button class="btn btn-mini" id="b-prev" ${s.page <= 1 ? "disabled" : ""}>← Préc.</button>
|
|
<span>Page ${s.page} / ${totalPages} (${s.total.toLocaleString("fr-FR")} bands)</span>
|
|
<button class="btn btn-mini" id="b-next" ${s.page >= totalPages ? "disabled" : ""}>Suiv. →</button>
|
|
`;
|
|
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 = `<div class="empty">Erreur : ${esc(e.message)}</div>`;
|
|
}
|
|
}
|
|
|
|
// 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 `<tr>
|
|
<td><strong>${esc(field)}</strong></td>
|
|
<td style="color:var(--bone)">${esc(String(myVal))}</td>
|
|
<td style="color:var(--warn)">${esc(String(crawlerVal))}</td>
|
|
<td>
|
|
<button class="btn btn-mini" data-conflict-action="keep_mine" data-field="${esc(field)}">Garder ma valeur</button>
|
|
<button class="btn btn-mini" style="border-color:rgba(198,26,26,0.4)" data-conflict-action="accept_crawler" data-field="${esc(field)}">Accepter MA</button>
|
|
</td>
|
|
</tr>`;
|
|
}).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) => `<div style="display:flex;justify-content:space-between;gap:12px;padding:2px 0;font-size:12px"><span style="color:var(--muted)">${esc(label)}</span><span style="text-align:right">${val}</span></div>`;
|
|
|
|
const conflictRows = conflictRowsHtml(band);
|
|
const conflictHtml = conflictRows ? `
|
|
<details style="margin-top:14px;border-top:1px solid rgba(198,26,26,0.3);padding-top:10px" open>
|
|
<summary style="cursor:pointer;font-weight:600;color:var(--blood)">⚠️ Conflits en attente</summary>
|
|
<p style="font-size:11px;color:var(--muted);margin:8px 0">
|
|
Champs verrouillés manuellement pour lesquels le crawler a trouvé une valeur différente.
|
|
</p>
|
|
<div class="table-wrap"><table>
|
|
<thead><tr><th>Champ</th><th>Ma valeur (verrouillée)</th><th>Valeur Metal Archives</th><th>Action</th></tr></thead>
|
|
<tbody id="m-conflict-body">${conflictRows}</tbody>
|
|
</table></div>
|
|
</details>` : "";
|
|
|
|
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 ? `<div style="color:var(--err);font-size:11px">${esc(l.geocode_error)}</div>` : "";
|
|
return `<tr>
|
|
<td>${l.step_order}${l.step_label ? ` <span style="color:var(--muted)">(${esc(l.step_label)})</span>` : ""}</td>
|
|
<td>${esc(l.location_raw)}${err}</td>
|
|
<td>${esc(l.geocode_status)}</td>
|
|
<td>${esc(l.geocode_provider || "—")}</td>
|
|
<td>${conf}</td>
|
|
<td>${coords}</td>
|
|
<td>${tries}</td>
|
|
<td style="font-size:11px;color:var(--muted)">${esc(l.geocode_query || "—")}</td>
|
|
</tr>`;
|
|
}).join("") : `<tr><td colspan="8" style="color:var(--muted)">Aucune localisation dans band_locations</td></tr>`;
|
|
|
|
const llmHtml = llmCalls.length ? llmCalls.map(c => {
|
|
const res = c.is_null ? `<span style="color:var(--warn)">city=null</span>` : `${esc(c.parsed_city || "?")} (${esc(c.parsed_country || "?")})`;
|
|
const cost = c.cost_usd != null ? `$${Number(c.cost_usd).toFixed(6)}` : "—";
|
|
return `<details style="margin:4px 0;background:rgba(255,255,255,0.03);border-radius:4px;padding:6px 8px">
|
|
<summary style="cursor:pointer;font-size:12px">${esc(c.model)} · ${esc(c.location_raw || "")} → ${res} · ${c.tokens_in || 0}+${c.tokens_out || 0}tok · ${cost} · ${fmtDate(c.created_at)}</summary>
|
|
<div style="margin-top:6px;font-size:11px;color:var(--muted);white-space:pre-wrap">PROMPT:\n${esc(c.prompt || "")}\n\nRESPONSE:\n${esc(c.response || "")}</div>
|
|
</details>`;
|
|
}).join("") : `<div style="color:var(--muted);font-size:12px">Aucun appel LLM enregistré pour ce groupe.</div>`;
|
|
|
|
const provenanceHtml = `
|
|
<details style="margin-top:14px;border-top:1px solid rgba(255,255,255,0.1);padding-top:10px" open>
|
|
<summary style="cursor:pointer;font-weight:600;color:var(--muted)">🔎 Provenance & géocodage (lecture seule)</summary>
|
|
|
|
<div style="margin-top:8px">
|
|
<div style="font-size:11px;text-transform:uppercase;color:var(--muted);margin-bottom:4px">Metal Archives (crawl)</div>
|
|
${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(", ")) : "—")}
|
|
</div>
|
|
|
|
<div style="margin-top:10px">
|
|
<div style="font-size:11px;text-transform:uppercase;color:var(--muted);margin-bottom:4px">Géocodage (point du groupe)</div>
|
|
${srcRow("provider", esc(band.geocode_provider || "—"))}
|
|
${srcRow("query", esc(band.geocode_query || "—"))}
|
|
${srcRow("geocoded_at", fmtDate(band.geocoded_at))}
|
|
${band.geocode_error ? srcRow("erreur", `<span style="color:var(--err)">${esc(band.geocode_error)}</span>`) : ""}
|
|
</div>
|
|
|
|
<div style="margin-top:10px">
|
|
<div style="font-size:11px;text-transform:uppercase;color:var(--muted);margin-bottom:4px">Localisations (band_locations)</div>
|
|
<div style="overflow-x:auto">
|
|
<table style="width:100%;border-collapse:collapse;font-size:12px">
|
|
<thead><tr style="text-align:left;color:var(--muted)">
|
|
<th>Step</th><th>Brut</th><th>Statut</th><th>Provider</th><th>Conf</th><th>Coords</th><th>Essais</th><th>Query</th>
|
|
</tr></thead>
|
|
<tbody>${locRowsHtml}</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="margin-top:10px">
|
|
<div style="font-size:11px;text-transform:uppercase;color:var(--muted);margin-bottom:4px">Appels LLM (Groq)</div>
|
|
${llmHtml}
|
|
</div>
|
|
</details>`;
|
|
|
|
const backdrop = document.createElement("div");
|
|
backdrop.className = "modal-backdrop";
|
|
backdrop.innerHTML = `
|
|
<div class="modal">
|
|
<span class="modal-close">×</span>
|
|
<h3>${esc(band.name)} <span style="color:var(--muted);font-weight:400">#${band.ma_id}</span></h3>
|
|
<label>Nom<input type="text" id="m-name" value="${esc(band.name || "")}"></label>
|
|
<label>Pays<input type="text" id="m-country" value="${esc(band.country || "")}"></label>
|
|
<label>Statut<input type="text" id="m-status" value="${esc(band.status || "")}"></label>
|
|
<label>Genre<input type="text" id="m-genre" value="${esc(band.genre || "")}"></label>
|
|
<label>Année de formation<input type="number" id="m-formed_year" value="${band.formed_year ?? ""}"></label>
|
|
<label>Localisation<input type="text" id="m-location_text" value="${esc(band.location_text || "")}"></label>
|
|
<label>Thèmes<input type="text" id="m-themes" value="${esc(band.themes || "")}"></label>
|
|
<label>Latitude<input type="number" step="any" id="m-lat" value="${band.lat ?? ""}"></label>
|
|
<label>Longitude<input type="number" step="any" id="m-lon" value="${band.lon ?? ""}"></label>
|
|
${conflictHtml}
|
|
${provenanceHtml}
|
|
<div class="modal-error" id="m-error"></div>
|
|
<div class="modal-actions">
|
|
<button class="btn btn-mini" id="m-cancel">Annuler</button>
|
|
<button class="btn" id="m-save">Enregistrer</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
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 = `
|
|
<div class="toolbar">
|
|
<select id="a-type">
|
|
<option value="">Type: tous</option>
|
|
<option value="run" ${s.type === "run" ? "selected" : ""}>Run (crawl/enrich/géocodage)</option>
|
|
<option value="admin_action" ${s.type === "admin_action" ? "selected" : ""}>Action admin</option>
|
|
</select>
|
|
<select id="a-status">
|
|
<option value="">Statut: tous</option>
|
|
<option value="running">running</option>
|
|
<option value="done">done</option>
|
|
<option value="error">error</option>
|
|
</select>
|
|
<button class="btn" id="a-apply">Filtrer</button>
|
|
<span id="a-refresh-status" style="font-size:11px;color:var(--muted);margin-left:auto">Auto-refresh 15s</span>
|
|
</div>
|
|
<div id="a-table" class="table-wrap"><div class="loading">Chargement…</div></div>
|
|
<div class="pager" id="a-pager"></div>
|
|
`;
|
|
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 = `
|
|
<table>
|
|
<thead><tr><th>Date</th><th>Type</th><th>Sous-type</th><th>Statut</th><th>Acteur</th><th>Résumé</th></tr></thead>
|
|
<tbody>
|
|
${r.items.map((x) => `
|
|
<tr class="clickable" data-type="${esc(x.type)}" data-id="${x.id}">
|
|
<td>${fmtDate(x.ts)}</td>
|
|
<td><span class="badge muted">${x.type === "run" ? "Run" : "Admin"}</span></td>
|
|
<td>${esc(x.subtype)}</td>
|
|
<td>${statusBadge(x.status)}</td>
|
|
<td>${esc(x.actor || "—")}</td>
|
|
<td style="max-width:340px;overflow:hidden;text-overflow:ellipsis">${activitySummary(x)}</td>
|
|
</tr>
|
|
`).join("") || `<tr><td colspan="6" class="empty">Aucune activité</td></tr>`}
|
|
</tbody>
|
|
</table>
|
|
`;
|
|
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 = `
|
|
<button class="btn btn-mini" id="a-prev" ${s.page <= 1 ? "disabled" : ""}>← Préc.</button>
|
|
<span>Page ${s.page} / ${totalPages} (${s.total.toLocaleString("fr-FR")})</span>
|
|
<button class="btn btn-mini" id="a-next" ${s.page >= totalPages ? "disabled" : ""}>Suiv. →</button>
|
|
`;
|
|
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 = `<div class="empty">Erreur : ${esc(e.message)}</div>`;
|
|
}
|
|
}
|
|
|
|
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 = `
|
|
<div style="font-size:12px;color:var(--muted);margin-bottom:10px">
|
|
${statusBadge(row?.status)} · démarré ${fmtDate(row?.ts)} · terminé ${fmtDate(row?.finished_at)}
|
|
${sum.countries ? ` · pays: ${esc((sum.countries||[]).join(", "))}` : ""}
|
|
</div>
|
|
<div style="font-size:12px;margin-bottom:12px">${activitySummary(row)}</div>
|
|
${row?.status === "running" ? `<button class="btn btn-mini" style="border-color:rgba(198,26,26,0.4);margin-bottom:12px" onclick="adminCancelRun(${id})">✕ Annuler ce run</button>` : ""}
|
|
<h3 style="font-size:13px;margin:0 0 8px">Logs</h3>
|
|
<div id="act-logs" class="log-stream" style="max-height:400px">Chargement…</div>
|
|
`;
|
|
} else {
|
|
const sum = row?.summary || {};
|
|
bodyHtml = `
|
|
<div style="font-size:12px;color:var(--muted);margin-bottom:10px">${fmtDate(row?.ts)} · ${esc(row?.actor || "")} · ${esc(row?.subtype || "")}</div>
|
|
<div style="font-size:12px;margin-bottom:10px">Cible : ${esc(sum.target_table || "")} #${sum.target_id ?? ""}</div>
|
|
<div class="grid grid-2">
|
|
<div>
|
|
<div style="font-size:11px;text-transform:uppercase;color:var(--muted);margin-bottom:4px">Avant</div>
|
|
<pre style="font-size:11px;white-space:pre-wrap;background:rgba(255,255,255,0.03);padding:8px;border-radius:6px;max-height:300px;overflow:auto">${esc(JSON.stringify(sum.before, null, 2))}</pre>
|
|
</div>
|
|
<div>
|
|
<div style="font-size:11px;text-transform:uppercase;color:var(--muted);margin-bottom:4px">Après</div>
|
|
<pre style="font-size:11px;white-space:pre-wrap;background:rgba(255,255,255,0.03);padding:8px;border-radius:6px;max-height:300px;overflow:auto">${esc(JSON.stringify(sum.after, null, 2))}</pre>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
backdrop.innerHTML = `
|
|
<div class="modal" style="max-width:720px">
|
|
<span class="modal-close">×</span>
|
|
<h3>${type === "run" ? `Run #${id} — ${esc(row?.subtype || "")}` : `Action admin #${id}`}</h3>
|
|
${bodyHtml}
|
|
</div>
|
|
`;
|
|
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 `<div class="log-line">
|
|
<span class="ts" style="color:var(--muted);flex-shrink:0">${fmtDate(x.created_at)}</span>
|
|
<span style="flex-shrink:0;width:56px;font-weight:700;color:var(--${lvlCls})">${esc(x.level)}</span>
|
|
<span style="word-break:break-all">${esc(x.message)}</span>
|
|
</div>`;
|
|
}).join("") || `<div class="empty">Aucun log pour ce run</div>`;
|
|
} catch (e) {
|
|
const logsEl = backdrop.querySelector("#act-logs");
|
|
if (logsEl) logsEl.innerHTML = `<div class="empty">Erreur : ${esc(e.message)}</div>`;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Actions — toutes les commandes possibles, groupées et vérifiées
|
|
// ------------------------------------------------------------------
|
|
async function renderActions() {
|
|
content().innerHTML = `
|
|
<div class="grid grid-2" style="margin-bottom:16px">
|
|
|
|
<div class="card">
|
|
<h2>🕷 Crawl</h2>
|
|
<p style="font-size:12px;color:var(--muted);margin:0 0 14px">Consommés par le crawler dans sa prochaine itération (~1 min).</p>
|
|
<div style="display:flex;flex-direction:column;gap:8px">
|
|
<button class="btn" data-job="enrich">▶ Enrich (500 bands)</button>
|
|
<button class="btn" data-job="incremental">▶ Crawl incrémental (créations + modifs)</button>
|
|
<button class="btn" data-job="full_crawl" style="border-color:rgba(198,26,26,0.5)">▶ Crawl complet Europe</button>
|
|
</div>
|
|
<div id="job-feedback" style="margin-top:10px;font-size:12px;color:var(--muted)"></div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>🗺 Géocodage</h2>
|
|
<p style="font-size:12px;color:var(--muted);margin:0 0 14px">L'enqueue tourne aussi automatiquement en tâche de fond (rattrapage). Statistiques dans Vue d'ensemble.</p>
|
|
<div style="display:flex;flex-direction:column;gap:8px">
|
|
<button class="btn" id="loc-enqueue" style="border-color:rgba(63,174,90,0.5);color:var(--ok)">▶ Lancer l'enqueue band_locations</button>
|
|
<button class="btn" id="loc-reset-errors">🔄 Reset erreurs</button>
|
|
<button class="btn" id="loc-reset-llm">🔄 Reset LLM needed / manuel</button>
|
|
<button class="btn" id="loc-requeue-all">♻️ Re-queue tout (y compris done)</button>
|
|
</div>
|
|
<div id="geo-feedback" style="margin-top:10px;font-size:12px;color:var(--muted)"></div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>🧹 Maintenance</h2>
|
|
<div style="display:flex;flex-direction:column;gap:8px">
|
|
<button class="btn" id="cmd-cleanup">🧹 Annuler crawl-runs bloqués (>30 min)</button>
|
|
</div>
|
|
<div id="maintenance-feedback" style="margin-top:10px;font-size:12px;color:var(--muted)"></div>
|
|
</div>
|
|
|
|
<div class="card" style="border-color:rgba(198,26,26,0.35)">
|
|
<h2 style="margin:0 0 6px">⚠️ Zone dangereuse — tout recommencer</h2>
|
|
<p style="font-size:12px;color:var(--muted);margin:0 0 10px">
|
|
À 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.
|
|
</p>
|
|
<div style="display:flex;flex-direction:column;gap:8px">
|
|
<button class="btn btn-mini" id="loc-reset-all" style="border-color:rgba(198,26,26,0.6);color:var(--err)">🔥 Reset complet du géocodage</button>
|
|
<button class="btn btn-mini" id="cache-purge-nominatim" style="border-color:rgba(198,26,26,0.6);color:var(--err)">🧹 Purger cache Nominatim</button>
|
|
</div>
|
|
<div id="danger-feedback" style="margin-top:8px;font-size:12px"></div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>Checkpoints crawler <span style="font-weight:400;font-size:12px;color:var(--muted)">(lecture seule)</span></h2>
|
|
<div id="checkpoints-wrap" class="table-wrap"><div class="loading">Chargement…</div></div>
|
|
</div>
|
|
`;
|
|
|
|
// 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 = `
|
|
<table>
|
|
<thead><tr><th>Clé</th><th>Valeur</th><th>Mis à jour</th></tr></thead>
|
|
<tbody>
|
|
${r.items.map((x) => `<tr><td>${esc(x.key)}</td><td>${esc(x.value || "—")}</td><td>${fmtDate(x.updated_at)}</td></tr>`).join("") || `<tr><td colspan="3" class="empty">Aucun checkpoint</td></tr>`}
|
|
</tbody>
|
|
</table>
|
|
`;
|
|
} catch (e) {
|
|
document.getElementById("checkpoints-wrap").innerHTML = `<div class="empty">Erreur : ${esc(e.message)}</div>`;
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// LLM — debug des appels Groq (désambiguïsation géocodage)
|
|
// ------------------------------------------------------------------
|
|
async function renderLLM() {
|
|
content().innerHTML = `
|
|
<div class="toolbar">
|
|
<input type="text" id="llm-q" placeholder="Filtrer par lieu / ville…" style="min-width:220px">
|
|
<select id="llm-model">
|
|
<option value="">Modèle: tous</option>
|
|
<option value="llama-3.3-70b-versatile">llama-3.3-70b-versatile</option>
|
|
<option value="llama-3.1-8b-instant">llama-3.1-8b-instant</option>
|
|
</select>
|
|
<label style="display:flex;align-items:center;gap:6px;font-size:13px">
|
|
<input type="checkbox" id="llm-null"> Uniquement city=null
|
|
</label>
|
|
<button class="btn" id="llm-apply">Filtrer</button>
|
|
<span id="llm-status" style="font-size:12px;color:var(--muted)"></span>
|
|
</div>
|
|
<p style="font-size:12px;color:var(--muted);margin:0 0 12px">
|
|
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.
|
|
</p>
|
|
<div id="llm-list" class="table-wrap"><div class="loading">Chargement…</div></div>
|
|
<div class="pager" id="llm-pager"></div>
|
|
`;
|
|
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
|
|
? `<span style="color:var(--warn)">null</span>`
|
|
: `${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
|
|
? `<a href="#" data-ma="${c.ma_id}" class="llm-band">${esc(c.band_name || ("#" + c.ma_id))}</a>`
|
|
: "—";
|
|
return `<tr>
|
|
<td>${fmtDate(c.created_at)}</td>
|
|
<td>${band}</td>
|
|
<td style="font-size:11px">${esc(c.model)}</td>
|
|
<td>${esc(c.location_raw || "—")}${c.country ? ` <span style="color:var(--muted)">[${esc(c.country)}]</span>` : ""}</td>
|
|
<td>${res}</td>
|
|
<td>${(c.tokens_in || 0)}+${(c.tokens_out || 0)}</td>
|
|
<td>${cost}</td>
|
|
<td><button class="btn btn-mini llm-detail" data-id="${c.id}">Prompt/réponse</button></td>
|
|
</tr>
|
|
<tr class="llm-detail-row" data-id="${c.id}" style="display:none"><td colspan="8">
|
|
<div style="font-size:11px;color:var(--muted);white-space:pre-wrap;background:rgba(255,255,255,0.03);padding:8px;border-radius:4px">PROMPT:\n${esc(c.prompt || "")}\n\nRESPONSE:\n${esc(c.response || "")}</div>
|
|
</td></tr>`;
|
|
}).join("");
|
|
document.getElementById("llm-list").innerHTML = rows
|
|
? `<table><thead><tr><th>Date</th><th>Groupe</th><th>Modèle</th><th>Lieu (brut)</th><th>Extrait</th><th>Tokens</th><th>Coût</th><th></th></tr></thead><tbody>${rows}</tbody></table>`
|
|
: `<div class="empty">Aucun appel LLM.</div>`;
|
|
const totalPages = Math.max(1, Math.ceil(r.total / 50));
|
|
document.getElementById("llm-pager").innerHTML = `
|
|
<button class="btn btn-mini" id="llm-prev" ${page <= 1 ? "disabled" : ""}>← Préc.</button>
|
|
<span>Page ${page} / ${totalPages}</span>
|
|
<button class="btn btn-mini" id="llm-next" ${page >= totalPages ? "disabled" : ""}>Suiv. →</button>
|
|
`;
|
|
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 = `<div class="empty">Erreur : ${esc(e.message)}</div>`;
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Boot
|
|
// ------------------------------------------------------------------
|
|
checkSession();
|