Bind activity row click handlers to the rendered row object instead of refinding it by id and type. Also guard activitySummary against missing rows to avoid runtime crashes in the admin activity modal.
1093 lines
52 KiB
JavaScript
1093 lines
52 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"];
|
|
const VIEW_ALIASES = {
|
|
"": "dashboard",
|
|
dashboard: "dashboard",
|
|
overview: "dashboard",
|
|
bands: "bands",
|
|
band: "bands",
|
|
activity: "activity",
|
|
activities: "activity",
|
|
activite: "activity",
|
|
activites: "activity",
|
|
actions: "actions",
|
|
action: "actions",
|
|
llm: "llm",
|
|
};
|
|
|
|
function normalizeView(raw) {
|
|
const key = String(raw || "").trim().toLowerCase().replace(/^\/+|\/+$/g, "");
|
|
const normalized = VIEW_ALIASES[key];
|
|
return VIEWS.includes(normalized) ? normalized : "dashboard";
|
|
}
|
|
|
|
function currentViewFromLocation() {
|
|
const hashView = normalizeView((location.hash || "").replace(/^#\/?/, ""));
|
|
if (location.hash) return hashView;
|
|
|
|
const pathParts = location.pathname.split("/").filter(Boolean);
|
|
return normalizeView(pathParts[0] || "");
|
|
}
|
|
|
|
function router() {
|
|
const view = currentViewFromLocation();
|
|
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) {
|
|
if (!row) return "—";
|
|
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, index) => {
|
|
const row = r.items[index];
|
|
tr.addEventListener("click", () => openActivityModal(tr.dataset.type, Number(tr.dataset.id), row));
|
|
});
|
|
|
|
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();
|