Migration 007 :
- bands.locked_fields : champs édités manuellement = ne jamais réécrire
- bands.crawler_pending : valeurs MA différentes des valeurs verrouillées
- job_triggers : déclenchement de jobs depuis l'admin sans docker socket
- DELETE non-EU bands (6852 bands US/BR/CA/… entrés via crawl incrémental)
Crawler :
- upsert_band_enriched respecte locked_fields et stocke crawler_pending
- run_incremental filtre les bands hors Europe (EU-only désormais confirmé)
- update_crawl_run_progress toutes les 10 bands (granularité améliorée)
- main loop consomme job_triggers (enrich / incremental / full_crawl)
- retry sur JSONDecodeError dans get_json (FlareSolverr corps vide)
Admin PATCH bands :
- les champs édités sont automatiquement ajoutés à locked_fields
Dashboard :
- Genres découpés en mots-clés (Doom/Death Metal → Doom, Death Metal…)
- Listes pays/statut/genre sans limite, avec scroll interne
Bands admin :
- Colonne Thèmes + champ recherche thèmes_q
- Indicateur 🔒 sur les bands avec champs verrouillés
Nouvelles pages admin :
- Conflits : résolution champ par champ (garder ma valeur / accepter MA)
- Géocodage : stats queue, barre de progression, 20 derniers géocodages
- Jobs : boutons pour déclencher enrich/incremental/full_crawl manuellement
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
840 lines
36 KiB
JavaScript
840 lines
36 KiB
JavaScript
"use strict";
|
|
|
|
const state = {
|
|
username: null,
|
|
view: "dashboard",
|
|
bands: { page: 1, pageSize: 50, q: "", location_q: "", country: "", status: "", genre: "", enriched: "", has_lat: "", has_location: "", sort: "ma_id", dir: "asc", total: 0 },
|
|
runs: { page: 1, pageSize: 50, run_type: "", status: "", total: 0 },
|
|
logs: { page: 1, pageSize: 100, level: "", autoRefresh: true },
|
|
audit: { page: 1, pageSize: 50 },
|
|
logsTimer: null,
|
|
queueTimer: null,
|
|
};
|
|
|
|
// ------------------------------------------------------------------
|
|
// API helper
|
|
// ------------------------------------------------------------------
|
|
async function api(path, opts = {}) {
|
|
const res = await fetch(path, {
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json", ...(opts.headers || {}) },
|
|
...opts,
|
|
});
|
|
if (res.status === 401) {
|
|
showLogin();
|
|
throw new Error("unauthorized");
|
|
}
|
|
const body = await res.json().catch(() => ({}));
|
|
if (!res.ok || body.ok === false) {
|
|
throw new Error(body.error || `HTTP ${res.status}`);
|
|
}
|
|
return body;
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Auth
|
|
// ------------------------------------------------------------------
|
|
function showLogin() {
|
|
document.getElementById("login-screen").classList.remove("hidden");
|
|
document.getElementById("app").classList.add("hidden");
|
|
if (state.logsTimer) { clearInterval(state.logsTimer); state.logsTimer = null; }
|
|
if (state.queueTimer) { clearInterval(state.queueTimer); state.queueTimer = null; }
|
|
}
|
|
|
|
function showApp() {
|
|
document.getElementById("login-screen").classList.add("hidden");
|
|
document.getElementById("app").classList.remove("hidden");
|
|
document.getElementById("whoami").textContent = state.username || "";
|
|
}
|
|
|
|
async function checkSession() {
|
|
try {
|
|
const r = await api("/admin/auth/me");
|
|
state.username = r.username;
|
|
showApp();
|
|
router();
|
|
} catch {
|
|
showLogin();
|
|
}
|
|
}
|
|
|
|
document.getElementById("login-form").addEventListener("submit", async (e) => {
|
|
e.preventDefault();
|
|
const username = document.getElementById("login-username").value.trim();
|
|
const password = document.getElementById("login-password").value;
|
|
const errEl = document.getElementById("login-error");
|
|
errEl.textContent = "";
|
|
try {
|
|
const res = await fetch("/admin/auth/login", {
|
|
method: "POST",
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ username, password }),
|
|
});
|
|
const body = await res.json().catch(() => ({}));
|
|
if (!res.ok || body.ok === false) {
|
|
errEl.textContent = body.error || "Échec de connexion";
|
|
return;
|
|
}
|
|
state.username = body.username;
|
|
document.getElementById("login-password").value = "";
|
|
showApp();
|
|
router();
|
|
} catch {
|
|
errEl.textContent = "Erreur réseau";
|
|
}
|
|
});
|
|
|
|
document.getElementById("logout-btn").addEventListener("click", async () => {
|
|
try { await fetch("/admin/auth/logout", { method: "POST", credentials: "include" }); } catch {}
|
|
state.username = null;
|
|
showLogin();
|
|
});
|
|
|
|
// ------------------------------------------------------------------
|
|
// Router
|
|
// ------------------------------------------------------------------
|
|
const VIEWS = ["dashboard", "bands", "queue", "runs", "logs", "geocoding", "conflicts", "jobs", "checkpoints", "audit"];
|
|
|
|
function router() {
|
|
const hash = (location.hash || "#/dashboard").replace("#/", "");
|
|
const view = VIEWS.includes(hash) ? hash : "dashboard";
|
|
state.view = view;
|
|
document.querySelectorAll(".nav a").forEach((a) => {
|
|
a.classList.toggle("active", a.dataset.view === view);
|
|
});
|
|
if (state.logsTimer) { clearInterval(state.logsTimer); state.logsTimer = null; }
|
|
if (state.queueTimer) { clearInterval(state.queueTimer); state.queueTimer = null; }
|
|
const renderers = {
|
|
dashboard: renderDashboard,
|
|
bands: renderBands,
|
|
conflicts: renderConflicts,
|
|
queue: renderQueue,
|
|
runs: renderRuns,
|
|
logs: renderLogs,
|
|
geocoding: renderGeocoding,
|
|
jobs: renderJobs,
|
|
checkpoints: renderCheckpoints,
|
|
audit: renderAudit,
|
|
};
|
|
renderers[view]();
|
|
}
|
|
window.addEventListener("hashchange", router);
|
|
|
|
function esc(s) {
|
|
return String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
|
}
|
|
function fmtDate(s) {
|
|
if (!s) return "—";
|
|
const d = new Date(s);
|
|
return d.toLocaleString("fr-FR", { day: "2-digit", month: "2-digit", year: "2-digit", hour: "2-digit", minute: "2-digit" });
|
|
}
|
|
const content = () => document.getElementById("content");
|
|
|
|
// ------------------------------------------------------------------
|
|
// Dashboard
|
|
// ------------------------------------------------------------------
|
|
// Découpe un genre complexe ("Doom/Death Metal; Gothic/Progressive") en mots-clés
|
|
function splitGenreElements(genreRows) {
|
|
const counts = new Map();
|
|
for (const { genre, total } of genreRows) {
|
|
if (!genre) continue;
|
|
// Retire les parenthèses et leur contenu (ex: "(early)", "(later)")
|
|
const cleaned = genre.replace(/\([^)]*\)/g, "");
|
|
// Découpe sur / ; ,
|
|
const tokens = cleaned.split(/[\/;,]+/);
|
|
for (const tok of tokens) {
|
|
const kw = tok.trim();
|
|
if (kw.length < 3) continue;
|
|
counts.set(kw, (counts.get(kw) || 0) + total);
|
|
}
|
|
}
|
|
return [...counts.entries()]
|
|
.map(([kw, n]) => ({ kw, n }))
|
|
.sort((a, b) => b.n - a.n);
|
|
}
|
|
|
|
async function renderDashboard() {
|
|
content().innerHTML = `<div class="loading">Chargement…</div>`;
|
|
try {
|
|
const r = await api("/admin/api/stats");
|
|
const t = r.totals;
|
|
const maxStatus = Math.max(1, ...r.by_status.map((x) => x.total));
|
|
const maxCountry = Math.max(1, ...r.by_country.map((x) => x.total));
|
|
const elements = splitGenreElements(r.by_genre);
|
|
const maxEl = Math.max(1, ...elements.map((x) => x.n));
|
|
|
|
content().innerHTML = `
|
|
<div class="grid grid-stats">
|
|
${statCard("Total bands", t.total)}
|
|
${statCard("Enrichis", t.enriched, "ok")}
|
|
${statCard("Non enrichis", t.not_enriched, "warn")}
|
|
${statCard("Géocodés", t.geocoded, "ok")}
|
|
${statCard("Sans localisation", t.not_geocoded, "warn")}
|
|
${statCard("Jamais crawlés (nouveau système)", t.total - t.crawled_by_current_system, "warn")}
|
|
${statCard("Jamais enrichis (band_page absent)", t.never_enriched, "err")}
|
|
${statCard("Stale (>30j)", t.stale, "warn")}
|
|
</div>
|
|
<div class="grid grid-2">
|
|
<div class="card" style="max-height:420px;overflow-y:auto">
|
|
<h2>Par statut</h2>
|
|
${r.by_status.map((x) => barRow(x.status, x.total, maxStatus)).join("") || emptyRow()}
|
|
</div>
|
|
<div class="card" style="max-height:420px;overflow-y:auto">
|
|
<h2>Par pays (${r.by_country.length})</h2>
|
|
${r.by_country.map((x) => barRow(x.country, x.total, maxCountry)).join("") || emptyRow()}
|
|
</div>
|
|
<div class="card" style="max-height:500px;overflow-y:auto">
|
|
<h2>Éléments de genre (${elements.length} mots-clés)</h2>
|
|
<p style="font-size:11px;color:var(--muted);margin:0 0 10px">Chaque genre complexe (ex: "Doom/Death Metal; Gothic") est découpé en mots-clés.</p>
|
|
${elements.map((x) => barRow(x.kw, x.n, maxEl)).join("") || emptyRow()}
|
|
</div>
|
|
</div>
|
|
`;
|
|
} catch (e) {
|
|
content().innerHTML = `<div class="empty">Erreur : ${esc(e.message)}</div>`;
|
|
}
|
|
}
|
|
|
|
function statCard(label, value, cls = "") {
|
|
return `<div class="card stat-card"><div class="label">${esc(label)}</div><div class="value ${cls}">${Number(value).toLocaleString("fr-FR")}</div></div>`;
|
|
}
|
|
function barRow(label, count, max) {
|
|
const pct = Math.max(2, Math.round((count / max) * 100));
|
|
return `<div class="bar-row"><div class="bar-label" title="${esc(label)}">${esc(label)}</div><div class="bar-track"><div class="bar-fill" style="width:${pct}%"></div></div><div class="bar-count">${count}</div></div>`;
|
|
}
|
|
function emptyRow() { return `<div class="empty">Aucune donnée</div>`; }
|
|
|
|
// ------------------------------------------------------------------
|
|
// Queue
|
|
// ------------------------------------------------------------------
|
|
async function renderQueue() {
|
|
if (!document.getElementById("q-body")) {
|
|
content().innerHTML = `
|
|
<div class="grid grid-stats" id="q-stats"></div>
|
|
<div class="card">
|
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:14px">
|
|
<h2 style="margin:0">Derniers runs d'enrichissement</h2>
|
|
<div style="display:flex;gap:8px;align-items:center">
|
|
<span id="q-refresh-status" style="font-size:11px;color:var(--muted)">Auto-refresh 15s</span>
|
|
<button class="btn btn-mini" id="q-cleanup-btn">🧹 Annuler runs bloqués (>30min)</button>
|
|
</div>
|
|
</div>
|
|
<div class="table-wrap"><table>
|
|
<thead><tr><th>ID</th><th>Statut</th><th>Début</th><th>Fin</th><th>Vus</th><th>Enrichis</th><th>Progression</th><th>Erreur</th></tr></thead>
|
|
<tbody id="q-body"></tbody>
|
|
</table></div>
|
|
</div>
|
|
`;
|
|
document.getElementById("q-cleanup-btn").addEventListener("click", async () => {
|
|
const btn = document.getElementById("q-cleanup-btn");
|
|
btn.disabled = true;
|
|
try {
|
|
const r = await api("/admin/api/crawl-runs/cleanup", { method: "POST", body: JSON.stringify({ older_than_minutes: 30 }) });
|
|
alert(`${r.cleaned} run(s) annulé(s).`);
|
|
await loadQueue();
|
|
} catch (e) {
|
|
alert(`Erreur: ${e.message}`);
|
|
} finally { btn.disabled = false; }
|
|
});
|
|
}
|
|
await loadQueue();
|
|
if (state.queueTimer) clearInterval(state.queueTimer);
|
|
state.queueTimer = setInterval(loadQueue, 15000);
|
|
}
|
|
|
|
async function loadQueue() {
|
|
try {
|
|
const r = await api("/admin/api/queue");
|
|
const b = r.breakdown;
|
|
const statsEl = document.getElementById("q-stats");
|
|
if (statsEl) statsEl.innerHTML = `
|
|
${statCard("Nouveaux (priorité 1)", b.new_bands, "err")}
|
|
${statCard("Modifiés depuis enrich (p.2)", b.modified_since_enrich, "warn")}
|
|
${statCard("Legacy à ré-enrichir (p.3)", b.legacy_pending, "warn")}
|
|
${statCard("Stale >30j (p.4)", b.stale, "ok")}
|
|
`;
|
|
const tbody = document.getElementById("q-body");
|
|
if (tbody) tbody.innerHTML = r.recent_enrich_runs.map((x) => {
|
|
const isRunning = x.status === "running";
|
|
const elapsed = isRunning ? Math.round((Date.now() - new Date(x.started_at)) / 60000) : null;
|
|
const prog = isRunning && x.bands_seen > 0
|
|
? `<span style="color:var(--muted)">${x.bands_seen} vus, ${x.bands_enriched} enrichis${elapsed ? `, ${elapsed}min` : ""}</span>`
|
|
: isRunning ? `<span style="color:var(--warn)">en attente… (${elapsed}min)</span>` : "—";
|
|
return `<tr>
|
|
<td>${x.id}</td>
|
|
<td>${statusBadge(x.status)}</td>
|
|
<td>${fmtDate(x.started_at)}</td>
|
|
<td>${fmtDate(x.finished_at)}</td>
|
|
<td>${x.bands_seen}</td>
|
|
<td>${x.bands_enriched}</td>
|
|
<td>${prog}</td>
|
|
<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;color:var(--err)">${esc(x.error || "")}</td>
|
|
</tr>`;
|
|
}).join("") || `<tr><td colspan="8" class="empty">Aucun run</td></tr>`;
|
|
} catch (e) {
|
|
const tbody = document.getElementById("q-body");
|
|
if (tbody) tbody.innerHTML = `<tr><td colspan="8" class="empty">Erreur : ${esc(e.message)}</td></tr>`;
|
|
}
|
|
}
|
|
|
|
function statusBadge(status) {
|
|
const cls = status === "done" ? "ok" : status === "error" ? "err" : status === "running" ? "warn" : "muted";
|
|
return `<span class="badge ${cls}">${esc(status)}</span>`;
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Bands
|
|
// ------------------------------------------------------------------
|
|
async function renderBands() {
|
|
const s = state.bands;
|
|
content().innerHTML = `
|
|
<div class="toolbar">
|
|
<input type="text" id="b-q" placeholder="Nom / genre…" value="${esc(s.q)}" style="min-width:180px">
|
|
<input type="text" id="b-location_q" placeholder="Lieu…" value="${esc(s.location_q)}" style="width:160px">
|
|
<input type="text" id="b-country" placeholder="Pays (FR, DE…)" value="${esc(s.country)}" style="width:110px">
|
|
<input type="text" id="b-status" placeholder="Statut" value="${esc(s.status)}" style="width:130px">
|
|
<input type="text" id="b-genre" placeholder="Genre" value="${esc(s.genre)}" style="width:150px">
|
|
<select id="b-enriched">
|
|
<option value="">Enrichi: tous</option>
|
|
<option value="true" ${s.enriched === "true" ? "selected" : ""}>Enrichi ✓</option>
|
|
<option value="false" ${s.enriched === "false" ? "selected" : ""}>Non enrichi ✗</option>
|
|
</select>
|
|
<select id="b-has_lat">
|
|
<option value="">Géocodé: tous</option>
|
|
<option value="true" ${s.has_lat === "true" ? "selected" : ""}>Géocodé ✓</option>
|
|
<option value="false" ${s.has_lat === "false" ? "selected" : ""}>Sans coords ✗</option>
|
|
</select>
|
|
<input type="text" id="b-themes_q" placeholder="Thèmes…" value="${esc(s.themes_q||"")}" style="width:150px">
|
|
<select id="b-has_location">
|
|
<option value="">Lieu texte: tous</option>
|
|
<option value="true" ${s.has_location === "true" ? "selected" : ""}>Lieu renseigné ✓</option>
|
|
<option value="false" ${s.has_location === "false" ? "selected" : ""}>Lieu vide ✗</option>
|
|
</select>
|
|
<button class="btn" id="b-apply">Filtrer</button>
|
|
</div>
|
|
<div id="b-table" class="table-wrap"><div class="loading">Chargement…</div></div>
|
|
<div class="pager" id="b-pager"></div>
|
|
`;
|
|
document.getElementById("b-apply").addEventListener("click", () => {
|
|
s.q = document.getElementById("b-q").value.trim();
|
|
s.location_q = document.getElementById("b-location_q").value.trim();
|
|
s.country = document.getElementById("b-country").value.trim();
|
|
s.status = document.getElementById("b-status").value.trim();
|
|
s.genre = document.getElementById("b-genre").value.trim();
|
|
s.enriched = document.getElementById("b-enriched").value;
|
|
s.themes_q = document.getElementById("b-themes_q").value.trim();
|
|
s.has_lat = document.getElementById("b-has_lat").value;
|
|
s.has_location = document.getElementById("b-has_location").value;
|
|
s.page = 1;
|
|
loadBands();
|
|
});
|
|
await loadBands();
|
|
}
|
|
|
|
const BAND_COLUMNS = [
|
|
{ key: "ma_id", label: "MA ID" },
|
|
{ key: "name", label: "Nom" },
|
|
{ key: "country", label: "Pays" },
|
|
{ key: "status", label: "Statut" },
|
|
{ key: "genre", label: "Genre" },
|
|
{ key: "themes", label: "Thèmes" },
|
|
{ key: "location_text", label: "Lieu" },
|
|
{ key: "formed_year", label: "Année" },
|
|
{ key: "enriched", label: "Enrichi" },
|
|
{ key: "crawled_at", label: "Crawlé le" },
|
|
{ key: "updated_at", label: "Modifié le" },
|
|
];
|
|
|
|
async function loadBands() {
|
|
const s = state.bands;
|
|
const params = new URLSearchParams({
|
|
page: s.page, pageSize: s.pageSize, sort: s.sort, dir: s.dir,
|
|
});
|
|
if (s.q) params.set("q", s.q);
|
|
if (s.location_q) params.set("location_q", s.location_q);
|
|
if (s.country) params.set("country", s.country);
|
|
if (s.status) params.set("status", s.status);
|
|
if (s.genre) params.set("genre", s.genre);
|
|
if (s.enriched) params.set("enriched", s.enriched);
|
|
if (s.themes_q) params.set("themes_q", s.themes_q);
|
|
if (s.has_lat) params.set("has_lat", s.has_lat);
|
|
if (s.has_location) params.set("has_location", s.has_location);
|
|
|
|
try {
|
|
const r = await api(`/admin/api/bands?${params}`);
|
|
s.total = r.total;
|
|
const tableEl = document.getElementById("b-table");
|
|
tableEl.innerHTML = `
|
|
<table>
|
|
<thead><tr>
|
|
${BAND_COLUMNS.map((c) => `<th data-sort="${c.key}">${c.label}${s.sort === c.key ? (s.dir === "asc" ? " ▲" : " ▼") : ""}</th>`).join("")}
|
|
</tr></thead>
|
|
<tbody>
|
|
${r.items.map((b) => `
|
|
<tr class="clickable" data-ma-id="${b.ma_id}">
|
|
<td>${b.ma_id}</td>
|
|
<td>${esc(b.name)}</td>
|
|
<td>${esc(b.country || "")}</td>
|
|
<td>${esc(b.status || "")}</td>
|
|
<td>${esc(b.genre || "")}</td>
|
|
<td style="max-width:140px;overflow:hidden;text-overflow:ellipsis" title="${esc(b.themes || "")}">${esc(b.themes || "")}</td>
|
|
<td style="max-width:140px;overflow:hidden;text-overflow:ellipsis" title="${esc(b.location_text || "")}">${esc(b.location_text || "")}</td>
|
|
<td>${b.formed_year || ""}</td>
|
|
<td>${b.enriched ? '<span class="badge ok">oui</span>' : '<span class="badge warn">non</span>'}${Object.keys(b.locked_fields||{}).length ? ' <span title="Champs verrouillés manuellement" style="color:var(--blood)">🔒</span>' : ""}</td>
|
|
<td>${fmtDate(b.crawled_at)}</td>
|
|
<td>${fmtDate(b.updated_at)}</td>
|
|
</tr>
|
|
`).join("") || `<tr><td colspan="${BAND_COLUMNS.length}" class="empty">Aucun résultat</td></tr>`}
|
|
</tbody>
|
|
</table>
|
|
`;
|
|
tableEl.querySelectorAll("th[data-sort]").forEach((th) => {
|
|
th.addEventListener("click", () => {
|
|
const key = th.dataset.sort;
|
|
if (s.sort === key) s.dir = s.dir === "asc" ? "desc" : "asc";
|
|
else { s.sort = key; s.dir = "asc"; }
|
|
loadBands();
|
|
});
|
|
});
|
|
tableEl.querySelectorAll("tr[data-ma-id]").forEach((tr) => {
|
|
tr.addEventListener("click", () => openBandModal(Number(tr.dataset.maId)));
|
|
});
|
|
|
|
const totalPages = Math.max(1, Math.ceil(s.total / s.pageSize));
|
|
document.getElementById("b-pager").innerHTML = `
|
|
<button class="btn btn-mini" id="b-prev" ${s.page <= 1 ? "disabled" : ""}>← Préc.</button>
|
|
<span>Page ${s.page} / ${totalPages} (${s.total.toLocaleString("fr-FR")} bands)</span>
|
|
<button class="btn btn-mini" id="b-next" ${s.page >= totalPages ? "disabled" : ""}>Suiv. →</button>
|
|
`;
|
|
document.getElementById("b-prev")?.addEventListener("click", () => { s.page--; loadBands(); });
|
|
document.getElementById("b-next")?.addEventListener("click", () => { s.page++; loadBands(); });
|
|
} catch (e) {
|
|
document.getElementById("b-table").innerHTML = `<div class="empty">Erreur : ${esc(e.message)}</div>`;
|
|
}
|
|
}
|
|
|
|
async function openBandModal(maId) {
|
|
let band;
|
|
try {
|
|
const r = await api(`/admin/api/bands/${maId}`);
|
|
band = r.item;
|
|
} catch (e) {
|
|
alert(`Erreur : ${e.message}`);
|
|
return;
|
|
}
|
|
const backdrop = document.createElement("div");
|
|
backdrop.className = "modal-backdrop";
|
|
backdrop.innerHTML = `
|
|
<div class="modal">
|
|
<span class="modal-close">×</span>
|
|
<h3>${esc(band.name)} <span style="color:var(--muted);font-weight:400">#${band.ma_id}</span></h3>
|
|
<label>Nom<input type="text" id="m-name" value="${esc(band.name || "")}"></label>
|
|
<label>Pays<input type="text" id="m-country" value="${esc(band.country || "")}"></label>
|
|
<label>Statut<input type="text" id="m-status" value="${esc(band.status || "")}"></label>
|
|
<label>Genre<input type="text" id="m-genre" value="${esc(band.genre || "")}"></label>
|
|
<label>Année de formation<input type="number" id="m-formed_year" value="${band.formed_year ?? ""}"></label>
|
|
<label>Localisation<input type="text" id="m-location_text" value="${esc(band.location_text || "")}"></label>
|
|
<label>Thèmes<input type="text" id="m-themes" value="${esc(band.themes || "")}"></label>
|
|
<label>Latitude<input type="number" step="any" id="m-lat" value="${band.lat ?? ""}"></label>
|
|
<label>Longitude<input type="number" step="any" id="m-lon" value="${band.lon ?? ""}"></label>
|
|
<div class="modal-error" id="m-error"></div>
|
|
<div class="modal-actions">
|
|
<button class="btn btn-mini" id="m-cancel">Annuler</button>
|
|
<button class="btn" id="m-save">Enregistrer</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
document.body.appendChild(backdrop);
|
|
const close = () => backdrop.remove();
|
|
backdrop.querySelector(".modal-close").addEventListener("click", close);
|
|
backdrop.addEventListener("click", (e) => { if (e.target === backdrop) close(); });
|
|
backdrop.querySelector("#m-cancel").addEventListener("click", close);
|
|
backdrop.querySelector("#m-save").addEventListener("click", async () => {
|
|
const errEl = backdrop.querySelector("#m-error");
|
|
errEl.textContent = "";
|
|
const payload = {
|
|
name: backdrop.querySelector("#m-name").value.trim() || null,
|
|
country: backdrop.querySelector("#m-country").value.trim() || null,
|
|
status: backdrop.querySelector("#m-status").value.trim() || null,
|
|
genre: backdrop.querySelector("#m-genre").value.trim() || null,
|
|
location_text: backdrop.querySelector("#m-location_text").value.trim() || null,
|
|
themes: backdrop.querySelector("#m-themes").value.trim() || null,
|
|
formed_year: backdrop.querySelector("#m-formed_year").value ? Number(backdrop.querySelector("#m-formed_year").value) : null,
|
|
lat: backdrop.querySelector("#m-lat").value ? Number(backdrop.querySelector("#m-lat").value) : null,
|
|
lon: backdrop.querySelector("#m-lon").value ? Number(backdrop.querySelector("#m-lon").value) : null,
|
|
};
|
|
try {
|
|
await api(`/admin/api/bands/${maId}`, { method: "PATCH", body: JSON.stringify(payload) });
|
|
close();
|
|
loadBands();
|
|
} catch (e) {
|
|
errEl.textContent = e.message;
|
|
}
|
|
});
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Crawl runs
|
|
// ------------------------------------------------------------------
|
|
async function renderRuns() {
|
|
const s = state.runs;
|
|
content().innerHTML = `
|
|
<div class="toolbar">
|
|
<select id="r-type">
|
|
<option value="">Type: tous</option>
|
|
<option value="full_europe">full_europe</option>
|
|
<option value="incremental_created">incremental_created</option>
|
|
<option value="incremental_modified">incremental_modified</option>
|
|
<option value="enrich">enrich</option>
|
|
</select>
|
|
<select id="r-status">
|
|
<option value="">Statut: tous</option>
|
|
<option value="running">running</option>
|
|
<option value="done">done</option>
|
|
<option value="error">error</option>
|
|
</select>
|
|
<button class="btn" id="r-apply">Filtrer</button>
|
|
</div>
|
|
<div id="r-table" class="table-wrap"><div class="loading">Chargement…</div></div>
|
|
<div class="pager" id="r-pager"></div>
|
|
`;
|
|
document.getElementById("r-apply").addEventListener("click", () => {
|
|
s.run_type = document.getElementById("r-type").value;
|
|
s.status = document.getElementById("r-status").value;
|
|
s.page = 1;
|
|
loadRuns();
|
|
});
|
|
await loadRuns();
|
|
}
|
|
|
|
async function loadRuns() {
|
|
const s = state.runs;
|
|
const params = new URLSearchParams({ page: s.page, pageSize: s.pageSize });
|
|
if (s.run_type) params.set("run_type", s.run_type);
|
|
if (s.status) params.set("status", s.status);
|
|
try {
|
|
const r = await api(`/admin/api/crawl-runs?${params}`);
|
|
s.total = r.total;
|
|
document.getElementById("r-table").innerHTML = `
|
|
<table>
|
|
<thead><tr><th>ID</th><th>Type</th><th>Pays</th><th>Statut</th><th>Début</th><th>Fin</th><th>Vus</th><th>Nouveaux</th><th>MAJ</th><th>Enrichis</th><th>Erreur</th></tr></thead>
|
|
<tbody>
|
|
${r.items.map((x) => `
|
|
<tr>
|
|
<td>${x.id}</td>
|
|
<td>${esc(x.run_type)}</td>
|
|
<td>${esc((x.countries || []).join(", "))}</td>
|
|
<td>${statusBadge(x.status)}</td>
|
|
<td>${fmtDate(x.started_at)}</td>
|
|
<td>${fmtDate(x.finished_at)}</td>
|
|
<td>${x.bands_seen}</td>
|
|
<td>${x.bands_new}</td>
|
|
<td>${x.bands_updated}</td>
|
|
<td>${x.bands_enriched}</td>
|
|
<td>${esc(x.error || "")}</td>
|
|
</tr>
|
|
`).join("") || `<tr><td colspan="11" class="empty">Aucun run</td></tr>`}
|
|
</tbody>
|
|
</table>
|
|
`;
|
|
const totalPages = Math.max(1, Math.ceil(s.total / s.pageSize));
|
|
document.getElementById("r-pager").innerHTML = `
|
|
<button class="btn btn-mini" id="r-prev" ${s.page <= 1 ? "disabled" : ""}>← Préc.</button>
|
|
<span>Page ${s.page} / ${totalPages} (${s.total} runs)</span>
|
|
<button class="btn btn-mini" id="r-next" ${s.page >= totalPages ? "disabled" : ""}>Suiv. →</button>
|
|
`;
|
|
document.getElementById("r-prev")?.addEventListener("click", () => { s.page--; loadRuns(); });
|
|
document.getElementById("r-next")?.addEventListener("click", () => { s.page++; loadRuns(); });
|
|
} catch (e) {
|
|
document.getElementById("r-table").innerHTML = `<div class="empty">Erreur : ${esc(e.message)}</div>`;
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Logs
|
|
// ------------------------------------------------------------------
|
|
async function renderLogs() {
|
|
const s = state.logs;
|
|
content().innerHTML = `
|
|
<div class="toolbar">
|
|
<select id="l-level">
|
|
<option value="">Niveau: tous</option>
|
|
<option value="info">info</option>
|
|
<option value="warning">warning</option>
|
|
<option value="error">error</option>
|
|
</select>
|
|
<button class="btn" id="l-apply">Filtrer</button>
|
|
<label style="font-size:12px;color:var(--muted);display:flex;align-items:center;gap:6px;">
|
|
<input type="checkbox" id="l-auto" ${s.autoRefresh ? "checked" : ""}> Auto-refresh (10s)
|
|
</label>
|
|
</div>
|
|
<div id="l-list" class="card"><div class="loading">Chargement…</div></div>
|
|
`;
|
|
document.getElementById("l-apply").addEventListener("click", () => {
|
|
s.level = document.getElementById("l-level").value;
|
|
loadLogs();
|
|
});
|
|
document.getElementById("l-auto").addEventListener("change", (e) => {
|
|
s.autoRefresh = e.target.checked;
|
|
setupLogsTimer();
|
|
});
|
|
await loadLogs();
|
|
setupLogsTimer();
|
|
}
|
|
|
|
function setupLogsTimer() {
|
|
if (state.logsTimer) { clearInterval(state.logsTimer); state.logsTimer = null; }
|
|
if (state.logs.autoRefresh) {
|
|
state.logsTimer = setInterval(loadLogs, 10000);
|
|
}
|
|
}
|
|
|
|
async function loadLogs() {
|
|
const s = state.logs;
|
|
const params = new URLSearchParams({ page: 1, pageSize: s.pageSize });
|
|
if (s.level) params.set("level", s.level);
|
|
try {
|
|
const r = await api(`/admin/api/logs?${params}`);
|
|
document.getElementById("l-list").innerHTML = r.items.map((x) => `
|
|
<div class="log-line">
|
|
<span class="ts">${fmtDate(x.created_at)}</span>
|
|
<span class="lvl ${esc(x.level)}">${esc(x.level)}</span>
|
|
<span>${esc(x.message)}${x.ma_id ? ` <span style="color:var(--muted)">(ma_id=${x.ma_id})</span>` : ""}</span>
|
|
</div>
|
|
`).join("") || `<div class="empty">Aucun log</div>`;
|
|
} catch (e) {
|
|
document.getElementById("l-list").innerHTML = `<div class="empty">Erreur : ${esc(e.message)}</div>`;
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Conflits (champs verrouillés ≠ valeur crawler)
|
|
// ------------------------------------------------------------------
|
|
async function renderConflicts() {
|
|
content().innerHTML = `<div class="loading">Chargement…</div>`;
|
|
try {
|
|
const r = await api("/admin/api/conflicts?page=1&pageSize=100");
|
|
if (!r.items.length) {
|
|
content().innerHTML = `<div class="card"><div class="empty">Aucun conflit — toutes les éditions manuelles sont cohérentes avec les données Metal Archives.</div></div>`;
|
|
return;
|
|
}
|
|
content().innerHTML = `
|
|
<p style="font-size:12px;color:var(--muted);margin:0 0 14px">
|
|
Ces bands ont des champs édités manuellement (🔒) pour lesquels le crawler a trouvé une valeur différente.
|
|
Choisissez champ par champ ce que vous voulez conserver.
|
|
</p>
|
|
<div id="conflict-list">${r.items.map(conflictCard).join("")}</div>
|
|
`;
|
|
document.getElementById("conflict-list").addEventListener("click", async (e) => {
|
|
const btn = e.target.closest("[data-action]");
|
|
if (!btn) return;
|
|
const { action, maId, field } = btn.dataset;
|
|
btn.disabled = true;
|
|
try {
|
|
await api(`/admin/api/bands/${maId}/resolve-conflict`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ field, action }),
|
|
});
|
|
renderConflicts();
|
|
} catch (err) { alert(err.message); btn.disabled = false; }
|
|
});
|
|
} catch (e) {
|
|
content().innerHTML = `<div class="empty">Erreur : ${esc(e.message)}</div>`;
|
|
}
|
|
}
|
|
|
|
function conflictCard(b) {
|
|
const pending = b.crawler_pending || {};
|
|
const locked = b.locked_fields || {};
|
|
const rows = Object.entries(pending).map(([field, crawlerVal]) => {
|
|
const myVal = b[field] ?? "—";
|
|
return `<tr>
|
|
<td><strong>${esc(field)}</strong></td>
|
|
<td style="color:var(--bone)">${esc(String(myVal))}</td>
|
|
<td style="color:var(--warn)">${esc(String(crawlerVal))}</td>
|
|
<td>
|
|
<button class="btn btn-mini" data-action="keep_mine" data-ma-id="${b.ma_id}" data-field="${field}">Garder ma valeur</button>
|
|
<button class="btn btn-mini" style="border-color:rgba(198,26,26,0.4)" data-action="accept_crawler" data-ma-id="${b.ma_id}" data-field="${field}">Accepter MA</button>
|
|
</td>
|
|
</tr>`;
|
|
}).join("");
|
|
return `<div class="card" style="margin-bottom:14px">
|
|
<h2 style="margin-bottom:8px">${esc(b.name)} <span style="color:var(--muted);font-weight:400">#${b.ma_id} · ${esc(b.country||"")}</span></h2>
|
|
<div class="table-wrap"><table>
|
|
<thead><tr><th>Champ</th><th>Ma valeur (verrouillée)</th><th>Valeur Metal Archives</th><th>Action</th></tr></thead>
|
|
<tbody>${rows}</tbody>
|
|
</table></div>
|
|
</div>`;
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Géocodage
|
|
// ------------------------------------------------------------------
|
|
async function renderGeocoding() {
|
|
content().innerHTML = `<div class="loading">Chargement…</div>`;
|
|
try {
|
|
const r = await api("/admin/api/geocoding");
|
|
const total = r.queue.reduce((s, x) => s + x.n, 0);
|
|
const done = r.queue.find((x) => x.status === "done")?.n || 0;
|
|
const queued = r.queue.find((x) => x.status === "queued")?.n || 0;
|
|
const errored = r.queue.find((x) => x.status === "error")?.n || 0;
|
|
const pct = total ? Math.round((done / total) * 100) : 0;
|
|
|
|
content().innerHTML = `
|
|
<div class="grid grid-stats">
|
|
${statCard("Total queue", total)}
|
|
${statCard("Géocodés ✓", done, "ok")}
|
|
${statCard("En attente", queued, "warn")}
|
|
${statCard("Erreurs", errored, "err")}
|
|
${statCard("Cache Geoapify", r.cache_size)}
|
|
</div>
|
|
<div class="card" style="margin-bottom:16px">
|
|
<h2>Progression</h2>
|
|
<div style="background:rgba(255,255,255,0.06);border-radius:8px;height:18px;overflow:hidden;margin-bottom:8px">
|
|
<div style="width:${pct}%;height:100%;background:linear-gradient(90deg,var(--blood),rgba(198,26,26,0.6))"></div>
|
|
</div>
|
|
<div style="font-size:12px;color:var(--muted)">${pct}% — ${done.toLocaleString("fr-FR")} / ${total.toLocaleString("fr-FR")}</div>
|
|
</div>
|
|
<div class="card">
|
|
<h2>20 derniers géocodages</h2>
|
|
<div class="table-wrap"><table>
|
|
<thead><tr><th>MA ID</th><th>Nom</th><th>Pays</th><th>Provider</th><th>Query</th><th>Géocodé le</th><th>Erreur</th></tr></thead>
|
|
<tbody>
|
|
${r.recent.map((x) => `<tr>
|
|
<td>${x.ma_id}</td>
|
|
<td>${esc(x.name)}</td>
|
|
<td>${esc(x.country||"")}</td>
|
|
<td>${esc(x.geocode_provider||"")}</td>
|
|
<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis">${esc(x.geocode_query||"")}</td>
|
|
<td>${fmtDate(x.geocoded_at)}</td>
|
|
<td style="color:var(--err)">${esc(x.geocode_error||"")}</td>
|
|
</tr>`).join("") || `<tr><td colspan="7" class="empty">Aucun</td></tr>`}
|
|
</tbody>
|
|
</table></div>
|
|
</div>
|
|
`;
|
|
} catch (e) {
|
|
content().innerHTML = `<div class="empty">Erreur : ${esc(e.message)}</div>`;
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Jobs (déclenchement manuel des workers)
|
|
// ------------------------------------------------------------------
|
|
async function renderJobs() {
|
|
content().innerHTML = `
|
|
<div class="card" style="margin-bottom:16px">
|
|
<h2>Déclencher un job manuellement</h2>
|
|
<p style="font-size:12px;color:var(--muted);margin:0 0 14px">
|
|
Le crawler consomme ces demandes dans sa prochaine itération (~1 min d'attente max).
|
|
</p>
|
|
<div style="display:flex;gap:10px;flex-wrap:wrap">
|
|
<button class="btn" data-job="enrich">▶ Enrich (500 bands)</button>
|
|
<button class="btn" data-job="incremental">▶ Crawl incrémental (créations + modifs)</button>
|
|
<button class="btn" data-job="full_crawl">▶ Crawl complet Europe</button>
|
|
</div>
|
|
<div id="job-feedback" style="margin-top:12px;font-size:12px;color:var(--muted)"></div>
|
|
</div>
|
|
<div class="card">
|
|
<h2>Historique des jobs déclenchés</h2>
|
|
<div id="jobs-list"><div class="loading">Chargement…</div></div>
|
|
</div>
|
|
`;
|
|
document.querySelectorAll("[data-job]").forEach((btn) => {
|
|
btn.addEventListener("click", async () => {
|
|
const job_type = btn.dataset.job;
|
|
btn.disabled = true;
|
|
const fb = document.getElementById("job-feedback");
|
|
fb.textContent = "Envoi…";
|
|
try {
|
|
const r = await api("/admin/api/job-triggers", { method: "POST", body: JSON.stringify({ job_type }) });
|
|
fb.textContent = `✓ Job #${r.id} créé — le crawler le consommera sous ~1 min.`;
|
|
fb.style.color = "var(--ok)";
|
|
await loadJobsList();
|
|
} catch (e) {
|
|
fb.textContent = `Erreur: ${e.message}`;
|
|
fb.style.color = "var(--err)";
|
|
} finally { btn.disabled = false; }
|
|
});
|
|
});
|
|
await loadJobsList();
|
|
}
|
|
|
|
async function loadJobsList() {
|
|
const el = document.getElementById("jobs-list");
|
|
if (!el) return;
|
|
try {
|
|
const r = await api("/admin/api/job-triggers");
|
|
el.innerHTML = `<div class="table-wrap"><table>
|
|
<thead><tr><th>ID</th><th>Type</th><th>Statut</th><th>Demandé par</th><th>Créé le</th><th>Démarré</th><th>Terminé</th><th>Erreur</th></tr></thead>
|
|
<tbody>
|
|
${r.items.map((x) => `<tr>
|
|
<td>${x.id}</td>
|
|
<td>${esc(x.job_type)}</td>
|
|
<td>${statusBadge(x.status)}</td>
|
|
<td>${esc(x.requested_by||"—")}</td>
|
|
<td>${fmtDate(x.created_at)}</td>
|
|
<td>${fmtDate(x.started_at)}</td>
|
|
<td>${fmtDate(x.finished_at)}</td>
|
|
<td style="color:var(--err)">${esc(x.error||"")}</td>
|
|
</tr>`).join("") || `<tr><td colspan="8" class="empty">Aucun job</td></tr>`}
|
|
</tbody>
|
|
</table></div>`;
|
|
} catch (e) {
|
|
el.innerHTML = `<div class="empty">Erreur : ${esc(e.message)}</div>`;
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Checkpoints
|
|
// ------------------------------------------------------------------
|
|
async function renderCheckpoints() {
|
|
content().innerHTML = `<div class="loading">Chargement…</div>`;
|
|
try {
|
|
const r = await api("/admin/api/crawl-checkpoints");
|
|
content().innerHTML = `
|
|
<div class="table-wrap"><table>
|
|
<thead><tr><th>Clé</th><th>Valeur</th><th>Mis à jour</th></tr></thead>
|
|
<tbody>
|
|
${r.items.map((x) => `<tr><td>${esc(x.key)}</td><td>${esc(x.value || "—")}</td><td>${fmtDate(x.updated_at)}</td></tr>`).join("") || `<tr><td colspan="3" class="empty">Aucun checkpoint</td></tr>`}
|
|
</tbody>
|
|
</table></div>
|
|
`;
|
|
} catch (e) {
|
|
content().innerHTML = `<div class="empty">Erreur : ${esc(e.message)}</div>`;
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Audit log
|
|
// ------------------------------------------------------------------
|
|
async function renderAudit() {
|
|
content().innerHTML = `<div class="loading">Chargement…</div>`;
|
|
try {
|
|
const r = await api(`/admin/api/audit-log?page=1&pageSize=${state.audit.pageSize}`);
|
|
content().innerHTML = `
|
|
<div class="table-wrap"><table>
|
|
<thead><tr><th>Date</th><th>Admin</th><th>Action</th><th>Table</th><th>ID cible</th></tr></thead>
|
|
<tbody>
|
|
${r.items.map((x) => `
|
|
<tr>
|
|
<td>${fmtDate(x.created_at)}</td>
|
|
<td>${esc(x.admin_username)}</td>
|
|
<td>${esc(x.action)}</td>
|
|
<td>${esc(x.target_table)}</td>
|
|
<td>${esc(x.target_id)}</td>
|
|
</tr>
|
|
`).join("") || `<tr><td colspan="5" class="empty">Aucune action</td></tr>`}
|
|
</tbody>
|
|
</table></div>
|
|
`;
|
|
} catch (e) {
|
|
content().innerHTML = `<div class="empty">Erreur : ${esc(e.message)}</div>`;
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Boot
|
|
// ------------------------------------------------------------------
|
|
checkSession();
|