refactor(admin): nav 12 onglets → 5, liste d'activité unifiée avec logs

L'admin mélangeait pipeline legacy et récent (onglet "Queue" pointait vers
geocode_queue mort → erreur "Cannot read properties of undefined (reading
'reduce')"), dispersait les logs (Monitor/Historique/Jobs) et les actions
(Géocodage/Jobs/Checkpoints) sur des onglets séparés sans lien entre eux.

Nouvelle nav (5 onglets) :
- Vue d'ensemble : stats bands + genre/pays (ex-Dashboard) + cartes live
  auto-refresh 15s (crawl en cours, géocodage, jobs en attente, file
  d'enrichissement) — fusion Dashboard+Monitor+Queue.
- Groupes : liste bands inchangée + filtre "Conflits seulement" (nouveau
  has_conflict sur GET /admin/api/bands) ; le modal band affiche maintenant
  une section "Conflits en attente" resolvable inline (garder ma valeur /
  accepter Metal Archives) — remplace l'onglet Conflits séparé.
- Activité : liste chronologique unique (GET /admin/api/activity, fusion
  crawl_run tous types + admin_audit_log), cliquable → dialog avec logs
  complets du run (GET /admin/api/logs?run_id=) ou diff avant/après pour une
  action admin. Remplace Crawl runs + Historique + Audit + la liste de jobs
  de l'ex-onglet Jobs.
- Actions : toutes les commandes (crawl, géocodage, maintenance) sur une
  seule page, groupées, chaque bouton vérifié contre son endpoint réel +
  checkpoints en lecture seule. Remplace Jobs + les boutons de Géocodage.
- LLM : inchangé.

Backend
- endpoints morts supprimés (audit-log, conflicts liste, crawl-runs liste —
  tous remplacés par /admin/api/activity ou le filtre has_conflict) ;
  vérifié 1:1 qu'aucun appel front ne pointe vers un endpoint disparu et
  qu'aucun endpoint restant n'est orphelin.
- GET /admin/api/bands : nouveau filtre has_conflict.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Nicolas Fryder 2026-07-02 21:25:14 +02:00
parent 1ae5aa4b57
commit 222e71a608
3 changed files with 513 additions and 921 deletions

File diff suppressed because it is too large Load diff

View file

@ -30,18 +30,11 @@
<header id="topbar">
<div class="brand">Admin</div>
<nav id="nav" class="nav">
<a href="#/dashboard" data-view="dashboard">Dashboard</a>
<a href="#/bands" data-view="bands">Bands</a>
<a href="#/conflicts" data-view="conflicts">Conflits</a>
<a href="#/queue" data-view="queue">Queue</a>
<a href="#/runs" data-view="runs">Crawl runs</a>
<a href="#/monitor" data-view="monitor">Monitor</a>
<a href="#/logs" data-view="logs">Historique</a>
<a href="#/geocoding" data-view="geocoding">Géocodage</a>
<a href="#/dashboard" data-view="dashboard">Vue d'ensemble</a>
<a href="#/bands" data-view="bands">Groupes</a>
<a href="#/activity" data-view="activity">Activité</a>
<a href="#/actions" data-view="actions">Actions</a>
<a href="#/llm" data-view="llm">LLM</a>
<a href="#/jobs" data-view="jobs">Jobs</a>
<a href="#/checkpoints" data-view="checkpoints">Checkpoints</a>
<a href="#/audit" data-view="audit">Audit</a>
</nav>
<div class="topbar-right">
<span id="whoami" class="whoami"></span>

View file

@ -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é" });
}
});
}