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 = ` +
+
+ + Connexion… +
+
+ + + +
+
+ +
+
+
+
+
+ +
+
+

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
- +
+ + +
diff --git a/apps/web/site/styles.css b/apps/web/site/styles.css index 0f33496..e22ed30 100644 --- a/apps/web/site/styles.css +++ b/apps/web/site/styles.css @@ -86,22 +86,60 @@ body::before { gap: 8px; } -.lang-select { +.lang-dropdown { position: relative; } + +.lang-trigger { background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12); border-radius: 8px; color: rgba(231,226,218,0.85); font-size: 12px; font-family: inherit; - padding: 5px 8px; + padding: 5px 10px; cursor: pointer; - outline: none; height: 36px; + display: flex; + align-items: center; + gap: 6px; transition: border-color 120ms ease; } -.lang-select:hover { border-color: rgba(198,26,26,0.40); } -.lang-select:focus { border-color: rgba(198,26,26,0.60); } -.lang-select option { background: #121418; } +.lang-trigger:hover { border-color: rgba(198,26,26,0.40); } +.lang-trigger:focus { outline: none; border-color: rgba(198,26,26,0.60); } +.lang-arrow { color: rgba(231,226,218,0.35); font-size: 10px; } + +.lang-options { + position: absolute; + top: calc(100% + 4px); + right: 0; + background: rgba(10,12,20,0.98); + border: 1px solid rgba(255,255,255,0.12); + border-radius: 10px; + min-width: 150px; + z-index: 999; + padding: 4px; + box-shadow: 0 8px 32px rgba(0,0,0,0.5); + backdrop-filter: blur(8px); +} +.lang-options.hidden { display: none; } + +.lang-opt { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 7px 10px; + background: none; + border: none; + color: rgba(231,226,218,0.85); + font-size: 13px; + font-family: inherit; + cursor: pointer; + border-radius: 7px; + text-align: left; + transition: background 80ms; +} +.lang-opt:hover { background: rgba(255,255,255,0.08); } +.lang-opt.current { color: rgba(231,226,218,1); background: rgba(198,26,26,0.18); } .social-link { display: inline-flex;