diff --git a/apps/admin/site/app.js b/apps/admin/site/app.js
index d23ba5c..f0eb9db 100644
--- a/apps/admin/site/app.js
+++ b/apps/admin/site/app.js
@@ -11,6 +11,11 @@ const state = {
queueTimer: null,
geoTimer: null,
cmdLogTimer: null,
+ monitorTimer: null,
+ monitorLogs: [],
+ monitorLastId: 0,
+ monitorPaused: false,
+ monitorInterval: 5000,
};
// ------------------------------------------------------------------
@@ -43,6 +48,7 @@ function showLogin() {
if (state.queueTimer) { clearInterval(state.queueTimer); state.queueTimer = null; }
if (state.geoTimer) { clearInterval(state.geoTimer); state.geoTimer = null; }
if (state.cmdLogTimer) { clearInterval(state.cmdLogTimer); state.cmdLogTimer = null; }
+ if (state.monitorTimer) { clearInterval(state.monitorTimer); state.monitorTimer = null; }
}
function showApp() {
@@ -98,7 +104,7 @@ document.getElementById("logout-btn").addEventListener("click", async () => {
// ------------------------------------------------------------------
// Router
// ------------------------------------------------------------------
-const VIEWS = ["dashboard", "bands", "queue", "runs", "logs", "geocoding", "conflicts", "jobs", "checkpoints", "audit"];
+const VIEWS = ["dashboard", "bands", "queue", "runs", "monitor", "logs", "geocoding", "conflicts", "jobs", "checkpoints", "audit"];
function router() {
const hash = (location.hash || "#/dashboard").replace("#/", "");
@@ -111,12 +117,14 @@ function router() {
if (state.queueTimer) { clearInterval(state.queueTimer); state.queueTimer = null; }
if (state.geoTimer) { clearInterval(state.geoTimer); state.geoTimer = null; }
if (state.cmdLogTimer) { clearInterval(state.cmdLogTimer); state.cmdLogTimer = null; }
+ if (state.monitorTimer) { clearInterval(state.monitorTimer); state.monitorTimer = null; }
const renderers = {
dashboard: renderDashboard,
bands: renderBands,
conflicts: renderConflicts,
queue: renderQueue,
runs: renderRuns,
+ monitor: renderMonitor,
logs: renderLogs,
geocoding: renderGeocoding,
jobs: renderJobs,
@@ -558,7 +566,238 @@ async function loadRuns() {
}
// ------------------------------------------------------------------
-// Logs
+// Live Monitor
+// ------------------------------------------------------------------
+async function renderMonitor() {
+ state.monitorLogs = [];
+ state.monitorLastId = 0;
+ state.monitorPaused = false;
+
+ content().innerHTML = `
+
+
+
+
+
+
+
Journal en direct
+
+
+
+
+
+
+ `;
+
+ document.getElementById("mon-pause-btn").addEventListener("click", () => {
+ state.monitorPaused = !state.monitorPaused;
+ const btn = document.getElementById("mon-pause-btn");
+ btn.textContent = state.monitorPaused ? "▶ Reprendre" : "⏸ Pause";
+ btn.style.borderColor = state.monitorPaused ? "rgba(198,26,26,0.5)" : "";
+ });
+
+ document.getElementById("mon-clear-btn").addEventListener("click", () => {
+ state.monitorLogs = [];
+ state.monitorLastId = 0;
+ renderMonitorStream();
+ });
+
+ document.getElementById("mon-level").addEventListener("input", renderMonitorStream);
+ document.getElementById("mon-search").addEventListener("input", renderMonitorStream);
+
+ document.getElementById("mon-interval").addEventListener("change", (e) => {
+ state.monitorInterval = Number(e.target.value) || 5000;
+ if (state.monitorTimer) clearInterval(state.monitorTimer);
+ state.monitorTimer = setInterval(tickMonitor, state.monitorInterval);
+ });
+
+ await tickMonitor();
+ state.monitorTimer = setInterval(tickMonitor, state.monitorInterval);
+}
+
+async function tickMonitor() {
+ if (state.monitorPaused) return;
+ const dot = document.getElementById("mon-dot");
+ const tsEl = document.getElementById("mon-ts");
+ try {
+ const [live, logs] = await Promise.all([
+ api("/admin/api/live"),
+ api(`/admin/api/logs?pageSize=100${state.monitorLastId ? `&min_id=${state.monitorLastId}` : ""}`),
+ ]);
+
+ renderMonitorStatus(live);
+
+ if (logs.items.length) {
+ const maxId = Math.max(...logs.items.map(x => x.id));
+ if (maxId > state.monitorLastId) state.monitorLastId = maxId;
+ state.monitorLogs = [...logs.items, ...state.monitorLogs].slice(0, 500);
+ renderMonitorStream(logs.items.length);
+ } else if (!state.monitorLogs.length) {
+ renderMonitorStream(0);
+ }
+
+ if (dot) { dot.style.background = "var(--ok)"; dot.classList.add("live"); }
+ if (tsEl) tsEl.textContent = `Actualisé à ${new Date().toLocaleTimeString("fr-FR")}`;
+ } catch (e) {
+ if (dot) { dot.style.background = "var(--err)"; dot.classList.remove("live"); }
+ if (tsEl) tsEl.textContent = `Erreur : ${e.message}`;
+ }
+}
+
+function renderMonitorStatus(live) {
+ const el = document.getElementById("mon-status");
+ if (!el) return;
+
+ // --- Crawl runs ---
+ const runsHtml = live.active_runs.length
+ ? live.active_runs.map(r => {
+ const elapsed = Math.round((Date.now() - new Date(r.started_at)) / 60000);
+ return `
+
+
▶ ${esc(r.run_type)}
+
+ ${elapsed}min · ${r.bands_seen} vus · ${r.bands_new} nouveaux · ${r.bands_enriched} enrichis
+
+ ${r.error ? `
${esc(r.error)}
` : ""}
+
+
+
`;
+ }).join("")
+ : `Aucun run actif
`;
+
+ // --- Géocodeur ---
+ const gTotal = live.geo_queue.reduce((s, x) => s + x.n, 0);
+ const gDone = live.geo_queue.find(x => x.status === "done")?.n || 0;
+ const gQ = live.geo_queue.find(x => x.status === "queued")?.n || 0;
+ const gErr = live.geo_queue.find(x => x.status === "error")?.n || 0;
+ const gProc = live.geo_queue.find(x => x.status === "processing")?.n || 0;
+ const gPct = gTotal ? Math.round((gDone / gTotal) * 100) : 0;
+ const geoHtml = `
+
+ ${gPct}% — ${gDone.toLocaleString("fr-FR")} / ${gTotal.toLocaleString("fr-FR")}
+
+ ${gQ} en attente
+ ${gProc ? `⚡ ${gProc} en cours` : ""}
+ ${gErr ? `✗ ${gErr} erreurs` : ""}
+
+ ${live.geo_processing.map(p => `
+
+ ⚡ ${esc(p.name)} (${esc(p.country || "?")})
+ ${p.tries > 1 ? `essai ${p.tries}` : ""}
+
`).join("")}
+ `;
+
+ // --- Jobs en attente ---
+ const jobsHtml = live.pending_jobs.length
+ ? live.pending_jobs.map(j => {
+ const age = Math.round((Date.now() - new Date(j.created_at)) / 60000);
+ return `
+
+
${esc(j.job_type)}
+
par ${esc(j.requested_by || "?")} · ${age}min
+
+
+
`;
+ }).join("")
+ : `Aucun job en attente
`;
+
+ el.innerHTML = `
+
+
Crawl en cours
+ (${live.active_runs.length})
+
+ ${runsHtml}
+
+
+
Géocodeur
+ ${geoHtml}
+
+
+
Jobs en attente
+ (${live.pending_jobs.length})
+
+ ${jobsHtml}
+
+ `;
+}
+
+function renderMonitorStream(newCount = 0) {
+ const stream = document.getElementById("mon-stream");
+ if (!stream) return;
+ const level = document.getElementById("mon-level")?.value || "";
+ const search = (document.getElementById("mon-search")?.value || "").toLowerCase();
+
+ const filtered = state.monitorLogs.filter(x => {
+ if (level && x.level !== level) return false;
+ if (search && !x.message.toLowerCase().includes(search)) return false;
+ return true;
+ });
+
+ const countEl = document.getElementById("mon-count");
+ if (countEl) {
+ countEl.textContent = `${filtered.length} entrée${filtered.length !== 1 ? "s" : ""}${state.monitorLogs.length !== filtered.length ? ` / ${state.monitorLogs.length} total` : ""}`;
+ }
+
+ const wasAtTop = stream.scrollTop < 40;
+ stream.innerHTML = filtered.map((x, i) => {
+ const lvlCls = x.level === "error" ? "err" : x.level === "warning" ? "warn" : "muted";
+ return `
+ ${fmtDate(x.created_at)}
+ ${esc(x.level)}
+ ${esc(x.message)}${x.ma_id ? ` #${x.ma_id}` : ""}
+
`;
+ }).join("") || `Aucun log${level || search ? " (filtres actifs)" : ""}
`;
+
+ if (wasAtTop || newCount > 0) stream.scrollTop = 0;
+}
+
+async function adminCancelRun(id) {
+ if (!confirm(`Annuler le run #${id} ?`)) return;
+ try {
+ await api(`/admin/api/crawl-runs/${id}/cancel`, { method: "POST", body: "{}" });
+ await tickMonitor();
+ } catch (e) { alert(`Erreur : ${e.message}`); }
+}
+
+async function adminCancelJob(id) {
+ if (!confirm(`Annuler le job trigger #${id} ?`)) return;
+ try {
+ await api(`/admin/api/job-triggers/${id}/cancel`, { method: "POST", body: "{}" });
+ await tickMonitor();
+ } catch (e) { alert(`Erreur : ${e.message}`); }
+}
+
+// ------------------------------------------------------------------
+// Logs (historique)
// ------------------------------------------------------------------
async function renderLogs() {
const s = state.logs;
diff --git a/apps/admin/site/index.html b/apps/admin/site/index.html
index 7f78733..11e1b80 100644
--- a/apps/admin/site/index.html
+++ b/apps/admin/site/index.html
@@ -35,7 +35,8 @@
Conflits
Queue
Crawl runs
- Logs
+ Monitor
+ Historique
Géocodage
Jobs
Checkpoints
diff --git a/apps/admin/site/styles.css b/apps/admin/site/styles.css
index 39919aa..e539d5c 100644
--- a/apps/admin/site/styles.css
+++ b/apps/admin/site/styles.css
@@ -291,3 +291,61 @@ tbody tr.clickable { cursor: pointer; }
.empty { color: var(--muted); font-size: 13px; padding: 20px; text-align: center; }
.loading { color: var(--muted); font-size: 13px; padding: 20px; text-align: center; }
+
+/* ===== MONITOR ===== */
+.grid-3 { grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); }
+
+.mon-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 16px;
+ flex-wrap: wrap;
+ gap: 10px;
+}
+
+.pulse-dot {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ flex-shrink: 0;
+ transition: background 300ms;
+}
+.pulse-dot.live { animation: pulse-anim 2s ease-in-out infinite; }
+
+@keyframes pulse-anim {
+ 0%, 100% { box-shadow: 0 0 0 0 rgba(63,174,90,0.5); }
+ 50% { box-shadow: 0 0 0 5px rgba(63,174,90,0); }
+}
+
+.log-stream {
+ height: 420px;
+ overflow-y: auto;
+ font-family: ui-monospace, Consolas, monospace;
+ font-size: 12px;
+ scroll-behavior: smooth;
+}
+
+.log-stream .log-line {
+ display: flex;
+ gap: 10px;
+ padding: 3px 0;
+ border-top: 1px solid rgba(255,255,255,0.04);
+ word-break: break-all;
+ white-space: pre-wrap;
+}
+
+@keyframes log-flash {
+ from { background: rgba(63,174,90,0.10); }
+ to { background: transparent; }
+}
+.log-new { animation: log-flash 1.8s ease-out; }
+
+.mon-run-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ padding: 8px 0;
+ border-top: 1px solid rgba(255,255,255,0.05);
+}
+.mon-run-row:first-child { border-top: none; padding-top: 0; }
diff --git a/apps/api/src/adminRoutes.js b/apps/api/src/adminRoutes.js
index 478e416..ab38a7d 100644
--- a/apps/api/src/adminRoutes.js
+++ b/apps/api/src/adminRoutes.js
@@ -325,7 +325,7 @@ export default async function adminRoutes(fastify, opts) {
// ------------------------------------------------------------------
fastify.get("/admin/api/logs", async (req, reply) => {
try {
- const { level, run_id, since } = req.query || {};
+ const { level, run_id, since, min_id } = req.query || {};
const { page, pageSize, offset } = pagination(req.query || {});
const where = [];
const vals = [];
@@ -333,6 +333,7 @@ export default async function adminRoutes(fastify, opts) {
if (level) { where.push(`level = $${i}`); vals.push(level); i++; }
if (run_id) { where.push(`run_id = $${i}`); vals.push(Number(run_id)); i++; }
if (since) { where.push(`created_at > $${i}`); vals.push(since); i++; }
+ if (min_id) { where.push(`id > $${i}`); vals.push(Number(min_id)); i++; }
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
vals.push(pageSize, offset);
@@ -538,6 +539,89 @@ export default async function adminRoutes(fastify, opts) {
}
});
+ // ------------------------------------------------------------------
+ // Live status (agrégé pour le Monitor)
+ // ------------------------------------------------------------------
+ fastify.get("/admin/api/live", async (req, reply) => {
+ try {
+ const [active_runs, pending_jobs, geo_queue, geo_processing, checkpoints] = await Promise.all([
+ pool.query(`
+ SELECT id, run_type, countries, status, started_at,
+ bands_seen, bands_new, bands_updated, bands_enriched, error
+ FROM crawl_run WHERE status = 'running' ORDER BY started_at DESC
+ `),
+ pool.query(`
+ SELECT id, job_type, status, requested_by, created_at
+ FROM job_triggers WHERE status = 'pending' ORDER BY created_at ASC
+ `),
+ pool.query(`SELECT status, count(*)::int AS n FROM geocode_queue GROUP BY status ORDER BY n DESC`),
+ pool.query(`
+ SELECT gq.ma_id, b.name, b.country, gq.tries, gq.last_error, gq.updated_at
+ FROM geocode_queue gq JOIN bands b ON b.ma_id = gq.ma_id
+ WHERE gq.status = 'processing' LIMIT 3
+ `),
+ pool.query(`SELECT key, value, updated_at FROM crawl_checkpoint ORDER BY key`),
+ ]);
+ return {
+ ok: true,
+ active_runs: active_runs.rows,
+ pending_jobs: pending_jobs.rows,
+ geo_queue: geo_queue.rows,
+ geo_processing: geo_processing.rows,
+ checkpoints: checkpoints.rows,
+ };
+ } catch (err) {
+ fastify.log.error(err);
+ return reply.code(500).send({ ok: false, error: "Erreur live status" });
+ }
+ });
+
+ // ------------------------------------------------------------------
+ // Annuler un crawl run spécifique
+ // ------------------------------------------------------------------
+ fastify.post("/admin/api/crawl-runs/:id/cancel", async (req, reply) => {
+ try {
+ const id = Number(req.params.id);
+ if (!Number.isFinite(id)) return reply.code(400).send({ ok: false, error: "bad id" });
+ const r = await pool.query(`
+ UPDATE crawl_run SET status='error', finished_at=now(), error='annulé manuellement par admin'
+ WHERE id=$1 AND status='running' RETURNING id, run_type
+ `, [id]);
+ if (!r.rows.length) return reply.code(404).send({ ok: false, error: "Run non trouvé ou déjà terminé" });
+ await pool.query(
+ `INSERT INTO crawl_log (level, message) VALUES ($1, $2)`,
+ ["warning", `[admin:${req.adminUsername}] run #${id} (${r.rows[0].run_type}) annulé manuellement`]
+ ).catch(() => {});
+ return { ok: true, id };
+ } catch (err) {
+ fastify.log.error(err);
+ return reply.code(500).send({ ok: false, error: "Erreur annulation run" });
+ }
+ });
+
+ // ------------------------------------------------------------------
+ // Annuler un job trigger en attente
+ // ------------------------------------------------------------------
+ fastify.post("/admin/api/job-triggers/:id/cancel", async (req, reply) => {
+ try {
+ const id = Number(req.params.id);
+ if (!Number.isFinite(id)) return reply.code(400).send({ ok: false, error: "bad id" });
+ const r = await pool.query(`
+ UPDATE job_triggers SET status='cancelled', finished_at=now()
+ WHERE id=$1 AND status='pending' RETURNING id, job_type
+ `, [id]);
+ if (!r.rows.length) return reply.code(404).send({ ok: false, error: "Job non trouvé ou déjà traité" });
+ await pool.query(
+ `INSERT INTO crawl_log (level, message) VALUES ($1, $2)`,
+ ["warning", `[admin:${req.adminUsername}] job trigger #${id} (${r.rows[0].job_type}) annulé`]
+ ).catch(() => {});
+ return { ok: true, id };
+ } catch (err) {
+ fastify.log.error(err);
+ return reply.code(500).send({ ok: false, error: "Erreur annulation job" });
+ }
+ });
+
// ------------------------------------------------------------------
// Journal d'audit (actions admin)
// ------------------------------------------------------------------
diff --git a/apps/web/site/app.js b/apps/web/site/app.js
index c9cf3a5..40efa34 100644
--- a/apps/web/site/app.js
+++ b/apps/web/site/app.js
@@ -1406,15 +1406,46 @@ if (window.matchMedia("(max-width: 1200px)").matches) {
}, 100);
}
-// Language selector
-const langSelectEl = document.getElementById("langSelect");
-if (langSelectEl) {
- langSelectEl.value = currentLang;
- langSelectEl.addEventListener("change", () => {
- currentLang = langSelectEl.value;
- localStorage.setItem("lang", currentLang);
- applyI18n();
+// Language dropdown (flag-icons custom widget)
+const LANG_FLAGS = { en:"gb", fr:"fr", de:"de", es:"es", it:"it", pl:"pl", nl:"nl", ro:"ro", pt:"pt", cs:"cz", sv:"se" };
+const LANG_NAMES = { en:"English", fr:"Français", de:"Deutsch", es:"Español", it:"Italiano", pl:"Polski", nl:"Nederlands", ro:"Română", pt:"Português", cs:"Čeština", sv:"Svenska" };
+
+function syncLangTrigger(lang) {
+ const trigger = document.getElementById("langTrigger");
+ if (!trigger) return;
+ const flagEl = trigger.querySelector(".lang-flag");
+ if (flagEl) flagEl.className = `fi fi-${LANG_FLAGS[lang] || "un"} lang-flag`;
+ const codeEl = trigger.querySelector(".lang-code");
+ if (codeEl) codeEl.textContent = lang.toUpperCase();
+ document.querySelectorAll(".lang-opt").forEach(b =>
+ b.classList.toggle("current", b.dataset.lang === lang)
+ );
+}
+
+const langTrigger = document.getElementById("langTrigger");
+const langOptions = document.getElementById("langOptions");
+if (langTrigger && langOptions) {
+ langTrigger.addEventListener("click", (e) => {
+ e.stopPropagation();
+ const open = langOptions.classList.toggle("hidden") === false;
+ langTrigger.setAttribute("aria-expanded", String(open));
});
+ document.addEventListener("click", () => {
+ langOptions.classList.add("hidden");
+ langTrigger.setAttribute("aria-expanded", "false");
+ });
+ langOptions.querySelectorAll(".lang-opt").forEach(btn => {
+ btn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ currentLang = btn.dataset.lang;
+ localStorage.setItem("lang", currentLang);
+ langOptions.classList.add("hidden");
+ langTrigger.setAttribute("aria-expanded", "false");
+ syncLangTrigger(currentLang);
+ applyI18n();
+ });
+ });
+ syncLangTrigger(currentLang);
}
applyI18n();
diff --git a/apps/web/site/index.html b/apps/web/site/index.html
index 242afc8..aaeefa0 100644
--- a/apps/web/site/index.html
+++ b/apps/web/site/index.html
@@ -30,6 +30,7 @@
+
@@ -41,19 +42,26 @@
Metal from Europe
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+