Add live server performance monitoring

This commit is contained in:
Jonathan Sykes
2026-10-14 02:43:20 +08:00
parent 4884589f36
commit e9a597c415
5 changed files with 159 additions and 3 deletions

View File

@@ -740,6 +740,16 @@
<div class="card"><h2>Most played</h2><div id="analyticsTop"></div></div>
</section>
<section id="p-ops" class="panel">
<div class="row"><div><h1 class="page">Performance</h1><p class="lead">Route latency, service queues, process health and storage headroom.</p></div><span class="grow"></span><button class="btn" id="opsRefresh" type="button">Refresh</button></div>
<p id="opsStatus" class="msg" role="status" aria-live="polite"></p>
<div class="analytics-grid" id="opsCards"></div>
<div class="card"><h2>Route health</h2><div class="scroll"><table class="tbl" id="opsRoutes"></table></div></div>
<div class="card"><h2>Storage volumes</h2><div id="opsVolumes"></div></div>
<div class="card"><h2>24-hour p95 history</h2><div id="opsHistory" class="analytics-grid"></div></div>
<div id="opsAlerts" aria-live="polite"></div>
</section>
<section id="p-deep-search" class="panel deep-search">
<h1 class="page">Deep search</h1>
<p class="lead">Build the local video index from YouTube searches. Runs are owned by the server and continue if this page closes.</p>
@@ -841,11 +851,12 @@
history: '<path d="M3 12a9 9 0 109-9 9 9 0 00-6.4 2.6L3 8"/><path d="M3 3v5h5M12 7v5l3 2"/>',
backups: '<path d="M4 5h16v15H4zM8 5V3h8v2M8 11h8M8 15h5"/>',
analytics: '<path d="M4 20h16M7 16V9M12 16V4M17 16v-5"/>',
ops: '<path d="M4 19V5m0 14h16M7 15l3-4 3 2 5-7"/><circle cx="18" cy="6" r="1.5"/>',
'deep-search': '<circle cx="10.5" cy="10.5" r="6.5"/><path d="m15.5 15.5 5 5M8 10.5h5M10.5 8v5"/>',
audit: '<path d="M5 4h14v16H5zM8 8h8M8 12h8M8 16h5"/>',
access: '<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M16 7l3 3M14 9l2 2"/>',
};
const TABS = [['editor', 'Editor'], ['quality', 'Lyrics queue'], ['lyric-search', 'Lyrics search'], ['cache', 'Cache'], ['index', 'Search index'], ['reports', 'Reports'], ['library', 'Library'], ['peers', 'P2P'], ['history', 'History'], ['backups', 'Backups'], ['analytics', 'Stats'], ['deep-search', 'Deep search'], ['audit', 'Audit log'], ['access', 'API']];
const TABS = [['editor', 'Editor'], ['quality', 'Lyrics queue'], ['lyric-search', 'Lyrics search'], ['cache', 'Cache'], ['index', 'Search index'], ['reports', 'Reports'], ['library', 'Library'], ['peers', 'P2P'], ['history', 'History'], ['backups', 'Backups'], ['analytics', 'Stats'], ['ops', 'Performance'], ['deep-search', 'Deep search'], ['audit', 'Audit log'], ['access', 'API']];
let tab = 'editor';
function buildTabs() {
const html = TABS.map(([k, l]) => `<button class="tab" role="tab" data-tab="${k}" aria-selected="false"><svg viewBox="0 0 24 24">${ICONS[k]}</svg><span>${l}</span>${k === 'reports' ? '<span class="badge hidden" data-badge>0</span>' : ''}</button>`).join('');
@@ -870,6 +881,7 @@
if (name === 'library') { loadUploads(); loadBulkLibrary(); loadBulkJobs(); }
if (name === 'access') loadTokens();
if (name === 'analytics') loadAnalytics();
if (name === 'ops') loadOpsMetrics();
if (name === 'deep-search') loadDeepSearch();
if (name === 'audit') loadAudit();
window.scrollTo({ top: 0 });
@@ -937,6 +949,30 @@
await Promise.all([loadCollections(), loadMetadata()]);
} catch (error) { $('analyticsStatus').textContent = error.message || 'Could not load server analytics.'; }
}
async function loadOpsMetrics() {
const status = $('opsStatus'); if (!status) return;
status.textContent = 'Sampling service health…';
try {
const { current, history = [] } = await api('/api/admin/ops/metrics');
const cards = [
['In flight', current.inflight], ['Memory RSS', bytes(current.rssBytes)], ['CPU', `${Number(current.cpuPercent || 0).toFixed(1)}%`],
['yt-dlp pool', current.ytdlp ? `${current.ytdlp.busy}/${current.ytdlp.workers} busy · ${current.ytdlp.queued} queued` : 'Unavailable'],
['Media queue', current.media ? `${current.media.queued} queued · ${current.media.active ? '1 active' : 'idle'}` : 'Unavailable'],
['Search cache', current.cache ? `${current.cache.hits} hits · ${current.cache.misses} misses` : 'Unavailable'],
];
$('opsCards').innerHTML = cards.map(([name,value]) => `<div class="metric"><span>${esc(name)}</span><b>${esc(value)}</b></div>`).join('');
const rows = Object.entries(current.routes || {}).sort((a,b)=>b[1].requests-a[1].requests);
$('opsRoutes').innerHTML = `<tr><th>Route</th><th>Requests</th><th>p50</th><th>p95</th><th>p99</th><th>Errors</th></tr>` + (rows.map(([path,r])=>`<tr><td><code>${esc(path)}</code></td><td>${r.requests}</td><td>${r.p50} ms</td><td>${r.p95} ms</td><td>${r.p99} ms</td><td>${r.errors} (${(r.errorRate*100).toFixed(1)}%)</td></tr>`).join('') || '<tr><td colspan="6" class="muted">No requests recorded yet.</td></tr>');
$('opsVolumes').innerHTML = (current.volumes || []).map(v=>`<p><b>${esc(v.name)}</b> · ${v.unavailable?'Unavailable':`${bytes(v.freeBytes)} free of ${bytes(v.totalBytes)}`}</p>`).join('');
const points = history.slice(-24).map(s=>Math.max(0,...Object.values(s.routes||{}).map(r=>r.p95)));
const maxPoint = Math.max(1,...points), poly = points.map((v,i)=>`${points.length < 2 ? 0 : i*100/(points.length-1)},${36-v/maxPoint*32}`).join(' ');
$('opsHistory').innerHTML = `<div class="metric" style="grid-column:1/-1"><span>Peak route p95 · last ${points.length} snapshots</span><b>${maxPoint} ms</b><svg role="img" aria-label="Peak p95 latency sparkline" viewBox="0 0 100 40" preserveAspectRatio="none" style="width:100%;height:64px;stroke:var(--a1);fill:none;stroke-width:2"><polyline points="${poly}" vector-effect="non-scaling-stroke"/></svg></div>`;
const alerts = [];
for (const [path,r] of rows) if (r.p95 > 5000 || r.errorRate > .1) alerts.push(`<div class="card" role="status"><b>Attention:</b> ${esc(path)} p95 ${r.p95} ms · errors ${(r.errorRate*100).toFixed(1)}%</div>`);
$('opsAlerts').innerHTML = alerts.join(''); status.textContent = `Updated ${new Date(current.at).toLocaleTimeString()}`;
} catch (error) { status.textContent = error.message || 'Monitoring unavailable.'; }
}
$('opsRefresh')?.addEventListener('click', loadOpsMetrics);
async function loadCollections() {
try {
const j = await api('/api/admin/collections'); if (!j.ok) return;