230 lines
15 KiB
HTML
230 lines
15 KiB
HTML
<!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 & sites</h2><div id="tunnelBody"></div></section>
|
||
</div>
|
||
<section id="diskSec"><h2>Disks & 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|