bunch of shit

This commit is contained in:
2026-11-06 19:05:47 -06:00
parent ca01516d42
commit 630b253278
29 changed files with 2793 additions and 303 deletions

416
streaming/index.html Normal file
View File

@@ -0,0 +1,416 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Streaming — Vanguard</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<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>Streaming</h1><div class="who" id="who"></div></header>
<nav><a href="#live" id="tab-live">Live</a><a href="#stuck" id="tab-stuck">Stuck recordings</a><a href="#dump" id="tab-dump">Dump a night</a><a href="/disk" style="margin-left:auto">Disk ↗</a></nav>
<!-- ===================== LIVE ===================== -->
<div id="pane-live">
<section>
<h2>Publishing now</h2>
<div id="pubs" class="note">Loading…</div>
</section>
<section>
<h2>Blocked from publishing</h2>
<div id="blocks" class="note">Loading…</div>
</section>
<section>
<h2>Why MediaMTX opened recording files — last <span id="win">10m</span></h2>
<div id="counts" class="note">Loading…</div>
<div id="verdict" class="verdict"></div>
</section>
<section>
<h2>Live paths</h2>
<div id="paths" class="note">Loading…</div>
</section>
<section>
<h2>Publish sessions — last 24 h</h2>
<div id="hist" class="note">Loading…</div>
</section>
<section>
<h2>Recorder log</h2>
<pre id="log">Loading…</pre>
</section>
<footer>Refreshes every 10 seconds. Loss is counted on the <em>publish</em> session, i.e. on the way in: that is what corrupts recordings. Viewers losing packets only affects what they see.</footer>
</div>
<!-- ===================== STUCK ===================== -->
<div id="pane-stuck" class="hidden">
<p class="note">Recordings that finished but never reached Jellyfin or copyparty. Retry puts one back in the
queue; bin moves it to the trash, recoverable for three days just like a dumped night.</p>
<section id="bulk" class="hidden">
<div class="row" style="grid-template-columns:1fr auto">
<div>
<b>Reconnect fragments</b>
<div class="meta">Every time a publisher drops and comes back, MediaMTX opens a new recording. Stream
recordings this small are stubs, not recordings. Clips are not affected: they never pass through <code>.live/</code>.</div>
</div>
<div class="acts">
<label class="meta">under <input id="mb" type="number" min="1" max="50" value="5" style="width:4em"> MB</label>
<button id="bulkbtn" class="danger">Bin <span id="bulkcount">0</span> fragments</button>
</div>
</div>
</section>
<div id="list"><section class="note">Loading…</section></div>
<footer>Refreshes every 30 seconds. Trends and history live on the dashboards.</footer>
</div>
<!-- ===================== DUMP ===================== -->
<div id="pane-dump" class="hidden">
<p class="note">A bad night goes to the trash in one move: every stream recording and clip a streamer saved
between <b>17:00 and 02:00 Eastern</b> on the chosen night. Recoverable for three days, like the Stuck tab's
bin. Admins can dump anyone or everyone; everyone else sees and dumps only their own.</p>
<section>
<div class="row" style="grid-template-columns:auto 1fr auto;align-items:center">
<label class="meta">night <input id="night" type="date" style="font:inherit;background:#262626;color:var(--fg);border:1px solid #3a3a3a;border-radius:8px;padding:4px 8px"></label>
<div class="meta" id="dumpwin"></div>
<div class="acts"><button id="dumpall" class="danger hidden">Dump everyone</button></div>
</div>
</section>
<div id="dumplist"><section class="note">Loading…</section></div>
<section id="wipe" class="hidden">
<div class="row" style="grid-template-columns:1fr auto">
<div>
<b>Unprocessed and stuck recordings</b>
<div class="meta" id="wipemeta"></div>
<div class="meta">Everything still sitting in a <code>.live/</code> folder, whatever night it is from: stuck, parked, waiting, fresh, reconnect fragments, and the failure logs that go with them. Any conversion the sweep has in flight is cancelled first. The only thing left alone is a stream MediaMTX is recording this minute.</div>
</div>
<div class="acts"><button id="wipebtn" class="danger">Bin all unprocessed</button></div>
</div>
<pre id="wipeout" class="hidden"></pre>
</section>
<footer>Nothing here moves until you confirm twice. Streamers can also dump their own night from the OBS dock.</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'}`; }
// ---------------- Live ----------------
async function loadLive() {
let d;
try { d = await getJSON(API + '/live'); }
catch (e) { document.getElementById('pubs').innerHTML = `<span class="err">${esc(e.message)}</span>`; return; }
whoami(d);
const pubs = d.publishers || [];
document.getElementById('pubs').innerHTML = pubs.length ? `<table>
<tr><th>path</th><th>via</th><th>from</th><th>since</th><th class="n">received</th><th class="n">lost</th><th>loss</th><th></th></tr>
${pubs.map(p => {
const pct = p.received ? (100 * (p.lost || 0) / p.received) : 0;
const cls = p.kind !== 'webrtc' ? 'info' : pct === 0 ? 'ok' : pct < 0.05 ? 'warn' : 'bad';
const txt = p.kind !== 'webrtc' ? 'tcp' : `${pct.toFixed(3)}%`;
// webrtc: the selected ICE pair's protocol. tcp = the Go Live lossy-uplink switch (or the box ticked)
const via = p.kind === 'webrtc' ? `webrtc <span class="pill ${p.transport === 'tcp' ? 'info' : p.transport === 'udp' ? 'ok' : 'warn'}">${esc(p.transport || 'connecting')}</span>` : esc(p.kind);
return `<tr><td class="mono">${esc(p.path)}</td><td>${via}</td><td class="mono">${esc(p.from)}</td><td class="note">${p.since ? new Date(p.since).toLocaleTimeString() : ''}</td>
<td class="n">${p.received != null ? p.received.toLocaleString() : (p.bytes != null ? mb(p.bytes) : '')}</td>
<td class="n">${p.lost != null ? p.lost.toLocaleString() : ''}</td><td><span class="pill ${cls}">${txt}</span></td>
<td class="acts">${admin && p.session ? `<select class="stopmins" data-session="${esc(p.session)}" title="how long the account stays unable to publish after the kick">
<option value="0">kick only</option><option value="30" selected>block 30 min</option><option value="120">block 2 h</option><option value="480">block 8 h</option></select>
<button class="danger stopbtn" data-path="${esc(p.path)}" data-kind="${esc(p.kind)}" data-session="${esc(p.session)}">Stop stream</button>` : ''}</td></tr>`;
}).join('')}</table>` : '<span class="note">nobody is publishing</span>';
for (const b of document.querySelectorAll('#pubs .stopbtn')) b.onclick = () => stopStream(b);
// accounts currently blocked from publishing (set by Stop stream), with an unblock
const blocks = d.blocks || [];
document.getElementById('blocks').innerHTML = blocks.length ? `<table>
<tr><th>account</th><th>blocked</th><th>until</th><th>by</th><th>why</th><th></th></tr>
${blocks.map(b => `<tr><td class="mono">${esc(b.user)}</td><td class="note">${b.since ? new Date(b.since).toLocaleTimeString() : ''}</td>
<td class="note">${new Date(b.until_iso).toLocaleTimeString()}</td><td>${esc(b.by || '')}</td><td class="note">${esc(b.reason || '')}</td>
<td class="acts">${admin ? `<button class="unblock" data-user="${esc(b.user)}">Allow again</button>` : ''}</td></tr>`).join('')}</table>`
: '<span class="note">nobody is blocked from publishing</span>';
for (const b of document.querySelectorAll('#blocks .unblock')) b.onclick = () => unblock(b.dataset.user);
const c = d.counts || {};
document.getElementById('win').textContent = c.window || '10m';
document.getElementById('counts').innerHTML = `<table>
<tr><td>new publish sessions</td><td class="n">${c.publish_sessions ?? '?'}</td><td class="note">a reconnect each</td></tr>
<tr><td>recorder (re)starts</td><td class="n">${c.recorder_restarts ?? '?'}</td><td class="note">a new .live file each</td></tr>
<tr><td>processing errors</td><td class="n">${c.processing_errors ?? '?'}</td><td class="note">lost packets corrupting frames</td></tr>
<tr><td>recorder access-unit errors</td><td class="n">${c.access_unit_errors ?? '?'}</td><td class="note">frames the recorder refused</td></tr>
</table>`;
let v = '';
if ((c.recorder_restarts|0) > 1) {
if ((c.processing_errors|0) > 0) v = '<span class="pill bad">loss</span> files are splitting because packets are being lost on the way in: check the publisher\'s uplink, and that MediaMTX is on the host network.';
else if ((c.publish_sessions|0) >= (c.recorder_restarts|0) - 1) v = '<span class="pill warn">reconnects</span> the publisher\'s session keeps ending: look at their browser console and the "closed" reasons below.';
else v = '<span class="pill warn">parameter changes</span> one stable session, no loss, yet files keep opening: the encoder is changing resolution (new SPS). Pin resolution in the publisher.';
} else if (pubs.length) v = '<span class="pill ok">steady</span> one file per stream, nothing lost.';
document.getElementById('verdict').innerHTML = v;
const paths = d.paths || [];
document.getElementById('paths').innerHTML = paths.length ? `<table>
<tr><th>path</th><th>tracks</th><th>encode</th><th class="n">viewers</th><th class="n">received</th><th>since</th></tr>
${paths.map(p => {
const h = p.health;
// MediaMTX refuses WebRTC for H.264 with B-frames: that is the single most common "I can't watch it".
const enc = p.mirror ? '' : !h ? '<span class="note">probing…</span>'
: h.bframes > 0 ? `<span class="pill bad">B-frames (depth ${h.bframes}) · WebRTC refused</span> <span class="note">${esc(h.res || '')} ${h.fps ? h.fps + ' fps' : ''}</span>`
: `<span class="pill ok">no B-frames</span> <span class="note">${esc(h.res || '')} ${h.fps ? h.fps + ' fps' : ''}</span>`;
return `<tr><td class="mono">${esc(p.name)}${p.mirror ? ' <span class="pill info">audio mirror</span>' : ''}</td>
<td>${esc((p.tracks || []).join(', '))}</td><td>${enc}</td><td class="n">${p.readers}</td><td class="n">${mb(p.bytes || 0)}</td>
<td class="note">${p.since ? new Date(p.since).toLocaleTimeString() : ''}</td></tr>`;
}).join('')}</table>` : '<span class="note">nothing live</span>';
// Every WebRTC publish session MediaMTX has had in 24 h, with the transport it ran on. A session that starts
// on a different transport than the previous one of the same path is a switch: that is "when he swapped".
const hist = d.history || [];
const span = (a, b) => { if (!a) return ''; const s = Math.max(0, ((b ? new Date(b) : new Date()) - new Date(a)) / 1000 | 0); return hms(s) || '<1m'; };
document.getElementById('hist').innerHTML = hist.length ? `<table>
<tr><th>path</th><th>transport</th><th>started</th><th>ended</th><th>ran</th><th class="n">received</th><th class="n">lost</th><th>loss</th><th></th></tr>
${hist.map(h => {
const pct = h.received ? (100 * (h.lost || 0) / h.received) : 0;
const cls = pct === 0 ? 'ok' : pct < 0.05 ? 'warn' : 'bad';
const sw = h.switched_from ? `<span class="pill info">switched from ${esc(h.switched_from)}</span>` : '';
return `<tr><td class="mono">${esc(h.path)}</td><td><span class="pill ${h.transport === 'tcp' ? 'info' : h.transport ? 'ok' : 'warn'}">${esc(h.transport || '?')}</span></td>
<td class="note">${h.created ? new Date(h.created).toLocaleTimeString() : ''}</td><td class="note">${h.ended ? new Date(h.ended).toLocaleTimeString() : '<b>live</b>'}</td>
<td class="note">${span(h.created, h.ended)}</td><td class="n">${(h.received || 0).toLocaleString()}</td><td class="n">${(h.lost || 0).toLocaleString()}</td>
<td><span class="pill ${cls}">${pct.toFixed(3)}%</span></td><td>${sw}</td></tr>`;
}).join('')}</table>` : '<span class="note">no WebRTC publish sessions in the last 24 h</span>';
document.getElementById('log').textContent = (d.log || []).join('\n') || '(quiet)';
if (d.errors && d.errors.length) document.getElementById('log').textContent += '\n\n' + d.errors.join('\n');
}
// Stop stream: MediaMTX kicks the publish session, and dumpd blocks the account's publishing for the chosen
// time so OBS's auto-reconnect or a Go Live restart cannot bring it straight back. Kick only = a hiccup.
async function stopStream(btn) {
const sel = document.querySelector(`#pubs .stopmins[data-session="${btn.dataset.session}"]`);
const minutes = parseInt(sel?.value || '30', 10);
const what = minutes ? `and block ${esc(btn.dataset.path.split('/')[0])} from publishing for ${minutes >= 60 ? (minutes / 60) + ' h' : minutes + ' min'}` : 'only (they can reconnect at once)';
if (!confirm(`Stop ${btn.dataset.path} now, ${what}?\n\nThe recording so far is kept.`)) return;
btn.disabled = true; btn.textContent = 'Stopping…';
try {
const r = await fetch(API + '/live', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'stop', path: btn.dataset.path, kind: btn.dataset.kind, session: btn.dataset.session, minutes }) });
const body = await r.json();
btn.textContent = body.error ? `Failed: ${body.error}` : (body.kicked ? 'Stopped' : 'Not stopped');
} catch (e) { btn.textContent = `Failed: ${e.message}`; }
setTimeout(loadLive, 1500);
}
async function unblock(user) {
if (!confirm(`Let ${user} publish again now?`)) return;
try {
await fetch(API + '/live', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'unblock', user }) });
} catch {}
loadLive();
}
// ---------------- Stuck ----------------
async function loadStuck() {
const list = document.getElementById('list');
let data;
try { data = await getJSON(API + '/stuck'); }
catch (e) { list.innerHTML = `<section class="note err">${esc(e.message)}</section>`; return; }
whoami(data);
const items = (data.items || []).sort((a, b) => (a.state === b.state ? b.age_s - a.age_s : a.state.localeCompare(b.state)));
const bulk = document.getElementById('bulk');
bulk.classList.toggle('hidden', !admin || !items.length);
window.__items = items; updateBulkCount();
if (!items.length) { list.innerHTML = '<section class="note">Nothing is stuck. Everything recorded has published.</section>'; return; }
list.innerHTML = '';
for (const it of items) {
const el = document.createElement('section');
const actionable = admin && it.state !== 'writing';
el.innerHTML = `
<div class="row">
<div class="state ${esc(it.state)}">${esc(it.state)}</div>
<div>
<div class="name">${esc(short(it.src))}</div>
<div class="meta">waiting ${hms(it.age_s)}${it.duration_s == null ? '' : ' · ' + dur(it.duration_s) + ' long'} · ${mb(it.bytes)}</div>
<div class="why">${esc(it.reason || '')}</div>
</div>
<div class="acts">
<button data-act="retry" ${actionable ? '' : 'disabled'}>Retry</button>
<button data-act="drop" class="danger" ${actionable ? '' : 'disabled'}>Bin</button>
</div>
</div>`;
for (const b of el.querySelectorAll('button')) b.onclick = () => act(it, b.dataset.act, el);
list.appendChild(el);
}
}
async function act(item, action, el) {
if (action === 'drop' && !confirm(`Move this recording to the trash?\n\n${short(item.src)}\n\nRecoverable for three days.`)) return;
for (const b of el.querySelectorAll('button')) b.disabled = true;
const out = document.createElement('pre'); out.textContent = action === 'retry' ? 'Clearing the blocker…' : 'Moving to trash…'; el.appendChild(out);
try {
const r = await fetch(API + '/stuck', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action, src: item.src }) });
const body = await r.json();
out.textContent = body.error ? `Failed: ${body.error}` : (body.output || []).join('\n') || 'Done.';
if (!body.error) setTimeout(loadStuck, 1200);
} catch (e) { out.textContent = `Failed: ${e.message}`; }
}
function updateBulkCount() {
const mbv = Math.max(1, Math.min(50, parseInt(document.getElementById('mb').value || '5', 10)));
const n = (window.__items || []).filter(i => i.state !== 'writing' && i.bytes < mbv * 1e6).length;
document.getElementById('bulkcount').textContent = n;
document.getElementById('bulkbtn').disabled = n === 0;
}
document.getElementById('mb').addEventListener('input', updateBulkCount);
document.getElementById('bulkbtn').onclick = async () => {
const mbv = parseInt(document.getElementById('mb').value || '5', 10);
const n = document.getElementById('bulkcount').textContent;
if (!confirm(`Move ${n} stream recordings smaller than ${mbv} MB to the trash?\n\nRecoverable for three days. Clips are not affected.`)) return;
const btn = document.getElementById('bulkbtn'); btn.disabled = true; btn.textContent = 'Binning…';
try {
const r = await fetch(API + '/stuck', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'drop_under', mb: mbv }) });
const body = await r.json();
btn.textContent = body.error ? `Failed: ${body.error}` : 'Done';
} catch (e) { btn.textContent = `Failed: ${e.message}`; }
setTimeout(loadStuck, 1500);
};
// ---------------- Dump ----------------
// Same endpoint as the dock's "Dump tonight" button (dumpd), reached here as /streaming/api/dump so the
// Authentik cookie for this host is enough. GET is always a dry run; only the POST moves anything.
const nightEl = document.getElementById('night');
let dumpNightTouched = false;
nightEl.addEventListener('change', () => { dumpNightTouched = true; loadDump(); });
async function loadDump() {
const list = document.getElementById('dumplist');
let d;
try {
const q = new URLSearchParams({ user: 'all' }); // non-admins get their own back regardless
if (dumpNightTouched && nightEl.value) q.set('night', nightEl.value);
d = await getJSON(API + '/dump?' + q);
} catch (e) { list.innerHTML = `<section class="note err">${esc(e.message)}</section>`; return; }
whoami(d);
if (!dumpNightTouched) nightEl.value = d.night;
document.getElementById('dumpwin').textContent = d.window ? `${d.window[0].replace('T', ' ').slice(0, 16)} → ${d.window[1].replace('T', ' ').slice(0, 16)}` : '';
const byUser = {};
for (const e of d.entries || []) {
const m = e.path.match(/^(streams|clips)\/([^/]+)\//);
const u = m ? m[2] : '(other)'; const kind = m ? m[1] : 'other';
const row = byUser[u] ||= { user: u, streams: 0, clips: 0, bytes: 0 };
row[kind === 'streams' ? 'streams' : 'clips']++; row.bytes += e.bytes;
}
const rows = Object.values(byUser).sort((a, b) => b.bytes - a.bytes);
document.getElementById('dumpall').classList.toggle('hidden', !(admin && rows.length > 1));
if (!rows.length) { list.innerHTML = `<section class="note">Nothing recorded on the night of ${esc(d.night)}${admin ? '' : ' under your name'}.</section>`; return; }
list.innerHTML = `<section><table>
<tr><th>streamer</th><th class="n">stream recordings</th><th class="n">clips</th><th class="n">size</th><th></th></tr>
${rows.map(r => `<tr data-user="${esc(r.user)}"><td><b>${esc(r.user)}</b></td><td class="n">${r.streams}</td><td class="n">${r.clips}</td><td class="n">${(r.bytes / 1e9).toFixed(2)} GB</td>
<td class="acts"><button class="danger" data-user="${esc(r.user)}">Dump ${esc(r.user)}'s night</button></td></tr>`).join('')}
<tr><td>total</td><td class="n">${rows.reduce((a, r) => a + r.streams, 0)}</td><td class="n">${rows.reduce((a, r) => a + r.clips, 0)}</td><td class="n"><b>${(d.gb || 0).toFixed(2)} GB</b></td><td></td></tr>
</table><pre id="dumpout" class="hidden"></pre></section>`;
for (const b of list.querySelectorAll('button[data-user]')) b.onclick = () => dumpNight(b.dataset.user, b);
}
async function dumpNight(user, btn) {
const night = nightEl.value;
const who = user === 'all' ? 'EVERYONE' : user;
if (!confirm(`Move ${who}'s recordings and clips from the night of ${night} to the trash?\n\nRecoverable for three days.`)) return;
if (btn.dataset.armed !== night) { // second click, same night: the dock's two-step
btn.dataset.armed = night; const was = btn.textContent; btn.textContent = 'Click again to confirm';
setTimeout(() => { if (btn.isConnected) { btn.textContent = was; delete btn.dataset.armed; } }, 10000);
return;
}
btn.disabled = true; btn.textContent = 'Dumping…';
const out = document.getElementById('dumpout'); out.classList.remove('hidden'); out.textContent = `Moving ${who}'s night of ${night}…`;
try {
const r = await fetch(API + '/dump', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user, night }) });
const body = await r.json();
out.textContent = body.error ? `Failed: ${body.error}` : `Dumped ${body.moved} file(s), ${body.gb} GB from ${body.user}'s night of ${body.night}. Recoverable until ${body.undo_until}.`;
} catch (e) { out.textContent = `Failed: ${e.message}`; }
setTimeout(loadDump, 1200);
}
document.getElementById('dumpall').onclick = () => dumpNight('all', document.getElementById('dumpall'));
// "Bin all unprocessed": the Stuck tab's survey gives the count and size; vgstuck.sh wipe does the moving.
async function loadWipe() {
const box = document.getElementById('wipe');
let d;
try { d = await getJSON(API + '/stuck'); } catch (e) { box.classList.add('hidden'); return; }
const items = d.items || []; // "writing" is only a guess from mtime; the tool checks what is really open
box.classList.toggle('hidden', !admin || !items.length);
const gb = items.reduce((a, i) => a + i.bytes, 0) / 1e9;
document.getElementById('wipemeta').textContent = `${items.length} recording(s), ${gb.toFixed(2)} GB in .live/`;
const b = document.getElementById('wipebtn'); b.disabled = false; b.textContent = `Bin all ${items.length} unprocessed`; delete b.dataset.armed;
}
document.getElementById('wipebtn').onclick = async () => {
const btn = document.getElementById('wipebtn');
const meta = document.getElementById('wipemeta').textContent;
if (!confirm(`Move every unprocessed recording to the trash and cancel any conversion in progress?\n\n${meta}\n\nRecoverable for three days. Only a stream being recorded this minute is left alone.`)) return;
if (!btn.dataset.armed) { btn.dataset.armed = '1'; const was = btn.textContent; btn.textContent = 'Click again to confirm'; setTimeout(() => { if (btn.dataset.armed) { btn.textContent = was; delete btn.dataset.armed; } }, 10000); return; }
delete btn.dataset.armed; btn.disabled = true; btn.textContent = 'Binning…';
const out = document.getElementById('wipeout'); out.classList.remove('hidden'); out.textContent = 'Moving to trash…';
try {
const r = await fetch(API + '/stuck', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'wipe' }) });
const body = await r.json();
out.textContent = body.error ? `Failed: ${body.error}` : (body.output || []).join('\n') || 'Done.';
} catch (e) { out.textContent = `Failed: ${e.message}`; }
setTimeout(loadWipe, 1500);
};
// ---------------- tabs ----------------
let timer = null;
function show(tab) {
if (!['live', 'stuck', 'dump'].includes(tab)) tab = 'live';
for (const t of ['live', 'stuck', 'dump']) {
document.getElementById('pane-' + t).classList.toggle('hidden', t !== tab);
document.getElementById('tab-' + t).classList.toggle('on', t === tab);
}
clearInterval(timer);
if (tab === 'live') { loadLive(); timer = setInterval(loadLive, 10000); }
else if (tab === 'stuck') { loadStuck(); timer = setInterval(loadStuck, 30000); }
else { dumpNightTouched = false; loadDump(); loadWipe(); }
}
window.addEventListener('hashchange', () => show(location.hash.replace('#', '')));
show(location.hash.replace('#', '') || 'live');
</script>
</body>
</html>