metalfrom.eu/apps/admin/site/app.js
Nicolas Fryder a7d7bc94de feat(admin): dashboard admin complet (auth forte, API, monitoring)
Nouveau service apps/admin (admin.metalfrom.eu / admin.dev.metalfrom.eu) :
- Frontend statique vanilla JS/CSS reprenant le design system du site
  (login, dashboard stats, table bands éditable, queue d'enrichissement,
  historique crawl_run, logs live, checkpoints, journal d'audit)
- nginx reverse-proxy /admin/api/* et /admin/auth/* vers le service api
  interne (same-origin côté navigateur, pas de CORS cross-site nécessaire
  pour le cookie de session)

apps/api :
- Nouvelle auth dédiée au dashboard, séparée du token BM_IMPORT_TOKEN
  existant : login bcrypt + session JWT en cookie httpOnly/secure/
  sameSite=strict, rate-limit + lockout après 5 échecs/15min, seeding
  du compte admin via env vars (jamais de mot de passe en clair en DB
  ou en git)
- Routes /admin/api/* : stats, queue (breakdown priorité identique au
  crawler Python), bands (recherche/tri/pagination/édition + audit log),
  crawl-runs, crawl-checkpoints, logs, audit-log
- trustProxy activé (Traefik + nginx en amont)

apps/crawler :
- log_event() écrit dans la nouvelle table crawl_log (run start/finish/
  erreurs) pour que le dashboard affiche les logs sans exposer le socket
  Docker (choix délibéré : pas de docker.sock monté, accès DB only)

migration 006_admin_dashboard.sql : admin_users, admin_login_attempts,
admin_audit_log, crawl_log

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 22:15:13 +02:00

577 lines
23 KiB
JavaScript

"use strict";
const state = {
username: null,
view: "dashboard",
bands: { page: 1, pageSize: 50, q: "", country: "", status: "", genre: "", enriched: "", 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,
};
// ------------------------------------------------------------------
// 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; }
}
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", "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; }
const renderers = {
dashboard: renderDashboard,
bands: renderBands,
queue: renderQueue,
runs: renderRuns,
logs: renderLogs,
checkpoints: renderCheckpoints,
audit: renderAudit,
};
renderers[view]();
}
window.addEventListener("hashchange", router);
function esc(s) {
return String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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
// ------------------------------------------------------------------
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 maxGenre = Math.max(1, ...r.by_genre.map((x) => x.total));
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">
<h2>Par statut</h2>
${r.by_status.map((x) => barRow(x.status, x.total, maxStatus)).join("") || emptyRow()}
</div>
<div class="card">
<h2>Par pays (top 20)</h2>
${r.by_country.map((x) => barRow(x.country, x.total, maxCountry)).join("") || emptyRow()}
</div>
<div class="card">
<h2>Par genre (top 20)</h2>
${r.by_genre.map((x) => barRow(x.genre, x.total, maxGenre)).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() {
content().innerHTML = `<div class="loading">Chargement…</div>`;
try {
const r = await api("/admin/api/queue");
const b = r.breakdown;
content().innerHTML = `
<div class="grid grid-stats">
${statCard("Nouveaux (priorité 1)", b.new_bands, "err")}
${statCard("Modifiés depuis enrich (priorité 2)", b.modified_since_enrich, "warn")}
${statCard("Legacy à ré-enrichir (priorité 3)", b.legacy_pending, "warn")}
${statCard("Stale >30j (priorité 4)", b.stale, "ok")}
</div>
<div class="card">
<h2>Derniers runs d'enrichissement</h2>
<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>Erreur</th></tr></thead>
<tbody>
${r.recent_enrich_runs.map((x) => `
<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>${esc(x.error || "")}</td>
</tr>
`).join("") || `<tr><td colspan="7" class="empty">Aucun run</td></tr>`}
</tbody>
</table></div>
</div>
`;
} catch (e) {
content().innerHTML = `<div class="empty">Erreur : ${esc(e.message)}</div>`;
}
}
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="Recherche (nom, genre, lieu)…" value="${esc(s.q)}" style="min-width:240px">
<input type="text" id="b-country" placeholder="Pays (FR, DE…)" value="${esc(s.country)}" style="width:120px">
<input type="text" id="b-status" placeholder="Statut" value="${esc(s.status)}" style="width:140px">
<input type="text" id="b-genre" placeholder="Genre" value="${esc(s.genre)}" style="width:160px">
<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>
<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.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.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: "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.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);
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>${b.formed_year || ""}</td>
<td>${b.enriched ? '<span class="badge ok">oui</span>' : '<span class="badge warn">non</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">&times;</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>`;
}
}
// ------------------------------------------------------------------
// 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();