metalfrom.eu/apps/admin/site/app.js
Nicolas Fryder 7b53d47d71
Some checks are pending
CI / javascript (push) Waiting to run
CI / python (push) Waiting to run
CI / mutation (push) Waiting to run
feat: intégration SQL sur PostgreSQL réel + boutons de crawl conscients du crawler
1. Suite d'intégration SQL (npm run test:integration:full)

Ferme le dernier angle mort : la SÉMANTIQUE du SQL. Le parseur de grammaire ne
voyait pas un nom de colonne inexistant, un type incompatible ou une fonction
PostGIS mal appelée — soit exactement la classe de bugs qui n'apparaissait
qu'en production.

Technique : chaque requête émise par l'application passe par `PREPARE`.
Postgres l'analyse et la planifie entièrement — colonnes, types, opérateurs
jsonb, fonctions PostGIS — SANS l'exécuter ni nécessiter de données. Rapide, et
ça couvre aussi les requêtes que le parseur JS ne sait pas lire :
jsonb_build_object, `jsonb - text[]`, `raw ? 'clé'`, make_interval, l'opérateur
spatial && et l'agrégation en grille des clusters.

Les migrations réelles sont appliquées dans l'ordre réel : une migration
invalide échoue ici, plus au redémarrage du conteneur en production.

54 tests. Exige Docker, donc HORS de `check` et du hook pre-push — la boucle
de développement reste à 6 s. Base jetable en tmpfs (fsync off).

Deux témoins vérifient que le détecteur n'est pas inopérant : une colonne
inexistante et un type incompatible doivent être rejetés.

2. Les boutons de crawl ne mentent plus

La production ne déploie pas le service crawler (docker-compose.yml ne le
contient pas). Les boutons « Crawl incrémental », « Enrichir » et « Crawl
complet » y créaient des demandes que personne ne consommait, avec un libellé
promettant une exécution « sous ~1 min ».

Ils s'appuient désormais sur le battement de cœur : pas de crawler vivant,
boutons désactivés et raison affichée. « Alimenter le géocodage » reste actif,
puisqu'il est traité par le geocoder.

Je n'ai PAS ajouté le crawler au compose de production : ce serait déclencher
du crawl depuis la prod, décision qui n'est pas la mienne.

Régression évitée au passage : re-rendre le bloc de traitements effaçait le
message de retour (« Demande #77 enregistrée »), puisque loadPilotage() est
rappelé après un clic réussi. L'état des boutons est donc mis à jour sans
reconstruire le DOM. Détecté par un test existant.

Correctif : la suite d'intégration était happée par le glob de vitest.config.js
et allongeait `check` de 6 à 22 s en exigeant Docker.

Tests : 580 JS + 54 intégration, 63 Python, 89 Playwright

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 20:53:35 +02:00

1366 lines
58 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);
}
/**
* Les trois premiers traitements sont consommés par le crawler. S'il n'est pas
* déployé sur cet environnement (c'est le cas de la production, dont le compose
* ne contient pas le service), les demandes resteraient « en attente » pour
* toujours — le bouton promettait une exécution qui n'arrivait jamais.
* On s'appuie sur le battement de cœur : pas de crawler vivant, pas de bouton.
*/
function renderTriggers() {
document.getElementById("p-triggers").innerHTML = `
<h2>Lancer un traitement</h2>
<p class="muted-note" id="p-triggers-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>
<!-- L'enqueue est traitée par le geocoder, pas par le crawler : jamais désactivée ici. -->
<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;
}
});
}
/**
* Active ou désactive les traitements consommés par le crawler.
*
* Modifie l'ÉTAT des boutons sans reconstruire le bloc : le re-rendre effacerait
* le message de retour qu'on vient d'y afficher (« Demande #77 enregistrée »),
* puisque loadPilotage() est rappelé juste après un clic réussi.
*/
function updateTriggerAvailability(services) {
const crawler = services.find((x) => x.service === "crawler");
const absent = !crawler;
const down = crawler && (crawler.stale || !crawler.ok);
const why = absent
? "Aucun crawler déployé sur cet environnement : la demande ne serait jamais consommée."
: down
? "Le crawler est silencieux ou dégradé : la demande resterait en attente."
: "";
// geocoder_enqueue est traité par le geocoder, pas par le crawler.
for (const job of ["incremental", "enrich", "full_crawl"]) {
const btn = document.querySelector(`[data-job="${job}"]`);
if (!btn) continue;
btn.disabled = Boolean(why);
btn.title = why;
}
const note = document.getElementById("p-triggers-note");
if (note) {
note.textContent = why
? `${why}`
: "Le crawler consomme la demande à son prochain cycle (~1 min).";
}
}
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;
}
});
}
/**
* État des services de fond (crawler, geocoder…).
*
* Ils ne sont pas exposés par Traefik : sans ce battement de cœur, un crawler
* dont FlareSolverr est injoignable reste invisible, et le seul symptôme est
* l'absence de données nouvelles. Un contrôle trop ancien vaut une panne :
* un service arrêté cesse simplement de mettre sa ligne à jour.
*/
function servicesCard(services) {
if (!services.length) {
return healthCard("Services", "—", "aucun battement de cœur reçu", "warn");
}
const ko = services.filter((s) => !s.ok || s.stale);
const detail = ko.length
? ko.map((s) => `${esc(s.service)} : ${s.stale ? "silencieux" : esc(s.error || "dégradé")}`).join(" · ")
: services.map((s) => esc(s.service)).join(" · ");
return healthCard("Services", `${services.length - ko.length}/${services.length}`, detail,
ko.length ? "err" : "ok");
}
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`),
servicesCard(live.services || []),
].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" }],
});
}
for (const svc of (live.services || []).filter((x) => !x.ok || x.stale)) {
alerts.push({
tone: "err",
text: svc.stale
? `Service « ${svc.service} » silencieux depuis plus de 2 h — arrêté ou bloqué`
: `Service « ${svc.service} » dégradé : ${svc.error || "sonde en échec"}`,
actions: [{ label: "Voir le journal", href: "#/activity" }],
});
}
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("")}` : ""}
`;
updateTriggerAvailability(live.services || []);
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();