Files
2026-10-27 16:57:53 -06:00

230 lines
15 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Beach</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='14' fill='%23e9b44c'/%3E%3C/svg%3E">
<style>
:root{--bg:#f6f4ef;--card:#fff;--fg:#1d2327;--mut:#6b7378;--line:#e3dfd5;--ok:#1f8a4c;--warn:#b7791f;--bad:#c53030;--acc:#0e6e8c}
@media (prefers-color-scheme:dark){:root{--bg:#14181b;--card:#1d2226;--fg:#e8eaeb;--mut:#8d979c;--line:#2d3439;--ok:#4ac17c;--warn:#e0a84a;--bad:#f06a6a;--acc:#4fb3d1}}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.45 system-ui,sans-serif}
header{display:flex;align-items:center;gap:12px;padding:12px 20px;border-bottom:1px solid var(--line);background:var(--card);position:sticky;top:0;z-index:2}
header h1{font-size:18px;margin:0;flex:1}
main{max-width:1200px;margin:0 auto;padding:16px 20px 60px;display:grid;gap:16px}
section{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:14px 16px;overflow-x:auto}
section h2{font-size:13px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);margin:0 0 10px;display:flex;gap:8px;align-items:center}
table{border-collapse:collapse;width:100%}
th,td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--line);white-space:nowrap}
th{color:var(--mut);font-weight:500;font-size:12px}
tr:last-child td{border-bottom:0}
.dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:6px;background:var(--mut)}
.ok{background:var(--ok)}.warn{background:var(--warn)}.bad{background:var(--bad)}
.t-ok{color:var(--ok)}.t-warn{color:var(--warn)}.t-bad{color:var(--bad)}.mut{color:var(--mut)}
button{font:inherit;border:1px solid var(--line);background:var(--card);color:var(--fg);border-radius:6px;padding:3px 10px;cursor:pointer}
button:hover{border-color:var(--acc)}button.primary{background:var(--acc);color:#fff;border-color:var(--acc)}
input[type=text],input[type=password],input[type=url],input[type=number]{font:inherit;width:100%;padding:6px 8px;border:1px solid var(--line);border-radius:6px;background:var(--bg);color:var(--fg)}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px}
.stat .v{font-size:22px;font-weight:600}.stat .l{color:var(--mut);font-size:12px}
.bar{height:6px;background:var(--line);border-radius:3px;overflow:hidden;margin-top:4px}.bar i{display:block;height:100%;background:var(--acc)}
.bar i.warn{background:var(--warn)}.bar i.bad{background:var(--bad)}
.err{color:var(--bad);font-size:13px;margin:4px 0}
.cols{display:grid;grid-template-columns:1fr 1fr;gap:16px}@media(max-width:800px){.cols{grid-template-columns:1fr}}
dialog{border:1px solid var(--line);border-radius:10px;background:var(--card);color:var(--fg);width:min(900px,94vw);padding:16px}
dialog::backdrop{background:#0008}
pre{background:var(--bg);padding:10px;border-radius:6px;max-height:60vh;overflow:auto;font-size:12px;white-space:pre-wrap;word-break:break-all}
label{display:block;margin:10px 0 3px;color:var(--mut);font-size:12px}
.login{max-width:340px;margin:12vh auto;background:var(--card);border:1px solid var(--line);border-radius:10px;padding:20px}
.hide{display:none!important}
</style>
</head>
<body>
<div id="auth" class="login hide">
<h1 id="authTitle" style="margin-top:0">Beach</h1>
<p class="mut" id="authHint"></p>
<label>Name</label><input type="text" id="aName" autocomplete="username">
<label>Password</label><input type="password" id="aPass" autocomplete="current-password">
<p class="err" id="aErr"></p>
<button class="primary" id="aGo"></button>
</div>
<div id="app" class="hide">
<header>
<h1>🏖 Beach <span class="mut" id="hostName" style="font-weight:400;font-size:13px"></span></h1>
<span class="mut" id="updated"></span>
<button id="btnSettings">Settings</button>
<button id="btnLogout">Sign out</button>
</header>
<main>
<section id="hostSec"><h2>Host</h2><div class="grid" id="hostStats"></div></section>
<section id="dockerSec"><h2>Containers <span id="dockerCount" class="mut"></span></h2><div id="dockerBody"></div></section>
<div class="cols">
<section id="poolSec"><h2>Pools</h2><div id="poolBody"></div></section>
<section id="tunnelSec"><h2>Tunnels &amp; sites</h2><div id="tunnelBody"></div></section>
</div>
<section id="diskSec"><h2>Disks &amp; SMART</h2><div id="diskBody"></div></section>
</main>
</div>
<dialog id="logDlg"><h2 id="logTitle" style="margin-top:0"></h2><pre id="logBody"></pre><button id="logClose">Close</button> <button id="logReload">Reload</button></dialog>
<dialog id="setDlg">
<h2 style="margin-top:0">Settings</h2>
<label>Docker (socket path, or tcp://host:2375 — empty turns Docker off)</label><input type="text" id="sDocker">
<div class="cols">
<div>
<h3>TrueNAS</h3>
<label>URL</label><input type="url" id="sTnUrl" placeholder="https://truenas.local">
<label>API key <span id="sTnSet" class="mut"></span></label><input type="password" id="sTnKey" autocomplete="off" placeholder="leave blank to keep">
<label><input type="checkbox" id="sTnInsecure"> Accept a self-signed certificate</label>
<label><input type="checkbox" id="sSmart"> Also read SMART from local <code>smartctl</code></label>
</div>
<div>
<h3>Hammerhead</h3>
<label>URL</label><input type="url" id="sHhUrl" placeholder="https://hammerhead.example.com">
<label>Organization ID</label><input type="text" id="sHhOrg">
<label>API key <span id="sHhSet" class="mut"></span></label><input type="password" id="sHhKey" autocomplete="off" placeholder="leave blank to keep">
</div>
</div>
<label>Refresh every (seconds)</label><input type="number" id="sRefresh" min="2" max="3600" style="width:100px">
<p class="err" id="sErr"></p>
<button class="primary" id="sSave">Save</button> <button id="sCancel">Cancel</button>
</dialog>
<script>
const $ = id => document.getElementById(id);
let timer = null, logId = null;
async function api(method, path, body) {
const r = await fetch(path, {method, headers: {'X-Beach': '1', 'Content-Type': 'application/json'}, body: body ? JSON.stringify(body) : undefined});
const j = await r.json().catch(() => ({}));
if (r.status === 401 && path !== '/api/login') { showAuth(false); throw new Error(j.error || 'signed out'); }
if (!r.ok) throw new Error(j.error || r.statusText);
return j;
}
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
function bytes(n) { if (!n) return '0 B'; const u = ['B','KB','MB','GB','TB','PB']; let i = Math.min(5, Math.floor(Math.log(n) / Math.log(1024))); return (n / 1024 ** i).toFixed(i > 1 ? 1 : 0) + ' ' + u[i]; }
function dur(s) { s = Math.floor(s); const d = Math.floor(s / 86400), h = Math.floor(s % 86400 / 3600), m = Math.floor(s % 3600 / 60); return d ? `${d}d ${h}h` : h ? `${h}h ${m}m` : `${m}m`; }
const dot = c => `<span class="dot ${c}"></span>`;
function bar(p) { p = Math.max(0, Math.min(100, p)); return `<div class="bar"><i class="${p > 90 ? 'bad' : p > 75 ? 'warn' : ''}" style="width:${p}%"></i></div>`; }
function stat(l, v, p) { return `<div class="stat"><div class="l">${esc(l)}</div><div class="v">${v}</div>${p == null ? '' : bar(p)}</div>`; }
function showAuth(setup) {
$('app').classList.add('hide'); $('auth').classList.remove('hide');
clearTimeout(timer);
$('authTitle').textContent = setup ? 'Welcome to Beach' : 'Sign in';
$('authHint').textContent = setup ? 'Create the administrator account.' : '';
$('aGo').textContent = setup ? 'Create account' : 'Sign in';
$('aGo').onclick = async () => {
$('aErr').textContent = '';
try { await api('POST', setup ? '/api/setup' : '/api/login', {name: $('aName').value, password: $('aPass').value}); $('aPass').value = ''; start(); }
catch (e) { $('aErr').textContent = e.message; }
};
}
async function start() {
const s = await api('GET', '/api/session');
if (!s.user) return showAuth(s.setup);
$('auth').classList.add('hide'); $('app').classList.remove('hide');
refresh();
}
async function refresh() {
clearTimeout(timer);
let o;
try { o = await api('GET', '/api/overview'); } catch (e) { $('updated').textContent = e.message; timer = setTimeout(refresh, 15000); return; }
render(o);
$('updated').textContent = 'Updated ' + new Date(o.time).toLocaleTimeString();
timer = setTimeout(refresh, o.refreshSeconds * 1000);
}
function render(o) {
const h = o.host;
$('hostName').textContent = h.hostname || '';
if (!h.supported) $('hostStats').innerHTML = '<span class="mut">Host metrics are only collected on Linux.</span>';
else {
const memP = h.memTotal ? h.memUsed / h.memTotal * 100 : 0;
let s = stat('CPU', h.cpuPct.toFixed(0) + '%', h.cpuPct) + stat('Memory', `${bytes(h.memUsed)} / ${bytes(h.memTotal)}`, memP)
+ stat('Load', h.load.map(x => x.toFixed(2)).join(' · '), Math.min(100, h.load[0] / h.cpus * 100)) + stat('Uptime', dur(h.uptimeSeconds));
if (h.swapTotal) s += stat('Swap', `${bytes(h.swapUsed)} / ${bytes(h.swapTotal)}`, h.swapUsed / h.swapTotal * 100);
for (const f of h.filesystems) s += stat(f.mount, `${bytes(f.used)} / ${bytes(f.total)}`, f.used / f.total * 100);
$('hostStats').innerHTML = s;
}
const d = o.docker;
$('dockerSec').classList.toggle('hide', !d);
if (d) {
const run = d.containers.filter(c => c.state === 'running').length;
$('dockerCount').textContent = d.error ? '' : `${run} running / ${d.containers.length}`;
$('dockerBody').innerHTML = (d.error ? `<div class="err">${esc(d.error)}</div>` : '') + (d.containers.length ? `<table><tr><th>Name</th><th>Image</th><th>Status</th><th>Ports</th><th></th></tr>${d.containers.map(c => {
const up = c.state === 'running', bad = /unhealthy/.test(c.status) || c.state === 'dead';
return `<tr><td>${dot(bad ? 'bad' : up ? 'ok' : '')}${esc(c.name)}</td><td class="mut">${esc(c.image)}</td><td>${esc(c.status)}</td><td class="mut">${esc(c.ports.join(', '))}</td>
<td>${up ? `<button data-a="stop" data-id="${esc(c.id)}">Stop</button> <button data-a="restart" data-id="${esc(c.id)}">Restart</button>` : `<button data-a="start" data-id="${esc(c.id)}">Start</button>`} <button data-a="logs" data-id="${esc(c.id)}" data-n="${esc(c.name)}">Logs</button></td></tr>`;
}).join('')}</table>` : d.error ? '' : '<span class="mut">No containers.</span>');
}
const s = o.storage;
$('poolSec').classList.toggle('hide', !s); $('diskSec').classList.toggle('hide', !s);
if (s) {
$('poolBody').innerHTML = (s.error ? `<div class="err">${esc(s.error)}</div>` : '')
+ s.pools.map(p => `<div style="margin-bottom:10px">${dot(p.healthy ? 'ok' : p.status === 'ONLINE' ? 'warn' : 'bad')}<b>${esc(p.name)}</b> <span class="${p.healthy ? 't-ok' : 't-bad'}">${esc(p.status)}</span> <span class="mut">${bytes(p.allocated)} / ${bytes(p.size)}</span>${bar(p.usedPct)}<div class="mut">${esc(p.scan || 'never scrubbed')}</div></div>`).join('')
+ s.alerts.map(a => `<div class="t-warn">⚠ ${esc(a.level)}: ${esc(a.message)}</div>`).join('');
$('diskBody').innerHTML = s.disks.length ? `<table><tr><th>Disk</th><th>Model</th><th>Serial</th><th>Size</th><th>Pool</th><th>Temp</th><th>SMART</th></tr>${s.disks.map(k => {
const hc = k.health === 'PASSED' ? 'ok' : k.health ? 'bad' : '', tc = k.tempC >= 55 ? 'bad' : k.tempC >= 45 ? 'warn' : '';
return `<tr><td>${esc(k.name)}</td><td>${esc(k.model)}</td><td class="mut">${esc(k.serial)}</td><td>${bytes(k.size)}</td><td>${esc(k.pool || '')}</td><td class="${tc && 't-' + tc}">${k.tempC ? k.tempC + ' °C' : '–'}</td><td>${dot(hc)}${esc(k.health || 'unknown')}</td></tr>`;
}).join('')}</table>` : '<span class="mut">No disks reported.</span>';
}
const t = o.tunnels;
$('tunnelSec').classList.toggle('hide', !t);
if (t) {
$('tunnelBody').innerHTML = (t.error ? `<div class="err">${esc(t.error)}</div>` : '')
+ `<table>${t.sites.map(x => `<tr><td>${dot(x.online ? 'ok' : 'bad')}${esc(x.name)}${x.local ? ' <span class="mut">(edge-local)</span>' : ''}</td><td class="mut">${esc(x.version || '')}</td><td>${x.online ? 'online' : 'offline'}</td></tr>`).join('')}
${t.ports.map(p => `<tr><td>${dot(!p.enabled ? '' : p.up ? 'ok' : 'bad')}${esc(p.name)}</td><td class="mut">${esc(p.hostname || p.proto + ':' + p.port)}</td><td>${!p.enabled ? 'disabled' : p.up ? `up${p.targets ? ` · ${p.healthy}/${p.targets} healthy` : ''}` : 'down'}</td></tr>`).join('')}</table>`;
}
}
$('dockerBody').addEventListener('click', async e => {
const b = e.target.closest('button'); if (!b) return;
const {a, id} = b.dataset;
if (a === 'logs') return openLogs(id, b.dataset.n);
b.disabled = true;
try { await api('POST', `/api/containers/${encodeURIComponent(id)}/${a}`); } catch (err) { alert(err.message); }
refresh();
});
async function openLogs(id, name) {
logId = id; $('logTitle').textContent = name + ' — last 200 lines';
if (!$('logDlg').open) $('logDlg').showModal();
$('logBody').textContent = 'Loading…';
try { $('logBody').textContent = (await api('GET', `/api/containers/${encodeURIComponent(id)}/logs?tail=200`)).logs || '(empty)'; } catch (e) { $('logBody').textContent = e.message; }
$('logBody').scrollTop = 1e9;
}
$('logClose').onclick = () => $('logDlg').close();
$('logReload').onclick = () => openLogs(logId, $('logTitle').textContent.split(' — ')[0]);
$('btnLogout').onclick = async () => { await api('POST', '/api/logout').catch(() => {}); start(); };
$('btnSettings').onclick = async () => {
const s = await api('GET', '/api/settings');
$('sDocker').value = s.dockerHost; $('sTnUrl').value = s.truenas.url; $('sTnInsecure').checked = s.truenas.insecure; $('sSmart').checked = s.smartctl;
$('sHhUrl').value = s.hammerhead.url; $('sHhOrg').value = s.hammerhead.org; $('sRefresh').value = s.refreshSeconds;
$('sTnKey').value = $('sHhKey').value = ''; $('sTnSet').textContent = s.truenasKeySet ? '(saved)' : ''; $('sHhSet').textContent = s.hammerheadKeySet ? '(saved)' : '';
$('sErr').textContent = ''; $('setDlg').showModal();
};
$('sCancel').onclick = () => $('setDlg').close();
$('sSave').onclick = async () => {
try {
await api('PUT', '/api/settings', {dockerHost: $('sDocker').value.trim(), smartctl: $('sSmart').checked, refreshSeconds: +$('sRefresh').value,
truenas: {url: $('sTnUrl').value.trim(), apiKey: $('sTnKey').value, insecure: $('sTnInsecure').checked},
hammerhead: {url: $('sHhUrl').value.trim(), org: $('sHhOrg').value.trim(), apiKey: $('sHhKey').value}});
$('setDlg').close(); refresh();
} catch (e) { $('sErr').textContent = e.message; }
};
['aName', 'aPass'].forEach(i => $(i).addEventListener('keydown', e => { if (e.key === 'Enter') $('aGo').click(); }));
start();
</script>
</body>
</html>