342 lines
12 KiB
JavaScript
342 lines
12 KiB
JavaScript
// 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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
// ---- Boot -----------------------------------------------------------------
|
|
|
|
TAB_LOADERS[activeTab]();
|
|
setInterval(() => TAB_LOADERS[activeTab](), REFRESH_INTERVAL_MS);
|