Dashboard admin : split stats/disk + preload disk au warmup + wc -l (1.85s -> 40ms)
This commit is contained in:
71
lib/stats.js
71
lib/stats.js
@@ -45,10 +45,17 @@ async function cached(key, ttlMs, fn) {
|
|||||||
|
|
||||||
async function countLines(file) {
|
async function countLines(file) {
|
||||||
if (!existsSync(file)) return 0;
|
if (!existsSync(file)) return 0;
|
||||||
let n = 0;
|
// wc -l is ~10x faster than Node readline on large files. Falls back to
|
||||||
const rl = createInterface({ input: createReadStream(file), crlfDelay: Infinity });
|
// readline if wc is unavailable (non-Linux dev env).
|
||||||
for await (const _ of rl) n++;
|
try {
|
||||||
return n;
|
const { stdout } = await execFile('wc', ['-l', file], { maxBuffer: 1024 * 1024 });
|
||||||
|
return parseInt(stdout.trim().split(/\s+/)[0], 10) || 0;
|
||||||
|
} catch {
|
||||||
|
let n = 0;
|
||||||
|
const rl = createInterface({ input: createReadStream(file), crlfDelay: Infinity });
|
||||||
|
for await (const _ of rl) n++;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function tailLines(file, n) {
|
async function tailLines(file, n) {
|
||||||
@@ -104,24 +111,11 @@ async function getCounterValue(name) {
|
|||||||
return arr.values.reduce((sum, v) => sum + (v.value || 0), 0);
|
return arr.values.reduce((sum, v) => sum + (v.value || 0), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getStats() {
|
// Disk usage is the only slow part (du -sb on 1.7M files = ~2-3s cold,
|
||||||
const [
|
// then 10 min cache). Exposed separately so the dashboard can show fast
|
||||||
movieTotal,
|
// stats first and fill in disk asynchronously.
|
||||||
tvTotal,
|
export async function getDiskUsage() {
|
||||||
cronText,
|
const [duMovie, duTv, duJwMovie, duJwTv, duRatings] = await Promise.all([
|
||||||
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_movie', 10 * 60_000, () => diskUsage(MOVIE_DIR)),
|
||||||
cached('du_tv', 10 * 60_000, () => diskUsage(TV_DIR)),
|
cached('du_tv', 10 * 60_000, () => diskUsage(TV_DIR)),
|
||||||
cached('du_jwmovie', 10 * 60_000, () => diskUsage(JUSTWATCH_MOVIE_DIR)),
|
cached('du_jwmovie', 10 * 60_000, () => diskUsage(JUSTWATCH_MOVIE_DIR)),
|
||||||
@@ -129,6 +123,31 @@ export async function getStats() {
|
|||||||
cached('du_ratings', 10 * 60_000, async () =>
|
cached('du_ratings', 10 * 60_000, async () =>
|
||||||
existsSync(IMDB_RATINGS) ? statSync(IMDB_RATINGS).size : 0,
|
existsSync(IMDB_RATINGS) ? statSync(IMDB_RATINGS).size : 0,
|
||||||
),
|
),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
movie_b: duMovie,
|
||||||
|
tv_b: duTv,
|
||||||
|
justwatch_movie_b: duJwMovie,
|
||||||
|
justwatch_tv_b: duJwTv,
|
||||||
|
ratings_b: duRatings,
|
||||||
|
total_b: duMovie + duTv + duJwMovie + duJwTv + duRatings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kicks off disk usage computation in the background to prime the 10 min cache
|
||||||
|
// before the first admin visitor needs it. Used at server warmup.
|
||||||
|
export function preloadDiskUsage() {
|
||||||
|
return getDiskUsage().catch((err) => console.warn('preloadDiskUsage:', err.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStats() {
|
||||||
|
// Fast path only: in-memory caches + small file reads + 1-2 wc -l. No du.
|
||||||
|
// Disk usage is exposed by the separate getDiskUsage() endpoint.
|
||||||
|
const [movieTotal, tvTotal, cronText, cronLogTail, 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)),
|
||||||
getRatings().catch(() => null),
|
getRatings().catch(() => null),
|
||||||
preloadMappings().catch(() => ({ movie: 0, tv: 0 })),
|
preloadMappings().catch(() => ({ movie: 0, tv: 0 })),
|
||||||
]);
|
]);
|
||||||
@@ -146,14 +165,6 @@ export async function getStats() {
|
|||||||
movies: { master: movieTotal, with_imdb: mappings.movie },
|
movies: { master: movieTotal, with_imdb: mappings.movie },
|
||||||
tv: { master: tvTotal, with_imdb: mappings.tv },
|
tv: { master: tvTotal, with_imdb: mappings.tv },
|
||||||
imdb_ratings: ratings ? ratings.size : 0,
|
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: {
|
||||||
...cron,
|
...cron,
|
||||||
|
|||||||
@@ -72,22 +72,62 @@ function setRefreshStatus(text) {
|
|||||||
$('#refresh-status').textContent = text;
|
$('#refresh-status').textContent = text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderDisk(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>`;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Dashboard ------------------------------------------------------------
|
// ---- Dashboard ------------------------------------------------------------
|
||||||
|
|
||||||
async function loadStats() {
|
async function loadStats() {
|
||||||
setRefreshStatus('Chargement…');
|
setRefreshStatus('Chargement…');
|
||||||
|
// Fast endpoint first; disk runs in parallel and fills in once ready.
|
||||||
|
const statsP = fetch('/admin/api/stats').then((r) => {
|
||||||
|
if (r.status === 401) {
|
||||||
|
location.reload();
|
||||||
|
throw new Error('auth');
|
||||||
|
}
|
||||||
|
return r.json();
|
||||||
|
});
|
||||||
|
const diskP = fetch('/admin/api/disk').then((r) => (r.ok ? r.json() : null));
|
||||||
let data;
|
let data;
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/admin/api/stats');
|
data = await statsP;
|
||||||
if (res.status === 401) {
|
|
||||||
location.reload();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
data = await res.json();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setRefreshStatus(`Erreur : ${err.message}`);
|
if (err.message !== 'auth') setRefreshStatus(`Erreur : ${err.message}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Schedule disk render independently (don't block the rest of the UI).
|
||||||
|
diskP.then((disk) => disk && renderDisk(disk)).catch(() => {});
|
||||||
|
$('#disk-bars').innerHTML =
|
||||||
|
'<div class="status loading" style="padding:20px">Calcul des tailles disque…</div>';
|
||||||
|
|
||||||
// Cron card
|
// Cron card
|
||||||
const cronEl = $('#cron-status');
|
const cronEl = $('#cron-status');
|
||||||
@@ -120,39 +160,7 @@ async function loadStats() {
|
|||||||
$('#process-uptime').textContent = `Uptime : ${fmtUptime(data.process.uptime_s)}`;
|
$('#process-uptime').textContent = `Uptime : ${fmtUptime(data.process.uptime_s)}`;
|
||||||
$('#process-node').textContent = `Node ${data.process.node} · pid ${data.process.pid}`;
|
$('#process-node').textContent = `Node ${data.process.node} · pid ${data.process.pid}`;
|
||||||
|
|
||||||
// Disk bars
|
// (disk handled in renderDisk via diskP)
|
||||||
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
|
// Cron summary
|
||||||
const sum = data.cron.log_summary;
|
const sum = data.cron.log_summary;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { readdir, readFile, stat } from 'node:fs/promises';
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { ADMIN_PASSWORD_HASH, ROOT, TITLE } from '../config.js';
|
import { ADMIN_PASSWORD_HASH, ROOT, TITLE } from '../config.js';
|
||||||
import { verifyPassword } from '../lib/password.js';
|
import { verifyPassword } from '../lib/password.js';
|
||||||
import { getMetricsForUI, getStats } from '../lib/stats.js';
|
import { getDiskUsage, getMetricsForUI, getStats } from '../lib/stats.js';
|
||||||
|
|
||||||
const HIDDEN = new Set([
|
const HIDDEN = new Set([
|
||||||
'node_modules',
|
'node_modules',
|
||||||
@@ -133,6 +133,11 @@ export default async function adminRoutes(fastify) {
|
|||||||
return getStats();
|
return getStats();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
fastify.get('/admin/api/disk', async (req, reply) => {
|
||||||
|
if (!requireAuth(req, reply)) return;
|
||||||
|
return getDiskUsage();
|
||||||
|
});
|
||||||
|
|
||||||
fastify.get('/admin/api/metrics', async (req, reply) => {
|
fastify.get('/admin/api/metrics', async (req, reply) => {
|
||||||
if (!requireAuth(req, reply)) return;
|
if (!requireAuth(req, reply)) return;
|
||||||
return getMetricsForUI();
|
return getMetricsForUI();
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { preloadMappings } from './lib/imdbMapping.js';
|
|||||||
import { getRatings } from './lib/imdbRatings.js';
|
import { getRatings } from './lib/imdbRatings.js';
|
||||||
import { httpDuration, httpRequests, imdbRatingsCount, searchWorkers } from './lib/metrics.js';
|
import { httpDuration, httpRequests, imdbRatingsCount, searchWorkers } from './lib/metrics.js';
|
||||||
import { getPool } from './lib/searchEngine.js';
|
import { getPool } from './lib/searchEngine.js';
|
||||||
|
import { preloadDiskUsage } from './lib/stats.js';
|
||||||
import adminRoutes from './routes/admin.js';
|
import adminRoutes from './routes/admin.js';
|
||||||
import apiRoutes from './routes/api.js';
|
import apiRoutes from './routes/api.js';
|
||||||
import healthRoutes from './routes/health.js';
|
import healthRoutes from './routes/health.js';
|
||||||
@@ -84,9 +85,12 @@ fastify.ready().then(async () => {
|
|||||||
searchWorkers.set({ type: 'tv' }, tv.workers.length);
|
searchWorkers.set({ type: 'tv' }, tv.workers.length);
|
||||||
const maps = await preloadMappings();
|
const maps = await preloadMappings();
|
||||||
startWatchers();
|
startWatchers();
|
||||||
|
// Disk usage takes ~3 s on cold cache (du -sb on 1.7M files). Run it in
|
||||||
|
// background so the first admin visitor finds the result already cached.
|
||||||
|
preloadDiskUsage();
|
||||||
fastify.log.info(
|
fastify.log.info(
|
||||||
{ ratings: ratings.size, imdb_movie: maps.movie, imdb_tv: maps.tv },
|
{ ratings: ratings.size, imdb_movie: maps.movie, imdb_tv: maps.tv },
|
||||||
'Warmup complete (data hot-reload watchers started)',
|
'Warmup complete (data hot-reload watchers started, disk usage preloading)',
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
fastify.log.warn({ err }, 'Warmup failed');
|
fastify.log.warn({ err }, 'Warmup failed');
|
||||||
|
|||||||
Reference in New Issue
Block a user