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
+
+
+
+
+
+
+
+
+
+
+
+
Dernier cron
+
—
+
—
+
—
+
+
+
Films (TMDB)
+
—
+
— avec mapping IMDb
+
+
+
Séries (TMDB)
+
—
+
— avec mapping IMDb
+
+
+
Notes IMDb
+
—
+
chargées en mémoire
+
+
+
Cache recherche
+
—
+
— hits · — misses
+
+
+
+
+
+
+
+ Résumé du dernier cron
+
+
+ Fin du log du cron (50 dernières lignes)
+ —
+
+
+
+
+
+
+
HTTP · requêtes par route
+
| Méthode | Route | Status | Requêtes |
|---|
+
+
+
HTTP · p50 / p95 par route
+
| Méthode | Route | p50 (ms) | p95 (ms) | Total |
|---|
+
+
+
+
+
+
+
+
+
Fichiers du projet
+
+
+
+
+
+
+
+
+
+
+
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}
+
-
-`;
+