Dashboard admin avec onglets (Tableau de bord / Metriques / Fichiers) + parse Prometheus humain

This commit is contained in:
unfr
2026-04-24 08:07:14 +02:00
parent 2e84e212ea
commit d767b6e581
6 changed files with 1053 additions and 72 deletions

311
public/admin.css Normal file
View File

@@ -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;
}
}

114
public/admin.html Normal file
View File

@@ -0,0 +1,114 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="theme-color" content="#0b1220">
<title>proxytmdb · Admin</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='6' fill='%2301b4e4'/%3E%3Ctext x='16' y='22' font-family='sans-serif' font-size='18' font-weight='bold' text-anchor='middle' fill='%23000'%3Et%3C/text%3E%3C/svg%3E">
<link rel="stylesheet" href="/style.css">
<link rel="stylesheet" href="/admin.css">
</head>
<body class="admin">
<header class="topbar">
<a class="brand" href="/admin">
<span class="brand-mark">t</span>
<span class="brand-text">proxytmdb · <span style="color:var(--text-muted)">admin</span></span>
</a>
<nav class="tabs" id="tabs">
<button type="button" class="tab active" data-tab="dashboard">Tableau de bord</button>
<button type="button" class="tab" data-tab="metrics">Métriques</button>
<button type="button" class="tab" data-tab="files">Fichiers</button>
</nav>
<div class="topbar-actions">
<a class="nav-link" href="/">← Retour</a>
<a class="nav-link" href="/admin?logout=1">Se déconnecter</a>
</div>
</header>
<main id="main-admin">
<section id="view-dashboard" class="view active">
<div class="stat-grid">
<div class="stat-card" id="card-cron">
<h3>Dernier cron</h3>
<div class="stat-value" id="cron-status"></div>
<div class="stat-sub" id="cron-when"></div>
<div class="stat-sub" id="cron-duration"></div>
</div>
<div class="stat-card" id="card-movies">
<h3>Films (TMDB)</h3>
<div class="stat-value" id="movies-count"></div>
<div class="stat-sub"><span id="movies-imdb"></span> avec mapping IMDb</div>
</div>
<div class="stat-card" id="card-tv">
<h3>Séries (TMDB)</h3>
<div class="stat-value" id="tv-count"></div>
<div class="stat-sub"><span id="tv-imdb"></span> avec mapping IMDb</div>
</div>
<div class="stat-card" id="card-imdb">
<h3>Notes IMDb</h3>
<div class="stat-value" id="imdb-count"></div>
<div class="stat-sub">chargées en mémoire</div>
</div>
<div class="stat-card" id="card-cache">
<h3>Cache recherche</h3>
<div class="stat-value" id="cache-hit-rate"></div>
<div class="stat-sub"><span id="cache-hits"></span> hits · <span id="cache-misses"></span> misses</div>
</div>
<div class="stat-card" id="card-process">
<h3>Process</h3>
<div class="stat-value" id="process-memory"></div>
<div class="stat-sub" id="process-uptime"></div>
<div class="stat-sub" id="process-node"></div>
</div>
</div>
<section class="panel">
<h2>Utilisation disque</h2>
<div id="disk-bars" class="disk-bars"></div>
</section>
<section class="panel">
<h2>Résumé du dernier cron</h2>
<div class="cron-summary" id="cron-summary"></div>
<details class="cron-log-wrapper">
<summary>Fin du log du cron (50 dernières lignes)</summary>
<pre id="cron-log" class="cron-log"></pre>
</details>
</section>
</section>
<section id="view-metrics" class="view">
<div class="panel">
<h2>HTTP · requêtes par route</h2>
<table class="tbl" id="tbl-http"><thead><tr><th>Méthode</th><th>Route</th><th>Status</th><th>Requêtes</th></tr></thead><tbody></tbody></table>
</div>
<div class="panel">
<h2>HTTP · p50 / p95 par route</h2>
<table class="tbl" id="tbl-http-latency"><thead><tr><th>Méthode</th><th>Route</th><th>p50 (ms)</th><th>p95 (ms)</th><th>Total</th></tr></thead><tbody></tbody></table>
</div>
<div class="panel">
<h2>Compteurs internes</h2>
<div class="kv" id="kv-counters"></div>
</div>
<div class="panel">
<h2>Process Node</h2>
<div class="kv" id="kv-process"></div>
</div>
<p class="footnote">Données brutes format Prometheus disponibles sur <a href="/metrics" target="_blank">/metrics</a>.</p>
</section>
<section id="view-files" class="view">
<div class="panel">
<h2>Fichiers du projet</h2>
<p class="footnote">Cliquer ouvre le fichier servi statiquement sous <code>/admin/files/</code>.</p>
<table class="tbl" id="tbl-files"><thead><tr><th>Nom</th><th>Taille</th><th>Modifié</th></tr></thead><tbody></tbody></table>
</div>
</section>
</main>
<footer class="footer"><span id="refresh-status"></span></footer>
<script type="module" src="/admin.js"></script>
</body>
</html>

341
public/admin.js Normal file
View File

@@ -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]) => `
<div class="disk-row">
<span class="label">${label}</span>
<div class="disk-bar ${cls}"><span style="width:${(b / max) * 100}%"></span></div>
<span class="size">${fmtBytes(b)}</span>
</div>
`,
)
.join('') +
`<div class="disk-row" style="margin-top:6px;border-top:1px solid var(--border);padding-top:10px">
<span class="label" style="color:var(--text)">Total</span><div></div>
<span class="size" style="color:var(--text);font-weight:600">${fmtBytes(disk.total_b)}</span>
</div>`;
// 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]) => `<span class="pill">${l} <b>${fmtInt(v)}</b></span>`)
.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) => `<tr>
<td>${s.labels.method || ''}</td>
<td><code>${escapeHtml(s.labels.route || '')}</code></td>
<td>${s.labels.status || ''}</td>
<td class="num">${fmtInt(s.value)}</td>
</tr>`,
)
.join('')
: `<tr><td colspan="4" style="text-align:center;color:var(--text-muted)">Aucune requête pour l'instant</td></tr>`;
// 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 `<tr>
<td>${b.method}</td>
<td><code>${escapeHtml(b.route)}</code></td>
<td class="num">${Number.isFinite(p50) ? (p50 * 1000).toFixed(1) : '>5000'}</td>
<td class="num">${Number.isFinite(p95) ? (p95 * 1000).toFixed(1) : '>5000'}</td>
<td class="num">${fmtInt(b.count)}</td>
</tr>`;
});
$('#tbl-http-latency tbody').innerHTML = latRows.length
? latRows.join('')
: `<tr><td colspan="5" style="text-align:center;color:var(--text-muted)">Aucune donnée</td></tr>`;
// 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 `<div><span>${label}</span><span>${sample ? fmtInt(sample.value) : '—'}</span></div>`;
})
.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 `<div><span>${label}</span><span>—</span></div>`;
if (name === 'nodejs_version_info') {
const s = g.samples[0];
return `<div><span>${label}</span><span>${s.labels.version || '—'}</span></div>`;
}
const val = g.samples[0].value;
return `<div><span>${label}</span><span>${fmt ? fmt(val) : val}</span></div>`;
})
.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 `<tr>
<td><a href="${href}">${escapeHtml(e.name)}${e.isDir ? ' /' : ''}</a></td>
<td class="num">${e.isDir ? '—' : fmtBytes(e.size)}</td>
<td>${escapeHtml(dateStr)}</td>
</tr>`;
})
.join('');
$('#tbl-files tbody').innerHTML = rows || `<tr><td colspan="3">(vide)</td></tr>`;
setRefreshStatus(`Actualisé à ${new Date().toLocaleTimeString('fr-FR')}`);
}
function escapeHtml(s) {
return String(s ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
// ---- Boot -----------------------------------------------------------------
TAB_LOADERS[activeTab]();
setInterval(() => TAB_LOADERS[activeTab](), REFRESH_INTERVAL_MS);