+
+
🧹 Maintenance
+
+ 🧹 Annuler crawl-runs bloqués (>30 min)
+
+
+
+
+
⚠️ Zone dangereuse — tout recommencer
À utiliser si le géocodage est corrompu. « Reset complet » remet toutes les
- localisations en queue et efface leurs coordonnées (les appels seront refaits, cache réutilisé
- quand possible). « Purger cache Nominatim » supprime les vieux géocodages de l'ancien pipeline
- pour forcer un re-géocodage Geoapify frais.
+ localisations en queue et efface leurs coordonnées (cache réutilisé quand
+ possible). « Purger cache Nominatim » supprime les vieux géocodages de
+ l'ancien pipeline pour forcer un re-géocodage Geoapify frais.
-
+
🔥 Reset complet du géocodage
🧹 Purger cache Nominatim
-
-
20 derniers géocodages (bands)
-
-
- `;
+
- // Nouveau pipeline — actions
- const makeLocAction = (btnId, fbId, url, body, confirmMsg) => {
- document.getElementById(btnId).addEventListener("click", async () => {
- if (confirmMsg && !confirm(confirmMsg)) return;
- const btn = document.getElementById(btnId);
- btn.disabled = true;
- const fb = document.getElementById(fbId);
- fb.textContent = "En cours…"; fb.style.color = "var(--muted)";
- try {
- const r = await api(url, { method: "POST", body: JSON.stringify(body || {}) });
- fb.textContent = `✓ ${r.count} entrée(s) modifiée(s).`;
- fb.style.color = "var(--ok)";
- await loadGeocoding();
- } catch (e) {
- fb.textContent = `Erreur: ${e.message}`; fb.style.color = "var(--err)";
- } finally { btn.disabled = false; }
- });
- };
+
+
Checkpoints crawler (lecture seule)
+
+
+ `;
- document.getElementById("loc-enqueue").addEventListener("click", async () => {
- const btn = document.getElementById("loc-enqueue");
+ // Crawl
+ document.querySelectorAll("[data-job]").forEach((btn) => {
+ btn.addEventListener("click", async () => {
+ const job_type = btn.dataset.job;
btn.disabled = true;
- const fb = document.getElementById("loc-feedback");
- fb.textContent = "Job envoyé, le daemon va démarrer l'enqueue (peut prendre quelques secondes)…";
- fb.style.color = "var(--muted)";
+ const fb = document.getElementById("job-feedback");
+ fb.textContent = "Envoi…"; fb.style.color = "var(--muted)";
try {
- await api("/admin/api/job-triggers", { method: "POST", body: JSON.stringify({ job_type: "geocoder_enqueue" }) });
- fb.textContent = "✓ Job geocoder_enqueue créé — le daemon parse les bands en arrière-plan.";
+ 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 (visible dans Activité).`;
fb.style.color = "var(--ok)";
- setTimeout(() => loadGeocoding(), 3000);
} catch (e) {
fb.textContent = `Erreur: ${e.message}`; fb.style.color = "var(--err)";
} finally { btn.disabled = false; }
});
+ });
- makeLocAction("loc-reset-errors", "loc-feedback", "/admin/api/locations/reset-errors", {}, null);
- makeLocAction("loc-reset-llm", "loc-feedback", "/admin/api/locations/reset-llm", {}, "Remettre en queue les entrées llm_needed et manual ?");
- makeLocAction("loc-requeue-all", "loc-feedback", "/admin/api/locations/requeue-all", { include_done: true }, "Re-queue TOUTES les band_locations (erreurs + LLM + manual + done) ?");
+ // Géocodage — actions simples
+ const makeLocAction = (btnId, fbId, url, body, confirmMsg, successMsg) => {
+ document.getElementById(btnId).addEventListener("click", async () => {
+ if (confirmMsg && !confirm(confirmMsg)) return;
+ const btn = document.getElementById(btnId);
+ btn.disabled = true;
+ const fb = document.getElementById(fbId);
+ fb.textContent = "En cours…"; fb.style.color = "var(--muted)";
+ try {
+ const r = await api(url, { method: "POST", body: JSON.stringify(body || {}) });
+ fb.textContent = successMsg ? successMsg(r) : `✓ ${r.count} entrée(s) modifiée(s).`;
+ fb.style.color = "var(--ok)";
+ } catch (e) {
+ fb.textContent = `Erreur: ${e.message}`; fb.style.color = "var(--err)";
+ } finally { btn.disabled = false; }
+ });
+ };
- // Zone dangereuse — reset complet & purge cache
- makeLocAction("loc-reset-all", "danger-feedback", "/admin/api/locations/reset-all", {},
- "⚠️ RESET COMPLET : remettre TOUTES les localisations en queue et effacer leurs coordonnées ? Le worker va tout re-géocoder (cache réutilisé quand possible).");
- makeLocAction("cache-purge-nominatim", "danger-feedback", "/admin/api/geocode-cache/purge-nominatim", {},
- "⚠️ Supprimer toutes les entrées de cache Nominatim (ancien pipeline) ? Ces lieux seront re-géocodés via Geoapify (coûte des appels API).");
- }
+ document.getElementById("loc-enqueue").addEventListener("click", async () => {
+ const btn = document.getElementById("loc-enqueue");
+ btn.disabled = true;
+ const fb = document.getElementById("geo-feedback");
+ fb.textContent = "Job envoyé, le daemon va démarrer l'enqueue (peut prendre quelques secondes)…";
+ fb.style.color = "var(--muted)";
+ try {
+ await api("/admin/api/job-triggers", { method: "POST", body: JSON.stringify({ job_type: "geocoder_enqueue" }) });
+ fb.textContent = "✓ Job geocoder_enqueue créé — suivre sa progression dans Activité.";
+ fb.style.color = "var(--ok)";
+ } catch (e) {
+ fb.textContent = `Erreur: ${e.message}`; fb.style.color = "var(--err)";
+ } finally { btn.disabled = false; }
+ });
- await loadGeocoding();
- if (state.geoTimer) clearInterval(state.geoTimer);
- state.geoTimer = setInterval(async () => {
- await loadGeocoding();
- const el = document.getElementById("geo-refresh-status");
- if (el) el.textContent = `Actualisé ${new Date().toLocaleTimeString("fr-FR")}`;
- }, 30000);
-}
+ makeLocAction("loc-reset-errors", "geo-feedback", "/admin/api/locations/reset-errors", {}, null);
+ makeLocAction("loc-reset-llm", "geo-feedback", "/admin/api/locations/reset-llm", {}, "Remettre en queue les entrées llm_needed et manual ?");
+ makeLocAction("loc-requeue-all", "geo-feedback", "/admin/api/locations/requeue-all", { include_done: true }, "Re-queue TOUTES les band_locations (erreurs + LLM + manual + done) ?");
-const LOC_STATUS_LABELS = {
- queued: "En queue", processing: "En cours", done: "Done",
- error: "Erreur", llm_needed: "LLM needed", manual: "Manuel",
- country_only: "Pays (centroïde)", skipped: "Ignoré",
-};
+ // Zone dangereuse
+ makeLocAction("loc-reset-all", "danger-feedback", "/admin/api/locations/reset-all", {},
+ "⚠️ RESET COMPLET : remettre TOUTES les localisations en queue et effacer leurs coordonnées ? Le worker va tout re-géocoder (cache réutilisé quand possible).");
+ makeLocAction("cache-purge-nominatim", "danger-feedback", "/admin/api/geocode-cache/purge-nominatim", {},
+ "⚠️ Supprimer toutes les entrées de cache Nominatim (ancien pipeline) ? Ces lieux seront re-géocodés via Geoapify (coûte des appels API).");
-async function loadGeocoding() {
+ // Maintenance
+ document.getElementById("cmd-cleanup").addEventListener("click", async () => {
+ const btn = document.getElementById("cmd-cleanup");
+ btn.disabled = true;
+ const fb = document.getElementById("maintenance-feedback");
+ fb.textContent = "Nettoyage…"; fb.style.color = "var(--muted)";
+ try {
+ const r = await api("/admin/api/crawl-runs/cleanup", { method: "POST", body: JSON.stringify({ older_than_minutes: 30 }) });
+ fb.textContent = `✓ ${r.cleaned} run(s) annulé(s).`;
+ fb.style.color = "var(--ok)";
+ } catch (e) {
+ fb.textContent = `Erreur: ${e.message}`; fb.style.color = "var(--err)";
+ } finally { btn.disabled = false; }
+ });
+
+ // Checkpoints (lecture seule)
try {
- const r = await api("/admin/api/geocoding");
-
- // Nouveau pipeline — band_locations
- const locs = r.locations || [];
- const locTotal = locs.reduce((s, x) => s + x.n, 0);
- const locDone = (locs.find((x) => x.status === "done")?.n || 0) +
- (locs.find((x) => x.status === "country_only")?.n || 0);
- const locLlm = locs.find((x) => x.status === "llm_needed")?.n || 0;
- const locManual = locs.find((x) => x.status === "manual")?.n || 0;
- const locErr = locs.find((x) => x.status === "error")?.n || 0;
- const locPct = locTotal ? Math.round((locDone / locTotal) * 100) : 0;
- const rawCost = Number(r.llm_cache?.total_cost_usd);
- const llmCost = (Number.isFinite(rawCost) ? rawCost : 0).toFixed(4);
-
- const llmNull = r.llm_cache?.n_null || 0;
- const locStatsEl = document.getElementById("loc-stats");
- if (locStatsEl) locStatsEl.innerHTML = `
- ${statCard("Total locations", locTotal)}
- ${statCard("Géocodées", locDone, "ok")}
- ${statCard("LLM needed", locLlm, locLlm ? "warn" : "")}
- ${statCard("Manuel", locManual, locManual ? "warn" : "")}
- ${statCard("Erreurs", locErr, locErr ? "err" : "")}
- ${statCard("LLM cache", r.llm_cache?.n || 0)}
- ${statCard("LLM null", llmNull, llmNull ? "warn" : "")}
- ${statCard("Coût LLM (USD)", "$" + llmCost)}
+ const r = await api("/admin/api/crawl-checkpoints");
+ document.getElementById("checkpoints-wrap").innerHTML = `
+
+ Clé Valeur Mis à jour
+
+ ${r.items.map((x) => `${esc(x.key)} ${esc(x.value || "—")} ${fmtDate(x.updated_at)} `).join("") || `Aucun checkpoint `}
+
+
`;
- const locBar = document.getElementById("loc-bar");
- if (locBar) locBar.style.width = locPct + "%";
- const locPctEl = document.getElementById("loc-pct");
- if (locPctEl) locPctEl.textContent = `${locPct}% — ${locDone.toLocaleString("fr-FR")} / ${locTotal.toLocaleString("fr-FR")} — statuts: ${locs.map((x) => `${LOC_STATUS_LABELS[x.status] || x.status} ${x.n}`).join(", ")}`;
-
- // Breakdown par provider (source du géocodage) + par modèle LLM
- const provEl = document.getElementById("loc-providers");
- if (provEl) {
- const provTxt = (r.providers || []).map((p) => `${esc(p.provider)}: ${p.n.toLocaleString("fr-FR")}${p.avg_conf != null ? ` (conf~${p.avg_conf})` : ""}`).join(" · ");
- const modelTxt = (r.llm_models || []).map((m) => `${esc(m.model)}: ${m.n} (null ${m.n_null})`).join(" · ");
- provEl.innerHTML = `
Sources géocodage : ${provTxt || "—"}
` +
- (modelTxt ? `
Modèles LLM : ${modelTxt}
` : "") +
- `
Cache géocodage : ${(r.cache_size || 0).toLocaleString("fr-FR")} entrées
`;
- }
-
- const recentWrap = document.getElementById("geo-recent-wrap");
- if (recentWrap) recentWrap.innerHTML = `
- MA ID Nom Pays Provider Query Géocodé le Erreur
-
- ${r.recent.map((x) => `
- ${x.ma_id}
- ${esc(x.name)}
- ${esc(x.country || "")}
- ${esc(x.geocode_provider || "")}
- ${esc(x.geocode_query || "")}
- ${fmtDate(x.geocoded_at)}
- ${esc(x.geocode_error || "")}
- `).join("") || `Aucun `}
-
-
`;
} catch (e) {
- const pctEl = document.getElementById("loc-pct");
- if (pctEl) pctEl.textContent = `Erreur : ${esc(e.message)}`;
+ document.getElementById("checkpoints-wrap").innerHTML = `
Erreur : ${esc(e.message)}
`;
}
}
@@ -1259,210 +1058,6 @@ async function loadLLM() {
}
}
-// ------------------------------------------------------------------
-// Centre de commandes (Jobs + Géocodeur + Maintenance + Live log)
-// ------------------------------------------------------------------
-async function renderJobs() {
- content().innerHTML = `
-
-
-
-
🕷 Crawler
-
Consommés par le crawler dans sa prochaine itération (~1 min).
-
- ▶ Enrich (500 bands)
- ▶ Crawl incrémental (créations + modifs)
- ▶ Crawl complet Europe
-
-
-
-
-
-
🗺 Géocodeur
-
Le géocodeur et le worker Groq tournent en continu. L'enqueue parse les bands en band_locations.
-
-
▶ Lancer l'enqueue band_locations
-
Les resets/purges du géocodage sont dans l'onglet Géocodage .
-
-
-
-
-
-
🧹 Maintenance
-
- 🧹 Annuler crawl-runs bloqués (>30 min)
-
-
-
-
-
-
-
-
Historique des jobs déclenchés
-
-
-
-
-
-
Journal en direct (20 dernières lignes — auto-refresh 10s)
-
-
-
-
- `;
-
- // Crawler jobs
- 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…"; fb.style.color = "var(--muted)";
- 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 Promise.all([loadJobsList(), loadCmdLogTail()]);
- } catch (e) {
- fb.textContent = `Erreur: ${e.message}`; fb.style.color = "var(--err)";
- } finally { btn.disabled = false; }
- });
- });
-
- // Geocoder enqueue
- document.getElementById("cmd-enqueue").addEventListener("click", async () => {
- const btn = document.getElementById("cmd-enqueue");
- btn.disabled = true;
- const fb = document.getElementById("geo-cmd-feedback");
- fb.textContent = "Envoi du job…"; fb.style.color = "var(--muted)";
- try {
- await api("/admin/api/job-triggers", { method: "POST", body: JSON.stringify({ job_type: "geocoder_enqueue" }) });
- fb.textContent = "✓ Job geocoder_enqueue créé — le daemon parse les bands en arrière-plan.";
- fb.style.color = "var(--ok)";
- await loadCmdLogTail();
- } catch (e) {
- fb.textContent = `Erreur: ${e.message}`; fb.style.color = "var(--err)";
- } finally { btn.disabled = false; }
- });
-
- // Cleanup
- document.getElementById("cmd-cleanup").addEventListener("click", async () => {
- const btn = document.getElementById("cmd-cleanup");
- btn.disabled = true;
- const fb = document.getElementById("maintenance-feedback");
- fb.textContent = "Nettoyage…"; fb.style.color = "var(--muted)";
- try {
- const r = await api("/admin/api/crawl-runs/cleanup", { method: "POST", body: JSON.stringify({ older_than_minutes: 30 }) });
- fb.textContent = `✓ ${r.cleaned} run(s) annulé(s).`;
- fb.style.color = "var(--ok)";
- await loadCmdLogTail();
- } catch (e) {
- fb.textContent = `Erreur: ${e.message}`; fb.style.color = "var(--err)";
- } finally { btn.disabled = false; }
- });
-
- await Promise.all([loadJobsList(), loadCmdLogTail()]);
- if (state.cmdLogTimer) clearInterval(state.cmdLogTimer);
- state.cmdLogTimer = setInterval(async () => {
- await loadCmdLogTail();
- const ts = document.getElementById("cmd-log-ts");
- if (ts) ts.textContent = `Actualisé à ${new Date().toLocaleTimeString("fr-FR")}`;
- }, 10000);
-}
-
-async function loadJobsList() {
- const el = document.getElementById("jobs-list");
- if (!el) return;
- try {
- const r = await api("/admin/api/job-triggers");
- el.innerHTML = `
- ID Type Statut Demandé par Créé le Démarré Terminé Erreur
-
- ${r.items.map((x) => `
- ${x.id}
- ${esc(x.job_type)}
- ${statusBadge(x.status)}
- ${esc(x.requested_by || "—")}
- ${fmtDate(x.created_at)}
- ${fmtDate(x.started_at)}
- ${fmtDate(x.finished_at)}
- ${esc(x.error || "")}
- `).join("") || `Aucun job `}
-
-
`;
- } catch (e) {
- el.innerHTML = `
Erreur : ${esc(e.message)}
`;
- }
-}
-
-async function loadCmdLogTail() {
- const el = document.getElementById("cmd-log-tail");
- if (!el) return;
- try {
- const r = await api("/admin/api/logs?pageSize=20&page=1");
- el.innerHTML = `
- Date Niveau Message
-
- ${r.items.map((x) => `
- ${fmtDate(x.created_at)}
- ${esc(x.level)}
- ${esc(x.message)}
- `).join("") || `Aucun log `}
-
-
`;
- } catch (e) {
- el.innerHTML = `
Erreur : ${esc(e.message)}
`;
- }
-}
-
-// ------------------------------------------------------------------
-// Checkpoints
-// ------------------------------------------------------------------
-async function renderCheckpoints() {
- content().innerHTML = `
Chargement…
`;
- try {
- const r = await api("/admin/api/crawl-checkpoints");
- content().innerHTML = `
-
- Clé Valeur Mis à jour
-
- ${r.items.map((x) => `${esc(x.key)} ${esc(x.value || "—")} ${fmtDate(x.updated_at)} `).join("") || `Aucun checkpoint `}
-
-
- `;
- } catch (e) {
- content().innerHTML = `
Erreur : ${esc(e.message)}
`;
- }
-}
-
-// ------------------------------------------------------------------
-// Audit log
-// ------------------------------------------------------------------
-async function renderAudit() {
- content().innerHTML = `
Chargement…
`;
- try {
- const r = await api(`/admin/api/audit-log?page=1&pageSize=${state.audit.pageSize}`);
- content().innerHTML = `
-
- Date Admin Action Table ID cible
-
- ${r.items.map((x) => `
-
- ${fmtDate(x.created_at)}
- ${esc(x.admin_username)}
- ${esc(x.action)}
- ${esc(x.target_table)}
- ${esc(x.target_id)}
-
- `).join("") || `Aucune action `}
-
-
- `;
- } catch (e) {
- content().innerHTML = `
Erreur : ${esc(e.message)}
`;
- }
-}
-
// ------------------------------------------------------------------
// Boot
// ------------------------------------------------------------------
diff --git a/apps/admin/site/index.html b/apps/admin/site/index.html
index 5fd26cf..a7a51f7 100644
--- a/apps/admin/site/index.html
+++ b/apps/admin/site/index.html
@@ -30,18 +30,11 @@
Admin
- Dashboard
- Bands
- Conflits
- Queue
- Crawl runs
- Monitor
- Historique
- Géocodage
+ Vue d'ensemble
+ Groupes
+ Activité
+ Actions
LLM
- Jobs
- Checkpoints
- Audit
diff --git a/apps/api/src/adminRoutes.js b/apps/api/src/adminRoutes.js
index 9f299f4..67002f0 100644
--- a/apps/api/src/adminRoutes.js
+++ b/apps/api/src/adminRoutes.js
@@ -110,7 +110,7 @@ export default async function adminRoutes(fastify, opts) {
// ------------------------------------------------------------------
fastify.get("/admin/api/bands", async (req, reply) => {
try {
- const { q, location_q, country, status, genre, themes_q, enriched, has_lat, has_location, sort, dir } = req.query || {};
+ const { q, location_q, country, status, genre, themes_q, enriched, has_lat, has_location, has_conflict, sort, dir } = req.query || {};
const { page, pageSize, offset } = pagination(req.query || {});
const where = [];
@@ -154,6 +154,7 @@ export default async function adminRoutes(fastify, opts) {
if (has_lat === "false" || has_lat === "0") where.push(`lat IS NULL`);
if (has_location === "true" || has_location === "1") where.push(`location_text IS NOT NULL AND location_text != ''`);
if (has_location === "false" || has_location === "0") where.push(`(location_text IS NULL OR location_text = '')`);
+ if (has_conflict === "true" || has_conflict === "1") where.push(`crawler_pending IS NOT NULL AND crawler_pending <> '{}'::jsonb`);
if (themes_q) {
const tq = String(themes_q).trim().slice(0, 100);
if (tq.length >= 1) { where.push(`themes ILIKE $${i}`); vals.push(`%${tq}%`); i++; }
@@ -302,36 +303,10 @@ export default async function adminRoutes(fastify, opts) {
});
// ------------------------------------------------------------------
- // Crawl runs / checkpoints
+ // Checkpoints
+ // (l'historique des crawl_run est exposé de façon unifiée par
+ // GET /admin/api/activity, avec le détail logs via GET /admin/api/logs?run_id=)
// ------------------------------------------------------------------
- fastify.get("/admin/api/crawl-runs", async (req, reply) => {
- try {
- const { run_type, status } = req.query || {};
- const { page, pageSize, offset } = pagination(req.query || {});
- const where = [];
- const vals = [];
- let i = 1;
- if (run_type) { where.push(`run_type = $${i}`); vals.push(run_type); i++; }
- if (status) { where.push(`status = $${i}`); vals.push(status); i++; }
- const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
-
- const countRes = await pool.query(`SELECT count(*)::int AS total FROM crawl_run ${whereSql}`, vals);
- vals.push(pageSize, offset);
- const dataRes = await pool.query(`
- SELECT id, run_type, countries, status, started_at, finished_at,
- bands_seen, bands_new, bands_updated, bands_enriched, error
- FROM crawl_run ${whereSql}
- ORDER BY started_at DESC
- LIMIT $${i} OFFSET $${i + 1}
- `, vals);
-
- return { ok: true, items: dataRes.rows, total: countRes.rows[0].total, page, pageSize };
- } catch (err) {
- fastify.log.error(err);
- return reply.code(500).send({ ok: false, error: "Erreur crawl runs" });
- }
- });
-
fastify.get("/admin/api/crawl-checkpoints", async (req, reply) => {
try {
const r = await pool.query(`SELECT key, value, updated_at FROM crawl_checkpoint ORDER BY key`);
@@ -397,27 +372,11 @@ export default async function adminRoutes(fastify, opts) {
});
// ------------------------------------------------------------------
- // Conflits : champs verrouillés avec valeur différente côté crawler
+ // Conflits : champs verrouillés avec valeur différente côté crawler.
+ // La liste bulk est remplacée par le filtre has_conflict=1 sur
+ // GET /admin/api/bands (visible dans l'onglet Groupes) ; la résolution
+ // champ par champ reste ici et est utilisée depuis le modal band.
// ------------------------------------------------------------------
- fastify.get("/admin/api/conflicts", async (req, reply) => {
- try {
- const { page, pageSize, offset } = pagination(req.query || {});
- const r = await pool.query(`
- SELECT ma_id, name, country, status, genre, themes, formed_year,
- locked_fields, crawler_pending
- FROM bands
- WHERE crawler_pending IS NOT NULL AND crawler_pending != '{}'::jsonb
- ORDER BY updated_at DESC
- LIMIT $1 OFFSET $2
- `, [pageSize, offset]);
- const count = await pool.query(`SELECT count(*)::int AS n FROM bands WHERE crawler_pending IS NOT NULL AND crawler_pending != '{}'::jsonb`);
- return { ok: true, items: r.rows, total: count.rows[0].n, page, pageSize };
- } catch (err) {
- fastify.log.error(err);
- return reply.code(500).send({ ok: false, error: "Erreur conflicts" });
- }
- });
-
fastify.post("/admin/api/bands/:ma_id/resolve-conflict", async (req, reply) => {
try {
const id = Number(req.params.ma_id);
@@ -780,22 +739,67 @@ export default async function adminRoutes(fastify, opts) {
}
});
+ // Le journal d'audit (actions admin) est désormais exposé de façon unifiée
+ // par GET /admin/api/activity (type=admin_action), avec crawl_run côté
+ // system. writeAuditLog() continue d'alimenter admin_audit_log ci-dessous.
+
// ------------------------------------------------------------------
- // Journal d'audit (actions admin)
+ // Activité — liste unifiée : crawl_run (crawl/enrich/geocoder_enqueue,
+ // manuel ou planifié) + admin_audit_log (actions admin). Une seule liste
+ // chronologique cliquable pour l'admin ; le détail d'un 'run' se récupère
+ // via GET /admin/api/logs?run_id=, celui d'un 'admin_action' est déjà
+ // inclus (before/after).
// ------------------------------------------------------------------
- fastify.get("/admin/api/audit-log", async (req, reply) => {
+ fastify.get("/admin/api/activity", async (req, reply) => {
try {
+ const { type, status } = req.query || {};
const { page, pageSize, offset } = pagination(req.query || {});
- const r = await pool.query(`
- SELECT id, admin_username, action, target_table, target_id, before_data, after_data, created_at
+
+ const base = `
+ SELECT
+ 'run'::text AS type, id, run_type AS subtype, status,
+ NULL::text AS actor, started_at AS ts, finished_at,
+ jsonb_build_object(
+ 'bands_seen', bands_seen, 'bands_new', bands_new,
+ 'bands_updated', bands_updated, 'bands_enriched', bands_enriched,
+ 'error', error, 'countries', countries
+ ) AS summary
+ FROM crawl_run
+ UNION ALL
+ SELECT
+ 'admin_action'::text AS type, id, action AS subtype, 'done'::text AS status,
+ admin_username AS actor, created_at AS ts, created_at AS finished_at,
+ jsonb_build_object(
+ 'target_table', target_table, 'target_id', target_id,
+ 'before', before_data, 'after', after_data
+ ) AS summary
FROM admin_audit_log
- ORDER BY created_at DESC
- LIMIT $1 OFFSET $2
- `, [pageSize, offset]);
- return { ok: true, items: r.rows, page, pageSize };
+ `;
+
+ const where = [];
+ const vals = [];
+ let i = 1;
+ if (type) { where.push(`type = $${i}`); vals.push(String(type)); i++; }
+ if (status) { where.push(`status = $${i}`); vals.push(String(status)); i++; }
+ const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
+
+ const countSql = `SELECT count(*)::int AS total FROM (${base}) t ${whereSql}`;
+ const dataSql = `
+ SELECT * FROM (${base}) t
+ ${whereSql}
+ ORDER BY ts DESC
+ LIMIT $${i} OFFSET $${i + 1}
+ `;
+
+ const [countRes, dataRes] = await Promise.all([
+ pool.query(countSql, vals),
+ pool.query(dataSql, [...vals, pageSize, offset]),
+ ]);
+
+ return { ok: true, items: dataRes.rows, total: countRes.rows[0].total, page, pageSize };
} catch (err) {
fastify.log.error(err);
- return reply.code(500).send({ ok: false, error: "Erreur audit log" });
+ return reply.code(500).send({ ok: false, error: "Erreur activité" });
}
});
}