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:
parent
1ae5aa4b57
commit
222e71a608
3 changed files with 513 additions and 921 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -30,18 +30,11 @@
|
||||||
<header id="topbar">
|
<header id="topbar">
|
||||||
<div class="brand">Admin</div>
|
<div class="brand">Admin</div>
|
||||||
<nav id="nav" class="nav">
|
<nav id="nav" class="nav">
|
||||||
<a href="#/dashboard" data-view="dashboard">Dashboard</a>
|
<a href="#/dashboard" data-view="dashboard">Vue d'ensemble</a>
|
||||||
<a href="#/bands" data-view="bands">Bands</a>
|
<a href="#/bands" data-view="bands">Groupes</a>
|
||||||
<a href="#/conflicts" data-view="conflicts">Conflits</a>
|
<a href="#/activity" data-view="activity">Activité</a>
|
||||||
<a href="#/queue" data-view="queue">Queue</a>
|
<a href="#/actions" data-view="actions">Actions</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="#/llm" data-view="llm">LLM</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>
|
</nav>
|
||||||
<div class="topbar-right">
|
<div class="topbar-right">
|
||||||
<span id="whoami" class="whoami"></span>
|
<span id="whoami" class="whoami"></span>
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,7 @@ export default async function adminRoutes(fastify, opts) {
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
fastify.get("/admin/api/bands", async (req, reply) => {
|
fastify.get("/admin/api/bands", async (req, reply) => {
|
||||||
try {
|
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 { page, pageSize, offset } = pagination(req.query || {});
|
||||||
|
|
||||||
const where = [];
|
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_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 === "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_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) {
|
if (themes_q) {
|
||||||
const tq = String(themes_q).trim().slice(0, 100);
|
const tq = String(themes_q).trim().slice(0, 100);
|
||||||
if (tq.length >= 1) { where.push(`themes ILIKE $${i}`); vals.push(`%${tq}%`); i++; }
|
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) => {
|
fastify.get("/admin/api/crawl-checkpoints", async (req, reply) => {
|
||||||
try {
|
try {
|
||||||
const r = await pool.query(`SELECT key, value, updated_at FROM crawl_checkpoint ORDER BY key`);
|
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) => {
|
fastify.post("/admin/api/bands/:ma_id/resolve-conflict", async (req, reply) => {
|
||||||
try {
|
try {
|
||||||
const id = Number(req.params.ma_id);
|
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 {
|
try {
|
||||||
|
const { type, status } = req.query || {};
|
||||||
const { page, pageSize, offset } = pagination(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
|
FROM admin_audit_log
|
||||||
ORDER BY created_at DESC
|
`;
|
||||||
LIMIT $1 OFFSET $2
|
|
||||||
`, [pageSize, offset]);
|
const where = [];
|
||||||
return { ok: true, items: r.rows, page, pageSize };
|
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) {
|
} catch (err) {
|
||||||
fastify.log.error(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é" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue