Routes Java Edition connections by the hostname in the handshake, with wildcard domains, backend failover, PROXY protocol v2, cached server-list status from health probes, hot-reloaded config, and an admin page and metrics endpoint. Includes CI, tagged releases and a container image. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e640c5bf02
commit
b53af0e196
29 files changed
+2640
No files matched your search
+543
@@ -0,0 +1,543 @@
|
||||
<!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>
|
||||
Reference in new issue
Block a user