diff --git a/lib/imdbMapping.js b/lib/imdbMapping.js index 91088e8..2bd6d9b 100644 --- a/lib/imdbMapping.js +++ b/lib/imdbMapping.js @@ -38,7 +38,15 @@ export async function lookupImdb(imdbId) { // 1s latency. Returns the number of entries loaded per type. export async function preloadMappings() { const out = { movie: 0, tv: 0 }; - try { out.movie = (await loadOne('movie')).size; } catch { /* missing */ } - try { out.tv = (await loadOne('tv')).size; } catch { /* missing */ } + try { + out.movie = (await loadOne('movie')).size; + } catch { + /* missing */ + } + try { + out.tv = (await loadOne('tv')).size; + } catch { + /* missing */ + } return out; } diff --git a/lib/stats.js b/lib/stats.js new file mode 100644 index 0000000..d910f20 --- /dev/null +++ b/lib/stats.js @@ -0,0 +1,227 @@ +// Aggregates runtime stats for the admin dashboard. +// +// Stat sources: +// - In-memory (free): IMDb ratings Map size, IMDb mapping Map sizes, search workers count +// - File reads (cheap, cached 30s): cron.txt, lastcron.txt tail +// - Streaming line counts (cached 5min): tmdbintegral/movie.json, tv.json +// - Disk usage via du (lazy, cached 10min): movie/, tv/, justwatchmovie/, justwatchtv/ +// - Prometheus counters: HTTP totals, search cache hits/misses + +import { execFile as execFileCb } from 'node:child_process'; +import { createReadStream, existsSync, statSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { createInterface } from 'node:readline'; +import { promisify } from 'node:util'; +import { + CRON_TXT, + IMDB_RATINGS, + JUSTWATCH_MOVIE_DIR, + JUSTWATCH_TV_DIR, + LASTCRON_TXT, + MOVIE_DIR, + TMDBINTEGRAL_DIR, + TV_DIR, +} from '../config.js'; +import { preloadMappings } from './imdbMapping.js'; +import { getRatings } from './imdbRatings.js'; +import { registry } from './metrics.js'; + +const execFile = promisify(execFileCb); + +// ---------- TTL cache helpers ---------- + +const ttlCache = new Map(); +async function cached(key, ttlMs, fn) { + const now = Date.now(); + const hit = ttlCache.get(key); + if (hit && now - hit.t < ttlMs) return hit.v; + const v = await fn(); + ttlCache.set(key, { t: now, v }); + return v; +} + +// ---------- Cheap helpers ---------- + +async function countLines(file) { + if (!existsSync(file)) return 0; + let n = 0; + const rl = createInterface({ input: createReadStream(file), crlfDelay: Infinity }); + for await (const _ of rl) n++; + return n; +} + +async function tailLines(file, n) { + if (!existsSync(file)) return []; + // Read whole file — log files are < 1 MB usually. If they grow, switch to a + // backwards-stream chunk reader. + const text = await readFile(file, 'utf8'); + const lines = text.split('\n'); + return lines.slice(-n - 1, -1); +} + +async function diskUsage(dir) { + if (!existsSync(dir)) return 0; + try { + const { stdout } = await execFile('du', ['-sb', dir], { maxBuffer: 1024 * 1024 }); + const bytes = parseInt(stdout.split('\t')[0], 10); + return Number.isFinite(bytes) ? bytes : 0; + } catch { + return 0; + } +} + +// ---------- Cron parsing ---------- + +function parseCronTxt(text) { + if (!text) return { started: null, finished: null, duration_s: null, status: 'unknown' }; + const startMatch = text.match(/Started At (.+)/); + const finishMatch = text.match(/Finished At (.+)/); + const started = startMatch ? new Date(startMatch[1]).getTime() || null : null; + const finished = finishMatch ? new Date(finishMatch[1]).getTime() || null : null; + const duration_s = started && finished ? Math.round((finished - started) / 1000) : null; + const status = finished ? 'ok' : started ? 'running_or_failed' : 'unknown'; + return { started, finished, duration_s, status }; +} + +function summarizeLogTail(lines) { + const counts = { downloading: 0, updating: 0, removing: 0, failed: 0, writing: 0 }; + for (const l of lines) { + if (l.startsWith('Downloading:')) counts.downloading++; + else if (l.startsWith('Updating:')) counts.updating++; + else if (l.startsWith('Removing:')) counts.removing++; + else if (l.startsWith('Writing ')) counts.writing++; + else if (l.startsWith('Failed')) counts.failed++; + } + return counts; +} + +// ---------- Aggregator ---------- + +async function getCounterValue(name) { + const arr = await registry.getSingleMetric(name)?.get(); + if (!arr?.values) return 0; + return arr.values.reduce((sum, v) => sum + (v.value || 0), 0); +} + +export async function getStats() { + const [ + movieTotal, + tvTotal, + cronText, + cronLogTail, + duMovie, + duTv, + duJwMovie, + duJwTv, + duRatings, + ratings, + mappings, + ] = await Promise.all([ + cached('movie_total', 5 * 60_000, () => countLines(join(TMDBINTEGRAL_DIR, 'movie.json'))), + cached('tv_total', 5 * 60_000, () => countLines(join(TMDBINTEGRAL_DIR, 'tv.json'))), + cached('cron_txt', 30_000, async () => (existsSync(CRON_TXT) ? await readFile(CRON_TXT, 'utf8') : '')), + cached('cron_tail', 30_000, () => tailLines(LASTCRON_TXT, 200)), + cached('du_movie', 10 * 60_000, () => diskUsage(MOVIE_DIR)), + cached('du_tv', 10 * 60_000, () => diskUsage(TV_DIR)), + cached('du_jwmovie', 10 * 60_000, () => diskUsage(JUSTWATCH_MOVIE_DIR)), + cached('du_jwtv', 10 * 60_000, () => diskUsage(JUSTWATCH_TV_DIR)), + cached('du_ratings', 10 * 60_000, async () => + existsSync(IMDB_RATINGS) ? statSync(IMDB_RATINGS).size : 0, + ), + getRatings().catch(() => null), + preloadMappings().catch(() => ({ movie: 0, tv: 0 })), + ]); + + const cron = parseCronTxt(cronText); + const cronLogSummary = summarizeLogTail(cronLogTail); + + const cacheHits = await getCounterValue('search_cache_hits_total'); + const cacheMisses = await getCounterValue('search_cache_misses_total'); + const httpRequests = await getCounterValue('http_requests_total'); + + return { + timestamp: Date.now(), + data: { + movies: { master: movieTotal, with_imdb: mappings.movie }, + tv: { master: tvTotal, with_imdb: mappings.tv }, + imdb_ratings: ratings ? ratings.size : 0, + disk: { + movie_b: duMovie, + tv_b: duTv, + justwatch_movie_b: duJwMovie, + justwatch_tv_b: duJwTv, + ratings_b: duRatings, + total_b: duMovie + duTv + duJwMovie + duJwTv + duRatings, + }, + }, + cron: { + ...cron, + log_summary: cronLogSummary, + log_tail: cronLogTail.slice(-50), + }, + search_cache: { + hits: cacheHits, + misses: cacheMisses, + hit_rate: cacheHits + cacheMisses > 0 ? cacheHits / (cacheHits + cacheMisses) : null, + }, + http: { + total_requests: httpRequests, + }, + process: { + uptime_s: Math.round(process.uptime()), + memory_mb: Math.round(process.memoryUsage().rss / 1024 / 1024), + heap_mb: Math.round(process.memoryUsage().heapUsed / 1024 / 1024), + node: process.version, + pid: process.pid, + }, + }; +} + +// Returns the parsed Prometheus metrics in a structured form for the admin UI. +// Groups by metric name + labels, returns counters as scalars and histograms +// with their bucket counts. +export async function getMetricsForUI() { + const text = await registry.metrics(); + const groups = {}; + let currentName = null; + let currentHelp = ''; + let currentType = ''; + + for (const line of text.split('\n')) { + if (!line) continue; + if (line.startsWith('# HELP ')) { + const m = line.match(/^# HELP (\S+) (.+)$/); + if (m) { + currentName = m[1]; + currentHelp = m[2]; + } + continue; + } + if (line.startsWith('# TYPE ')) { + const m = line.match(/^# TYPE (\S+) (\S+)$/); + if (m) { + currentName = m[1]; + currentType = m[2]; + groups[currentName] = { name: currentName, help: currentHelp, type: currentType, samples: [] }; + } + continue; + } + if (line.startsWith('#')) continue; + const m = line.match(/^(\S+?)(?:\{([^}]*)\})?\s+(\S+)/); + if (!m) continue; + const baseName = m[1].replace(/_(bucket|count|sum)$/, ''); + const labelsStr = m[2] || ''; + const value = parseFloat(m[3]); + const labels = {}; + if (labelsStr) { + for (const pair of labelsStr.split(',')) { + const [k, v] = pair.split('='); + if (k && v) labels[k.trim()] = v.replace(/^"|"$/g, ''); + } + } + const grp = groups[baseName] || groups[m[1]]; + if (grp) grp.samples.push({ name: m[1], labels, value }); + } + return Object.values(groups); +} diff --git a/public/admin.css b/public/admin.css new file mode 100644 index 0000000..af84915 --- /dev/null +++ b/public/admin.css @@ -0,0 +1,311 @@ +/* Admin dashboard — extends public/style.css. */ + +.topbar { + gap: 10px; +} + +.tabs { + display: flex; + gap: 2px; + background: var(--bg-3); + padding: 4px; + border-radius: 10px; + flex: 1; + justify-content: center; + max-width: 500px; +} +.tab { + background: transparent; + color: var(--text-muted); + border: 0; + padding: 7px 16px; + border-radius: 7px; + cursor: pointer; + font-size: 13px; + font-weight: 500; + transition: + background 0.15s, + color 0.15s; +} +.tab:hover { + color: var(--text); +} +.tab.active { + background: var(--bg-hover); + color: var(--text); +} + +.topbar-actions { + display: flex; + gap: 14px; + align-items: center; + flex-shrink: 0; +} + +#main-admin { + max-width: 1400px; + margin: 0 auto; + padding: 24px 20px 80px; +} + +.view { + display: none; +} +.view.active { + display: block; +} + +/* Stat cards */ + +.stat-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 16px; + margin-bottom: 24px; +} + +.stat-card { + background: var(--bg-3); + border-radius: var(--radius); + padding: 18px; + border: 1px solid transparent; +} +.stat-card h3 { + margin: 0 0 8px; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-muted); + font-weight: 600; +} +.stat-value { + font-size: 28px; + font-weight: 700; + color: var(--text); + margin-bottom: 4px; + font-variant-numeric: tabular-nums; +} +.stat-sub { + font-size: 12px; + color: var(--text-muted); + line-height: 1.5; +} + +#card-cron .stat-value.ok { + color: #4ade80; +} +#card-cron .stat-value.err { + color: var(--danger); +} +#card-cron .stat-value.running { + color: var(--accent-2); +} + +/* Panels */ + +.panel { + background: var(--bg-3); + border-radius: var(--radius); + padding: 20px; + margin-bottom: 20px; +} +.panel h2 { + margin: 0 0 16px; + font-size: 14px; + font-weight: 600; + color: var(--text); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* Disk bars */ + +.disk-bars { + display: flex; + flex-direction: column; + gap: 10px; +} +.disk-row { + display: grid; + grid-template-columns: 160px 1fr 100px; + gap: 12px; + align-items: center; + font-size: 13px; +} +.disk-row .label { + color: var(--text-muted); +} +.disk-row .size { + text-align: right; + font-variant-numeric: tabular-nums; + color: var(--text); +} +.disk-bar { + background: var(--bg-2); + height: 14px; + border-radius: 7px; + overflow: hidden; +} +.disk-bar > span { + display: block; + height: 100%; + background: var(--accent); + transition: width 0.3s; +} +.disk-bar.tv > span { + background: #a78bfa; +} +.disk-bar.jwmovie > span { + background: #f59e0b; +} +.disk-bar.jwtv > span { + background: #f472b6; +} +.disk-bar.ratings > span { + background: var(--imdb); +} + +/* Cron */ + +.cron-summary { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-bottom: 14px; +} +.cron-summary .pill { + background: var(--bg-hover); + padding: 6px 12px; + border-radius: 999px; + font-size: 12px; + color: var(--text-muted); +} +.cron-summary .pill b { + color: var(--text); + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.cron-log-wrapper { + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; + background: var(--bg-2); +} +.cron-log-wrapper summary { + padding: 10px 14px; + cursor: pointer; + font-size: 13px; + color: var(--text-muted); + user-select: none; + font-weight: 500; +} +.cron-log-wrapper summary:hover { + color: var(--text); +} +.cron-log { + margin: 0; + padding: 14px; + background: #020617; + color: #cbd5e1; + font-family: "SF Mono", "Monaco", "Courier New", monospace; + font-size: 12px; + line-height: 1.5; + overflow: auto; + max-height: 400px; + white-space: pre-wrap; + word-break: break-all; +} + +/* Tables */ + +.tbl { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} +.tbl th, +.tbl td { + padding: 10px 12px; + border-bottom: 1px solid var(--border); + text-align: left; +} +.tbl th { + background: var(--bg-2); + color: var(--text-muted); + font-weight: 600; + position: sticky; + top: 0; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; +} +.tbl td { + font-variant-numeric: tabular-nums; +} +.tbl td.num { + text-align: right; + font-variant-numeric: tabular-nums; +} +.tbl tr:hover td { + background: var(--bg-2); +} +.tbl a { + color: var(--accent-2); + text-decoration: none; +} +.tbl a:hover { + text-decoration: underline; +} + +/* Key-value list */ + +.kv { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 8px 20px; +} +.kv > div { + display: flex; + justify-content: space-between; + padding: 6px 0; + border-bottom: 1px solid var(--border); + font-size: 13px; +} +.kv > div > span:first-child { + color: var(--text-muted); +} +.kv > div > span:last-child { + color: var(--text); + font-variant-numeric: tabular-nums; +} + +.footnote { + font-size: 12px; + color: var(--text-dim); + margin: 8px 0; +} +.footnote code { + background: var(--bg-2); + padding: 1px 6px; + border-radius: 4px; + font-family: "SF Mono", monospace; + font-size: 11px; +} + +#refresh-status { + color: var(--text-dim); + font-size: 11px; +} + +@media (max-width: 700px) { + .tabs { + max-width: none; + } + .topbar { + flex-wrap: wrap; + } + .disk-row { + grid-template-columns: 100px 1fr 80px; + } +} diff --git a/public/admin.html b/public/admin.html new file mode 100644 index 0000000..4c31dad --- /dev/null +++ b/public/admin.html @@ -0,0 +1,114 @@ + + + + + + + proxytmdb · Admin + + + + + +
+ + t + proxytmdb · admin + + +
+ ← Retour + Se déconnecter +
+
+ +
+
+
+
+

Dernier cron

+
+
+
+
+
+

Films (TMDB)

+
+
avec mapping IMDb
+
+
+

Séries (TMDB)

+
+
avec mapping IMDb
+
+
+

Notes IMDb

+
+
chargées en mémoire
+
+
+

Cache recherche

+
+
hits · misses
+
+
+

Process

+
+
+
+
+
+ +
+

Utilisation disque

+
+
+ +
+

Résumé du dernier cron

+
+
+ Fin du log du cron (50 dernières lignes) +
+
+
+
+ +
+
+

HTTP · requêtes par route

+
MéthodeRouteStatusRequêtes
+
+
+

HTTP · p50 / p95 par route

+
MéthodeRoutep50 (ms)p95 (ms)Total
+
+
+

Compteurs internes

+
+
+
+

Process Node

+
+
+

Données brutes format Prometheus disponibles sur /metrics.

+
+ +
+
+

Fichiers du projet

+

Cliquer ouvre le fichier servi statiquement sous /admin/files/.

+
NomTailleModifié
+
+
+
+ + + + + + diff --git a/public/admin.js b/public/admin.js new file mode 100644 index 0000000..fe335a9 --- /dev/null +++ b/public/admin.js @@ -0,0 +1,341 @@ +// 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); diff --git a/routes/admin.js b/routes/admin.js index e237f60..7853fcd 100644 --- a/routes/admin.js +++ b/routes/admin.js @@ -1,10 +1,14 @@ -// Protected file listing (was the public root in the PHP version). -// Now mounted at /admin so the new public UI can live at /. +// Admin — dashboard, stats API, metrics UI, and the legacy file listing. +// +// All protected by session cookie (argon2id password). The login form is +// rendered inline; everything else is served as static HTML from public/admin/ +// once authenticated. -import { readdir, stat } from 'node:fs/promises'; +import { readdir, readFile, stat } from 'node:fs/promises'; import { join } from 'node:path'; import { ADMIN_PASSWORD_HASH, ROOT, TITLE } from '../config.js'; import { verifyPassword } from '../lib/password.js'; +import { getMetricsForUI, getStats } from '../lib/stats.js'; const HIDDEN = new Set([ 'node_modules', @@ -39,54 +43,34 @@ function esc(s) { .replace(/'/g, '''); } -function formatBytes(bytes) { - const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; - let i = bytes > 0 ? Math.floor(Math.log(bytes) / Math.log(1024)) : 0; - if (i >= units.length) i = units.length - 1; - return `${bytes / 1024 ** i || 0} ${units[i]}`; -} - -function pad(n) { - return n < 10 ? `0${n}` : String(n); -} -function fmtDate(ms) { - const d = new Date(ms); - return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; -} - function loginPage(error = '') { return ` - - - + Admin · ${esc(TITLE)} + - +body{display:flex;align-items:center;justify-content:center;min-height:100vh} +.card{background:var(--bg-3);padding:24px;border-radius:12px;width:100%;max-width:360px;box-shadow:0 10px 30px rgba(0,0,0,.35)} +h1{font-size:18px;margin:0 0 16px;color:#f9fafb} +label{display:block;font-size:14px;margin-bottom:8px;color:#cbd5e1} +input[type=password]{width:100%;padding:10px 12px;border:1px solid #334155;border-radius:8px;background:#0f172a;color:#e5e7eb;outline:none} +input[type=password]:focus{border-color:var(--accent-2)} +.btn-login{margin-top:12px;width:100%;padding:10px 12px;border:0;border-radius:8px;background:#2563eb;color:white;font-weight:600;cursor:pointer} +.err{color:var(--danger);font-size:14px;margin-top:10px;min-height:18px} +

Admin · ${esc(TITLE)}

- +
${esc(error)}
- -`; +`; } -async function listingPage() { +async function renderFilesList() { const names = await readdir(ROOT); const entries = []; for (const name of names) { @@ -109,39 +93,11 @@ async function listingPage() { if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); }); - const rows = entries - .map((e) => { - const href = encodeURIComponent(e.name); - return ` - ${esc(e.name)}${e.isDir ? 'dossier' : ''} - ${e.isDir ? '—' : esc(formatBytes(e.size))} - ${esc(fmtDate(e.mtime))} - `; - }) - .join(''); - return ` - -Admin · ${esc(TITLE)} - -

Admin · ${esc(TITLE)}

Se déconnecter
-
${rows}
NomTailleModifié
-`; + return entries; } export default async function adminRoutes(fastify) { + // ---- Login / logout / dashboard shell ---- fastify.get('/admin', async (req, reply) => { reply.header('Content-Type', 'text/html; charset=utf-8'); if (req.query?.logout != null) { @@ -149,17 +105,41 @@ export default async function adminRoutes(fastify) { return reply.redirect('/admin'); } if (!req.session.get('auth_ok')) return loginPage(); - return listingPage(); + // Serve the dashboard SPA + return reply.send(await readFile(join(ROOT, 'public', 'admin.html'))); }); fastify.post('/admin', async (req, reply) => { reply.header('Content-Type', 'text/html; charset=utf-8'); - const submitted = req.body?.password ?? ''; - const ok = await verifyPassword(ADMIN_PASSWORD_HASH, submitted); + const ok = await verifyPassword(ADMIN_PASSWORD_HASH, req.body?.password ?? ''); if (ok) { req.session.set('auth_ok', true); return reply.redirect('/admin'); } return loginPage('Mot de passe incorrect.'); }); + + // ---- JSON APIs (auth required) ---- + function requireAuth(req, reply) { + if (!req.session.get('auth_ok')) { + reply.code(401).send({ error: 'auth required' }); + return false; + } + return true; + } + + fastify.get('/admin/api/stats', async (req, reply) => { + if (!requireAuth(req, reply)) return; + return getStats(); + }); + + fastify.get('/admin/api/metrics', async (req, reply) => { + if (!requireAuth(req, reply)) return; + return getMetricsForUI(); + }); + + fastify.get('/admin/api/files', async (req, reply) => { + if (!requireAuth(req, reply)) return; + return { entries: await renderFilesList() }; + }); }