Files
Media-Server-Setup/streaming/disk.html
2026-11-06 19:05:47 -06:00

133 lines
11 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Disk — Vanguard</title>
<style>
:root{--bg:#141414;--fg:#e6e6e6;--mut:#9a9a9a;--acc:#3d8bfd;--ok:#2ecc71;--warn:#e3a008;--bad:#e05260;--box:#1f1f1f;--line:#2e2e2e}
html,body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 system-ui,sans-serif}
main{max-width:1100px;margin:0 auto;padding:24px 16px 48px}
header{display:flex;align-items:baseline;gap:14px;flex-wrap:wrap;margin-bottom:10px}
h1{font-size:24px;margin:0}.who{color:var(--mut)}.who b{color:var(--fg)}
nav{display:flex;gap:6px;margin:6px 0 18px;border-bottom:1px solid var(--line)}
nav a{padding:8px 14px;color:var(--mut);text-decoration:none;border-bottom:2px solid transparent}
nav a.on{color:var(--fg);border-color:var(--acc)}
section{background:var(--box);border:1px solid var(--line);border-radius:14px;padding:14px 18px;margin-bottom:12px}
section h2{margin:0 0 8px;font-size:15px;color:var(--mut);font-weight:600;text-transform:uppercase;letter-spacing:.3px}
table{width:100%;border-collapse:collapse;font-size:14px}th,td{text-align:left;padding:6px 8px;border-top:1px solid var(--line);vertical-align:top}
th{color:var(--mut);font-weight:600;border-top:0}td.n{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}
.mono{font-family:ui-monospace,monospace;font-size:13px;word-break:break-all}
.pill{display:inline-block;font-size:12px;border-radius:999px;padding:1px 9px;white-space:nowrap}
.ok{background:#1f3b2a;color:var(--ok)}.warn{background:#3a3220;color:var(--warn)}.bad{background:#43242a;color:var(--bad)}.info{background:#1e3147;color:var(--acc)}
.state{font-size:12px;border-radius:999px;padding:2px 10px;text-align:center;white-space:nowrap}
.parked{background:#43242a;color:var(--bad)}.published{background:#3a3220;color:var(--warn)}.waiting{background:#1e3147;color:var(--acc)}.writing{background:#1f3b2a;color:var(--ok)}
.row{display:grid;grid-template-columns:96px 1fr auto;gap:12px;align-items:start}
.name{font-family:ui-monospace,monospace;font-size:13px;word-break:break-all}
.meta{color:var(--mut);font-size:13px;margin-top:2px}
.why{color:var(--mut);font-size:13px;margin-top:6px;border-left:2px solid var(--line);padding-left:10px;white-space:pre-wrap}
.acts{display:flex;gap:8px;align-items:center}
button{font:inherit;font-size:13px;border-radius:8px;border:1px solid #3a3a3a;background:#262626;color:var(--fg);padding:6px 12px;cursor:pointer}
select.stopmins{font:inherit;font-size:12px;border-radius:8px;border:1px solid #3a3a3a;background:#262626;color:var(--fg);padding:5px 6px}
button:hover:not(:disabled){border-color:var(--acc)}button:disabled{opacity:.45;cursor:default}
button.danger:hover:not(:disabled){border-color:var(--bad);color:var(--bad)}
input[type=number]{font:inherit;background:#121212;color:var(--fg);border:1px solid #3a3a3a;border-radius:6px;padding:4px 6px}
.note{color:var(--mut);font-size:13px}.err{color:var(--bad)}.hidden{display:none}
pre{background:#121212;border-radius:8px;padding:10px;overflow:auto;font-size:12px;margin:8px 0 0;max-height:280px}
.verdict{margin-top:8px;font-size:14px}
footer{color:var(--mut);font-size:13px;margin-top:20px}
</style>
</head>
<body>
<main>
<header><h1>Disk</h1><div class="who" id="who"></div></header>
<nav><a href="/disk" class="on">Disk</a><a href="/" style="margin-left:auto">Dashboards ↗</a></nav>
<div id="pane-disk">
<section>
<div class="row" style="grid-template-columns:1fr auto;align-items:center">
<div id="fs" class="note">Loading…</div>
<div class="acts"><span class="note" id="diskgen"></span><button id="diskrefresh">Rewalk now</button></div>
</div>
</section>
<section><h2>Streamers — recordings by month</h2><div id="dstreamers" class="note">Loading…</div></section>
<section><h2>Clippers — clips by month</h2><div id="dclippers" class="note">Loading…</div></section>
<section><h2>Logs</h2><div id="dlogs" class="note">Loading…</div></section>
<section><h2>System</h2><div id="dsystem" class="note">Loading…</div></section>
<footer>Sizes are allocated blocks, what the disk actually loses. A recording's month is the date in its own name; clips and
everything else go by modification time. Cached for ten minutes; Rewalk now re-reads every file.</footer>
</div>
</main>
<script>
const API = '/streaming/api';
let admin = false;
const esc = s => String(s ?? '').replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const hms = s => { s = Math.max(0, s|0); const h = s/3600|0, m = (s%3600)/60|0; return h ? `${h}h ${m}m` : `${m}m`; };
const dur = s => { s = Math.max(0, s|0); return `${s/3600|0}:${String((s%3600)/60|0).padStart(2,'0')}:${String(s%60).padStart(2,'0')}`; };
const short = p => p.replace(/^.*\/streams\//, '').replace('/.live/', ' · ');
const mb = b => (b/1e6).toFixed(b < 10e6 ? 1 : 0) + ' MB';
async function getJSON(path) {
const r = await fetch(path, { credentials: 'same-origin' });
if (r.status === 401) throw new Error('Not signed in. Reload the page.');
const text = await r.text();
let data; try { data = JSON.parse(text); }
catch { throw new Error(`Server answered HTTP ${r.status} with no usable body${text ? ': ' + text.slice(0, 160) : ' (an empty 502 means dumpd did not reply)'}`); }
if (data.error) throw new Error(`HTTP ${r.status}: ${data.error}`);
return data;
}
function whoami(d) { admin = !!d.admin; document.getElementById('who').innerHTML = `signed in as <b>${esc(d.user || '?')}</b>${admin ? '' : ' · read only'}`; }
const gb = b => b >= 1e12 ? (b / 1e12).toFixed(2) + ' TB' : b >= 1e9 ? (b / 1e9).toFixed(1) + ' GB' : b >= 1e6 ? (b / 1e6).toFixed(0) + ' MB' : b ? (b / 1e3).toFixed(0) + ' kB' : '';
const monthName = m => { const [y, mo] = m.split('-'); return new Date(+y, +mo - 1, 1).toLocaleString(undefined, { month: 'short', year: '2-digit' }); };
function monthTable(rows, months, extra) {
// rows: [{name, total, months:{}, note}] ; months: newest first, older ones folded into "older"
const shown = months.slice(0, 6), older = months.slice(6);
const cell = (r, m) => { const v = r.months[m] || 0; return `<td class="n">${v ? gb(v) : '<span class="note">·</span>'}</td>`; };
const oldSum = r => older.reduce((a, m) => a + (r.months[m] || 0), 0);
const tot = key => rows.reduce((a, r) => a + (key === 'total' ? r.total : key === 'older' ? oldSum(r) : (r.months[key] || 0)), 0);
return `<table>
<tr><th></th>${shown.map(m => `<th class="n">${monthName(m)}</th>`).join('')}${older.length ? '<th class="n">older</th>' : ''}<th class="n">total</th>${extra ? '<th></th>' : ''}</tr>
${rows.map(r => `<tr><td><b>${esc(r.name)}</b></td>${shown.map(m => cell(r, m)).join('')}${older.length ? `<td class="n">${gb(oldSum(r)) || '<span class="note">·</span>'}</td>` : ''}<td class="n"><b>${gb(r.total)}</b></td>${extra ? `<td class="note">${r.note || ''}</td>` : ''}</tr>`).join('')}
${rows.length > 1 ? `<tr><td class="note">all</td>${shown.map(m => `<td class="n">${gb(tot(m))}</td>`).join('')}${older.length ? `<td class="n">${gb(tot('older'))}</td>` : ''}<td class="n"><b>${gb(tot('total'))}</b></td>${extra ? '<td></td>' : ''}</tr>` : ''}
</table>`;
}
async function loadDisk(force) {
let d;
document.getElementById('diskgen').textContent = force ? 'walking every file…' : '';
try { d = await getJSON(API + '/disk' + (force ? '?refresh=1' : '')); }
catch (e) { document.getElementById('fs').innerHTML = `<span class="err">${esc(e.message)}</span>`; return; }
whoami(d);
document.getElementById('diskgen').textContent = `walked ${new Date(d.generated).toLocaleTimeString()} in ${d.seconds}s`;
const bar = (label, f) => { const pct = f.total ? 100 * f.used / f.total : 0; const cls = pct > 90 ? 'bad' : pct > 75 ? 'warn' : 'ok';
return `<div style="margin:4px 0"><b>${esc(label)}</b> <span class="mono note">${esc(f.path)}</span> — ${gb(f.used)} of ${gb(f.total)} used, <span class="pill ${cls}">${gb(f.free)} free</span>
<div style="height:8px;background:#262626;border-radius:4px;margin-top:4px"><div style="width:${pct.toFixed(1)}%;height:8px;border-radius:4px;background:var(--${cls === 'ok' ? 'ok' : cls === 'warn' ? 'warn' : 'bad'})"></div></div></div>`; };
const fs = d.fs || {};
document.getElementById('fs').innerHTML = (fs.media ? bar('media', fs.media) : '') + (fs.root && !fs.same_filesystem ? bar('system disk', fs.root) : '') + (fs.same_filesystem ? '<div class="note">media and the system share one filesystem</div>' : '');
const monthsOf = objs => [...new Set(objs.flatMap(o => Object.keys(o.months || {})))].sort().reverse();
const toRows = obj => Object.entries(obj).map(([name, v]) => ({ name, total: v.total, months: v.months || {},
note: [v.raw ? `${gb(v.raw)} raw in .src` : '', v.unpublished ? `${gb(v.unpublished)} unpublished in .live` : ''].filter(Boolean).join(' · ') })).sort((a, b) => b.total - a.total);
const sRows = toRows(d.streamers || {}); const trash = d.trash || { total: 0, months: {} };
if (trash.total) sRows.push({ name: 'trash (all users, 3-day undo)', total: trash.total, months: trash.months, note: 'dumped nights and binned recordings, purged after 3 days' });
document.getElementById('dstreamers').innerHTML = sRows.length ? monthTable(sRows, monthsOf(sRows), true) : '<span class="note">no recordings</span>';
const cRows = toRows(d.clippers || {});
document.getElementById('dclippers').innerHTML = cRows.length ? monthTable(cRows, monthsOf(cRows), false) : '<span class="note">no clips</span>';
const L = d.logs || {};
const lRows = [];
if (L.months) lRows.push({ name: 'Elasticsearch indices (logs + metrics)', total: L.es_total || 0, months: L.months, note: L.es_data_dir ? `${gb(L.es_data_dir)} on disk in appdata/elasticsearch` : '' });
document.getElementById('dlogs').innerHTML = (lRows.length ? monthTable(lRows, monthsOf(lRows), true) : '') + `<table style="margin-top:8px">
${L.es_data_dir != null ? `<tr><td>Elasticsearch data dir</td><td class="n">${gb(L.es_data_dir)}</td><td class="note">the on-disk truth for the indices above</td></tr>` : ''}
<tr><td>journald</td><td class="n">${gb(L.journald || 0)}</td><td class="note">systemd journal (dumpd, sweep, timers)</td></tr>
<tr><td>Docker json logs</td><td class="n">${gb(L.docker_json_logs || 0)}</td><td class="note">per-container log files Filebeat reads</td></tr>
</table>` + (L.indices ? `<details style="margin-top:8px"><summary class="note">${L.indices.length} indices</summary><table>${L.indices.sort((a, b) => b.bytes - a.bytes).map(i => `<tr><td class="mono">${esc(i.index)}</td><td class="n">${gb(i.bytes)}</td><td class="note">${esc(i.month)}</td></tr>`).join('')}</table></details>` : '');
const S = d.system || {};
document.getElementById('dsystem').innerHTML = `<table>${Object.entries(S).sort((a, b) => (typeof b[1] === 'number' ? b[1] : 0) - (typeof a[1] === 'number' ? a[1] : 0))
.map(([k, v]) => `<tr><td>${esc(k)}</td><td class="n">${typeof v === 'number' ? gb(v) : esc(v)}</td><td class="note">${k.startsWith('docker') ? "Docker's own figure" : k === 'media_other' ? 'markers, Syncthing state, versions under /srv/media' : ''}</td></tr>`).join('')}</table>`
+ (d.errors && d.errors.length ? `<div class="err" style="margin-top:8px">${d.errors.map(esc).join('<br>')}</div>` : '');
}
document.getElementById('diskrefresh').onclick = () => loadDisk(true);
loadDisk(false);
</script>
</body>
</html>