Files
Spinner/web/index.html
T
VeillaxandClaude Sonnet 5.5 4afbf2d266
CI / test (push) Successful in 54s
Release / release (push) Successful in 2m31s
Docker / image (push) Successful in 2m52s
Fix the Servers page failing when no servers are configured
An empty server list was sent as null, so the page threw on .length and
showed only the loading placeholder. The API now always returns a list,
the page tolerates null, and tests cover the empty config and the token.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 23:58:26 -06:00

544 lines
39 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 32 32%27%3E%3Crect width=%2732%27 height=%2732%27 rx=%276%27 fill=%27%231f5c5c%27/%3E%3Ccircle cx=%2716%27 cy=%2716%27 r=%274%27 fill=%27none%27 stroke=%27white%27 stroke-width=%272.2%27/%3E%3Cpath d=%27M16 5a11 11 0 0 1 11 11M16 27A11 11 0 0 1 5 16%27 fill=%27none%27 stroke=%27white%27 stroke-width=%272.2%27 stroke-linecap=%27round%27/%3E%3C/svg%3E">
<title>Spinner</title>
<style>
/* tokens:begin Design tokens, the Lightwell design language (docs/design.md). This is the only place raw colors and sizes are defined. */
:root {
--accent:#1f5c5c; --accent-contrast:#ffffff;
--bg:#f6f5f3; --surface:#ffffff; --surface-2:#efede9; --border:#e0ddd7; --border-strong:#c4c0b8;
--text:#1d1c1a; --text-muted:#5f5b54; --text-faint:#7a766e;
--accent-text:var(--accent);
--accent-soft:color-mix(in srgb, var(--accent) 9%, var(--surface));
--accent-hover:color-mix(in srgb, var(--accent) 88%, #000000);
--focus:color-mix(in srgb, var(--accent) 70%, #ffffff);
--success:#1c6b3a; --success-bg:#e3f1e8; --warning:#85560a; --warning-bg:#f9eed6; --danger:#a3271e; --danger-bg:#f9e6e3; --neutral-bg:#eceae6;
--overlay:rgb(20 19 17 / 0.55); --shadow:0 8px 24px rgb(20 19 17 / 0.14);
--s1:4px; --s2:8px; --s3:12px; --s4:16px; --s5:24px; --s6:32px; --s7:48px; --s8:64px;
--radius:6px; --radius-lg:10px; --border-w:1px;
--font:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
--font-mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
--text-xs:12px; --text-sm:13px; --text-base:15px; --text-lg:18px; --text-xl:22px; --text-2xl:28px; --leading:1.5;
--sidebar-w:232px; --content-max:1120px; --touch:44px; --control-h:38px;
--z-sticky:20; --z-overlay:100; --z-toast:200; --ease:140ms ease;
color-scheme:light;
}
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) {
--bg:#131312; --surface:#1b1b19; --surface-2:#252523; --border:#33322e; --border-strong:#4a4943;
--text:#ecebe7; --text-muted:#adaaa2; --text-faint:#8f8c84;
--accent-text:color-mix(in srgb, var(--accent) 45%, #ffffff);
--accent-soft:color-mix(in srgb, var(--accent) 22%, var(--surface));
--accent-hover:color-mix(in srgb, var(--accent) 85%, #ffffff);
--success:#86d3a0; --success-bg:#17301f; --warning:#e8bd6b; --warning-bg:#33280f; --danger:#f19a92; --danger-bg:#3a1b18; --neutral-bg:#2b2a27;
--overlay:rgb(0 0 0 / 0.65); --shadow:0 8px 24px rgb(0 0 0 / 0.5); color-scheme:dark;
} }
:root[data-theme="dark"] {
--bg:#131312; --surface:#1b1b19; --surface-2:#252523; --border:#33322e; --border-strong:#4a4943;
--text:#ecebe7; --text-muted:#adaaa2; --text-faint:#8f8c84;
--accent-text:color-mix(in srgb, var(--accent) 45%, #ffffff);
--accent-soft:color-mix(in srgb, var(--accent) 22%, var(--surface));
--accent-hover:color-mix(in srgb, var(--accent) 85%, #ffffff);
--success:#86d3a0; --success-bg:#17301f; --warning:#e8bd6b; --warning-bg:#33280f; --danger:#f19a92; --danger-bg:#3a1b18; --neutral-bg:#2b2a27;
--overlay:rgb(0 0 0 / 0.65); --shadow:0 8px 24px rgb(0 0 0 / 0.5); color-scheme:dark;
}
/* tokens:end */
*, *::before, *::after { box-sizing:border-box; }
html, body { height:100%; }
html { -webkit-text-size-adjust:100%; }
body { margin:0; background:var(--bg); color:var(--text); font:var(--text-base)/var(--leading) var(--font); -webkit-font-smoothing:antialiased; }
h1, h2, h3, h4 { margin:0; line-height:1.25; font-weight:600; letter-spacing:-.01em; }
h1 { font-size:var(--text-2xl); } h2 { font-size:var(--text-xl); } h3 { font-size:var(--text-lg); }
p { margin:0 0 var(--s3); }
a { color:var(--accent-text); text-underline-offset:2px; }
a:hover { text-decoration-thickness:2px; }
:focus-visible { outline:2px solid var(--focus); outline-offset:2px; border-radius:var(--radius); }
.sr-only { position:absolute; width:1px; height:1px; margin:-1px; padding:0; overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap; border:0; }
.skip { position:absolute; left:var(--s3); top:-100px; z-index:var(--z-toast); background:var(--surface); border:var(--border-w) solid var(--border-strong); border-radius:var(--radius); padding:var(--s2) var(--s3); }
.skip:focus { top:var(--s3); }
.dim { color:var(--text-muted); } .err { color:var(--danger); } .small { font-size:var(--text-sm); }
.num { font-variant-numeric:tabular-nums; }
/* long machine messages (certificate errors) wrap and stop at three lines; the full text is in the tooltip */
.clip { display:-webkit-box; -webkit-line-clamp:3; -webkit-box-orient:vertical; overflow:hidden; overflow-wrap:anywhere; max-width:340px; }
code, .mono { font-family:var(--font-mono); font-size:var(--text-sm); }
.icon { width:18px; height:18px; flex:none; stroke-width:1.75; }
/* shell */
.shell { display:grid; grid-template-columns:var(--sidebar-w) minmax(0,1fr); min-height:100vh; }
.sidebar { position:sticky; top:0; height:100vh; display:flex; flex-direction:column; gap:var(--s3); padding:var(--s5) var(--s3); background:var(--surface); border-right:var(--border-w) solid var(--border); overflow-y:auto; }
.sidebar-head { display:flex; align-items:center; justify-content:space-between; gap:var(--s2); }
.sidebar button.sidebar-close { display:none; }
.brand { display:flex; align-items:center; gap:var(--s2); font-size:var(--text-lg); font-weight:700; letter-spacing:-.02em; padding:0 var(--s3); color:var(--text); text-decoration:none; }
.brand .icon { color:var(--accent-text); }
.orgsel select { width:100%; }
.navsec { font-size:var(--text-xs); font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--text-muted); padding:var(--s4) var(--s3) var(--s1); }
.nav { display:flex; flex-direction:column; gap:2px; }
.navitem { display:flex; align-items:center; gap:var(--s3); width:100%; min-height:36px; padding:var(--s2) var(--s3); border-radius:var(--radius); color:var(--text-muted); text-decoration:none; font:inherit; font-weight:500; background:none; border:0; cursor:pointer; text-align:left; }
.navitem:hover { background:var(--surface-2); color:var(--text); }
.navitem.on { background:var(--accent-soft); color:var(--accent-text); }
.grow { flex:1 1 auto; min-width:0; }
.userbox { border-top:var(--border-w) solid var(--border); padding-top:var(--s3); }
.userbox .who { padding:var(--s1) var(--s3) var(--s2); font-size:var(--text-sm); color:var(--text-muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.content { min-width:0; }
main { max-width:var(--content-max); margin:0 auto; padding:var(--s6) var(--s5) var(--s8); }
.topbar, .backdrop { display:none; }
.page-h { display:flex; align-items:flex-start; justify-content:space-between; gap:var(--s4); margin-bottom:var(--s5); flex-wrap:wrap; }
.page-h p { margin:var(--s1) 0 0; color:var(--text-muted); max-width:70ch; }
.page-h .btn { white-space:nowrap; }
/* cards, layout */
.card { background:var(--surface); border:var(--border-w) solid var(--border); border-radius:var(--radius-lg); padding:var(--s5); margin-bottom:var(--s4); overflow-x:auto; }
.card.flush { padding:0; overflow:hidden; }
.card > h2 { font-size:var(--text-lg); margin:0 0 var(--s3); }
.row-h { display:flex; align-items:center; justify-content:flex-end; gap:var(--s3); margin-bottom:var(--s3); flex-wrap:wrap; }
.row-h h2 { display:none; }
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(170px,1fr)); gap:var(--s4); align-items:start; }
.stack { display:flex; flex-direction:column; gap:var(--s4); }
.stat { padding:var(--s4); margin-bottom:0; }
.stat-n { font-size:var(--text-xl); font-weight:600; letter-spacing:-.01em; }
.grid + .card, .grid + .empty { margin-top:var(--s4); }
[hidden] { display:none !important; }
/* a button or checkbox in a row of fields lines up with the inputs, below their labels */
.grid > button { justify-self:start; margin-top:calc(var(--text-sm) * var(--leading) + var(--s1)); }
.grid > label.chk { margin-top:calc(var(--text-sm) * var(--leading) + var(--s1) + var(--s2)); }
.sect-h { padding:var(--s3) var(--s4); font-weight:600; border-bottom:var(--border-w) solid var(--border); }
.scroll { overflow-x:auto; }
.actions { display:flex; gap:var(--s2); flex-wrap:wrap; }
.form-actions { justify-content:flex-end; margin-top:var(--s2); }
.toolbar { display:flex; align-items:center; gap:var(--s3); margin-bottom:var(--s3); flex-wrap:wrap; }
.toolbar input[type=search] { max-width:320px; }
.center { max-width:440px; margin:var(--s8) auto; padding:0 var(--s4); }
.center .brand { justify-content:center; font-size:var(--text-xl); padding-bottom:var(--s4); }
/* choices (add resource) */
.choices { display:grid; grid-template-columns:repeat(auto-fill,minmax(210px,1fr)); gap:var(--s3); }
.choice { text-align:left; background:var(--surface); border:var(--border-w) solid var(--border-strong); border-radius:var(--radius-lg); padding:var(--s3) var(--s4); color:var(--text); cursor:pointer; font:inherit; display:flex; flex-direction:column; gap:var(--s1); }
.choice:hover { background:var(--surface-2); }
.choice.on { border-color:var(--accent); background:var(--accent-soft); }
.choice-t { font-weight:600; }
/* update progress and log */
.progress { height:6px; background:var(--surface-2); border-radius:999px; overflow:hidden; }
.progress > div { height:100%; background:var(--accent); transition:width var(--ease); }
.progress > div.bad { background:var(--danger); }
.logbox { margin:0; background:var(--bg); border:var(--border-w) solid var(--border); border-radius:var(--radius); padding:var(--s3); font:var(--text-xs)/1.6 var(--font-mono); max-height:260px; overflow:auto; white-space:pre-wrap; word-break:break-word; }
/* forms */
label { display:flex; flex-direction:column; gap:var(--s1); font-size:var(--text-sm); font-weight:600; color:var(--text); }
label.chk { flex-direction:row; align-items:flex-start; gap:var(--s2); font-size:var(--text-base); font-weight:400; }
input, select, textarea { background:var(--surface); color:var(--text); border:var(--border-w) solid var(--border-strong); border-radius:var(--radius); padding:var(--s2) var(--s3); min-height:var(--control-h); font:inherit; font-weight:400; min-width:0; width:100%; }
textarea { min-height:96px; resize:vertical; }
input[type=checkbox] { width:16px; height:16px; min-height:0; padding:0; margin-top:3px; accent-color:var(--accent); }
input:focus, select:focus, textarea:focus { outline:2px solid var(--focus); outline-offset:0; border-color:var(--accent); }
input:disabled, select:disabled { opacity:.6; }
input[aria-invalid="true"] { border-color:var(--danger); }
.sub { color:var(--text-muted); font-size:var(--text-sm); font-weight:400; }
/* buttons: one height and shape. btn is the primary action, ghost the default, danger and quiet the others */
button.btn, button.ghost, .btn-link { display:inline-flex; align-items:center; justify-content:center; gap:var(--s2); min-height:var(--control-h); padding:0 var(--s4); border-radius:var(--radius); font:inherit; font-weight:500; cursor:pointer; white-space:nowrap; text-decoration:none; transition:background var(--ease), border-color var(--ease); }
button.btn { background:var(--accent); color:var(--accent-contrast); border:var(--border-w) solid var(--accent); }
button.btn:hover { background:var(--accent-hover); border-color:var(--accent-hover); }
button.ghost { background:var(--surface); border:var(--border-w) solid var(--border-strong); color:var(--text); }
button.ghost:hover { background:var(--surface-2); }
button.quiet { background:transparent; border-color:transparent; }
button.danger { color:var(--danger); border-color:var(--danger); }
button.danger:hover { background:var(--danger-bg); }
button.btn-icon { width:var(--control-h); padding:0; }
td button.ghost, .actions button.ghost, .tgt button.ghost { min-height:30px; padding:0 var(--s3); font-size:var(--text-sm); }
button:disabled { opacity:.55; cursor:not-allowed; }
/* tables */
table { width:100%; border-collapse:collapse; }
th { text-align:left; font-size:var(--text-sm); color:var(--text-muted); font-weight:600; padding:var(--s3) var(--s4); border-bottom:var(--border-w) solid var(--border); white-space:nowrap; background:var(--surface); }
td { padding:var(--s3) var(--s4); border-bottom:var(--border-w) solid var(--border); vertical-align:middle; }
tbody tr:hover td { background:var(--surface-2); }
tr:last-child td { border-bottom:0; }
/* badges and tags: status always carries a text label, never color alone */
.badge { display:inline-flex; align-items:center; gap:var(--s1); padding:2px var(--s2); border-radius:999px; font-size:var(--text-xs); font-weight:600; background:var(--neutral-bg); color:var(--text-muted); white-space:nowrap; }
.badge-success { background:var(--success-bg); color:var(--success); }
.badge-warning { background:var(--warning-bg); color:var(--warning); }
.badge-danger { background:var(--danger-bg); color:var(--danger); }
.badge-accent { background:var(--accent-soft); color:var(--accent-text); }
.tag { display:inline-block; padding:1px var(--s2); border:var(--border-w) solid var(--border); border-radius:var(--radius); font-size:var(--text-xs); color:var(--text-muted); }
pre { background:var(--surface-2); border:var(--border-w) solid var(--border); border-radius:var(--radius); padding:var(--s3); overflow:auto; margin:var(--s2) 0 0; user-select:all; white-space:pre-wrap; word-break:break-all; }
/* empty state, notice, loading */
.empty { text-align:center; padding:var(--s7) var(--s5); color:var(--text-muted); border:var(--border-w) dashed var(--border-strong); border-radius:var(--radius-lg); margin:var(--s3); }
.empty h3 { color:var(--text); margin-bottom:var(--s1); }
.empty p { margin:0 auto var(--s3); max-width:56ch; }
.empty .icon { width:28px; height:28px; margin:0 auto var(--s3); color:var(--text-faint); display:block; }
.card > .empty { margin:0; }
.notice { padding:var(--s3) var(--s4); border-radius:var(--radius); border:var(--border-w) solid var(--border); background:var(--surface-2); font-size:var(--text-sm); margin-bottom:var(--s4); }
.notice-danger { background:var(--danger-bg); border-color:var(--danger); color:var(--danger); }
.notice-success { background:var(--success-bg); border-color:var(--success); color:var(--success); }
.skeleton { background:linear-gradient(90deg, var(--surface-2), var(--border), var(--surface-2)); background-size:200% 100%; animation:shimmer 1.3s infinite; border-radius:var(--radius); }
@keyframes shimmer { to { background-position:-200% 0; } }
/* dialogs, tabs, toasts */
.shade { position:fixed; inset:0; background:var(--overlay); display:grid; place-items:start center; padding:var(--s7) var(--s4); overflow:auto; z-index:var(--z-overlay); }
.modal { background:var(--surface); border-radius:var(--radius-lg); box-shadow:var(--shadow); padding:var(--s5); width:min(480px,100%); }
.modal.wide { width:min(780px,100%); }
.modal h2 { margin:0 0 var(--s3); font-size:var(--text-lg); }
fieldset { border:var(--border-w) solid var(--border); border-radius:var(--radius-lg); padding:var(--s4); margin:0; min-width:0; }
legend { font-size:var(--text-sm); font-weight:600; color:var(--text-muted); padding:0 var(--s2); }
.tgt { display:grid; grid-template-columns:1.2fr 1.4fr .7fr auto auto; gap:var(--s2); align-items:end; }
.pillrow { display:flex; flex-wrap:wrap; gap:var(--s2) var(--s4); }
.tabs2 { display:inline-flex; margin-bottom:var(--s4); border:var(--border-w) solid var(--border-strong); border-radius:var(--radius); overflow:hidden; max-width:100%; flex-wrap:wrap; background:var(--surface); }
.tabs2 button { background:var(--surface); border:0; color:var(--text-muted); padding:0 var(--s3); min-height:var(--control-h); cursor:pointer; font:inherit; font-weight:500; }
.tabs2 button + button { border-left:var(--border-w) solid var(--border-strong); }
.tabs2 button:hover { background:var(--surface-2); }
.tabs2 button.on { background:var(--accent-soft); color:var(--accent-text); }
.toasts { position:fixed; right:var(--s4); bottom:var(--s4); z-index:var(--z-toast); display:flex; flex-direction:column; gap:var(--s2); max-width:min(360px, calc(100vw - 32px)); }
.toast { background:var(--text); color:var(--bg); padding:var(--s3) var(--s4); border-radius:var(--radius); box-shadow:var(--shadow); font-size:var(--text-sm); }
.toast-error { background:var(--danger); color:var(--accent-contrast); }
@media (prefers-reduced-motion: reduce) { .skeleton { animation:none; } * { transition:none !important; } html { scroll-behavior:auto; } }
@media (pointer: coarse) {
button.btn, button.ghost, .navitem, input, select { min-height:var(--touch); }
td button.ghost, .actions button.ghost, .tgt button.ghost { min-height:36px; }
input, select, textarea { font-size:16px; }
}
@media (max-width:860px) {
.shell { grid-template-columns:minmax(0,1fr); grid-template-rows:auto minmax(0,1fr); }
.sidebar { display:none; }
.sidebar.open { display:flex; position:fixed; inset:0 auto 0 0; width:min(280px,85vw); height:100dvh; z-index:var(--z-overlay); box-shadow:var(--shadow); }
.sidebar button.sidebar-close { display:inline-flex; }
.topbar { display:flex; align-items:center; justify-content:space-between; min-height:52px; padding:var(--s2) var(--s4); background:var(--surface); border-bottom:var(--border-w) solid var(--border); position:sticky; top:0; z-index:var(--z-sticky); }
.backdrop.open { display:block; position:fixed; inset:0; background:var(--overlay); z-index:calc(var(--z-overlay) - 1); }
main { padding:var(--s5) var(--s4) var(--s7); }
.tgt { grid-template-columns:1fr 1fr; }
}
@media (max-width:640px) {
.modal { padding:var(--s4); }
.shade { padding:var(--s4) var(--s3); }
.toasts { left:var(--s3); right:var(--s3); bottom:var(--s3); max-width:none; }
.page-h > .btn { width:100%; }
}
</style>
<body>
<div id="app"></div>
<script>
// ---------- tiny toolkit ----------
function h(tag, props, ...kids) {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(props || {})) {
if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
else if (k === 'class') el.className = v;
else if (v === true) el.setAttribute(k, '');
else if (v !== false && v != null) el.setAttribute(k, v);
}
for (const k of kids.flat(Infinity)) if (k != null && k !== false) el.append(k.nodeType ? k : document.createTextNode(String(k)));
return el;
}
const $app = document.getElementById('app');
// theme: follows the system unless the person picked one
try { const t = localStorage.getItem('theme'); if (t) document.documentElement.dataset.theme = t; } catch (e) {}
function toggleTheme() {
const dark = document.documentElement.dataset.theme ? document.documentElement.dataset.theme === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches;
const next = dark ? 'light' : 'dark';
document.documentElement.dataset.theme = next;
try { localStorage.setItem('theme', next); } catch (e) {}
}
// short confirmations at the bottom right; errors stay longer and are announced as alerts
let toastBox;
function toast(msg, bad) {
if (!toastBox) { toastBox = h('div', {class:'toasts', 'aria-live':'polite'}); document.body.append(toastBox); }
const t = h('div', {class:'toast' + (bad ? ' toast-error' : ''), role:bad ? 'alert' : 'status'}, msg);
toastBox.append(t);
setTimeout(() => t.remove(), bad ? 7000 : 3000);
}
const SIGNED_OUT = 'Your token was not accepted. Sign in again to continue.';
const cap = s => s.charAt(0).toUpperCase() + s.slice(1);
const S = { token: '', authed: false, tab: 'servers', cfg: null, headerAction: null, drawn: null };
try { S.token = sessionStorage.getItem('token') || ''; } catch (e) {}
async function api(method, url, body) {
const r = await fetch(url, { method, headers: { Authorization: 'Bearer ' + S.token, 'Content-Type': 'application/json' }, body: body !== undefined ? JSON.stringify(body) : undefined });
const text = await r.text();
if (r.status === 401) {
if (S.authed) { S.authed = false; S.token = ''; try { sessionStorage.removeItem('token'); } catch (e) {} boot(); }
throw new Error(SIGNED_OUT);
}
if (!r.ok) throw new Error(cap(text.trim() || r.statusText));
return text ? JSON.parse(text) : {};
}
// run an action; show errors as a toast instead of throwing into the void
const act = fn => async (...a) => { try { await fn(...a); } catch (e) { if (e.message !== SIGNED_OUT) toast(e.message, true); } };
const bytes = n => { n = n || 0; const u = ['B','KB','MB','GB','TB']; let i = 0; while (n >= 1024 && i < 4) { n /= 1024; i++; } return (i ? n.toFixed(1) : n) + ' ' + u[i]; };
const fmtTime = d => new Date(d).toLocaleString(undefined, {month:'short', day:'numeric', year:'numeric', hour:'numeric', minute:'2-digit'});
const uptime = s => s < 90 ? 'Under a minute' : s < 5400 ? Math.round(s / 60) + ' min' : s < 129600 ? Math.round(s / 3600) + ' h' : Math.round(s / 86400) + ' d';
const lines = t => t.split(/[\n,]/).map(x => x.trim()).filter(Boolean);
const field = (label, el, hint) => h('label', {}, label, el, hint ? h('span', {class:'sub'}, hint) : null);
const chk = (label, checked) => { const i = h('input', {type:'checkbox', checked: !!checked}); return Object.assign(h('label', {class:'chk'}, i, label), {box:i}); };
const withValue = (el, v) => { el.value = v == null ? '' : v; return el; };
// ---------- components ----------
const card = (title, ...kids) => h('div', {class:'card'}, title ? h('h2', {}, title) : null, ...kids);
const stat = (label, value) => h('div', {class:'card stat'}, h('div', {class:'small dim'}, label), h('div', {class:'stat-n num'}, value));
// A dialog: focus moves in, Tab stays inside, Escape closes, and focus goes back to what opened it.
let dialogSeq = 0;
function modal(title, body, opts = {}) {
const opener = document.activeElement, id = 'dialog-title-' + (++dialogSeq);
const shade = h('div', {class:'shade'});
const box = h('div', {class:'modal' + (opts.wide || opts.sticky ? ' wide' : ''), role:'dialog', 'aria-modal':'true', 'aria-labelledby':id, tabindex:'-1'}, h('h2', {id}, title), body);
let closed = false;
const close = () => {
if (closed) return; closed = true;
document.removeEventListener('keydown', onKey, true); shade.remove();
if (opener && opener.isConnected && opener.focus) opener.focus();
if (opts.onClose) opts.onClose();
};
const focusables = () => [...box.querySelectorAll('a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])')].filter(e => e.offsetParent !== null);
function onKey(e) {
const top = [...document.querySelectorAll('.shade')].pop();
if (top !== shade) return;
if (e.key === 'Escape') { e.stopPropagation(); close(); }
else if (e.key === 'Tab') {
const f = focusables(); if (!f.length) { e.preventDefault(); return; }
const first = f[0], last = f[f.length - 1];
if (e.shiftKey && (document.activeElement === first || document.activeElement === box)) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}
}
shade.addEventListener('mousedown', e => { if (e.target === shade && !opts.sticky) close(); });
shade.append(box);
document.body.append(shade);
document.addEventListener('keydown', onKey, true);
(box.querySelector('input,select,textarea') || focusables()[0] || box).focus();
return close;
}
function dialogForm(title, intro, fields, label, submit) {
const err = h('div', {class:'notice notice-danger', role:'alert', hidden:true});
const close = modal(title, h('div', {class:'stack'}, intro ? h('p', {class:'dim'}, intro) : null, ...fields, err,
h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => close()}, 'Cancel'),
h('button', {class:'btn', onclick: async () => {
err.hidden = true;
try { await submit(); close(); } catch (e) { if (e.message === SIGNED_OUT) return close(); err.textContent = e.message; err.hidden = false; }
}}, label))), {sticky:true});
}
// A confirmation that names what it affects and says what is lost. Resolves true only when confirmed.
function confirmDialog({title, body, confirm = 'Confirm', cancel = 'Cancel', danger = true}) {
return new Promise(resolve => {
let close;
const done = v => { resolve(v); close(); };
close = modal(title, h('div', {class:'stack'}, h('p', {class:'dim', style:'margin:0'}, body),
h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => done(false)}, cancel),
h('button', {class:danger ? 'ghost danger' : 'btn', onclick:() => done(true)}, confirm))), {onClose:() => resolve(false)});
});
}
const ask = (opts, run) => act(async () => { if (await confirmDialog(opts)) await run(); });
// status pill: color always travels with a text label
const badge = (text, tone) => h('span', {class:'badge' + (tone ? ' badge-' + tone : '')}, text);
const emptyState = (name, title, text, action) => h('div', {class:'empty'}, icon(name, 22), h('h3', {}, title), h('p', {}, text), action || null);
// placeholder rows while a page loads, so the layout does not jump
const skeleton = () => h('div', {class:'card', role:'status', 'aria-busy':'true'}, h('span', {class:'sr-only'}, 'Loading'),
...[60, 100, 90, 100, 75].map(w => h('div', {class:'skeleton', style:`height:16px;width:${w}%;margin-bottom:14px`})));
const modalOpen = () => !!document.querySelector('.shade');
// ---------- icons (lucide-style, static strings) ----------
const ICONS = {
spinner:'<circle cx="12" cy="12" r="3"/><path d="M12 3a9 9 0 0 1 9 9M12 21a9 9 0 0 1-9-9"/>',
servers:'<rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><path d="M6 6h.01M6 18h.01"/>',
settings:'<circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2 12h3M19 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1"/>',
lock:'<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
out:'<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/>',
menu:'<path d="M4 6h16M4 12h16M4 18h16"/>',
close:'<path d="M18 6 6 18M6 6l12 12"/>',
add:'<path d="M5 12h14M12 5v14"/>',
check:'<path d="M20 6 9 17l-5-5"/>',
light:'<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/>',
dark:'<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
alert:'<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4M12 17h.01"/>',
};
function icon(name, size = 18) {
const s = document.createElement('span');
s.style.display = 'inline-flex'; s.setAttribute('aria-hidden', 'true');
s.innerHTML = `<svg class="icon" xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" style="width:${size}px;height:${size}px">${ICONS[name] || ''}</svg>`;
return s;
}
const brand = () => h('div', {class:'brand'}, icon('spinner', 22), 'Spinner');
// ---------- sign in ----------
async function boot() {
if (S.token) {
try { await api('GET', '/api/overview'); S.authed = true; } catch (e) { S.authed = false; }
}
if (!S.authed) return $app.replaceChildren(loginView());
S.tab = (location.hash.slice(1) in PAGES) ? location.hash.slice(1) : 'servers';
await render();
}
function loginView(message) {
const token = h('input', {type:'password', autocomplete:'current-password', required:true, 'aria-describedby':'token-hint'});
const err = h('div', {class:'notice notice-danger', role:'alert', hidden:!message}, message || '');
const form = h('form', {class:'stack', onsubmit: async e => {
e.preventDefault(); err.hidden = true; S.token = token.value;
try {
await api('GET', '/api/overview'); S.authed = true;
try { sessionStorage.setItem('token', S.token); } catch (x) {}
boot();
} catch (x) { S.token = ''; err.textContent = x.message === SIGNED_OUT ? 'That token was not accepted. Check SPINNER_ADMIN_TOKEN in the .env file, then try again.' : x.message; err.hidden = false; token.focus(); }
}}, h('div', {}, h('h1', {style:'font-size:var(--text-xl)'}, 'Sign in'), h('p', {class:'dim', style:'margin-top:var(--s1)'}, 'Use the admin token Spinner was started with.')),
err, field('Admin token', token, 'Set as SPINNER_ADMIN_TOKEN in the .env file.'),
h('div', {class:'actions form-actions'}, h('button', {class:'btn', type:'submit'}, 'Sign in')));
return h('div', {class:'center'}, brand(), card(null, form));
}
// ---------- shell ----------
const PAGES = {
servers: ['Servers', 'Players reach a server by the address they type. Spinner passes the connection to that server\'s backend.'],
settings: ['Settings', 'Changes apply as soon as you save. New listen addresses need a restart.'],
};
async function render() {
const nav = (tab, label, ic) => h('a', {class:'navitem' + (S.tab === tab ? ' on' : ''), href:'#' + tab, 'aria-current':S.tab === tab ? 'page' : null,
onclick: e => { e.preventDefault(); selectTab(tab); setDrawer(false); } }, icon(ic), label);
const side = [h('div', {class:'sidebar-head'}, brand(), h('button', {class:'ghost quiet btn-icon sidebar-close', 'aria-label':'Close menu', onclick:() => setDrawer(false)}, icon('close'))),
h('nav', {class:'nav', 'aria-label':'Main'}, nav('servers', 'Servers', 'servers'), nav('settings', 'Settings', 'settings')),
h('div', {class:'grow'}),
h('div', {class:'userbox'},
h('button', {class:'navitem', onclick:toggleTheme}, icon('dark'), 'Switch theme'),
h('button', {class:'navitem', onclick: () => { S.authed = false; S.token = ''; try { sessionStorage.removeItem('token'); } catch (e) {} boot(); }}, icon('out'), 'Sign out'))];
S.drawn = null;
$app.replaceChildren(h('a', {class:'skip', href:'#view'}, 'Skip to content'), h('div', {class:'shell'},
h('div', {class:'topbar'}, brand(), h('button', {class:'ghost quiet btn-icon', id:'menu-btn', 'aria-label':'Open menu', 'aria-controls':'sidebar', 'aria-expanded':'false', onclick:() => setDrawer(true)}, icon('menu'))),
h('div', {class:'backdrop', id:'backdrop', onclick:() => setDrawer(false)}),
h('aside', {class:'sidebar', id:'sidebar', 'aria-label':'Sidebar'}, side),
h('div', {class:'content'}, h('main', {id:'view', tabindex:'-1'}))));
await refresh();
}
function selectTab(tab) { S.tab = tab; history.replaceState(null, '', '#' + tab); render(); }
// the sidebar is a slide-in drawer on small screens
function setDrawer(open) {
const sb = document.getElementById('sidebar'), bd = document.getElementById('backdrop'), mb = document.getElementById('menu-btn');
if (!sb) return;
sb.classList.toggle('open', open); bd.classList.toggle('open', open); mb.setAttribute('aria-expanded', open);
if (open) { const f = sb.querySelector('a,button'); if (f) f.focus(); } else if (mb.offsetParent) mb.focus();
}
document.addEventListener('keydown', e => { if (e.key === 'Escape' && !modalOpen()) { const sb = document.getElementById('sidebar'); if (sb && sb.classList.contains('open')) setDrawer(false); } });
function pageHeader(tab) {
const [t, d] = PAGES[tab], a = S.headerAction;
return h('div', {class:'page-h'}, h('div', {}, h('h1', {}, t), d ? h('p', {}, d) : null), a ? h('button', {class:'btn', onclick:a[1]}, icon('add', 16), a[0]) : null);
}
let seq = 0;
async function refresh() {
const my = ++seq;
try {
S.headerAction = null;
const first = document.getElementById('view');
if (first && S.drawn !== S.tab) first.replaceChildren(pageHeader(S.tab), skeleton());
const view = await views[S.tab]();
if (my !== seq) return;
const v = document.getElementById('view');
if (v) { v.replaceChildren(pageHeader(S.tab), view); S.drawn = S.tab; }
} catch (e) { if (e.message !== SIGNED_OUT) toast(e.message, true); }
}
setInterval(() => {
if (!S.authed || document.hidden || modalOpen() || S.tab !== 'servers') return;
refresh();
}, 4000);
// ---------- views ----------
const views = {};
views.servers = async () => {
const [ov, cfg] = await Promise.all([api('GET', '/api/overview'), api('GET', '/api/config')]);
S.cfg = cfg;
S.headerAction = ['Add server', () => serverDialog()];
const t = ov.totals;
const totals = h('div', {class:'grid'}, stat('Connected now', t.active), stat('Connections since start', t.total), stat('Turned away', t.rejected), stat('Unknown address', t.unknown_host), stat('Running for', uptime(t.uptime_secs)));
if (!(ov.servers || []).length) return h('div', {}, totals, emptyState('servers', 'No servers yet', 'Add a server to start routing players. Each one needs an address players type and a backend to send them to.',
h('button', {class:'btn', onclick:() => serverDialog()}, 'Add server')));
const rows = (ov.servers || []).map(s => {
const hl = s.health, st = s.stats || {};
const status = !hl ? badge('Checking') : hl.up ? badge('Online', 'success') : badge('Offline', 'danger');
const backend = h('td', {}, s.addresses.map(a => h('div', {class:'mono'}, a)),
hl && hl.up ? h('div', {class:'small dim'}, hl.latency_ms + ' ms' + (hl.version ? ', ' + hl.version : '')) : null,
hl && !hl.up ? h('div', {class:'small dim clip', title:hl.error}, hl.error) : null);
return h('tr', {},
h('td', {}, h('div', {}, h('strong', {}, s.name)), h('div', {style:'margin-top:var(--s1)'}, status)),
h('td', {}, s.domains.map(d => h('div', {class:'mono'}, d))),
backend,
h('td', {class:'num'}, hl && hl.up ? hl.online + ' of ' + hl.max : h('span', {class:'dim'}, 'Not available')),
h('td', {class:'num'}, (st.active || 0) + ' now', h('div', {class:'small dim'}, (st.total || 0) + ' total')),
h('td', {class:'num small'}, h('div', {}, 'In ' + bytes(st.bytes_in)), h('div', {class:'dim'}, 'Out ' + bytes(st.bytes_out))),
h('td', {}, h('div', {class:'actions'},
h('button', {class:'ghost', onclick:() => serverDialog(s)}, 'Edit'),
h('button', {class:'ghost danger', onclick: ask({title:'Delete ' + s.name + '?', confirm:'Delete server',
body:'Players who use ' + s.domains[0] + ' will see the unknown server message. The backend is not touched, and you can add the server again later.'},
async () => { await saveCfg(c => { c.servers = c.servers.filter(x => x.name !== s.name); }); toast(s.name + ' deleted.'); refresh(); })}, 'Delete'))));
});
return h('div', {}, totals, h('div', {class:'card flush scroll'}, h('table', {},
h('thead', {}, h('tr', {}, ['Server', 'Addresses players use', 'Backend', 'Players', 'Connections', 'Traffic', ''].map(x => h('th', {}, x)))), h('tbody', {}, rows))));
};
// Reads the current config, lets fn change a copy, and saves it. The server checks it and says what is wrong.
async function saveCfg(fn) {
const c = await api('GET', '/api/config');
fn(c);
S.cfg = await api('PUT', '/api/config', c);
}
function serverDialog(existing) {
const s = existing || {name:'', domains:[], addresses:[]};
const name = withValue(h('input', {autocomplete:'off'}), s.name);
const domains = withValue(h('textarea', {rows:3, placeholder:'survival.example.com'}), s.domains.join('\n'));
const addresses = withValue(h('textarea', {rows:2, placeholder:'127.0.0.1:25566'}), s.addresses.join('\n'));
const offline = withValue(h('input', {autocomplete:'off'}), s.offline_motd);
const max = withValue(h('input', {type:'number', min:'0', inputmode:'numeric'}), s.max_connections || '');
const proxy = chk('Send the player\'s address to the backend with the PROXY protocol', s.send_proxy_protocol);
dialogForm(existing ? 'Edit ' + s.name : 'Add server', null, [
field('Name', name, 'Shown here and in metrics.'),
field('Addresses players use', domains, 'One per line. Start with *. to match every subdomain, for example *.example.com.'),
field('Backend addresses', addresses, 'One per line, as host:port. Spinner tries them in order.'),
field('Message when the backend is offline', offline, 'Shown in the server list and when a player tries to join.'),
field('Most players at once', max, 'Leave empty for no limit.'),
proxy,
], existing ? 'Save server' : 'Add server', async () => {
const next = {name:name.value.trim(), domains:lines(domains.value), addresses:lines(addresses.value)};
if (offline.value.trim()) next.offline_motd = offline.value.trim();
if (+max.value > 0) next.max_connections = +max.value;
if (proxy.box.checked) next.send_proxy_protocol = true;
await saveCfg(c => {
const i = existing ? c.servers.findIndex(x => x.name === existing.name) : -1;
if (i >= 0) c.servers[i] = next; else c.servers.push(next);
});
toast(next.name + ' saved.');
refresh();
});
}
views.settings = async () => {
const c = S.cfg = await api('GET', '/api/config');
const num = (v, min) => withValue(h('input', {type:'number', min:String(min), inputmode:'numeric'}), v);
const bind = withValue(h('input', {autocomplete:'off'}), c.bind), admin = withValue(h('input', {autocomplete:'off'}), c.admin_bind);
const hs = num(c.handshake_timeout_secs, 1), ct = num(c.connect_timeout_secs, 1), pi = num(c.probe_interval_secs, 1), ip = num(c.max_conns_per_ip, 0);
const unknown = withValue(h('textarea', {rows:3}), c.unknown_motd);
const err = h('div', {class:'notice notice-danger', role:'alert', hidden:true});
const save = h('button', {class:'ghost', onclick: async () => {
err.hidden = true;
try {
await saveCfg(n => {
n.bind = bind.value.trim(); n.admin_bind = admin.value.trim(); n.unknown_motd = unknown.value;
n.handshake_timeout_secs = +hs.value; n.connect_timeout_secs = +ct.value; n.probe_interval_secs = +pi.value; n.max_conns_per_ip = +ip.value;
});
toast(c.bind !== S.cfg.bind || c.admin_bind !== S.cfg.admin_bind ? 'Settings saved. Restart Spinner to use the new listen address.' : 'Settings saved.');
} catch (e) { if (e.message !== SIGNED_OUT) { err.textContent = e.message; err.hidden = false; } }
}}, 'Save settings');
return h('div', {}, card('Listening',
h('div', {class:'grid'}, field('Minecraft address', bind, 'Where players connect, for example :25565.'), field('Admin address', admin, 'Where this page is served. Empty turns it off.'))),
card('Timeouts and limits', h('div', {class:'grid'},
field('Wait for a handshake (seconds)', hs), field('Wait for a backend (seconds)', ct), field('Check backends every (seconds)', pi),
field('Most connections per IP address', ip, '0 means no limit.'))),
card('Unknown addresses', field('Message', unknown, 'Shown when a player uses an address that no server claims.')),
err, h('div', {class:'actions form-actions'}, save));
};
boot();
</script>
</body>
</html>