feat: flag-icons dropdown + admin live monitor
- Sélecteur de langue : dropdown custom avec flag-icons CDN (fi fi-xx), fermeture click-outside, aria-expanded - Admin Monitor : nouvelle vue live avec status temps réel (crawl runs, géocodeur, jobs en attente) - Log stream incrémental : fetch uniquement les nouveaux logs via min_id, buffer 500 entrées, filtre level + recherche texte client-side, flash animation sur nouvelles lignes - Boutons inline Annuler pour runs actifs et jobs en attente - API : GET /admin/api/live (agrégé), POST crawl-runs/:id/cancel, POST job-triggers/:id/cancel, param min_id sur GET /logs - Refresh configurable 3/5/10/30s, pause/reprendre, vider le buffer Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
dc9239c38e
commit
68e483762e
7 changed files with 490 additions and 31 deletions
|
|
@ -11,6 +11,11 @@ const state = {
|
||||||
queueTimer: null,
|
queueTimer: null,
|
||||||
geoTimer: null,
|
geoTimer: null,
|
||||||
cmdLogTimer: 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.queueTimer) { clearInterval(state.queueTimer); state.queueTimer = null; }
|
||||||
if (state.geoTimer) { clearInterval(state.geoTimer); state.geoTimer = null; }
|
if (state.geoTimer) { clearInterval(state.geoTimer); state.geoTimer = null; }
|
||||||
if (state.cmdLogTimer) { clearInterval(state.cmdLogTimer); state.cmdLogTimer = null; }
|
if (state.cmdLogTimer) { clearInterval(state.cmdLogTimer); state.cmdLogTimer = null; }
|
||||||
|
if (state.monitorTimer) { clearInterval(state.monitorTimer); state.monitorTimer = null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function showApp() {
|
function showApp() {
|
||||||
|
|
@ -98,7 +104,7 @@ document.getElementById("logout-btn").addEventListener("click", async () => {
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// Router
|
// 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() {
|
function router() {
|
||||||
const hash = (location.hash || "#/dashboard").replace("#/", "");
|
const hash = (location.hash || "#/dashboard").replace("#/", "");
|
||||||
|
|
@ -111,12 +117,14 @@ function router() {
|
||||||
if (state.queueTimer) { clearInterval(state.queueTimer); state.queueTimer = null; }
|
if (state.queueTimer) { clearInterval(state.queueTimer); state.queueTimer = null; }
|
||||||
if (state.geoTimer) { clearInterval(state.geoTimer); state.geoTimer = null; }
|
if (state.geoTimer) { clearInterval(state.geoTimer); state.geoTimer = null; }
|
||||||
if (state.cmdLogTimer) { clearInterval(state.cmdLogTimer); state.cmdLogTimer = null; }
|
if (state.cmdLogTimer) { clearInterval(state.cmdLogTimer); state.cmdLogTimer = null; }
|
||||||
|
if (state.monitorTimer) { clearInterval(state.monitorTimer); state.monitorTimer = null; }
|
||||||
const renderers = {
|
const renderers = {
|
||||||
dashboard: renderDashboard,
|
dashboard: renderDashboard,
|
||||||
bands: renderBands,
|
bands: renderBands,
|
||||||
conflicts: renderConflicts,
|
conflicts: renderConflicts,
|
||||||
queue: renderQueue,
|
queue: renderQueue,
|
||||||
runs: renderRuns,
|
runs: renderRuns,
|
||||||
|
monitor: renderMonitor,
|
||||||
logs: renderLogs,
|
logs: renderLogs,
|
||||||
geocoding: renderGeocoding,
|
geocoding: renderGeocoding,
|
||||||
jobs: renderJobs,
|
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 = `
|
||||||
|
<div class="mon-header">
|
||||||
|
<div style="display:flex;align-items:center;gap:10px">
|
||||||
|
<span class="pulse-dot" id="mon-dot" style="background:var(--warn)"></span>
|
||||||
|
<span style="font-size:12px;color:var(--muted)" id="mon-ts">Connexion…</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||||
|
<label style="font-size:12px;color:var(--muted);display:flex;align-items:center;gap:6px">
|
||||||
|
Refresh
|
||||||
|
<select id="mon-interval" style="padding:4px 8px;font-size:12px;border-radius:8px;border:1px solid rgba(255,255,255,0.10);background:rgba(4,6,10,0.72);color:var(--text);outline:none">
|
||||||
|
<option value="3000">3 s</option>
|
||||||
|
<option value="5000" selected>5 s</option>
|
||||||
|
<option value="10000">10 s</option>
|
||||||
|
<option value="30000">30 s</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button class="btn btn-mini" id="mon-pause-btn">⏸ Pause</button>
|
||||||
|
<button class="btn btn-mini" id="mon-clear-btn">🗑 Vider logs</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-3" id="mon-status" style="margin-bottom:16px">
|
||||||
|
<div class="card"><div class="loading">…</div></div>
|
||||||
|
<div class="card"><div class="loading">…</div></div>
|
||||||
|
<div class="card"><div class="loading">…</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap">
|
||||||
|
<h2 style="margin:0">Journal en direct</h2>
|
||||||
|
<select id="mon-level" style="padding:5px 8px;font-size:12px;border-radius:8px;border:1px solid rgba(255,255,255,0.10);background:rgba(4,6,10,0.72);color:var(--text);outline:none">
|
||||||
|
<option value="">Tous</option>
|
||||||
|
<option value="info">info</option>
|
||||||
|
<option value="warning">warning</option>
|
||||||
|
<option value="error">error</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" id="mon-search" placeholder="Filtrer…" style="padding:5px 10px;font-size:12px;border-radius:8px;border:1px solid rgba(255,255,255,0.10);background:rgba(4,6,10,0.72);color:var(--text);width:200px;outline:none">
|
||||||
|
<span id="mon-count" style="font-size:11px;color:var(--muted);margin-left:auto"></span>
|
||||||
|
</div>
|
||||||
|
<div id="mon-stream" class="log-stream"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 `<div class="mon-run-row">
|
||||||
|
<div>
|
||||||
|
<span class="badge warn">▶ ${esc(r.run_type)}</span>
|
||||||
|
<div style="font-size:11px;color:var(--muted);margin-top:5px">
|
||||||
|
${elapsed}min · ${r.bands_seen} vus · ${r.bands_new} nouveaux · ${r.bands_enriched} enrichis
|
||||||
|
</div>
|
||||||
|
${r.error ? `<div style="font-size:11px;color:var(--err);margin-top:3px">${esc(r.error)}</div>` : ""}
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-mini" style="border-color:rgba(198,26,26,0.4)" onclick="adminCancelRun(${r.id})">✕ Annuler</button>
|
||||||
|
</div>`;
|
||||||
|
}).join("")
|
||||||
|
: `<div class="empty" style="padding:16px 0">Aucun run actif</div>`;
|
||||||
|
|
||||||
|
// --- 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 = `
|
||||||
|
<div style="background:rgba(255,255,255,0.06);border-radius:6px;height:6px;overflow:hidden;margin-bottom:8px">
|
||||||
|
<div style="width:${gPct}%;height:100%;background:linear-gradient(90deg,var(--blood),rgba(198,26,26,0.5));transition:width 0.5s ease"></div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:13px;font-weight:700;margin-bottom:6px">${gPct}% — ${gDone.toLocaleString("fr-FR")} / ${gTotal.toLocaleString("fr-FR")}</div>
|
||||||
|
<div style="font-size:11px;color:var(--muted);display:flex;flex-wrap:wrap;gap:8px">
|
||||||
|
<span>${gQ} en attente</span>
|
||||||
|
${gProc ? `<span style="color:var(--warn)">⚡ ${gProc} en cours</span>` : ""}
|
||||||
|
${gErr ? `<span style="color:var(--err)">✗ ${gErr} erreurs</span>` : ""}
|
||||||
|
</div>
|
||||||
|
${live.geo_processing.map(p => `
|
||||||
|
<div style="margin-top:8px;padding:6px 8px;background:rgba(255,255,255,0.04);border-radius:7px;font-size:11px">
|
||||||
|
<span style="color:var(--warn)">⚡</span> ${esc(p.name)} <span style="color:var(--muted)">(${esc(p.country || "?")})</span>
|
||||||
|
${p.tries > 1 ? `<span style="color:var(--warn);margin-left:6px">essai ${p.tries}</span>` : ""}
|
||||||
|
</div>`).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 `<div class="mon-run-row">
|
||||||
|
<div>
|
||||||
|
<span class="badge muted">${esc(j.job_type)}</span>
|
||||||
|
<div style="font-size:11px;color:var(--muted);margin-top:4px">par ${esc(j.requested_by || "?")} · ${age}min</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-mini" onclick="adminCancelJob(${j.id})">✕</button>
|
||||||
|
</div>`;
|
||||||
|
}).join("")
|
||||||
|
: `<div class="empty" style="padding:16px 0">Aucun job en attente</div>`;
|
||||||
|
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin-bottom:10px">Crawl en cours
|
||||||
|
<span style="font-weight:400;color:var(--muted)">(${live.active_runs.length})</span>
|
||||||
|
</h2>
|
||||||
|
${runsHtml}
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin-bottom:10px">Géocodeur</h2>
|
||||||
|
${geoHtml}
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin-bottom:10px">Jobs en attente
|
||||||
|
<span style="font-weight:400;color:var(--muted)">(${live.pending_jobs.length})</span>
|
||||||
|
</h2>
|
||||||
|
${jobsHtml}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 `<div class="log-line${i < newCount ? " log-new" : ""}">
|
||||||
|
<span class="ts" style="color:var(--muted);flex-shrink:0">${fmtDate(x.created_at)}</span>
|
||||||
|
<span style="flex-shrink:0;width:56px;font-weight:700;color:var(--${lvlCls})">${esc(x.level)}</span>
|
||||||
|
<span style="word-break:break-all">${esc(x.message)}${x.ma_id ? ` <span style="color:var(--muted)">#${x.ma_id}</span>` : ""}</span>
|
||||||
|
</div>`;
|
||||||
|
}).join("") || `<div class="empty">Aucun log${level || search ? " (filtres actifs)" : ""}</div>`;
|
||||||
|
|
||||||
|
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() {
|
async function renderLogs() {
|
||||||
const s = state.logs;
|
const s = state.logs;
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,8 @@
|
||||||
<a href="#/conflicts" data-view="conflicts">Conflits</a>
|
<a href="#/conflicts" data-view="conflicts">Conflits</a>
|
||||||
<a href="#/queue" data-view="queue">Queue</a>
|
<a href="#/queue" data-view="queue">Queue</a>
|
||||||
<a href="#/runs" data-view="runs">Crawl runs</a>
|
<a href="#/runs" data-view="runs">Crawl runs</a>
|
||||||
<a href="#/logs" data-view="logs">Logs</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="#/geocoding" data-view="geocoding">Géocodage</a>
|
||||||
<a href="#/jobs" data-view="jobs">Jobs</a>
|
<a href="#/jobs" data-view="jobs">Jobs</a>
|
||||||
<a href="#/checkpoints" data-view="checkpoints">Checkpoints</a>
|
<a href="#/checkpoints" data-view="checkpoints">Checkpoints</a>
|
||||||
|
|
|
||||||
|
|
@ -291,3 +291,61 @@ tbody tr.clickable { cursor: pointer; }
|
||||||
|
|
||||||
.empty { color: var(--muted); font-size: 13px; padding: 20px; text-align: center; }
|
.empty { color: var(--muted); font-size: 13px; padding: 20px; text-align: center; }
|
||||||
.loading { 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; }
|
||||||
|
|
|
||||||
|
|
@ -325,7 +325,7 @@ export default async function adminRoutes(fastify, opts) {
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
fastify.get("/admin/api/logs", async (req, reply) => {
|
fastify.get("/admin/api/logs", async (req, reply) => {
|
||||||
try {
|
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 { page, pageSize, offset } = pagination(req.query || {});
|
||||||
const where = [];
|
const where = [];
|
||||||
const vals = [];
|
const vals = [];
|
||||||
|
|
@ -333,6 +333,7 @@ export default async function adminRoutes(fastify, opts) {
|
||||||
if (level) { where.push(`level = $${i}`); vals.push(level); i++; }
|
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 (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 (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 ")}` : "";
|
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
|
||||||
|
|
||||||
vals.push(pageSize, offset);
|
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)
|
// Journal d'audit (actions admin)
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -1406,15 +1406,46 @@ if (window.matchMedia("(max-width: 1200px)").matches) {
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Language selector
|
// Language dropdown (flag-icons custom widget)
|
||||||
const langSelectEl = document.getElementById("langSelect");
|
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" };
|
||||||
if (langSelectEl) {
|
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" };
|
||||||
langSelectEl.value = currentLang;
|
|
||||||
langSelectEl.addEventListener("change", () => {
|
function syncLangTrigger(lang) {
|
||||||
currentLang = langSelectEl.value;
|
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);
|
localStorage.setItem("lang", currentLang);
|
||||||
|
langOptions.classList.add("hidden");
|
||||||
|
langTrigger.setAttribute("aria-expanded", "false");
|
||||||
|
syncLangTrigger(currentLang);
|
||||||
applyI18n();
|
applyI18n();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
syncLangTrigger(currentLang);
|
||||||
}
|
}
|
||||||
applyI18n();
|
applyI18n();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@
|
||||||
<script data-goatcounter="https://metalfromeurope.goatcounter.com/count"
|
<script data-goatcounter="https://metalfromeurope.goatcounter.com/count"
|
||||||
async src="//gc.zgo.at/count.js"></script>
|
async src="//gc.zgo.at/count.js"></script>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flag-icons@7.2.3/css/flag-icons.min.css">
|
||||||
<link rel="stylesheet" href="./styles.css" />
|
<link rel="stylesheet" href="./styles.css" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|
@ -41,19 +42,26 @@
|
||||||
</button>
|
</button>
|
||||||
<div class="brand">Metal from Europe</div>
|
<div class="brand">Metal from Europe</div>
|
||||||
<div class="topbar-right">
|
<div class="topbar-right">
|
||||||
<select id="langSelect" class="lang-select" aria-label="Language">
|
<div class="lang-dropdown">
|
||||||
<option value="en">English</option>
|
<button class="lang-trigger" id="langTrigger" type="button" aria-haspopup="listbox" aria-expanded="false">
|
||||||
<option value="fr">Français</option>
|
<span class="fi lang-flag"></span>
|
||||||
<option value="de">Deutsch</option>
|
<span class="lang-code">EN</span>
|
||||||
<option value="es">Español</option>
|
<span class="lang-arrow">▾</span>
|
||||||
<option value="it">Italiano</option>
|
</button>
|
||||||
<option value="pl">Polski</option>
|
<div class="lang-options hidden" id="langOptions" role="listbox">
|
||||||
<option value="nl">Nederlands</option>
|
<button class="lang-opt" type="button" data-lang="en"><span class="fi fi-gb"></span>English</button>
|
||||||
<option value="ro">Română</option>
|
<button class="lang-opt" type="button" data-lang="fr"><span class="fi fi-fr"></span>Français</button>
|
||||||
<option value="pt">Português</option>
|
<button class="lang-opt" type="button" data-lang="de"><span class="fi fi-de"></span>Deutsch</button>
|
||||||
<option value="cs">Čeština</option>
|
<button class="lang-opt" type="button" data-lang="es"><span class="fi fi-es"></span>Español</button>
|
||||||
<option value="sv">Svenska</option>
|
<button class="lang-opt" type="button" data-lang="it"><span class="fi fi-it"></span>Italiano</button>
|
||||||
</select>
|
<button class="lang-opt" type="button" data-lang="pl"><span class="fi fi-pl"></span>Polski</button>
|
||||||
|
<button class="lang-opt" type="button" data-lang="nl"><span class="fi fi-nl"></span>Nederlands</button>
|
||||||
|
<button class="lang-opt" type="button" data-lang="ro"><span class="fi fi-ro"></span>Română</button>
|
||||||
|
<button class="lang-opt" type="button" data-lang="pt"><span class="fi fi-pt"></span>Português</button>
|
||||||
|
<button class="lang-opt" type="button" data-lang="cs"><span class="fi fi-cz"></span>Čeština</button>
|
||||||
|
<button class="lang-opt" type="button" data-lang="sv"><span class="fi fi-se"></span>Svenska</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<a href="https://discord.gg/P2DE57Ab" target="_blank" rel="noopener noreferrer" class="social-link" title="Rejoindre notre Discord">
|
<a href="https://discord.gg/P2DE57Ab" target="_blank" rel="noopener noreferrer" class="social-link" title="Rejoindre notre Discord">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515a.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0a12.64 12.64 0 0 0-.617-1.25a.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057a19.9 19.9 0 0 0 5.993 3.03a.078.078 0 0 0 .084-.028a14.09 14.09 0 0 0 1.226-1.994a.076.076 0 0 0-.041-.106a13.107 13.107 0 0 1-1.872-.892a.077.077 0 0 1-.008-.128a10.2 10.2 0 0 0 .372-.292a.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127a12.299 12.299 0 0 1-1.873.892a.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028a19.839 19.839 0 0 0 6.002-3.03a.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.956-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.955-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.946 2.418-2.157 2.418z"/>
|
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515a.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0a12.64 12.64 0 0 0-.617-1.25a.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057a19.9 19.9 0 0 0 5.993 3.03a.078.078 0 0 0 .084-.028a14.09 14.09 0 0 0 1.226-1.994a.076.076 0 0 0-.041-.106a13.107 13.107 0 0 1-1.872-.892a.077.077 0 0 1-.008-.128a10.2 10.2 0 0 0 .372-.292a.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127a12.299 12.299 0 0 1-1.873.892a.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028a19.839 19.839 0 0 0 6.002-3.03a.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.956-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.955-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.946 2.418-2.157 2.418z"/>
|
||||||
|
|
|
||||||
|
|
@ -86,22 +86,60 @@ body::before {
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lang-select {
|
.lang-dropdown { position: relative; }
|
||||||
|
|
||||||
|
.lang-trigger {
|
||||||
background: rgba(255,255,255,0.06);
|
background: rgba(255,255,255,0.06);
|
||||||
border: 1px solid rgba(255,255,255,0.12);
|
border: 1px solid rgba(255,255,255,0.12);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: rgba(231,226,218,0.85);
|
color: rgba(231,226,218,0.85);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
padding: 5px 8px;
|
padding: 5px 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
outline: none;
|
|
||||||
height: 36px;
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
transition: border-color 120ms ease;
|
transition: border-color 120ms ease;
|
||||||
}
|
}
|
||||||
.lang-select:hover { border-color: rgba(198,26,26,0.40); }
|
.lang-trigger:hover { border-color: rgba(198,26,26,0.40); }
|
||||||
.lang-select:focus { border-color: rgba(198,26,26,0.60); }
|
.lang-trigger:focus { outline: none; border-color: rgba(198,26,26,0.60); }
|
||||||
.lang-select option { background: #121418; }
|
.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 {
|
.social-link {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue