metalfrom.eu/apps/admin/site/app.js
Nicolas Fryder c30656544c
Some checks are pending
CI / javascript (push) Waiting to run
CI / python (push) Waiting to run
CI / mutation (push) Waiting to run
fix(admin): passe de debug guidée par la mutation — 4 bugs, mutation 64 → 68 %
La liste des mutants survivants est la carte de ce qui n'est pas vérifié. Une
passe dessus a montré que TOUS les filtres de /admin/api/bands étaient non
testés (sept mutants survivants par drapeau booléen) — précisément ceux dont
dépendent les filtres rapides du dashboard. Ils pouvaient être inopérants sans
que rien ne le signale.

Bugs trouvés et corrigés

1. crawler_pending détruit silencieusement (backend)
   Le PATCH reconstruisait crawler_pending avec jsonb_build_object() sur les
   seuls champs édités. Il gardait donc le conflit qu'on venait de trancher ET
   supprimait ceux des champs non touchés : éditer le nom d'un groupe effaçait
   ses conflits de genre, de pays et de localisation, sans trace.
   Corrigé en suivant la convention de /resolve-conflict : retrait des clés
   arbitrées (`crawler_pending - ARRAY[...]`).

2. Filtre genre non borné (backend)
   q, location_q et themes_q tronquaient à 100 caractères ; genre non. Un motif
   ILIKE de taille arbitraire partait vers Postgres, qui ne peut pas l'indexer.
   country et status sont bornés au passage, par cohérence.

3. Chips inutilisables après une alerte (frontend)
   Le paramètre de statut de l'URL était relu à CHAQUE rendu. Arrivé depuis une
   alerte du Pilotage, cliquer un autre chip n'avait aucun effet : le filtre
   revenait aussitôt à celui de l'URL. Le paramètre est désormais consommé
   (replaceState, qui ne déclenche pas hashchange).

4. Minuteur d'auto-refresh orphelin (frontend)
   Le minuteur était armé APRÈS le chargement des données. Quitter la vue
   pendant celui-ci le laissait s'armer après le clearTimer() du routeur : le
   Pilotage continuait d'interroger quatre endpoints toutes les 15 s depuis un
   autre onglet, indéfiniment. Résolu par un jeton de rendu.

Commentaire dangereux corrigé
La saisie manuelle de coordonnées écrit geocode_status='done'. Le commentaire
annonçait 'manual', ce qui aurait conduit à une « correction » aux conséquences
invisibles : /api/clusters ne retient que ('done','country_only') — le point
n'apparaîtrait pas sur la carte — et /locations/reset-llm remet en file
('llm_needed','manual') — le bouton effacerait la saisie. Invariant verrouillé
par un test.

Test instable supprimé
Les interceptions Playwright lisaient la réponse après une possible navigation
(« Response has been disposed ») : un échec sur trois exécutions, sans rapport
avec ce qui était vérifié. Un test instable finit par être ignoré, ce qui est
pire qu'un test absent. Helper patchJson() ; stable sur 4 exécutions.

Tests ajoutés : 529 JS (+78), 43 Python, 82 Playwright (+27)
- bandFilters.test.js  : les 3 drapeaux × 2 polarités × présence/absence,
  bornes de q, tri, pagination, alignement des paramètres liés
- conflicts.test.js    : effet du PATCH sur crawler_pending, casts par type,
  allowlist, cohérence avec resolve-conflict
- tools.spec.js        : tri, pagination, arbitrage de conflit, annulations,
  filtres LLM, cycle de vie des vues

Les trois tests de régression ont été vérifiés NON VACUOUS : chaque bug
réintroduit les fait échouer.

Mutation : 64,00 % -> 68,29 % (seuil 55), obtenu en écrivant des tests et non
en réduisant le périmètre muté.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 15:42:52 +02:00

1293 lines
55 KiB
JavaScript

"use strict";
/**
* Dashboard admin metalfrom.eu.
*
* Organisation : un onglet = une question que se pose l'admin, et non une table
* de la base. Chaque chiffre problématique porte son action à côté de lui —
* l'ancienne séparation « je constate ici / j'agis dans un autre onglet » était
* la principale source de confusion.
*
* Pilotage → est-ce que ça tourne ?
* Groupes → trouver et corriger un groupe
* Localisations → qu'est-ce qui coince dans le géocodage ?
* Journal → que s'est-il passé ?
* LLM & coûts → combien ça coûte ?
*/
const state = {
username: null,
view: "pilotage",
bands: {
page: 1, pageSize: 50, q: "", location_q: "", country: "", status: "", genre: "",
themes_q: "", enriched: "", has_lat: "", has_location: "", has_conflict: "",
sort: "ma_id", dir: "asc", total: 0, advanced: false,
},
locations: { page: 1, pageSize: 50, status: "error,llm_needed", q: "", country: "", total: 0 },
activity: { page: 1, pageSize: 50, type: "", status: "", total: 0 },
llm: { page: 1 },
timer: null,
};
// ------------------------------------------------------------------
// API
// ------------------------------------------------------------------
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;
}
// ------------------------------------------------------------------
// Utilitaires de rendu
// ------------------------------------------------------------------
function esc(s) {
return String(s ?? "").replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
function fmtDate(s) {
if (!s) return "—";
return new Date(s).toLocaleString("fr-FR", {
day: "2-digit", month: "2-digit", year: "2-digit", hour: "2-digit", minute: "2-digit",
});
}
function fmtNum(n) {
return Number(n || 0).toLocaleString("fr-FR");
}
/** Durée écoulée, lisible ("3 min", "2 h 10"). */
function since(ts) {
if (!ts) return "—";
const min = Math.max(0, Math.round((Date.now() - new Date(ts)) / 60000));
if (min < 60) return `${min} min`;
const h = Math.floor(min / 60);
return `${h} h ${String(min % 60).padStart(2, "0")}`;
}
const content = () => document.getElementById("content");
const STATUS_TONE = {
done: "ok", country_only: "ok", error: "err", running: "warn",
cancelled: "muted", queued: "muted", processing: "warn",
llm_needed: "warn", manual: "warn",
};
/** Les valeurs stockées sont techniques ; l'admin lit du français. */
const STATUS_LABEL = {
done: "résolu", country_only: "pays seul", error: "erreur",
running: "en cours", cancelled: "annulé", queued: "en file",
processing: "traitement", llm_needed: "attente LLM", manual: "manuel",
};
function statusBadge(status) {
return `<span class="badge ${STATUS_TONE[status] || "muted"}" title="${esc(status)}">${esc(STATUS_LABEL[status] || status)}</span>`;
}
function emptyRow(msg = "Aucune donnée") { return `<div class="empty">${esc(msg)}</div>`; }
/** Bloc d'erreur uniforme, avec le message réel de l'API. */
function errorBox(e) {
return `<div class="empty err-box">⚠ ${esc(e.message)}</div>`;
}
function clearTimer() {
if (state.timer) { clearInterval(state.timer); state.timer = null; }
}
/**
* Jeton de rendu.
*
* Les vues chargent leurs données de façon asynchrone puis arment un
* auto-refresh. Sans ce jeton, quitter une vue PENDANT son chargement laissait
* le minuteur s'armer après coup : router() avait déjà fait son clearTimer(),
* et le Pilotage continuait d'interroger quatre endpoints toutes les 15 s
* depuis un autre onglet, indéfiniment.
*/
let renderToken = 0;
const isStale = (token) => token !== renderToken;
// ------------------------------------------------------------------
// Auth
// ------------------------------------------------------------------
function showLogin() {
document.getElementById("login-screen").classList.remove("hidden");
document.getElementById("app").classList.add("hidden");
clearTimer();
}
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");
const btn = e.target.querySelector('button[type="submit"]');
errEl.textContent = "";
btn.disabled = true;
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";
} finally {
btn.disabled = false;
}
});
document.getElementById("logout-btn").addEventListener("click", async () => {
try { await fetch("/admin/auth/logout", { method: "POST", credentials: "include" }); } catch {}
state.username = null;
showLogin();
});
// ------------------------------------------------------------------
// Routeur
// ------------------------------------------------------------------
const VIEWS = ["pilotage", "bands", "locations", "activity", "llm"];
const VIEW_ALIASES = {
"": "pilotage", pilotage: "pilotage", dashboard: "pilotage", overview: "pilotage",
bands: "bands", band: "bands", groupes: "bands",
locations: "locations", localisations: "locations", geocoding: "locations",
activity: "activity", activite: "activity", journal: "activity",
llm: "llm", couts: "llm",
// L'onglet « Actions » a été fondu dans Pilotage : on redirige les favoris.
actions: "pilotage",
};
function normalizeView(raw) {
// La query string doit être retirée AVANT la recherche d'alias : les liens du
// Pilotage pointent vers "#/locations?status=error", qui sinon ne
// correspondrait à aucune vue et retomberait sur Pilotage.
const key = String(raw || "").split("?")[0]
.trim().toLowerCase().replace(/^\/+|\/+$/g, "");
const normalized = VIEW_ALIASES[key];
return VIEWS.includes(normalized) ? normalized : "pilotage";
}
function currentViewFromLocation() {
if (location.hash) return normalizeView((location.hash || "").replace(/^#\/?/, ""));
return normalizeView(location.pathname.split("/").filter(Boolean)[0] || "");
}
const RENDERERS = {
pilotage: renderPilotage,
bands: renderBands,
locations: renderLocations,
activity: renderActivity,
llm: renderLLM,
};
function router() {
const view = currentViewFromLocation();
state.view = view;
const token = ++renderToken;
document.querySelectorAll(".nav a").forEach((a) => {
const active = a.dataset.view === view;
a.classList.toggle("active", active);
if (active) a.setAttribute("aria-current", "page");
else a.removeAttribute("aria-current");
});
clearTimer();
RENDERERS[view](token);
}
window.addEventListener("hashchange", router);
// ==================================================================
// PILOTAGE — « est-ce que ça tourne ? »
// ==================================================================
async function renderPilotage(token = renderToken) {
content().innerHTML = `
<div class="view-head">
<h1>Pilotage</h1>
<span id="p-refresh" class="refresh-note" aria-live="polite">Actualisation auto toutes les 15 s</span>
</div>
<div id="p-health" class="health-strip"><div class="loading">Chargement…</div></div>
<div id="p-alerts"></div>
<div class="grid grid-2">
<div class="card" id="p-runs"><div class="loading">Chargement…</div></div>
<div class="card" id="p-triggers"></div>
</div>
<details class="card danger-zone" id="p-danger">
<summary>⚠️ Opérations destructives</summary>
<p class="muted-note">
À n'utiliser que si le géocodage est corrompu. Ces actions relancent des
milliers d'appels Geoapify et Groq, qui sont facturés.
</p>
<div class="btn-row">
<button class="btn btn-danger" data-danger="reset-all">🔥 Réinitialiser tout le géocodage</button>
<button class="btn btn-danger" data-danger="purge-nominatim">🧹 Purger le cache Nominatim</button>
<button class="btn btn-danger" data-danger="requeue-all">♻️ Remettre TOUT en file (dont « done »)</button>
</div>
<div id="p-danger-feedback" class="feedback" role="status"></div>
</details>
`;
renderTriggers();
wireDangerZone();
await loadPilotage();
if (isStale(token)) return; // l'utilisateur a changé de vue pendant le chargement
clearTimer();
state.timer = setInterval(loadPilotage, 15000);
}
function renderTriggers() {
document.getElementById("p-triggers").innerHTML = `
<h2>Lancer un traitement</h2>
<p class="muted-note">Le crawler consomme la demande à son prochain cycle (~1 min).</p>
<div class="btn-col">
<button class="btn" data-job="incremental">🕷 Crawl incrémental <span class="hint">créations et modifications récentes</span></button>
<button class="btn" data-job="enrich">📖 Enrichir les groupes <span class="hint">visite les fiches non enrichies</span></button>
<button class="btn" data-job="geocoder_enqueue">🗺 Alimenter le géocodage <span class="hint">reparse les lieux en attente</span></button>
<button class="btn" data-job="full_crawl">🌍 Crawl complet Europe <span class="hint">long — plusieurs heures</span></button>
</div>
<div id="p-job-feedback" class="feedback" role="status"></div>
`;
document.getElementById("p-triggers").addEventListener("click", async (e) => {
const btn = e.target.closest("[data-job]");
if (!btn) return;
const job_type = btn.dataset.job;
if (job_type === "full_crawl" &&
!confirm("Lancer un crawl complet de l'Europe ?\n\nPlusieurs heures, et beaucoup de requêtes vers Metal Archives.")) return;
const fb = document.getElementById("p-job-feedback");
btn.disabled = true;
fb.textContent = "Envoi…"; fb.className = "feedback";
try {
const r = await api("/admin/api/job-triggers", { method: "POST", body: JSON.stringify({ job_type }) });
fb.textContent = `✓ Demande #${r.id} enregistrée — suivi dans Journal.`;
fb.className = "feedback ok";
await loadPilotage();
} catch (err) {
fb.textContent = `${err.message}`;
fb.className = "feedback err";
} finally {
btn.disabled = false;
}
});
}
const DANGER_ACTIONS = {
"reset-all": {
url: "/admin/api/locations/reset-all",
confirm: "RÉINITIALISER TOUT LE GÉOCODAGE ?\n\nToutes les localisations repassent en file et perdent leurs coordonnées. Le cache est réutilisé quand c'est possible, mais cela reste coûteux.",
},
"purge-nominatim": {
url: "/admin/api/geocode-cache/purge-nominatim",
confirm: "Supprimer les entrées de cache de l'ancien pipeline Nominatim ?\n\nCes lieux seront re-géocodés via Geoapify — appels facturés.",
},
"requeue-all": {
url: "/admin/api/locations/requeue-all",
body: { include_done: true },
confirm: "Remettre TOUTES les localisations en file, y compris celles déjà résolues ?",
},
};
function wireDangerZone() {
document.getElementById("p-danger").addEventListener("click", async (e) => {
const btn = e.target.closest("[data-danger]");
if (!btn) return;
const action = DANGER_ACTIONS[btn.dataset.danger];
if (!action || !confirm(action.confirm)) return;
const fb = document.getElementById("p-danger-feedback");
btn.disabled = true;
fb.textContent = "En cours…"; fb.className = "feedback";
try {
const r = await api(action.url, { method: "POST", body: JSON.stringify(action.body || {}) });
fb.textContent = `${fmtNum(r.count)} entrée(s) modifiée(s).`;
fb.className = "feedback ok";
await loadPilotage();
} catch (err) {
fb.textContent = `${err.message}`;
fb.className = "feedback err";
} finally {
btn.disabled = false;
}
});
}
function healthCard(label, value, sub, tone = "") {
return `<div class="health-card ${tone}">
<div class="h-label">${esc(label)}</div>
<div class="h-value">${value}</div>
<div class="h-sub">${sub}</div>
</div>`;
}
async function loadPilotage() {
try {
const [stats, live, geo, queue] = await Promise.all([
api("/admin/api/stats"),
api("/admin/api/live"),
api("/admin/api/geocoding"),
api("/admin/api/queue"),
]);
const t = stats.totals;
const locs = geo.locations || [];
const n = (s) => locs.find((x) => x.status === s)?.n || 0;
const locTotal = locs.reduce((sum, x) => sum + x.n, 0);
const locDone = n("done") + n("country_only");
const locPct = locTotal ? Math.round((locDone / locTotal) * 100) : 0;
const errors = n("error");
const llmNeeded = n("llm_needed");
const queued = n("queued") + n("processing");
const cost = Number(geo.llm_cache?.total_cost_usd || 0);
// La vue a pu être remplacée pendant les requêtes.
const healthEl = document.getElementById("p-health");
if (!healthEl) return;
// ---- Bandeau de santé ----
healthEl.innerHTML = [
healthCard("Groupes", fmtNum(t.total), `${fmtNum(t.enriched)} enrichis`),
healthCard("Géocodage", `${locPct} %`,
`${fmtNum(locDone)} / ${fmtNum(locTotal)} lieux`,
locPct >= 95 ? "ok" : locPct >= 70 ? "warn" : "err"),
healthCard("En file", fmtNum(queued),
queued ? "traitement en cours" : "rien à traiter",
queued ? "warn" : "ok"),
healthCard("Bloqués", fmtNum(errors + llmNeeded),
`${fmtNum(errors)} erreurs · ${fmtNum(llmNeeded)} LLM`,
errors + llmNeeded === 0 ? "ok" : "err"),
healthCard("Coût LLM", `$${cost.toFixed(2)}`, `${fmtNum(geo.llm_cache?.n || 0)} appels`),
].join("");
// ---- Alertes actionnables ----
// Le principe de cette vue : aucun chiffre problématique sans le bouton qui
// le résout juste à côté.
const alerts = [];
if (errors > 0) {
alerts.push({
tone: "err",
text: `${fmtNum(errors)} localisation(s) en erreur de géocodage`,
actions: [
{ label: "Examiner", href: "#/locations?status=error" },
{ label: "Tout remettre en file", post: "/admin/api/locations/reset-errors" },
],
});
}
if (llmNeeded > 0) {
alerts.push({
tone: "warn",
text: `${fmtNum(llmNeeded)} lieu(x) en attente de désambiguïsation LLM`,
actions: [{ label: "Examiner", href: "#/locations?status=llm_needed" }],
});
}
if (t.never_enriched > 0) {
alerts.push({
tone: "warn",
text: `${fmtNum(t.never_enriched)} groupe(s) jamais enrichis`,
actions: [{ label: "Lancer l'enrichissement", job: "enrich" }],
});
}
if (queue.breakdown?.stale > 0) {
alerts.push({
tone: "muted",
text: `${fmtNum(queue.breakdown.stale)} groupe(s) non revus depuis plus de 30 jours`,
actions: [{ label: "Crawl incrémental", job: "incremental" }],
});
}
const stuck = (live.active_runs || []).filter(
(r) => Date.now() - new Date(r.started_at) > 30 * 60000 && !r.cancel_requested);
if (stuck.length) {
alerts.push({
tone: "err",
text: `${stuck.length} run(s) actifs depuis plus de 30 min — possiblement bloqués`,
actions: [{ label: "Nettoyer les runs bloqués", post: "/admin/api/crawl-runs/cleanup", body: { older_than_minutes: 30 } }],
});
}
document.getElementById("p-alerts").innerHTML = alerts.length
? `<div class="alerts">${alerts.map((a, idx) => `
<div class="alert alert-${a.tone}">
<span class="alert-text">${esc(a.text)}</span>
<span class="alert-actions">
${a.actions.map((act, j) => act.href
? `<a class="btn btn-mini" href="${esc(act.href)}">${esc(act.label)}</a>`
: `<button class="btn btn-mini" data-alert="${idx}" data-act="${j}">${esc(act.label)}</button>`
).join("")}
</span>
</div>`).join("")}</div>`
: `<div class="alerts"><div class="alert alert-ok"><span class="alert-text">✓ Rien ne bloque : le pipeline est sain.</span></div></div>`;
document.getElementById("p-alerts").onclick = async (e) => {
const btn = e.target.closest("[data-alert]");
if (!btn) return;
const act = alerts[Number(btn.dataset.alert)].actions[Number(btn.dataset.act)];
btn.disabled = true;
try {
if (act.job) {
await api("/admin/api/job-triggers", { method: "POST", body: JSON.stringify({ job_type: act.job }) });
} else {
await api(act.post, { method: "POST", body: JSON.stringify(act.body || {}) });
}
await loadPilotage();
} catch (err) {
alert(err.message);
btn.disabled = false;
}
};
// ---- Runs actifs ----
const runs = live.active_runs || [];
document.getElementById("p-runs").innerHTML = `
<h2>Traitements en cours <span class="count">(${runs.length})</span></h2>
${runs.length ? runs.map((r) => {
const pending = r.cancel_requested === true;
return `<div class="run-row">
<div>
<span class="badge ${pending ? "muted" : "warn"}">▶ ${esc(r.run_type)}</span>
<div class="run-meta">
${since(r.started_at)} · ${fmtNum(r.bands_seen)} vus · ${fmtNum(r.bands_new)} nouveaux · ${fmtNum(r.bands_enriched)} enrichis
</div>
${pending ? `<div class="run-cancel-note">⏳ Arrêt demandé${r.cancel_requested_by ? ` par ${esc(r.cancel_requested_by)}` : ""} — s'arrêtera au prochain lot</div>` : ""}
</div>
${pending
? `<button class="btn btn-mini" disabled>⏳ Arrêt…</button>`
: `<button class="btn btn-mini btn-danger" data-cancel-run="${r.id}">Arrêter</button>`}
</div>`;
}).join("") : emptyRow("Aucun traitement en cours")}
${(live.pending_jobs || []).length ? `
<h2 class="mt">En attente <span class="count">(${live.pending_jobs.length})</span></h2>
${live.pending_jobs.map((j) => `<div class="run-row">
<div>
<span class="badge muted">${esc(j.job_type)}</span>
<div class="run-meta">demandé par ${esc(j.requested_by || "?")} · il y a ${since(j.created_at)}</div>
</div>
<button class="btn btn-mini" data-cancel-job="${j.id}">Retirer</button>
</div>`).join("")}` : ""}
`;
document.getElementById("p-runs").onclick = async (e) => {
const runBtn = e.target.closest("[data-cancel-run]");
const jobBtn = e.target.closest("[data-cancel-job]");
if (!runBtn && !jobBtn) return;
const id = Number((runBtn || jobBtn).dataset.cancelRun || (runBtn || jobBtn).dataset.cancelJob);
const url = runBtn ? `/admin/api/crawl-runs/${id}/cancel` : `/admin/api/job-triggers/${id}/cancel`;
const msg = runBtn
? `Demander l'arrêt du traitement #${id} ?\n\nIl s'arrêtera à son prochain point de contrôle.`
: `Retirer la demande #${id} de la file ?`;
if (!confirm(msg)) return;
(runBtn || jobBtn).disabled = true;
try { await api(url, { method: "POST", body: "{}" }); await loadPilotage(); }
catch (err) { alert(err.message); await loadPilotage(); }
};
const note = document.getElementById("p-refresh");
if (note) note.textContent = `Actualisé à ${new Date().toLocaleTimeString("fr-FR")}`;
} catch (e) {
const note = document.getElementById("p-refresh");
if (note) note.textContent = `Erreur : ${e.message}`;
}
}
// ==================================================================
// GROUPES — « trouver et corriger »
// ==================================================================
const BAND_CHIPS = [
{ key: "has_conflict", value: "true", label: "🔒 Conflits crawler" },
{ key: "has_lat", value: "false", label: "Sans coordonnées" },
{ key: "has_location", value: "false", label: "Sans lieu renseigné" },
{ key: "enriched", value: "false", label: "Non enrichis" },
];
async function renderBands() {
const s = state.bands;
content().innerHTML = `
<div class="view-head"><h1>Groupes</h1><span id="b-count" class="refresh-note"></span></div>
<div class="search-bar">
<input type="search" id="b-q" placeholder="Rechercher un groupe (nom ou genre)…" value="${esc(s.q)}" aria-label="Rechercher un groupe">
<button class="btn btn-primary-sm" id="b-search">Rechercher</button>
<button class="btn btn-mini" id="b-advanced-toggle" aria-expanded="${s.advanced}">Filtres avancés</button>
</div>
<div class="chips" role="group" aria-label="Filtres rapides">
${BAND_CHIPS.map((c) => `<button class="chip ${s[c.key] === c.value ? "on" : ""}" data-chip="${c.key}" data-val="${c.value}" aria-pressed="${s[c.key] === c.value}">${esc(c.label)}</button>`).join("")}
<button class="chip chip-clear" id="b-clear">Tout effacer</button>
</div>
<div class="toolbar ${s.advanced ? "" : "hidden"}" id="b-advanced">
<input type="text" id="b-country" placeholder="Pays (FR, DE…)" value="${esc(s.country)}" aria-label="Pays">
<input type="text" id="b-status" placeholder="Statut" value="${esc(s.status)}" aria-label="Statut">
<input type="text" id="b-genre" placeholder="Genre" value="${esc(s.genre)}" aria-label="Genre">
<input type="text" id="b-location_q" placeholder="Lieu…" value="${esc(s.location_q)}" aria-label="Lieu">
<input type="text" id="b-themes_q" placeholder="Thèmes…" value="${esc(s.themes_q)}" aria-label="Thèmes">
<button class="btn btn-mini" id="b-apply">Appliquer</button>
</div>
<div id="b-table" class="table-wrap"><div class="loading">Chargement…</div></div>
<div class="pager" id="b-pager"></div>
`;
const applySearch = () => { s.q = document.getElementById("b-q").value.trim(); s.page = 1; loadBands(); };
document.getElementById("b-search").addEventListener("click", applySearch);
document.getElementById("b-q").addEventListener("keydown", (e) => { if (e.key === "Enter") applySearch(); });
document.getElementById("b-advanced-toggle").addEventListener("click", (e) => {
s.advanced = !s.advanced;
document.getElementById("b-advanced").classList.toggle("hidden", !s.advanced);
e.target.setAttribute("aria-expanded", String(s.advanced));
});
document.querySelector(".chips").addEventListener("click", (e) => {
const chip = e.target.closest("[data-chip]");
if (chip) {
const { chip: key, val } = chip.dataset;
s[key] = s[key] === val ? "" : val;
s.page = 1;
renderBands();
return;
}
if (e.target.id === "b-clear") {
for (const c of BAND_CHIPS) s[c.key] = "";
s.q = s.country = s.status = s.genre = s.location_q = s.themes_q = "";
s.page = 1;
renderBands();
}
});
document.getElementById("b-apply")?.addEventListener("click", () => {
for (const f of ["country", "status", "genre", "location_q", "themes_q"]) {
s[f] = document.getElementById(`b-${f}`).value.trim();
}
s.page = 1;
loadBands();
});
await loadBands();
}
const BAND_COLUMNS = [
{ key: "ma_id", label: "ID" },
{ key: "name", label: "Nom" },
{ key: "country", label: "Pays" },
{ key: "status", label: "Statut" },
{ key: "genre", label: "Genre" },
{ key: "location_text", label: "Lieu" },
{ key: "formed_year", label: "Année" },
{ key: "enriched", label: "État" },
{ key: "updated_at", label: "Modifié" },
];
async function loadBands() {
const s = state.bands;
const params = new URLSearchParams({ page: s.page, pageSize: s.pageSize, sort: s.sort, dir: s.dir });
for (const f of ["q", "location_q", "country", "status", "genre", "themes_q", "enriched", "has_lat", "has_location", "has_conflict"]) {
if (s[f]) params.set(f, s[f]);
}
try {
const r = await api(`/admin/api/bands?${params}`);
s.total = r.total;
document.getElementById("b-count").textContent = `${fmtNum(r.total)} groupe(s)`;
document.getElementById("b-table").innerHTML = `
<table>
<thead><tr>
${BAND_COLUMNS.map((c) => `<th><button class="th-sort" data-sort="${c.key}">${c.label}${s.sort === c.key ? (s.dir === "asc" ? " ▲" : " ▼") : ""}</button></th>`).join("")}
</tr></thead>
<tbody>
${r.items.map((b) => `
<tr class="clickable" data-ma-id="${b.ma_id}" tabindex="0" role="button" aria-label="Ouvrir ${esc(b.name)}">
<td>${b.ma_id}</td>
<td>${esc(b.name)}</td>
<td>${esc(b.country || "")}</td>
<td>${esc(b.status || "")}</td>
<td class="truncate" title="${esc(b.genre || "")}">${esc(b.genre || "")}</td>
<td class="truncate" title="${esc(b.location_text || "")}">${esc(b.location_text || "")}</td>
<td>${b.formed_year || ""}</td>
<td>
${b.enriched ? '<span class="badge ok">enrichi</span>' : '<span class="badge warn">à enrichir</span>'}
${b.lat == null ? '<span class="badge muted" title="Pas de coordonnées">📍?</span>' : ""}
${Object.keys(b.locked_fields || {}).length ? '<span class="lock" title="Champs verrouillés manuellement">🔒</span>' : ""}
</td>
<td>${fmtDate(b.updated_at)}</td>
</tr>`).join("") || `<tr><td colspan="${BAND_COLUMNS.length}" class="empty">Aucun résultat</td></tr>`}
</tbody>
</table>`;
const tableEl = document.getElementById("b-table");
tableEl.querySelectorAll("[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();
});
});
// Les lignes sont activables au clavier (role=button + tabindex), pas
// seulement à la souris.
tableEl.querySelectorAll("tr[data-ma-id]").forEach((tr) => {
const open = () => openBandModal(Number(tr.dataset.maId));
tr.addEventListener("click", open);
tr.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); open(); }
});
});
renderPager("b-pager", s, loadBands);
} catch (e) {
document.getElementById("b-table").innerHTML = errorBox(e);
}
}
/** Pagination partagée par les trois listes. */
function renderPager(elId, s, reload) {
const totalPages = Math.max(1, Math.ceil(s.total / s.pageSize));
const el = document.getElementById(elId);
if (!el) return;
el.innerHTML = `
<button class="btn btn-mini" data-page="prev" ${s.page <= 1 ? "disabled" : ""}>← Précédent</button>
<span>Page ${s.page} / ${totalPages}${fmtNum(s.total)} résultat(s)</span>
<button class="btn btn-mini" data-page="next" ${s.page >= totalPages ? "disabled" : ""}>Suivant →</button>`;
el.onclick = (e) => {
const btn = e.target.closest("[data-page]");
if (!btn || btn.disabled) return;
s.page += btn.dataset.page === "next" ? 1 : -1;
reload();
};
}
// ==================================================================
// LOCALISATIONS — « qu'est-ce qui coince dans le géocodage ? »
// ==================================================================
const LOCATION_FILTERS = [
{ value: "error,llm_needed", label: "À débloquer" },
{ value: "error", label: "En erreur" },
{ value: "llm_needed", label: "Attente LLM" },
{ value: "queued,processing", label: "En file" },
{ value: "manual", label: "Manuel" },
{ value: "done,country_only", label: "Résolues" },
{ value: "", label: "Toutes" },
];
async function renderLocations() {
const s = state.locations;
// Les alertes du Pilotage pointent sur un statut précis (#/locations?status=error).
// Ce paramètre est CONSOMMÉ : on le retire de l'URL une fois appliqué, sinon
// chaque rendu suivant le réappliquerait et annulerait les clics sur les
// chips — arrivé depuis une alerte, le filtre devenait impossible à changer.
// replaceState ne déclenche pas hashchange : pas de boucle de rendu.
const [hashBase, hashQuery] = location.hash.split("?");
if (hashQuery) {
const fromHash = new URLSearchParams(hashQuery);
if (fromHash.has("status")) { s.status = fromHash.get("status"); s.page = 1; }
history.replaceState(null, "", hashBase || "#/locations");
}
content().innerHTML = `
<div class="view-head">
<h1>Localisations</h1>
<span id="l-count" class="refresh-note"></span>
</div>
<p class="muted-note">
Une ligne par lieu à géocoder. C'est ici qu'on débloque un lieu précis,
sans relancer des milliers d'appels facturés.
</p>
<div class="search-bar">
<input type="search" id="l-q" placeholder="Filtrer par lieu ou nom de groupe…" value="${esc(s.q)}" aria-label="Filtrer les localisations">
<input type="text" id="l-country" placeholder="Pays" value="${esc(s.country)}" style="width:90px" aria-label="Pays">
<button class="btn btn-primary-sm" id="l-search">Filtrer</button>
</div>
<div class="chips" role="group" aria-label="Filtrer par statut">
${LOCATION_FILTERS.map((f) => `<button class="chip ${s.status === f.value ? "on" : ""}" data-status="${esc(f.value)}" aria-pressed="${s.status === f.value}">${esc(f.label)}</button>`).join("")}
</div>
<div id="l-table" class="table-wrap"><div class="loading">Chargement…</div></div>
<div class="pager" id="l-pager"></div>
`;
const apply = () => {
s.q = document.getElementById("l-q").value.trim();
s.country = document.getElementById("l-country").value.trim();
s.page = 1;
loadLocations();
};
document.getElementById("l-search").addEventListener("click", apply);
document.getElementById("l-q").addEventListener("keydown", (e) => { if (e.key === "Enter") apply(); });
document.querySelector(".chips").addEventListener("click", (e) => {
const chip = e.target.closest("[data-status]");
if (!chip) return;
s.status = chip.dataset.status;
s.page = 1;
renderLocations();
});
await loadLocations();
}
async function loadLocations() {
const s = state.locations;
const params = new URLSearchParams({ page: s.page, pageSize: s.pageSize });
if (s.status) params.set("status", s.status);
if (s.q) params.set("q", s.q);
if (s.country) params.set("country", s.country);
try {
const r = await api(`/admin/api/locations?${params}`);
s.total = r.total;
document.getElementById("l-count").textContent = `${fmtNum(r.total)} localisation(s)`;
document.getElementById("l-table").innerHTML = `
<table>
<thead><tr>
<th>Groupe</th><th>Lieu brut</th><th>Statut</th><th>Coordonnées</th>
<th>Essais</th><th>Dernière erreur</th><th>Actions</th>
</tr></thead>
<tbody>
${r.items.map((l) => `
<tr data-loc-id="${l.id}">
<td>
<button class="link-btn" data-open-band="${l.ma_id}">${esc(l.band_name || "#" + l.ma_id)}</button>
<div class="sub">${esc(l.country || "")}${l.step_label ? ` · ${esc(l.step_label)}` : ""}</div>
</td>
<td class="truncate" title="${esc(l.location_raw)}">${esc(l.location_raw)}</td>
<td>${statusBadge(l.geocode_status)}</td>
<td class="mono">${l.lat != null && l.lon != null ? `${Number(l.lat).toFixed(4)}, ${Number(l.lon).toFixed(4)}` : "—"}
<div class="sub">${esc(l.geocode_provider || "")}${l.geocode_confidence != null ? ` · ${Number(l.geocode_confidence).toFixed(2)}` : ""}</div>
</td>
<td class="nowrap sub">${l.geocode_tries_geo || 0} géo · ${l.geocode_tries_llm || 0} LLM</td>
<td class="truncate err-text" title="${esc(l.geocode_error || "")}">${esc(l.geocode_error || "—")}</td>
<td class="nowrap">
<button class="btn btn-mini" data-requeue="${l.id}">Relancer</button>
<button class="btn btn-mini" data-setcoords="${l.id}">Coordonnées…</button>
</td>
</tr>`).join("") || `<tr><td colspan="7" class="empty">Aucune localisation pour ce filtre</td></tr>`}
</tbody>
</table>`;
document.getElementById("l-table").onclick = (e) => {
const band = e.target.closest("[data-open-band]");
if (band) return openBandModal(Number(band.dataset.openBand));
const rq = e.target.closest("[data-requeue]");
if (rq) return requeueLocation(Number(rq.dataset.requeue), rq);
const sc = e.target.closest("[data-setcoords]");
if (sc) {
const id = Number(sc.dataset.setcoords);
return openCoordsModal(r.items.find((x) => x.id === id));
}
};
renderPager("l-pager", s, loadLocations);
} catch (e) {
document.getElementById("l-table").innerHTML = errorBox(e);
}
}
async function requeueLocation(id, btn) {
btn.disabled = true;
try {
await api(`/admin/api/locations/${id}/requeue`, { method: "POST", body: "{}" });
await loadLocations();
} catch (e) {
alert(e.message);
btn.disabled = false;
}
}
function openCoordsModal(loc) {
if (!loc) return;
const body = `
<p class="muted-note">
Saisir les coordonnées à la main pour <strong>${esc(loc.location_raw)}</strong>
(${esc(loc.band_name || "#" + loc.ma_id)}). Le lieu sera marqué comme résolu
par « admin » et ne repassera plus par le géocodeur.
</p>
<label>Latitude<input type="number" step="any" id="c-lat" value="${loc.lat ?? ""}" required></label>
<label>Longitude<input type="number" step="any" id="c-lon" value="${loc.lon ?? ""}" required></label>
<div class="modal-error" id="c-error" role="alert"></div>
<div class="modal-actions">
<button class="btn btn-mini" data-close>Annuler</button>
<button class="btn btn-primary-sm" id="c-save">Enregistrer</button>
</div>`;
const modal = openModal(`Coordonnées — ${esc(loc.location_raw)}`, body);
modal.querySelector("#c-save").addEventListener("click", async () => {
const errEl = modal.querySelector("#c-error");
errEl.textContent = "";
try {
await api(`/admin/api/locations/${loc.id}`, {
method: "PATCH",
body: JSON.stringify({
lat: modal.querySelector("#c-lat").value,
lon: modal.querySelector("#c-lon").value,
}),
});
closeModal(modal);
await loadLocations();
} catch (e) {
errEl.textContent = e.message;
}
});
}
// ==================================================================
// Modales — ouverture, fermeture, piège à focus
// ==================================================================
let lastFocused = null;
function openModal(title, bodyHtml, extraClass = "") {
lastFocused = document.activeElement;
const backdrop = document.createElement("div");
backdrop.className = "modal-backdrop";
backdrop.innerHTML = `
<div class="modal ${extraClass}" role="dialog" aria-modal="true" aria-label="${esc(title)}">
<button class="modal-close" data-close aria-label="Fermer">&times;</button>
<h3>${title}</h3>
${bodyHtml}
</div>`;
document.body.appendChild(backdrop);
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop || e.target.closest("[data-close]")) closeModal(backdrop);
});
// Échap ferme, Tab reste enfermé dans la modale : sans ça le focus repartait
// derrière le voile, invisible.
backdrop.addEventListener("keydown", (e) => {
if (e.key === "Escape") { closeModal(backdrop); return; }
if (e.key !== "Tab") return;
const focusables = backdrop.querySelectorAll(
'button, [href], input, select, textarea, summary, [tabindex]:not([tabindex="-1"])');
if (!focusables.length) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
});
const target = backdrop.querySelector("input, button:not(.modal-close)");
(target || backdrop.querySelector(".modal-close")).focus();
return backdrop;
}
function closeModal(backdrop) {
backdrop.remove();
if (lastFocused && document.contains(lastFocused)) lastFocused.focus();
}
// ==================================================================
// Modal groupe (édition + provenance)
// ==================================================================
function conflictRowsHtml(band) {
const pending = band.crawler_pending || {};
return Object.entries(pending).map(([field, crawlerVal]) => `
<tr>
<td><strong>${esc(field)}</strong></td>
<td>${esc(String(band[field] ?? "—"))}</td>
<td class="warn-text">${esc(String(crawlerVal))}</td>
<td class="nowrap">
<button class="btn btn-mini" data-conflict-action="keep_mine" data-field="${esc(field)}">Garder</button>
<button class="btn btn-mini btn-danger" 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(e.message);
return;
}
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 conflictRows = conflictRowsHtml(band);
const fields = [
["name", "Nom", "text"], ["country", "Pays", "text"], ["status", "Statut", "text"],
["genre", "Genre", "text"], ["formed_year", "Année de formation", "number"],
["location_text", "Localisation (texte)", "text"], ["themes", "Thèmes", "text"],
["lat", "Latitude", "number"], ["lon", "Longitude", "number"],
];
const body = `
${conflictRows ? `
<section class="modal-section conflicts">
<h4>⚠️ Conflits en attente d'arbitrage</h4>
<p class="muted-note">Champs verrouillés pour lesquels Metal Archives propose une autre valeur.</p>
<div class="table-wrap"><table>
<thead><tr><th>Champ</th><th>Valeur actuelle</th><th>Metal Archives</th><th>Action</th></tr></thead>
<tbody id="m-conflict-body">${conflictRows}</tbody>
</table></div>
</section>` : ""}
<section class="modal-section">
<h4>Fiche</h4>
<div class="field-grid">
${fields.map(([k, label, type]) => `
<label>${esc(label)}
<input type="${type}" ${type === "number" ? 'step="any"' : ""} id="m-${k}" value="${esc(band[k] ?? "")}">
</label>`).join("")}
</div>
<p class="muted-note">
Modifier un champ le verrouille : le crawler ne l'écrasera plus.
Saisir des coordonnées crée aussi un point manuel prioritaire sur la carte.
</p>
</section>
<details class="modal-section">
<summary>Provenance et géocodage</summary>
<div class="src-grid">
<div><span>Enrichi</span><span>${band.enriched ? "oui" : "non"}</span></div>
<div><span>Dernier crawl</span><span>${fmtDate(band.crawled_at)}</span></div>
<div><span>MA créé / modifié</span><span>${fmtDate(band.ma_created_at)} / ${fmtDate(band.ma_modified_at)}</span></div>
<div><span>Champs verrouillés</span><span>${lockedFields.length ? esc(lockedFields.join(", ")) : "—"}</span></div>
<div><span>Point principal</span><span>${esc(band.geocode_provider || "—")} · ${fmtDate(band.geocoded_at)}</span></div>
</div>
<h4 class="mt">Localisations (${locations.length})</h4>
<div class="table-wrap"><table>
<thead><tr><th>Étape</th><th>Lieu brut</th><th>Statut</th><th>Provider</th><th>Conf.</th><th>Coordonnées</th></tr></thead>
<tbody>
${locations.map((l) => `<tr>
<td>${l.step_order}${l.step_label ? ` <span class="sub">(${esc(l.step_label)})</span>` : ""}</td>
<td>${esc(l.location_raw)}${l.geocode_error ? `<div class="err-text sub">${esc(l.geocode_error)}</div>` : ""}</td>
<td>${statusBadge(l.geocode_status)}</td>
<td>${esc(l.geocode_provider || "—")}</td>
<td>${l.geocode_confidence != null ? Number(l.geocode_confidence).toFixed(2) : "—"}</td>
<td class="mono">${l.lat != null ? `${Number(l.lat).toFixed(3)}, ${Number(l.lon).toFixed(3)}` : "—"}</td>
</tr>`).join("") || `<tr><td colspan="6" class="empty">Aucune localisation</td></tr>`}
</tbody>
</table></div>
<h4 class="mt">Appels LLM (${llmCalls.length})</h4>
${llmCalls.map((c) => `
<details class="llm-item">
<summary>${esc(c.model)} · ${esc(c.location_raw || "")}${c.is_null ? "aucun résultat" : esc(c.parsed_city || "?")} · ${c.cost_usd != null ? `$${Number(c.cost_usd).toFixed(6)}` : "—"}</summary>
<pre class="llm-detail">PROMPT:\n${esc(c.prompt || "")}\n\nRÉPONSE:\n${esc(c.response || "")}</pre>
</details>`).join("") || `<p class="muted-note">Aucun appel LLM pour ce groupe.</p>`}
</details>
<div class="modal-error" id="m-error" role="alert"></div>
<div class="modal-actions">
<button class="btn btn-mini" data-close>Annuler</button>
<button class="btn btn-primary-sm" id="m-save">Enregistrer</button>
</div>`;
const modal = openModal(`${band.name} — #${band.ma_id}`, body, "modal-wide");
const conflictBody = modal.querySelector("#m-conflict-body");
conflictBody?.addEventListener("click", async (e) => {
const btn = e.target.closest("[data-conflict-action]");
if (!btn) return;
btn.disabled = true;
try {
await api(`/admin/api/bands/${maId}/resolve-conflict`, {
method: "POST",
body: JSON.stringify({ field: btn.dataset.field, action: btn.dataset.conflictAction }),
});
closeModal(modal);
openBandModal(maId);
} catch (err) { alert(err.message); btn.disabled = false; }
});
modal.querySelector("#m-save").addEventListener("click", async () => {
const errEl = modal.querySelector("#m-error");
errEl.textContent = "";
const val = (k) => modal.querySelector(`#m-${k}`).value.trim();
const num = (k) => (val(k) === "" ? null : Number(val(k)));
const payload = {
name: val("name") || null, country: val("country") || null,
status: val("status") || null, genre: val("genre") || null,
location_text: val("location_text") || null, themes: val("themes") || null,
formed_year: num("formed_year"), lat: num("lat"), lon: num("lon"),
};
try {
await api(`/admin/api/bands/${maId}`, { method: "PATCH", body: JSON.stringify(payload) });
closeModal(modal);
if (state.view === "bands") loadBands();
else if (state.view === "locations") loadLocations();
} catch (e) {
errEl.textContent = e.message;
}
});
}
// ==================================================================
// JOURNAL — « que s'est-il passé ? »
// ==================================================================
async function renderActivity(token = renderToken) {
const s = state.activity;
content().innerHTML = `
<div class="view-head">
<h1>Journal</h1>
<span id="a-refresh" class="refresh-note" aria-live="polite">Actualisation auto toutes les 15 s</span>
</div>
<div class="chips" role="group" aria-label="Filtrer le journal">
<button class="chip ${s.type === "" ? "on" : ""}" data-atype="">Tout</button>
<button class="chip ${s.type === "run" ? "on" : ""}" data-atype="run">Traitements</button>
<button class="chip ${s.type === "admin_action" ? "on" : ""}" data-atype="admin_action">Actions admin</button>
<span class="chip-sep"></span>
<button class="chip ${s.status === "error" ? "on" : ""}" data-astatus="error">Erreurs seules</button>
</div>
<div id="a-table" class="table-wrap"><div class="loading">Chargement…</div></div>
<div class="pager" id="a-pager"></div>
`;
document.querySelector(".chips").addEventListener("click", (e) => {
const t = e.target.closest("[data-atype]");
const st = e.target.closest("[data-astatus]");
if (t) { s.type = t.dataset.atype; s.page = 1; renderActivity(); }
else if (st) { s.status = s.status === st.dataset.astatus ? "" : st.dataset.astatus; s.page = 1; renderActivity(); }
});
await loadActivity();
if (isStale(token)) return;
clearTimer();
state.timer = setInterval(async () => {
await loadActivity();
const el = document.getElementById("a-refresh");
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(`${fmtNum(sum.bands_seen)} vus`);
if (sum.bands_new) parts.push(`${fmtNum(sum.bands_new)} nouveaux`);
if (sum.bands_updated) parts.push(`${fmtNum(sum.bands_updated)} MAJ`);
if (sum.bands_enriched) parts.push(`${fmtNum(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;
const tableEl = document.getElementById("a-table");
if (!tableEl) return;
tableEl.innerHTML = `
<table>
<thead><tr><th>Date</th><th>Type</th><th>Détail</th><th>Statut</th><th>Auteur</th><th>Résumé</th></tr></thead>
<tbody>
${r.items.map((x, i) => `
<tr class="clickable" data-idx="${i}" tabindex="0" role="button">
<td class="nowrap">${fmtDate(x.ts)}</td>
<td><span class="badge muted">${x.type === "run" ? "Traitement" : "Admin"}</span></td>
<td>${esc(x.subtype)}</td>
<td>${statusBadge(x.status)}</td>
<td>${esc(x.actor || "—")}</td>
<td class="truncate">${activitySummary(x)}</td>
</tr>`).join("") || `<tr><td colspan="6" class="empty">Aucune activité</td></tr>`}
</tbody>
</table>`;
document.getElementById("a-table").querySelectorAll("tr[data-idx]").forEach((tr) => {
const row = r.items[Number(tr.dataset.idx)];
const open = () => openActivityModal(row);
tr.addEventListener("click", open);
tr.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); open(); }
});
});
renderPager("a-pager", s, loadActivity);
} catch (e) {
document.getElementById("a-table").innerHTML = errorBox(e);
}
}
async function openActivityModal(row) {
const sum = row?.summary || {};
const isRun = row?.type === "run";
const body = isRun
? `<div class="muted-note">
${statusBadge(row.status)} · démarré ${fmtDate(row.ts)} · terminé ${fmtDate(row.finished_at)}
${sum.countries ? ` · pays : ${esc((sum.countries || []).join(", "))}` : ""}
</div>
<p>${activitySummary(row)}</p>
${row.status === "running" ? `<button class="btn btn-mini btn-danger" id="am-cancel">Arrêter ce traitement</button>` : ""}
<h4 class="mt">Journal détaillé</h4>
<div id="act-logs" class="log-stream">Chargement…</div>`
: `<div class="muted-note">${fmtDate(row.ts)} · ${esc(row.actor || "")} · ${esc(row.subtype || "")}</div>
<p>Cible : ${esc(sum.target_table || "")} #${sum.target_id ?? ""}</p>
<div class="grid grid-2">
<div><h4>Avant</h4><pre class="json">${esc(JSON.stringify(sum.before, null, 2))}</pre></div>
<div><h4>Après</h4><pre class="json">${esc(JSON.stringify(sum.after, null, 2))}</pre></div>
</div>`;
const modal = openModal(
isRun ? `Traitement #${row.id}${esc(row.subtype || "")}` : `Action admin #${row.id}`,
body, "modal-wide");
modal.querySelector("#am-cancel")?.addEventListener("click", async (e) => {
if (!confirm(`Demander l'arrêt du traitement #${row.id} ?`)) return;
e.target.disabled = true;
try { await api(`/admin/api/crawl-runs/${row.id}/cancel`, { method: "POST", body: "{}" }); closeModal(modal); loadActivity(); }
catch (err) { alert(err.message); }
});
if (isRun) {
try {
const logs = await api(`/admin/api/logs?run_id=${row.id}&pageSize=200`);
const el = modal.querySelector("#act-logs");
if (el) {
el.innerHTML = logs.items.map((x) => `
<div class="log-line">
<span class="ts">${fmtDate(x.created_at)}</span>
<span class="lvl lvl-${esc(x.level)}">${esc(x.level)}</span>
<span class="msg">${esc(x.message)}</span>
</div>`).join("") || emptyRow("Aucun log pour ce traitement");
}
} catch (e) {
const el = modal.querySelector("#act-logs");
if (el) el.innerHTML = errorBox(e);
}
}
}
// ==================================================================
// LLM & COÛTS
// ==================================================================
async function renderLLM() {
content().innerHTML = `
<div class="view-head"><h1>LLM &amp; coûts</h1><span id="llm-status" class="refresh-note"></span></div>
<div id="llm-summary" class="health-strip"><div class="loading">Chargement…</div></div>
<p class="muted-note">
Chaque appel Groq est mis en cache (modèle + pays + lieu) et rattaché au groupe
qui l'a déclenché. Déplier une ligne montre le prompt exact et la réponse brute.
</p>
<div class="search-bar">
<input type="search" id="llm-q" placeholder="Filtrer par lieu ou ville…" aria-label="Filtrer les appels LLM">
<select id="llm-model" aria-label="Modèle">
<option value="">Tous les modèles</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 class="inline-check"><input type="checkbox" id="llm-null"> Sans résultat uniquement</label>
<button class="btn btn-primary-sm" id="llm-apply">Filtrer</button>
</div>
<div id="llm-list" class="table-wrap"><div class="loading">Chargement…</div></div>
<div class="pager" id="llm-pager"></div>
`;
document.getElementById("llm-apply").addEventListener("click", () => { state.llm.page = 1; loadLLM(); });
document.getElementById("llm-q").addEventListener("keydown", (e) => {
if (e.key === "Enter") { state.llm.page = 1; loadLLM(); }
});
document.getElementById("llm-list").addEventListener("click", (e) => {
const band = e.target.closest("[data-open-band]");
if (band) openBandModal(Number(band.dataset.openBand));
});
loadLLMSummary();
await loadLLM();
}
async function loadLLMSummary() {
try {
const geo = await api("/admin/api/geocoding");
const c = geo.llm_cache || {};
const models = geo.llm_models || [];
document.getElementById("llm-summary").innerHTML = [
healthCard("Coût total", `$${Number(c.total_cost_usd || 0).toFixed(4)}`, "cumul Groq"),
healthCard("Appels", fmtNum(c.n), `${fmtNum(c.n_null)} sans résultat`),
healthCard("Cache géocodage", fmtNum(geo.cache_size), "lieux déjà résolus"),
...models.slice(0, 2).map((m) =>
healthCard(m.model, fmtNum(m.n), `$${Number(m.cost || 0).toFixed(4)}`)),
].join("");
} catch (e) {
document.getElementById("llm-summary").innerHTML = errorBox(e);
}
}
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}`);
document.getElementById("llm-status").textContent = `${fmtNum(r.total)} appel(s)`;
document.getElementById("llm-list").innerHTML = r.items.length ? `
<table>
<thead><tr><th>Date</th><th>Groupe</th><th>Modèle</th><th>Lieu</th><th>Résultat</th><th>Tokens</th><th>Coût</th></tr></thead>
<tbody>
${r.items.map((c) => `
<tr>
<td class="nowrap">${fmtDate(c.created_at)}</td>
<td>${c.ma_id ? `<button class="link-btn" data-open-band="${c.ma_id}">${esc(c.band_name || "#" + c.ma_id)}</button>` : "—"}</td>
<td class="sub">${esc(c.model)}</td>
<td>${esc(c.location_raw || "—")}${c.country ? ` <span class="sub">[${esc(c.country)}]</span>` : ""}</td>
<td>${c.is_null ? '<span class="badge warn">aucun</span>' : `${esc(c.parsed_city || "?")} <span class="sub">(${esc(c.parsed_country || "?")})</span>`}</td>
<td class="mono">${(c.tokens_in || 0)}+${(c.tokens_out || 0)}</td>
<td class="mono">${c.cost_usd != null ? `$${Number(c.cost_usd).toFixed(6)}` : "—"}</td>
</tr>
<tr class="detail-row"><td colspan="7">
<details><summary>Prompt et réponse</summary>
<pre class="llm-detail">PROMPT:\n${esc(c.prompt || "")}\n\nRÉPONSE:\n${esc(c.response || "")}</pre>
</details>
</td></tr>`).join("")}
</tbody>
</table>` : emptyRow("Aucun appel LLM");
const s = { page, pageSize: 50, total: r.total };
renderPager("llm-pager", s, () => { state.llm.page = s.page; loadLLM(); });
} catch (e) {
document.getElementById("llm-list").innerHTML = errorBox(e);
}
}
// ------------------------------------------------------------------
checkSession();