health.py existait en deux copies rigoureusement identiques — crawler et geocoder — dont une seule était couverte par des tests. Rien ne signalait une divergence : une correction appliquée d'un seul côté serait passée inaperçue. Le module part dans libs/bm_health.py, embarqué dans les images via un contexte de build ramené à la racine du dépôt, et rendu importable en local par les conftest.py. Un .dockerignore racine évite que node_modules parte dans les images au passage. Les deux images ont été reconstruites et le module vérifié importable à l'exécution dans chacune. La migration 015 annonçait un battement de coeur pour groq-worker, mais aucun n'était jamais écrit : sa ligne n'existait pas, et le bandeau de santé de l'admin ne pouvait donc rien signaler — y compris quand le service était mort. Même trou pour geocoder-enqueue. Les deux écrivent désormais leur état, avec des sondes propres à leur rôle (clé API, progression de leur file). Deux endpoints étaient définis et testés sans qu'aucun bouton ne les appelle. /admin/api/locations/reset-llm était pourtant le seul moyen de relancer les lieux passés en 'manual' après épuisement des tentatives LLM : la fonctionnalité existait sans que personne puisse l'atteindre. Elle rejoint la zone de danger de l'admin. /admin/api/crawl-checkpoints faisait doublon avec /admin/api/live et disparaît, avec ses tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1385 lines
59 KiB
JavaScript
1385 lines
59 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 = {}) {
|
|
// Timeout explicite : sans lui, une API qui ne répond jamais (pool DB saturé,
|
|
// upstream nginx muet) laisse la vue bloquée sur « Chargement… » indéfiniment.
|
|
const ctrl = new AbortController();
|
|
const t = setTimeout(() => ctrl.abort(), opts.timeoutMs || 20000);
|
|
let res;
|
|
try {
|
|
res = await fetch(path, {
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json", ...(opts.headers || {}) },
|
|
signal: ctrl.signal,
|
|
...opts,
|
|
});
|
|
} finally {
|
|
clearTimeout(t);
|
|
}
|
|
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) =>
|
|
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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>
|
|
<button class="btn btn-danger" data-danger="reset-llm">🤖 Relancer les lieux abandonnés par le LLM</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 ?",
|
|
},
|
|
// Cette route existait, était testée, et n'était appelée par aucun bouton :
|
|
// c'était pourtant le seul moyen de relancer les lieux passés en 'manual'
|
|
// après épuisement des tentatives LLM. La fonctionnalité existait sans que
|
|
// personne puisse l'atteindre.
|
|
"reset-llm": {
|
|
url: "/admin/api/locations/reset-llm",
|
|
confirm: "Relancer les lieux que le LLM n'a pas su résoudre ?\n\nIls repartent en file de géocodage — appels Geoapify et Groq facturés.",
|
|
},
|
|
};
|
|
|
|
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">×</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 : ${esc(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 & 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();
|