Add cache pinning and health controls

This commit is contained in:
Jonathan Sykes
2026-10-14 01:59:23 +08:00
parent 180a28d3e4
commit 42c68609ec
9 changed files with 161 additions and 7 deletions

View File

@@ -557,6 +557,16 @@
</div>
</section>
<section id="p-cache" class="panel">
<h1 class="page">Cache health</h1>
<p class="lead">Review server copies by state and recency. Pinned copies stay out of automatic eviction.</p>
<div class="analytics-grid" id="cacheMetrics"></div>
<div class="card"><div class="row"><h2 style="margin:0">Server media</h2><span class="grow"></span><button class="btn sm" id="cacheRefresh" type="button">Refresh</button></div>
<p id="cacheStatus" class="msg" role="status" aria-live="polite"></p><div class="scroll"><table class="tbl" id="cacheTable"></table></div>
<div class="row"><button class="btn" id="cachePrev" type="button">Previous</button><button class="btn" id="cacheNext" type="button">Next</button><span class="grow"></span><span id="cacheCount" class="muted">50 rows per page</span></div>
</div>
</section>
<!-- ================= LIBRARY ================= -->
<section id="p-library" class="panel">
<h1 class="page">Library</h1>
@@ -749,6 +759,7 @@
editor: '<path d="M4 20h4L19 9a2.8 2.8 0 00-4-4L4 16v4z"/><path d="M13.5 6.5l4 4"/>',
reports: '<path d="M5 21V4M5 4h12l-2.5 4L17 12H5"/>',
quality: '<path d="M4 5h16M4 12h16M4 19h10"/><path d="m17 18 2 2 4-5"/>',
cache: '<ellipse cx="12" cy="5" rx="8" ry="3"/><path d="M4 5v14c0 1.7 3.6 3 8 3s8-1.3 8-3V5M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3"/>',
library: '<path d="M9 18V6l10-2v12"/><circle cx="6.5" cy="18" r="2.5"/><circle cx="16.5" cy="16" r="2.5"/>',
peers: '<circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="6" r="2.5"/><circle cx="18" cy="18" r="2.5"/><path d="M8.3 10.9l7.4-3.8M8.3 13.1l7.4 3.8"/>',
history: '<path d="M3 12a9 9 0 109-9 9 9 0 00-6.4 2.6L3 8"/><path d="M3 3v5h5M12 7v5l3 2"/>',
@@ -757,7 +768,7 @@
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'], ['reports', 'Reports'], ['library', 'Library'], ['peers', 'P2P'], ['history', 'History'], ['analytics', 'Stats'], ['deep-search', 'Deep search'], ['audit', 'Audit log'], ['access', 'API']];
const TABS = [['editor', 'Editor'], ['quality', 'Lyrics queue'], ['cache', 'Cache'], ['reports', 'Reports'], ['library', 'Library'], ['peers', 'P2P'], ['history', 'History'], ['analytics', 'Stats'], ['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('');
@@ -773,6 +784,7 @@
syncBars();
if (name === 'reports') loadReports();
if (name === 'quality') loadLyricsQuality();
if (name === 'cache') loadCacheHealth();
if (name === 'history') loadRevs();
if (name === 'peers') loadP2p();
if (name === 'library') loadUploads();
@@ -993,6 +1005,45 @@
loadLyricsQuality();
} catch (error) { $('qualityStatus').className = 'msg err'; $('qualityStatus').textContent = error.message; }
});
/* ---------------- cache health and pinning ---------------- */
let cacheOffset = 0;
async function loadCacheHealth() {
$('cacheTable').innerHTML = '<tr><td class="muted">Loading cache…</td></tr>';
try {
const j = await api(`/api/admin/cache/health?limit=50&offset=${cacheOffset}`);
if (!j.ok) throw Error(j.error || 'Cache health unavailable.');
const ready = j.statuses.find(x => x.status === 'ready') || {}, pending = j.statuses.find(x => ['queued', 'downloading', 'validating'].includes(x.status)), failed = j.statuses.find(x => x.status === 'failed') || {};
$('cacheMetrics').innerHTML = [
['Cache used', `${bytes(j.bytes)} / ${bytes(j.budget)}`, `${Math.round(j.utilization * 100)}% of configured budget`],
['Ready', Number(ready.count || 0).toLocaleString(), bytes(ready.bytes || 0)],
['Pending / failed', `${Number(pending?.count || 0)} / ${Number(failed.count || 0)}`, 'Jobs in progress or needing retry'],
['Pinned', `${bytes(j.pinnedBytes)} / ${bytes(j.pinnedBudget)}`, j.pinnedBytes > j.pinnedBudget ? 'Over cap: unpin copies to restore automatic eviction' : 'Protected from automatic eviction'],
].map(([label, value, detail]) => `<div class="metric"><span>${esc(label)}</span><b>${esc(value)}</b><small>${esc(detail)}</small></div>`).join('');
$('cacheCount').textContent = `${j.total.toLocaleString()} copies · ${j.media.length ? cacheOffset + 1 : 0}–${cacheOffset + j.media.length}`;
$('cachePrev').disabled = cacheOffset === 0; $('cacheNext').disabled = cacheOffset + j.media.length >= j.total;
$('cacheTable').innerHTML = j.media.length ? `<tr><th>Video</th><th>Status</th><th>Size</th><th>Last access</th><th>Actions</th></tr>${j.media.map(item => `<tr><td><button class="btn sm" data-open-cache="${esc(item.id)}">${esc(item.id)}</button><br><small>Generation ${item.gen}</small></td><td>${esc(item.status)}${item.pinned ? ' · pinned' : ''}</td><td>${bytes(item.bytes)}</td><td>${when(Math.floor(item.lastAccess / 1000))}</td><td><div class="row">${item.status === 'ready' ? `<button class="btn sm" data-cache-pin="${esc(item.id)}" data-pinned="${item.pinned}">${item.pinned ? 'Unpin' : 'Pin'}</button><button class="btn sm dng" data-cache-evict="${esc(item.id)}" ${item.pinned ? 'disabled' : ''}>Evict now</button>` : ''}<button class="btn sm" data-cache-redownload="${esc(item.id)}">Re-download</button></div></td></tr>`).join('')}` : '<tr><td class="muted">No media copies are recorded yet.</td></tr>';
} catch (error) { $('cacheStatus').className = 'msg err'; $('cacheStatus').textContent = error.message; }
}
$('cacheRefresh').addEventListener('click', loadCacheHealth);
$('cachePrev').addEventListener('click', () => { cacheOffset = Math.max(0, cacheOffset - 50); loadCacheHealth(); });
$('cacheNext').addEventListener('click', () => { cacheOffset += 50; loadCacheHealth(); });
$('cacheTable').addEventListener('click', async event => {
const button = event.target.closest('button'); if (!button) return;
if (button.dataset.openCache) { $('songId').value = button.dataset.openCache; showTab('editor'); return; }
let path, body = {};
if (button.dataset.cachePin) { path = `/api/admin/cache/${encodeURIComponent(button.dataset.cachePin)}/pin`; body = { pinned: button.dataset.pinned !== 'true' }; }
else if (button.dataset.cacheEvict) {
if (!confirm('Evict this server copy now? Clients may need to stream or save it again.')) return;
path = `/api/admin/cache/${encodeURIComponent(button.dataset.cacheEvict)}/evict`;
} else if (button.dataset.cacheRedownload) {
if (!confirm('Re-download this media from its source? This replaces the server copy.')) return;
path = `/api/admin/media/${encodeURIComponent(button.dataset.cacheRedownload)}/redownload`;
} else return;
$('cacheStatus').className = 'msg'; $('cacheStatus').textContent = 'Applying change…';
try { const result = await api(path, { method: 'POST', body }); if (!result.ok) throw Error(result.error || 'Action failed.'); $('cacheStatus').className = 'msg ok'; $('cacheStatus').textContent = 'Cache updated.'; await loadCacheHealth(); }
catch (error) { $('cacheStatus').className = 'msg err'; $('cacheStatus').textContent = error.message; }
});
$('tokenForm').addEventListener('submit', async (e) => {
e.preventDefault();
const label = $('tokenLabel').value.trim();