// Admin dashboard — vanilla JS, polls /admin/api/* and renders. const $ = (s) => document.querySelector(s); const $$ = (s) => document.querySelectorAll(s); const REFRESH_INTERVAL_MS = 10_000; // ---- Tabs ----------------------------------------------------------------- const TAB_LOADERS = { dashboard: loadStats, metrics: loadMetrics, files: loadFiles }; let activeTab = 'dashboard'; for (const btn of $$('.tab')) { btn.addEventListener('click', () => { activeTab = btn.dataset.tab; for (const b of $$('.tab')) b.classList.toggle('active', b.dataset.tab === activeTab); for (const v of $$('.view')) v.classList.toggle('active', v.id === `view-${activeTab}`); history.replaceState({}, '', `/admin#${activeTab}`); TAB_LOADERS[activeTab](); }); } if (location.hash) { const t = location.hash.slice(1); if (TAB_LOADERS[t]) { activeTab = t; for (const b of $$('.tab')) b.classList.toggle('active', b.dataset.tab === t); for (const v of $$('.view')) v.classList.toggle('active', v.id === `view-${t}`); } } // ---- Helpers -------------------------------------------------------------- function fmtInt(n) { return Number.isFinite(n) ? n.toLocaleString('fr-FR') : '—'; } function fmtPct(n) { return Number.isFinite(n) ? `${(n * 100).toFixed(1)} %` : '—'; } function fmtBytes(b) { if (!b) return '0 B'; const u = ['B', 'KB', 'MB', 'GB', 'TB']; let i = Math.floor(Math.log(b) / Math.log(1024)); if (i >= u.length) i = u.length - 1; return `${(b / 1024 ** i).toFixed(i >= 2 ? 1 : 0)} ${u[i]}`; } function fmtDuration(s) { if (!Number.isFinite(s)) return '—'; if (s < 60) return `${s}s`; const m = Math.floor(s / 60), r = s % 60; return r ? `${m}m ${r}s` : `${m}m`; } function fmtUptime(s) { if (!s) return '—'; const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60); if (d) return `${d}j ${h}h`; if (h) return `${h}h ${m}m`; return `${m}m`; } function fmtAgo(t) { if (!t) return '—'; const s = Math.round((Date.now() - t) / 1000); if (s < 60) return `il y a ${s}s`; if (s < 3600) return `il y a ${Math.floor(s / 60)}m`; if (s < 86400) return `il y a ${Math.floor(s / 3600)}h`; return `il y a ${Math.floor(s / 86400)}j`; } function setRefreshStatus(text) { $('#refresh-status').textContent = text; } // ---- Dashboard ------------------------------------------------------------ async function loadStats() { setRefreshStatus('Chargement…'); let data; try { const res = await fetch('/admin/api/stats'); if (res.status === 401) { location.reload(); return; } data = await res.json(); } catch (err) { setRefreshStatus(`Erreur : ${err.message}`); return; } // Cron card const cronEl = $('#cron-status'); const statusText = { ok: 'OK', running_or_failed: '⚠️ incomplet', unknown: '—' }[data.cron.status] || data.cron.status; cronEl.textContent = statusText; cronEl.className = `stat-value ${data.cron.status === 'ok' ? 'ok' : data.cron.status === 'unknown' ? '' : 'err'}`; $('#cron-when').textContent = data.cron.finished ? `Terminé ${fmtAgo(data.cron.finished)}` : '—'; $('#cron-duration').textContent = data.cron.duration_s ? `Durée : ${fmtDuration(data.cron.duration_s)}` : ''; // Movies / TV $('#movies-count').textContent = fmtInt(data.data.movies.master); $('#movies-imdb').textContent = fmtInt(data.data.movies.with_imdb); $('#tv-count').textContent = fmtInt(data.data.tv.master); $('#tv-imdb').textContent = fmtInt(data.data.tv.with_imdb); // IMDb ratings $('#imdb-count').textContent = fmtInt(data.data.imdb_ratings); // Cache $('#cache-hit-rate').textContent = data.search_cache.hit_rate == null ? '—' : fmtPct(data.search_cache.hit_rate); $('#cache-hits').textContent = fmtInt(data.search_cache.hits); $('#cache-misses').textContent = fmtInt(data.search_cache.misses); // Process $('#process-memory').textContent = `${data.process.memory_mb} MB`; $('#process-uptime').textContent = `Uptime : ${fmtUptime(data.process.uptime_s)}`; $('#process-node').textContent = `Node ${data.process.node} · pid ${data.process.pid}`; // Disk bars const disk = data.data.disk; const max = Math.max( disk.movie_b, disk.tv_b, disk.justwatch_movie_b, disk.justwatch_tv_b, disk.ratings_b, 1, ); const rows = [ ['Films (détails TMDB)', disk.movie_b, ''], ['Séries (détails TMDB)', disk.tv_b, 'tv'], ['Providers films', disk.justwatch_movie_b, 'jwmovie'], ['Providers séries', disk.justwatch_tv_b, 'jwtv'], ['IMDb ratings', disk.ratings_b, 'ratings'], ]; $('#disk-bars').innerHTML = rows .map( ([label, b, cls]) => `
${label}
${fmtBytes(b)}
`, ) .join('') + `
Total
${fmtBytes(disk.total_b)}
`; // Cron summary const sum = data.cron.log_summary; $('#cron-summary').innerHTML = [ ['Téléchargements', sum.downloading], ['Mises à jour', sum.updating], ['Suppressions', sum.removing], ['Chunks écrits', sum.writing], ['Échecs', sum.failed], ] .map(([l, v]) => `${l} ${fmtInt(v)}`) .join(''); $('#cron-log').textContent = data.cron.log_tail.join('\n') || '(aucun log)'; setRefreshStatus(`Actualisé à ${new Date().toLocaleTimeString('fr-FR')}`); } // ---- Metrics -------------------------------------------------------------- async function loadMetrics() { setRefreshStatus('Chargement…'); let metrics; try { const res = await fetch('/admin/api/metrics'); if (res.status === 401) { location.reload(); return; } metrics = await res.json(); } catch (err) { setRefreshStatus(`Erreur : ${err.message}`); return; } // HTTP requests table const httpReqs = metrics.find((g) => g.name === 'http_requests_total'); const httpRows = (httpReqs?.samples || []) .filter((s) => !String(s.labels.route).startsWith('/admin/files/')) .sort((a, b) => b.value - a.value); $('#tbl-http tbody').innerHTML = httpRows.length ? httpRows .map( (s) => ` ${s.labels.method || ''} ${escapeHtml(s.labels.route || '')} ${s.labels.status || ''} ${fmtInt(s.value)} `, ) .join('') : `Aucune requête pour l'instant`; // HTTP latency (histogram quantiles) const httpDur = metrics.find((g) => g.name === 'http_request_duration_seconds'); const buckets = {}; for (const s of httpDur?.samples || []) { const key = `${s.labels.method}|${s.labels.route}|${s.labels.status}`; if (!buckets[key]) buckets[key] = { method: s.labels.method, route: s.labels.route, status: s.labels.status, buckets: [], count: 0, sum: 0, }; if (s.name.endsWith('_bucket')) buckets[key].buckets.push({ le: parseFloat(s.labels.le), v: s.value }); else if (s.name.endsWith('_count')) buckets[key].count = s.value; else if (s.name.endsWith('_sum')) buckets[key].sum = s.value; } const latRows = Object.values(buckets) .filter((b) => b.count > 0 && !String(b.route).startsWith('/admin/files/')) .sort((a, b) => b.count - a.count) .map((b) => { b.buckets.sort((a, c) => a.le - c.le); const p = (q) => { const target = b.count * q; for (const bk of b.buckets) if (bk.v >= target) return bk.le; return Infinity; }; const p50 = p(0.5), p95 = p(0.95); return ` ${b.method} ${escapeHtml(b.route)} ${Number.isFinite(p50) ? (p50 * 1000).toFixed(1) : '>5000'} ${Number.isFinite(p95) ? (p95 * 1000).toFixed(1) : '>5000'} ${fmtInt(b.count)} `; }); $('#tbl-http-latency tbody').innerHTML = latRows.length ? latRows.join('') : `Aucune donnée`; // Internal counters const counters = [ ['Notes IMDb chargées', 'imdb_ratings_total'], ['Cache search · hits', 'search_cache_hits_total'], ['Cache search · misses', 'search_cache_misses_total'], ['Workers films', 'search_workers', { type: 'movie' }], ['Workers séries', 'search_workers', { type: 'tv' }], ]; $('#kv-counters').innerHTML = counters .map(([label, name, labels]) => { const g = metrics.find((m) => m.name === name); const sample = g?.samples.find( (s) => !labels || Object.entries(labels).every(([k, v]) => s.labels[k] === v), ); return `
${label}${sample ? fmtInt(sample.value) : '—'}
`; }) .join(''); // Process metrics const processKeys = [ ['process_resident_memory_bytes', 'RSS', fmtBytes], ['nodejs_heap_size_used_bytes', 'Heap utilisé', fmtBytes], ['nodejs_heap_size_total_bytes', 'Heap total', fmtBytes], ['nodejs_external_memory_bytes', 'Externe', fmtBytes], ['process_cpu_user_seconds_total', 'CPU user', (v) => `${v.toFixed(1)} s`], ['process_cpu_system_seconds_total', 'CPU système', (v) => `${v.toFixed(1)} s`], ['nodejs_eventloop_lag_seconds', 'Event loop lag', (v) => `${(v * 1000).toFixed(2)} ms`], ['nodejs_active_handles_total', 'Handles actifs', fmtInt], ['nodejs_version_info', 'Node', null], ]; $('#kv-process').innerHTML = processKeys .map(([name, label, fmt]) => { const g = metrics.find((m) => m.name === name); if (!g?.samples.length) return `
${label}
`; if (name === 'nodejs_version_info') { const s = g.samples[0]; return `
${label}${s.labels.version || '—'}
`; } const val = g.samples[0].value; return `
${label}${fmt ? fmt(val) : val}
`; }) .join(''); setRefreshStatus(`Actualisé à ${new Date().toLocaleTimeString('fr-FR')}`); } // ---- Files ---------------------------------------------------------------- async function loadFiles() { setRefreshStatus('Chargement…'); let data; try { const res = await fetch('/admin/api/files'); if (res.status === 401) { location.reload(); return; } data = await res.json(); } catch (err) { setRefreshStatus(`Erreur : ${err.message}`); return; } const rows = data.entries .map((e) => { const href = `/admin/files/${encodeURIComponent(e.name)}${e.isDir ? '/' : ''}`; const dateStr = new Date(e.mtime).toLocaleString('fr-FR'); return ` ${escapeHtml(e.name)}${e.isDir ? ' /' : ''} ${e.isDir ? '—' : fmtBytes(e.size)} ${escapeHtml(dateStr)} `; }) .join(''); $('#tbl-files tbody').innerHTML = rows || `(vide)`; setRefreshStatus(`Actualisé à ${new Date().toLocaleTimeString('fr-FR')}`); } function escapeHtml(s) { return String(s ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } // ---- Boot ----------------------------------------------------------------- TAB_LOADERS[activeTab](); setInterval(() => TAB_LOADERS[activeTab](), REFRESH_INTERVAL_MS);