Deux angles morts fermés. 1. Syntaxe SQL sans conteneur (apps/api/test/sqlSyntax.test.js) Le faux pool vérifiait la FORME du SQL mais ne l'exécutait jamais : une requête syntaxiquement invalide passait tous les tests et n'échouait qu'en production — c'est précisément ce qui s'était produit avec crawler_pending. Chaque requête réellement émise par les 28 routes est désormais parsée avec la grammaire PostgreSQL (node-sql-parser), y compris les SET dynamiques du PATCH et les casts par type de resolve-conflict. 48 tests, aucun conteneur. Limite déclarée explicitement : la sémantique n'est pas validée, et deux requêtes bâties sur jsonb_build_object ne sont pas parsables — le test échoue si une route cesse d'avoir la moindre requête vérifiable, pour éviter qu'il passe au vert à vide. 2. Supervision des services de fond (migration 015) Le crawler et les workers ne sont pas exposés par Traefik : aucune sonde HTTP ne peut les atteindre. Un crawler dont FlareSolverr était injoignable, ou un worker à court de quota Geoapify, restait muet — le seul symptôme était l'absence de données nouvelles, qu'il fallait remarquer soi-même. Chaque service écrit un battement de cœur horaire dans service_health : - crawler : base, FlareSolverr joignable, dernier run terminé < 12 h - geocoder : base, clé Geoapify présente, API joignable, progression < 6 h Une ligne par service, écrasée à chaque contrôle. L'API calcule `stale` en SQL (> 2 h sans écriture) : un service arrêté cesse d'écrire, et son dernier contrôle réussi le ferait sinon passer pour sain indéfiniment. Le Pilotage affiche une carte « Services » et remonte chaque service dégradé ou silencieux en alerte actionnable. Règle appliquée aux sondes : aucune ne peut interrompre le service qu'elle surveille. Toute exception devient un échec de sonde, l'écriture du résultat et la journalisation échouent en silence. Un contrôle de santé qui fait tomber le crawler serait pire que pas de contrôle. Tests : 580 JS (+51), 63 Python (+20), 85 Playwright (+3) Co-Authored-By: Claude <noreply@anthropic.com>
1324 lines
56 KiB
JavaScript
1324 lines
56 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) =>
|
|
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
|
}
|
|
function fmtDate(s) {
|
|
if (!s) return "—";
|
|
return new Date(s).toLocaleString("fr-FR", {
|
|
day: "2-digit", month: "2-digit", year: "2-digit", hour: "2-digit", minute: "2-digit",
|
|
});
|
|
}
|
|
function fmtNum(n) {
|
|
return Number(n || 0).toLocaleString("fr-FR");
|
|
}
|
|
/** Durée écoulée, lisible ("3 min", "2 h 10"). */
|
|
function since(ts) {
|
|
if (!ts) return "—";
|
|
const min = Math.max(0, Math.round((Date.now() - new Date(ts)) / 60000));
|
|
if (min < 60) return `${min} min`;
|
|
const h = Math.floor(min / 60);
|
|
return `${h} h ${String(min % 60).padStart(2, "0")}`;
|
|
}
|
|
const content = () => document.getElementById("content");
|
|
|
|
const STATUS_TONE = {
|
|
done: "ok", country_only: "ok", error: "err", running: "warn",
|
|
cancelled: "muted", queued: "muted", processing: "warn",
|
|
llm_needed: "warn", manual: "warn",
|
|
};
|
|
/** Les valeurs stockées sont techniques ; l'admin lit du français. */
|
|
const STATUS_LABEL = {
|
|
done: "résolu", country_only: "pays seul", error: "erreur",
|
|
running: "en cours", cancelled: "annulé", queued: "en file",
|
|
processing: "traitement", llm_needed: "attente LLM", manual: "manuel",
|
|
};
|
|
function statusBadge(status) {
|
|
return `<span class="badge ${STATUS_TONE[status] || "muted"}" title="${esc(status)}">${esc(STATUS_LABEL[status] || status)}</span>`;
|
|
}
|
|
function emptyRow(msg = "Aucune donnée") { return `<div class="empty">${esc(msg)}</div>`; }
|
|
|
|
/** Bloc d'erreur uniforme, avec le message réel de l'API. */
|
|
function errorBox(e) {
|
|
return `<div class="empty err-box">⚠ ${esc(e.message)}</div>`;
|
|
}
|
|
|
|
function clearTimer() {
|
|
if (state.timer) { clearInterval(state.timer); state.timer = null; }
|
|
}
|
|
|
|
/**
|
|
* Jeton de rendu.
|
|
*
|
|
* Les vues chargent leurs données de façon asynchrone puis arment un
|
|
* auto-refresh. Sans ce jeton, quitter une vue PENDANT son chargement laissait
|
|
* le minuteur s'armer après coup : router() avait déjà fait son clearTimer(),
|
|
* et le Pilotage continuait d'interroger quatre endpoints toutes les 15 s
|
|
* depuis un autre onglet, indéfiniment.
|
|
*/
|
|
let renderToken = 0;
|
|
const isStale = (token) => token !== renderToken;
|
|
|
|
// ------------------------------------------------------------------
|
|
// Auth
|
|
// ------------------------------------------------------------------
|
|
function showLogin() {
|
|
document.getElementById("login-screen").classList.remove("hidden");
|
|
document.getElementById("app").classList.add("hidden");
|
|
clearTimer();
|
|
}
|
|
function showApp() {
|
|
document.getElementById("login-screen").classList.add("hidden");
|
|
document.getElementById("app").classList.remove("hidden");
|
|
document.getElementById("whoami").textContent = state.username || "";
|
|
}
|
|
|
|
async function checkSession() {
|
|
try {
|
|
const r = await api("/admin/auth/me");
|
|
state.username = r.username;
|
|
showApp();
|
|
router();
|
|
} catch {
|
|
showLogin();
|
|
}
|
|
}
|
|
|
|
document.getElementById("login-form").addEventListener("submit", async (e) => {
|
|
e.preventDefault();
|
|
const username = document.getElementById("login-username").value.trim();
|
|
const password = document.getElementById("login-password").value;
|
|
const errEl = document.getElementById("login-error");
|
|
const btn = e.target.querySelector('button[type="submit"]');
|
|
errEl.textContent = "";
|
|
btn.disabled = true;
|
|
try {
|
|
const res = await fetch("/admin/auth/login", {
|
|
method: "POST",
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ username, password }),
|
|
});
|
|
const body = await res.json().catch(() => ({}));
|
|
if (!res.ok || body.ok === false) {
|
|
errEl.textContent = body.error || "Échec de connexion";
|
|
return;
|
|
}
|
|
state.username = body.username;
|
|
document.getElementById("login-password").value = "";
|
|
showApp();
|
|
router();
|
|
} catch {
|
|
errEl.textContent = "Erreur réseau";
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
});
|
|
|
|
document.getElementById("logout-btn").addEventListener("click", async () => {
|
|
try { await fetch("/admin/auth/logout", { method: "POST", credentials: "include" }); } catch {}
|
|
state.username = null;
|
|
showLogin();
|
|
});
|
|
|
|
// ------------------------------------------------------------------
|
|
// Routeur
|
|
// ------------------------------------------------------------------
|
|
const VIEWS = ["pilotage", "bands", "locations", "activity", "llm"];
|
|
const VIEW_ALIASES = {
|
|
"": "pilotage", pilotage: "pilotage", dashboard: "pilotage", overview: "pilotage",
|
|
bands: "bands", band: "bands", groupes: "bands",
|
|
locations: "locations", localisations: "locations", geocoding: "locations",
|
|
activity: "activity", activite: "activity", journal: "activity",
|
|
llm: "llm", couts: "llm",
|
|
// L'onglet « Actions » a été fondu dans Pilotage : on redirige les favoris.
|
|
actions: "pilotage",
|
|
};
|
|
|
|
function normalizeView(raw) {
|
|
// La query string doit être retirée AVANT la recherche d'alias : les liens du
|
|
// Pilotage pointent vers "#/locations?status=error", qui sinon ne
|
|
// correspondrait à aucune vue et retomberait sur Pilotage.
|
|
const key = String(raw || "").split("?")[0]
|
|
.trim().toLowerCase().replace(/^\/+|\/+$/g, "");
|
|
const normalized = VIEW_ALIASES[key];
|
|
return VIEWS.includes(normalized) ? normalized : "pilotage";
|
|
}
|
|
|
|
function currentViewFromLocation() {
|
|
if (location.hash) return normalizeView((location.hash || "").replace(/^#\/?/, ""));
|
|
return normalizeView(location.pathname.split("/").filter(Boolean)[0] || "");
|
|
}
|
|
|
|
const RENDERERS = {
|
|
pilotage: renderPilotage,
|
|
bands: renderBands,
|
|
locations: renderLocations,
|
|
activity: renderActivity,
|
|
llm: renderLLM,
|
|
};
|
|
|
|
function router() {
|
|
const view = currentViewFromLocation();
|
|
state.view = view;
|
|
const token = ++renderToken;
|
|
document.querySelectorAll(".nav a").forEach((a) => {
|
|
const active = a.dataset.view === view;
|
|
a.classList.toggle("active", active);
|
|
if (active) a.setAttribute("aria-current", "page");
|
|
else a.removeAttribute("aria-current");
|
|
});
|
|
clearTimer();
|
|
RENDERERS[view](token);
|
|
}
|
|
window.addEventListener("hashchange", router);
|
|
|
|
// ==================================================================
|
|
// PILOTAGE — « est-ce que ça tourne ? »
|
|
// ==================================================================
|
|
async function renderPilotage(token = renderToken) {
|
|
content().innerHTML = `
|
|
<div class="view-head">
|
|
<h1>Pilotage</h1>
|
|
<span id="p-refresh" class="refresh-note" aria-live="polite">Actualisation auto toutes les 15 s</span>
|
|
</div>
|
|
<div id="p-health" class="health-strip"><div class="loading">Chargement…</div></div>
|
|
<div id="p-alerts"></div>
|
|
<div class="grid grid-2">
|
|
<div class="card" id="p-runs"><div class="loading">Chargement…</div></div>
|
|
<div class="card" id="p-triggers"></div>
|
|
</div>
|
|
<details class="card danger-zone" id="p-danger">
|
|
<summary>⚠️ Opérations destructives</summary>
|
|
<p class="muted-note">
|
|
À n'utiliser que si le géocodage est corrompu. Ces actions relancent des
|
|
milliers d'appels Geoapify et Groq, qui sont facturés.
|
|
</p>
|
|
<div class="btn-row">
|
|
<button class="btn btn-danger" data-danger="reset-all">🔥 Réinitialiser tout le géocodage</button>
|
|
<button class="btn btn-danger" data-danger="purge-nominatim">🧹 Purger le cache Nominatim</button>
|
|
<button class="btn btn-danger" data-danger="requeue-all">♻️ Remettre TOUT en file (dont « done »)</button>
|
|
</div>
|
|
<div id="p-danger-feedback" class="feedback" role="status"></div>
|
|
</details>
|
|
`;
|
|
|
|
renderTriggers();
|
|
wireDangerZone();
|
|
await loadPilotage();
|
|
if (isStale(token)) return; // l'utilisateur a changé de vue pendant le chargement
|
|
clearTimer();
|
|
state.timer = setInterval(loadPilotage, 15000);
|
|
}
|
|
|
|
function renderTriggers() {
|
|
document.getElementById("p-triggers").innerHTML = `
|
|
<h2>Lancer un traitement</h2>
|
|
<p class="muted-note">Le crawler consomme la demande à son prochain cycle (~1 min).</p>
|
|
<div class="btn-col">
|
|
<button class="btn" data-job="incremental">🕷 Crawl incrémental <span class="hint">créations et modifications récentes</span></button>
|
|
<button class="btn" data-job="enrich">📖 Enrichir les groupes <span class="hint">visite les fiches non enrichies</span></button>
|
|
<button class="btn" data-job="geocoder_enqueue">🗺 Alimenter le géocodage <span class="hint">reparse les lieux en attente</span></button>
|
|
<button class="btn" data-job="full_crawl">🌍 Crawl complet Europe <span class="hint">long — plusieurs heures</span></button>
|
|
</div>
|
|
<div id="p-job-feedback" class="feedback" role="status"></div>
|
|
`;
|
|
|
|
document.getElementById("p-triggers").addEventListener("click", async (e) => {
|
|
const btn = e.target.closest("[data-job]");
|
|
if (!btn) return;
|
|
const job_type = btn.dataset.job;
|
|
if (job_type === "full_crawl" &&
|
|
!confirm("Lancer un crawl complet de l'Europe ?\n\nPlusieurs heures, et beaucoup de requêtes vers Metal Archives.")) return;
|
|
const fb = document.getElementById("p-job-feedback");
|
|
btn.disabled = true;
|
|
fb.textContent = "Envoi…"; fb.className = "feedback";
|
|
try {
|
|
const r = await api("/admin/api/job-triggers", { method: "POST", body: JSON.stringify({ job_type }) });
|
|
fb.textContent = `✓ Demande #${r.id} enregistrée — suivi dans Journal.`;
|
|
fb.className = "feedback ok";
|
|
await loadPilotage();
|
|
} catch (err) {
|
|
fb.textContent = `✗ ${err.message}`;
|
|
fb.className = "feedback err";
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
const DANGER_ACTIONS = {
|
|
"reset-all": {
|
|
url: "/admin/api/locations/reset-all",
|
|
confirm: "RÉINITIALISER TOUT LE GÉOCODAGE ?\n\nToutes les localisations repassent en file et perdent leurs coordonnées. Le cache est réutilisé quand c'est possible, mais cela reste coûteux.",
|
|
},
|
|
"purge-nominatim": {
|
|
url: "/admin/api/geocode-cache/purge-nominatim",
|
|
confirm: "Supprimer les entrées de cache de l'ancien pipeline Nominatim ?\n\nCes lieux seront re-géocodés via Geoapify — appels facturés.",
|
|
},
|
|
"requeue-all": {
|
|
url: "/admin/api/locations/requeue-all",
|
|
body: { include_done: true },
|
|
confirm: "Remettre TOUTES les localisations en file, y compris celles déjà résolues ?",
|
|
},
|
|
};
|
|
|
|
function wireDangerZone() {
|
|
document.getElementById("p-danger").addEventListener("click", async (e) => {
|
|
const btn = e.target.closest("[data-danger]");
|
|
if (!btn) return;
|
|
const action = DANGER_ACTIONS[btn.dataset.danger];
|
|
if (!action || !confirm(action.confirm)) return;
|
|
const fb = document.getElementById("p-danger-feedback");
|
|
btn.disabled = true;
|
|
fb.textContent = "En cours…"; fb.className = "feedback";
|
|
try {
|
|
const r = await api(action.url, { method: "POST", body: JSON.stringify(action.body || {}) });
|
|
fb.textContent = `✓ ${fmtNum(r.count)} entrée(s) modifiée(s).`;
|
|
fb.className = "feedback ok";
|
|
await loadPilotage();
|
|
} catch (err) {
|
|
fb.textContent = `✗ ${err.message}`;
|
|
fb.className = "feedback err";
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* É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("")}` : ""}
|
|
`;
|
|
|
|
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 : ${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();
|