Dashboard admin avec onglets (Tableau de bord / Metriques / Fichiers) + parse Prometheus humain
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
227
lib/stats.js
Normal file
227
lib/stats.js
Normal file
@@ -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);
|
||||
}
|
||||
311
public/admin.css
Normal file
311
public/admin.css
Normal 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
114
public/admin.html
Normal 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
341
public/admin.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// ---- Boot -----------------------------------------------------------------
|
||||
|
||||
TAB_LOADERS[activeTab]();
|
||||
setInterval(() => TAB_LOADERS[activeTab](), REFRESH_INTERVAL_MS);
|
||||
120
routes/admin.js
120
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 `<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<html lang="fr"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Admin · ${esc(TITLE)}</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background:#0b1220; color:#e5e7eb; display:flex; align-items:center; justify-content:center; min-height:100vh; margin:0; }
|
||||
.card { background:#111827; 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:#60a5fa; }
|
||||
.btn { margin-top:12px; width:100%; padding:10px 12px; border:0; border-radius:8px; background:#2563eb; color:white; font-weight:600; cursor:pointer; }
|
||||
.btn:hover { filter:brightness(1.05); }
|
||||
.err { color:#fca5a5; font-size:14px; margin-top:10px; min-height:18px; }
|
||||
</style>
|
||||
</head>
|
||||
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}
|
||||
</style></head>
|
||||
<body>
|
||||
<form class="card" method="post" autocomplete="off">
|
||||
<h1>Admin · ${esc(TITLE)}</h1>
|
||||
<label for="pw">Mot de passe</label>
|
||||
<input id="pw" name="password" type="password" required autofocus>
|
||||
<button class="btn" type="submit">Entrer</button>
|
||||
<button class="btn-login" type="submit">Entrer</button>
|
||||
<div class="err">${esc(error)}</div>
|
||||
</form>
|
||||
</body>
|
||||
</html>`;
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
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 `<tr>
|
||||
<td class="name"><a href="/admin/files/${href}${e.isDir ? '/' : ''}">${esc(e.name)}</a>${e.isDir ? '<span class="badge">dossier</span>' : ''}</td>
|
||||
<td>${e.isDir ? '—' : esc(formatBytes(e.size))}</td>
|
||||
<td>${esc(fmtDate(e.mtime))}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join('');
|
||||
return `<!doctype html>
|
||||
<html lang="fr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Admin · ${esc(TITLE)}</title>
|
||||
<style>
|
||||
body{ font-family: ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif; background:#0b1220; color:#e5e7eb; margin:0; }
|
||||
header{ display:flex; gap:12px; align-items:center; justify-content:space-between; padding:16px 20px; background:#0f172a; position:sticky; top:0; }
|
||||
h1{ font-size:18px; margin:0; color:#f9fafb; }
|
||||
a.logout{ color:#93c5fd; text-decoration:none; font-size:14px; }
|
||||
.wrap{ max-width:1100px; margin:20px auto; padding:0 16px; }
|
||||
table{ width:100%; border-collapse:collapse; background:#111827; border-radius:12px; overflow:hidden; }
|
||||
th,td{ padding:12px 14px; border-bottom:1px solid #1f2937; text-align:left; font-size:14px; }
|
||||
th{ background:#0f172a; color:#cbd5e1; font-weight:600; }
|
||||
tr:hover td{ background:#0b1324; }
|
||||
.name a{ color:#93c5fd; text-decoration:none; }
|
||||
.name a:hover{ text-decoration:underline; }
|
||||
.badge{ font-size:11px; padding:2px 8px; border-radius:999px; background:#1f2937; color:#cbd5e1; margin-left:8px; }
|
||||
</style></head>
|
||||
<body><header><h1>Admin · ${esc(TITLE)}</h1><a class="logout" href="/admin?logout=1">Se déconnecter</a></header>
|
||||
<div class="wrap"><table><thead><tr><th>Nom</th><th>Taille</th><th>Modifié</th></tr></thead><tbody>${rows}</tbody></table></div>
|
||||
</body></html>`;
|
||||
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() };
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user