Files
Hammerhead/web/index.html
T
VeillaxandClaude Opus 5.5 c2845d8630
CI / test (push) Successful in 2m22s
Release / release (push) Successful in 3m45s
Pick a resource's public IP from the edge's addresses, labeled with the domains pointed at each
A hostname under a domain that points at one of the edge's addresses selects that address. An address
saved earlier stays selectable even before the edge reports it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 18:56:20 -06:00

1346 lines
120 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">
<title>Hammerhead</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); }
/* 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>
</head>
<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 session ended. Sign in again to continue.';
async function api(method, url, body) {
const r = await fetch(url, { method, headers: {'Content-Type':'application/json'}, body: body !== undefined ? JSON.stringify(body) : undefined });
const j = await r.json().catch(() => ({}));
if (r.status === 401 && url !== '/api/login' && !url.startsWith('/api/login/') && url !== '/api/me' && url !== '/api/public') {
// the session ended while we were signed in: go back to the login page once.
// (Not for /api/me or /api/public: boot() calls those, and reacting to them would loop forever.)
if (S.me) { S.me = null; boot(); }
throw new Error(SIGNED_OUT);
}
if (!r.ok) throw new Error(j.error || r.statusText);
return j;
}
// 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) { 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 ago = d => { if (!d || d.startsWith('0001')) return 'never'; const s = (Date.now() - new Date(d)) / 1000; return s < 90 ? 'just now' : s < 5400 ? Math.round(s / 60) + ' min ago' : s < 129600 ? Math.round(s / 3600) + ' h ago' : Math.round(s / 86400) + ' d ago'; };
// dates with the month written out, as the design language asks ("Oct 21, 2026")
const fmtDate = d => new Date(d).toLocaleDateString(undefined, {month:'short', day:'numeric', year:'numeric'});
const fmtTime = d => new Date(d).toLocaleString(undefined, {month:'short', day:'numeric', year:'numeric', hour:'numeric', minute:'2-digit'});
const byId = (list, id) => (list || []).find(x => x.id === id);
const field = (label, el, hint) => h('label', {}, label, el, hint ? h('span', {class:'sub'}, hint) : null);
const chk = (label, checked, onchange) => { const i = h('input', {type:'checkbox', checked: !!checked}); if (onchange) i.onchange = () => onchange(i.checked); return Object.assign(h('label', {class:'chk'}, i, label), {box:i}); };
// ----- versions and updates -----
// A progress bar and live log for an update. load() returns {running, stage, pct, lines}; the panel polls it
// until the update is over, and keeps going through the gap while a program restarts into its new version.
function updatePanel(load, onDone) {
const bar = h('div', {style:'width:0%'}), stageEl = h('div', {class:'small'}, 'Starting…'), logEl = h('pre', {class:'logbox'}, 'Waiting for the first message…');
const el = h('div', {class:'stack'}, stageEl, h('div', {class:'progress'}, bar), logEl);
const label = {starting:'Starting', checking:'Checking for the release', downloading:'Downloading', verifying:'Verifying', installing:'Installing', restarting:'Restarting', done:'Done', failed:'Failed'};
let misses = 0;
const tick = async () => {
if (!el.isConnected) return; // the dialog or card was closed
let again = true;
try {
const s = await load(); misses = 0;
const pct = Math.max(0, Math.min(100, s.pct || 0)), lines = s.lines || [];
bar.style.width = pct + '%'; bar.className = s.stage === 'failed' ? 'bad' : '';
stageEl.textContent = (label[s.stage] || s.stage || 'Waiting') + (pct ? ' · ' + pct + '%' : '');
if (lines.length) { const atEnd = logEl.scrollTop + logEl.clientHeight >= logEl.scrollHeight - 4; logEl.textContent = lines.join('\n'); if (atEnd) logEl.scrollTop = logEl.scrollHeight; }
if (!s.running && (s.stage === 'done' || s.stage === 'failed')) { again = false; if (onDone) onDone(s); }
} catch (e) {
misses++; // the server is probably restarting into the new version
if (misses > 2) stageEl.textContent = 'Restarting. Waiting for it to come back.';
if (misses > 150) { again = false; stageEl.textContent = 'It has not come back after a few minutes. Check its log on the server.'; }
}
if (again) setTimeout(tick, 800);
};
setTimeout(tick, 100);
return el;
}
// compares vX.Y.Z strings: -1 if a is older than b, 1 if newer, 0 if equal or not comparable
const cmpVer = (a, b) => {
const p = v => (/^v(\d+)\.(\d+)\.(\d+)$/.exec(v || '') || []).slice(1).map(Number), x = p(a), y = p(b);
if (x.length < 3 || y.length < 3) return 0;
for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] < y[i] ? -1 : 1;
return 0;
};
const latestVersions = async () => { try { return await api('GET', '/api/versions'); } catch (e) { return {}; } };
// A table cell with a machine's version, a note when a newer release exists, and an Update button
// when that machine allows being updated from here (it must run with auto-update on).
const updateDialog = (m, logUrl) => modal('Updating ' + m.name, h('div', {class:'stack'}, updatePanel(() => api('GET', logUrl), () => refresh()), h('p', {class:'small dim'}, 'You can close this; the update carries on.')));
const versionCell = (m, latest, pending, updateUrl, logUrl) => {
const behind = latest && m.version && cmpVer(m.version, latest) < 0, kids = [h('span', {class:'mono small'}, m.version || 'unknown')];
if (behind) kids.push(h('div', {class:'small'}, latest + ' is available'));
if (pending) {
kids.push(h('div', {class:'small dim'}, 'Updating…'));
clearTimeout(S.updTimer); S.updTimer = setTimeout(() => refresh(), 4000); // look again for the result
} else if (m.updateMsg) kids.push(h('div', {class:'small dim'}, m.updateMsg));
if (behind && !pending) kids.push(h('button', {class:'ghost', disabled:!m.autoUpdate,
title:m.autoUpdate ? 'Install ' + latest + ' now' : 'Turn on auto-update on that machine (…_AUTO_UPDATE=1) to update it from here',
onclick: act(async () => { await api('POST', updateUrl); updateDialog(m, logUrl); refresh(); })}, 'Update'));
if (pending || m.updateMsg) kids.push(h('button', {class:'ghost', onclick:() => updateDialog(m, logUrl)}, 'Log'));
return h('td', {}, h('div', {class:'stack'}, ...kids));
};
const card = (title, ...kids) => h('div', {class:'card'}, title ? h('h2', {}, title) : null, ...kids);
function dialogForm(title, intro, fields, label, submit) {
const close = modal(title, h('div', {class:'stack'}, intro ? h('p', {class:'dim'}, intro) : null, ...fields,
h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => close()}, 'Cancel'), h('button', {class:'btn', onclick: act(async () => { await submit(); close(); })}, label))));
}
const cmdBox = t => h('pre', {class:'mono'}, t);
// A dialog: focus moves in, Tab stays inside, Escape closes, and focus goes back to what opened it.
// Forms pass sticky (they are wide and ignore clicks outside); onClose runs however the dialog ends.
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;
}
// 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)});
});
}
// button action with a confirmation in front of it
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 = {
fin:'<path d="M3 20c4-1 7-5 8-14 3 5 8 7 10 14z"/>',
mine:'<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/>',
ports:'<circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>',
sites:'<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"/>',
domains:'<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
access:'<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
logs:'<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M16 13H8M16 17H8"/>',
edges:'<path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"/>',
orgs:'<rect x="4" y="2" width="16" height="20" rx="2"/><path d="M9 22v-4h6v4M8 6h.01M12 6h.01M16 6h.01M8 10h.01M12 10h.01M16 10h.01M8 14h.01M12 14h.01M16 14h.01"/>',
users:'<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
idps:'<path d="m21 2-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0 3 3L22 7l-3-3m-3.5 3.5L19 4"/>',
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"/>',
audit:'<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>',
account:'<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
egress:'<path d="M7 17 17 7M8 7h9v9"/>',
alerts:'<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>',
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('fin', 22), 'Hammerhead');
// ---------- state ----------
const S = { me: null, org: localStorage.getItem('org'), tab: 'ports', st: null, pub: null };
const isAdmin = () => S.me && (S.me.serverAdmin || (S.me.orgs.find(o => o.id === S.org) || {}).admin);
async function boot() {
try { S.pub = await api('GET', '/api/public'); } catch (e) { $app.replaceChildren(card('Cannot reach the server', e.message)); return; }
const inv = (location.hash.match(/^#invite=(.+)$/) || [])[1];
if (S.pub.needsSetup) return $app.replaceChildren(setupView());
try { S.me = await api('GET', '/api/me'); } catch { S.me = null; }
if (inv) return $app.replaceChildren(await inviteView(inv));
if (!S.me) return $app.replaceChildren(loginView());
if (!S.me.orgs.find(o => o.id === S.org)) S.org = (S.me.orgs[0] || {}).id || null;
if (S.org) localStorage.setItem('org', S.org);
const fromPath = tabFromPath(location.pathname);
if (fromPath) selectTab(fromPath);
await render();
}
// ---------- first run, login, invites ----------
function setupView() {
const f = { org: h('input', {value:'Default'}), name: h('input', {placeholder:'Your name'}), email: h('input', {type:'email'}), pw: h('input', {type:'password'}) };
return h('div', {class:'center'}, card('', brand(), h('h2', {}, 'Welcome to Hammerhead'), h('p', {class:'dim'}, 'Create the first administrator.'),
h('div', {class:'stack'}, field('Organization name', f.org), field('Your name', f.name), field('Email', f.email), field('Password', f.pw, 'At least 8 characters'),
h('button', {class:'btn', onclick: act(async () => {
await api('POST', '/api/setup', {orgName:f.org.value, name:f.name.value, email:f.email.value, password:f.pw.value});
await api('POST', '/api/login', {email:f.email.value, password:f.pw.value}); boot();
})}, 'Create account'))));
}
function loginView() {
const email = h('input', {type:'email', autocomplete:'username'}), pw = h('input', {type:'password', autocomplete:'current-password'});
const code = h('input', {placeholder:'6-digit or backup code', autocomplete:'one-time-code'}); let ticket = null;
const codeBox = h('div', {class:'stack', style:'display:none'}, field('Authenticator code', code));
const go = act(async () => {
const j = ticket ? await api('POST', '/api/login/totp', {ticket, code:code.value}) : await api('POST', '/api/login', {email:email.value, password:pw.value});
if (j.needTotp) { ticket = j.ticket; codeBox.style.display = ''; pw.parentElement.style.display = email.parentElement.style.display = 'none'; code.focus(); return; }
if (toastBox) toastBox.replaceChildren(); // an earlier failed attempt no longer applies
boot();
});
[email, pw, code].forEach(i => i.addEventListener('keydown', e => e.key === 'Enter' && go()));
return h('div', {class:'center'}, card('', brand(), h('h2', {}, 'Sign in'), h('div', {class:'stack'},
field('Email', email), field('Password', pw), codeBox, h('button', {class:'btn', onclick:go}, 'Sign in'),
(S.pub.providers || []).map(p => h('a', {href:'/auth/oidc/' + p.id + '/start?return=/'}, h('button', {class:'ghost', style:'width:100%;padding:8px'}, 'Continue with ' + p.name))))));
}
async function inviteView(tok) {
let info;
try { info = await api('GET', '/api/invites/' + tok); } catch (e) { return h('div', {class:'center'}, card('Invitation', e.message)); }
const name = h('input'), pw = h('input', {type:'password'});
const accept = act(async () => { await api('POST', '/api/invites/accept', {token:tok, name:name.value, password:pw.value}); location.hash = ''; boot(); });
if (info.existing) return h('div', {class:'center'}, card('Join ' + info.org, h('p', {class:'dim'}, `${info.email} already has an account.`),
S.me && S.me.email === info.email ? h('button', {class:'btn', onclick:accept}, 'Join ' + info.org) : h('p', {}, 'Sign in as that account first, then open this link again.'),
S.me ? null : h('button', {class:'ghost', onclick:() => { location.hash = ''; boot(); }}, 'Go to sign in')));
return h('div', {class:'center'}, card('Join ' + info.org, h('p', {class:'dim'}, 'Create your account for ' + info.email + '.'),
h('div', {class:'stack'}, field('Your name', name), field('Password', pw, 'At least 8 characters'), h('button', {class:'btn', onclick:accept}, 'Create account'))));
}
// ---------- shell ----------
async function loadOrg() { S.st = S.org ? await api('GET', `/api/orgs/${S.org}/state`) : null; }
const O = p => `/api/orgs/${S.org}${p}`;
const PAGES = {
mine: ['My apps', 'Apps you have been given access to.'],
ports: ['Resources', 'Websites and ports that visitors reach through your edges, with an optional login in front.'],
sites: ['Sites', 'Private networks, or the edge machine itself, that hold your services.'],
domains: ['Domains', 'Domains you own. Web hostnames must live under one of them.'],
access: ['Access control', 'Members, roles, invitations and API keys for this organization.'],
egress: ['Mail egress', 'Send your mail server\'s outgoing mail from an edge\'s public IP, so reverse DNS, SPF and your HELO name all line up.'],
alerts: ['Alerts', 'Get told by email or webhook when something stops working, and when it recovers.'],
srvalerts:['Server alerts', 'Where to send alerts about edges, which belong to the whole server.'],
logs: ['Logs', 'Who reached what, and what changed.'],
account: ['Account', 'Your profile, password and two-factor authentication.'],
edges: ['Edges', 'Public servers running Blacktip that own your IP addresses.'],
srvorgs: ['Organizations', 'Separate workspaces on this server.'],
users: ['Users', 'Everyone with an account on this server.'],
idps: ['Single sign-on', 'Sign in with an external OpenID Connect provider.'],
settings:['Settings', 'Dashboard URL, certificates and email.'],
srvaudit:['Audit log', 'Server-level activity.'],
};
// server pages are top-level sidebar entries that map onto serverViews
const SRV = {edges:'edges', srvorgs:'orgs', users:'users', idps:'idps', settings:'settings', srvalerts:'alerts', srvaudit:'audit'};
// every page has its own address, so links, reloads and the back button work
const TAB_PATH = {mine:'/apps', ports:'/resources', sites:'/sites', domains:'/domains', egress:'/mail-egress', alerts:'/alerts', access:'/access', logs:'/logs', account:'/account',
edges:'/server/edges', srvorgs:'/server/organizations', users:'/server/users', idps:'/server/sso', settings:'/server/settings', srvalerts:'/server/alerts', srvaudit:'/server/audit'};
const tabFromPath = p => Object.keys(TAB_PATH).find(k => TAB_PATH[k] === p.replace(/\/+$/, '')) || null;
function selectTab(key) { S.tab = key; if (SRV[key]) serverTab = SRV[key]; }
window.addEventListener('popstate', () => { const k = tabFromPath(location.pathname); if (S.me && k) { selectTab(k); render(); } });
async function render() {
const nav = (key, label, ic) => h('a', {class:'navitem' + (S.tab === key ? ' on' : ''), href:TAB_PATH[key], 'aria-current':S.tab === key ? 'page' : null, onclick:e => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button) return;
e.preventDefault(); selectTab(key); history.pushState(null, '', TAB_PATH[key]); render(); }}, 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')))];
if (S.me.orgs.length) side.push(h('div', {class:'orgsel'}, h('select', {onchange: act(async e => { S.org = e.target.value; localStorage.setItem('org', S.org); await render(); })},
S.me.orgs.map(o => h('option', {value:o.id, selected:o.id === S.org}, o.name)))));
const all = [];
if (S.org) {
side.push(h('div', {class:'navsec'}, 'Organization'), nav('mine', 'My apps', 'mine'));
all.push('mine');
if (isAdmin()) {
side.push(nav('ports', 'Resources', 'ports'), nav('sites', 'Sites', 'sites'), nav('domains', 'Domains', 'domains'), nav('egress', 'Mail egress', 'egress'), nav('alerts', 'Alerts', 'alerts'), nav('access', 'Access control', 'access'), nav('logs', 'Logs', 'logs'));
all.push('ports', 'sites', 'domains', 'egress', 'alerts', 'access', 'logs');
}
}
if (S.me.serverAdmin) {
side.push(h('div', {class:'navsec'}, 'Server'), nav('edges', 'Edges', 'edges'), nav('srvorgs', 'Organizations', 'orgs'), nav('users', 'Users', 'users'), nav('idps', 'Single sign-on', 'idps'), nav('settings', 'Settings', 'settings'), nav('srvalerts', 'Alerts', 'alerts'), nav('srvaudit', 'Audit log', 'audit'));
all.push(...Object.keys(SRV));
}
all.push('account');
if (!all.includes(S.tab)) S.tab = all[0];
if (location.pathname !== TAB_PATH[S.tab]) history.replaceState(null, '', TAB_PATH[S.tab]);
side.push(h('div', {class:'grow'}), h('div', {class:'userbox'}, h('div', {class:'who'}, S.me.email), nav('account', 'Account', 'account'),
h('button', {class:'navitem', onclick:toggleTheme}, icon('dark'), 'Switch theme'),
h('button', {class:'navitem', onclick: act(async () => { await api('POST', '/api/logout'); S.me = null; 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':'Main'}, side),
h('div', {class:'content'}, h('main', {id:'view', tabindex:'-1'}))));
await refresh();
}
// 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 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] || ['', ''];
const 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 {
if (S.org && !SRV[S.tab] && S.tab !== 'account') await loadOrg();
S.headerAction = null;
const first = document.getElementById('view');
if (first && S.drawn !== S.tab) first.replaceChildren(pageHeader(S.tab), skeleton()); // loading placeholder, not a spinner
const view = await (SRV[S.tab] ? views.server() : 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.me || document.hidden || modalOpen()) return;
const a = document.activeElement;
if (a && ['INPUT', 'SELECT', 'TEXTAREA'].includes(a.tagName)) return;
if (['ports', 'sites', 'logs', 'mine', 'alerts', 'egress'].includes(S.tab) && !(S.tab === 'logs' && logPaged)) refresh();
}, 4000);
const dashURL = () => (S.st && S.st.dashboardUrl) || location.origin;
// ---------- views ----------
const views = {};
views.mine = async () => {
const list = await api('GET', '/api/me/resources');
return card('Apps you can open', list.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Organization', ''].map(t => h('th', {}, t)))),
h('tbody', {}, list.map(r => h('tr', {}, h('td', {}, r.name), h('td', {class:'dim'}, r.org), h('td', {}, h('a', {href:r.url, target:'_blank', rel:'noopener'}, h('button', {class:'ghost'}, 'Open'))))))) :
emptyState('mine', 'No apps yet', 'Apps you are given access to appear here. Ask an administrator for access.'));
};
// ----- ports -----
const PROTOS = [['tcp','TCP'],['udp','UDP'],['both','TCP + UDP'],['http','HTTP (by hostname)'],['https','HTTPS (automatic certificate)'],['tls','TLS passthrough (by SNI)']];
views.ports = async () => {
const st = S.st;
if (!st.edges.length) return emptyState('edges', 'No edges yet', S.me.serverAdmin ? 'An edge is the public server, running Blacktip, that owns your IP addresses. Add one under Edges first.' : 'Ask a server administrator to add an edge for your organization.',
S.me.serverAdmin ? h('button', {class:'btn', onclick:() => { selectTab('edges'); history.pushState(null, '', TAB_PATH.edges); render(); }}, 'Go to Edges') : null);
const unsetDash = !st.dashboardUrl && st.mappings.some(m => m.auth && (m.auth.sso || m.auth.password || m.auth.pin || m.auth.email));
const traffic = await api('GET', O('/traffic?hours=24')).catch(() => ({}));
const rows = st.mappings.slice().sort((a, b) => a.edgeId.localeCompare(b.edgeId) || a.bindIp.localeCompare(b.bindIp) || a.port - b.port);
const isWebRow = m => m.proto === 'http' || m.proto === 'https' || m.proto === 'tls';
const section = (title, list) => list.length ? h('div', {class:'card flush'}, h('div', {class:'sect-h'}, title + ' (' + list.length + ')'), h('div', {class:'scroll'}, h('table', {},
h('thead', {}, h('tr', {}, ['Status', 'Name', title === 'Websites' ? 'Address' : 'Public address', 'Targets', 'Traffic (24 hours)', ''].map(t => h('th', {}, t)))),
h('tbody', {}, list.map(m => mappingRow(m, st, traffic[m.id])))))) : null;
const table = rows.length ? h('div', {}, section('Websites', rows.filter(isWebRow)), section('Ports', rows.filter(m => !isWebRow(m)))) : h('div', {class:'card'}, emptyState('ports', 'No resources yet', 'A resource is a website or a port that visitors reach through one of your edges.', h('button', {class:'btn', onclick:() => portForm()}, icon('add', 16), 'Add resource')));
S.headerAction = ['Add resource', () => portForm()];
const q = h('input', {type:'search', placeholder:'Search resources…', oninput:() => { const t = q.value.toLowerCase(); table.querySelectorAll('tbody tr').forEach(tr => { tr.style.display = tr.textContent.toLowerCase().includes(t) ? '' : 'none'; }); }});
return h('div', {}, unsetDash ? h('div', {class:'notice'}, 'Some ports have a login in front of them, but the dashboard URL isn\'t set. Visitors are sent back to this address\'s login page, so set it under Server → Settings if people reach Hammerhead by a different address.') : null,
rows.length > 5 ? h('div', {class:'toolbar'}, q) : null, table);
};
function spark(series) {
if (!series || !series.some(v => v > 0)) return null;
const w = 84, hgt = 22, max = Math.max(...series, 1), ns = 'http://www.w3.org/2000/svg';
const pts = series.map((v, i) => `${(i / (series.length - 1) * w).toFixed(1)},${(hgt - 2 - v / max * (hgt - 4)).toFixed(1)}`).join(' ');
const svg = document.createElementNS(ns, 'svg'), pl = document.createElementNS(ns, 'polyline');
svg.setAttribute('width', w); svg.setAttribute('height', hgt); svg.setAttribute('viewBox', `0 0 ${w} ${hgt}`);
pl.setAttribute('points', pts); pl.setAttribute('fill', 'none'); pl.setAttribute('stroke', 'var(--accent)'); pl.setAttribute('stroke-width', '1.5');
svg.append(pl);
return h('div', {title:'traffic per hour, last 24 hours'}, svg);
}
const portText = m => m.portEnd > m.port ? m.port + '-' + m.portEnd : String(m.port);
function mappingRow(m, st, series) {
const edge = byId(st.edges, m.edgeId), s = m.status || {};
let tone = '', label = 'Paused', why = 'paused';
if (m.enabled) {
const th = s.targets || [];
if (!m.statusKnown) { tone = 'warning'; label = 'Waiting'; why = 'waiting for the edge'; }
else if (s.error) { tone = 'danger'; label = 'Error'; why = s.error; }
else if (th.length && th.every(t => !t.healthy)) { tone = 'danger'; label = 'Down'; why = 'no healthy target'; }
else if (th.some(t => !t.healthy)) { tone = 'warning'; label = 'Degraded'; why = 'some targets are down'; }
else { tone = 'success'; label = 'Listening'; why = 'listening'; }
}
const web = m.proto === 'http' || m.proto === 'https';
const prot = m.auth && (m.auth.sso || m.auth.password || m.auth.pin || m.auth.email);
const pubAddr = web ? m.proto + '://' + (m.hostname || '*') + (m.port === (m.proto === 'https' ? 443 : 80) ? '' : ':' + portText(m)) : (m.hostname ? m.hostname + ' @ ' : '') + m.bindIp + ':' + portText(m);
return h('tr', {},
h('td', {title:why}, badge(label, tone)),
h('td', {}, h('div', {}, m.name, ' ', h('span', {class:'tag'}, m.proto), m.proxyProtocol ? h('span', {class:'tag', style:'margin-left:4px', title:'the target is told the real client address'}, 'proxy ' + m.proxyProtocol) : null, prot ? h('span', {style:'margin-left:4px', title:'Visitors have to sign in'}, badge('Login', 'accent')) : null),
s.error ? h('div', {class:'err small mono clip', title:s.error}, s.error) : null,
s.cert && m.proto === 'https' ? h('div', {class:'small clip ' + (s.cert.startsWith('error') ? 'err' : 'dim'), title:s.cert}, 'Certificate: ' + s.cert) : null),
h('td', {}, h('span', {class:'mono'}, pubAddr), h('div', {class:'dim small'}, (edge ? edge.name : '?') + (web && m.bindIp !== '0.0.0.0' ? ' · ' + m.bindIp : ''))),
h('td', {class:'small'}, m.targets.map((t, i) => {
const th = (s.targets || [])[i], site = byId(st.sites, t.siteId);
return h('div', {title:th && !th.healthy ? th.detail : '', style:'margin-bottom:4px'}, badge(th ? (th.healthy ? 'Up' : 'Down') : 'Unknown', th ? (th.healthy ? 'success' : 'danger') : ''), ' ', site ? site.name : '?', h('div', {class:'mono'}, t.host + ':' + t.port));
})),
h('td', {class:'small dim'}, m.total && m.total.conns ? [`${m.total.conns} conn`, h('br'), `${bytes(m.total.bytesIn)} in, ${bytes(m.total.bytesOut)} out`] : 'None yet', spark(series)),
h('td', {}, h('div', {class:'actions'},
h('button', {class:'ghost', onclick:() => portForm(m)}, 'Edit'),
h('button', {class:'ghost', onclick: act(async () => { await api('PUT', O('/mappings/' + m.id), {...m, enabled:!m.enabled}); refresh(); })}, m.enabled ? 'Pause' : 'Enable'),
web && prot ? h('button', {class:'ghost', onclick:() => shareDialog(m)}, 'Share') : null,
web && prot ? h('button', {class:'ghost', title:'Everyone currently signed in to this resource has to sign in again', onclick: ask({title:`Sign everyone out of ${m.name}?`, body:'Everyone who is signed in to this resource has to sign in again. They can sign back in straight away.', confirm:'Sign out visitors', danger:false}, async () => { await api('POST', O(`/mappings/${m.id}/revoke`)); toast('Visitors signed out'); })}, 'Sign out visitors') : null,
h('button', {class:'ghost danger', onclick: ask({title:`Close ${m.name}?`, body:'The port closes and visitors can no longer reach it. You can open it again later.', confirm:'Close resource'}, async () => { await api('DELETE', O('/mappings/' + m.id)); refresh(); })}, 'Close'))));
}
function portForm(existing) {
const st = S.st, m = existing || { proto:'tcp', enabled:true, bindIp:'0.0.0.0', targets:[{siteId:'', host:'127.0.0.1', port:''}], auth:{}, rules:[] };
const a = m.auth || {};
const edgeSel = h('select', {}, st.edges.map(e => h('option', {value:e.id, selected:e.id === m.edgeId}, e.name + (e.online ? '' : ' (offline)'))));
// the public address is picked from the edge's own addresses, each shown with the domains pointed at it
const ipIn = h('select', {}), ipList = document.createTextNode('');
const protoSel = h('select', {disabled:!!existing}, PROTOS.filter(p => !existing || p[0] !== 'both').map(([v, t]) => h('option', {value:v, selected:v === m.proto}, t)));
const name = h('input', {value:m.name || '', placeholder:'optional'}), port = h('input', {value:m.port ? portText(m) : '', placeholder:'25565 or 2456-2458'});
const host = h('input', {value:m.hostname || '', list:'doms', placeholder:'app.example.com or *.example.com'});
const domList = h('datalist', {id:'doms'}, st.domains.map(d => h('option', {value:d.name})));
const hostBox = field('Hostname', host, st.domains.length ? 'Must be under one of your domains' : 'Add a domain on the Domains tab first');
const proxySel = h('select', {}, [['', 'Off'], ['v2', 'PROXY protocol v2 (recommended)'], ['v1', 'PROXY protocol v1']].map(([v, t]) => h('option', {value:v, selected:v === (m.proxyProtocol || '')}, t)));
const proxyBox = field('Tell the target who the real client is', proxySel, 'Without this the target sees every visitor as coming from the tunnel. Turn it on only if your server expects it: Velocity/BungeeCord haproxy-protocol, Postfix postscreen_upstream_proxy_protocol = haproxy, Dovecot haproxy_trusted_networks, nginx proxy_protocol.');
const tgtBox = h('div', {class:'stack'});
let targets = m.targets.map(t => ({...t}));
const roleChecks = st.roles.map(r => ({r, c:chk(r.name + (r.admin ? ' (admin)' : ''), (a.allowedRoles || []).includes(r.id))}));
const pwIn = h('input', {type:'password', placeholder:existing && a.password ? 'set: leave blank to keep' : 'shared password', autocomplete:'new-password'});
const pinIn = h('input', {type:'password', placeholder:existing && a.pin ? 'set: leave blank to keep' : '4 to 12 digits', inputmode:'numeric', autocomplete:'off'});
const ssoC = chk('Sign in with a Hammerhead account', a.sso), pwC = chk('Shared password', a.password), pinC = chk('PIN', a.pin), mailC = chk('Emailed one-time code', a.email);
const mails = h('input', {value:(a.allowedMails || []).join(', '), placeholder:'a@x.com, *@company.com'});
const hours = h('input', {type:'number', min:1, max:2160, value:a.sessionHours || 24});
const ruleBox = h('div', {class:'stack'}); let rules = (m.rules || []).map(r => ({...r}));
const healthPath = h('input', {value:m.healthPath || '', placeholder:'/healthz, or leave blank to just connect'});
const healthSec = h('input', {type:'number', value:m.healthSeconds > 0 ? m.healthSeconds : 30, min:5});
const healthOff = chk('Turn health checks off', m.healthSeconds === -1);
const redir = chk('Also answer on port 80 with a redirect to HTTPS', m.redirectHttp), bhttps = chk('Talk HTTPS to the targets (certificate not verified)', m.backendHttps);
const rewrite = h('input', {value:m.rewriteHost || '', placeholder:'Leave blank to keep the visitor\'s Host'});
const webBox = h('div', {class:'stack'});
const hostLbl = hostBox;
const ipLabel = (e, ip) => {
const doms = st.domains.filter(d => d.ip === ip && (!d.edgeId || !e || d.edgeId === e.id)).map(d => d.name);
return doms.length ? `${ip} (${doms.join(', ')})` : ip;
};
const refreshIPs = () => {
const e = byId(st.edges, edgeSel.value), keep = ipIn.value || m.bindIp || '0.0.0.0';
// an address saved earlier stays selectable even if the edge has not reported it yet
const saved = existing && m.edgeId === edgeSel.value && m.bindIp !== '0.0.0.0' ? [m.bindIp] : [];
const ips = [...new Set([...((e && e.ips) || []), ...saved])];
ipIn.replaceChildren(h('option', {value:'0.0.0.0'}, 'Every address on the edge'), ...ips.map(ip => h('option', {value:ip}, ipLabel(e, ip))));
ipIn.value = ips.includes(keep) ? keep : '0.0.0.0';
};
// choosing a hostname under a domain that points at one of the edge's addresses selects that address
const pickIPForHost = () => {
const name = host.value.trim().toLowerCase().replace(/^\*\./, ''), e = byId(st.edges, edgeSel.value);
const d = st.domains.find(d => d.ip && (name === d.name || name.endsWith('.' + d.name)) && (!d.edgeId || d.edgeId === edgeSel.value));
if (d && ipIn.value === '0.0.0.0' && e && (e.ips || []).includes(d.ip)) ipIn.value = d.ip;
};
host.addEventListener('change', pickIPForHost);
const siteOpts = () => st.sites.filter(s => !s.local || s.edgeId === edgeSel.value);
const drawTargets = () => {
const many = protoSel.value !== 'udp' && protoSel.value !== 'both';
tgtBox.replaceChildren(...targets.map((t, i) => {
const site = h('select', {onchange:e => { t.siteId = e.target.value; const s = byId(st.sites, t.siteId); if (s && s.local && !t.host) t.host = '127.0.0.1'; }},
h('option', {value:'', disabled:true, selected:!t.siteId}, 'Choose a site…'), siteOpts().map(s => h('option', {value:s.id, selected:s.id === t.siteId}, s.name)));
const th = h('input', {value:t.host, oninput:e => t.host = e.target.value, placeholder:'127.0.0.1 or 192.168.1.20'});
const tp = h('input', {type:'number', value:t.port, placeholder:'port', oninput:e => t.port = +e.target.value});
return h('div', {class:'tgt'}, field(i ? 'Target ' + (i + 1) : 'Target site', site), field('Host', th), field('Port', tp),
h('button', {class:'ghost', type:'button', title:'Pick a Docker container on that site', onclick:() => discover(t, th, tp)}, 'Containers'),
targets.length > 1 ? h('button', {class:'ghost danger', type:'button', 'aria-label':'Remove target', onclick:() => { targets.splice(i, 1); drawTargets(); }}, icon('close', 16)) : h('span'));
}), many ? h('button', {class:'ghost', type:'button', style:'align-self:flex-start', title:'Spread visitors across targets, or fail over when one is down', onclick:() => { targets.push({siteId:targets[0] ? targets[0].siteId : '', host:'127.0.0.1', port:''}); drawTargets(); }}, icon('add', 16), 'Add another target') : null);
};
const discover = act(async (t, th, tp) => {
if (!t.siteId) throw new Error('Choose a site first');
const list = await api('GET', O(`/sites/${t.siteId}/containers`));
if (!list.length) throw new Error('No containers reported. Run the agent with --docker (and make sure Docker is running).');
const opts = [];
for (const c of list) for (const p of c.ports) {
if (p.public) opts.push({label:`${c.name} 127.0.0.1:${p.public} (published)`, host:'127.0.0.1', port:p.public});
for (const [n, ip] of Object.entries(c.networks)) opts.push({label:`${c.name} ${ip}:${p.private} (${n} network)`, host:ip, port:p.private});
}
const sel = h('select', {size:Math.min(12, opts.length + 1)}, opts.map((o, i) => h('option', {value:i}, o.label)));
const close = modal('Pick a container', h('div', {class:'stack'}, sel, h('button', {class:'btn', onclick:() => { const o = opts[sel.value]; if (!o) return; t.host = th.value = o.host; t.port = o.port; tp.value = o.port; close(); }}, 'Use this')));
});
const drawRules = () => ruleBox.replaceChildren(...rules.map((r, i) => h('div', {class:'grid', style:'grid-template-columns:110px 110px 1fr auto'},
h('select', {onchange:e => r.action = e.target.value}, ['allow', 'deny'].map(v => h('option', {value:v, selected:r.action === v}, v === 'allow' ? 'Skip login' : 'Block'))),
h('select', {onchange:e => r.match = e.target.value}, ['path', 'ip'].map(v => h('option', {value:v, selected:r.match === v}, v === 'path' ? 'Path starts with' : 'IP / CIDR'))),
h('input', {value:r.value, oninput:e => r.value = e.target.value, placeholder:r.match === 'ip' ? '203.0.113.0/24' : '/api/public'}),
h('button', {class:'ghost danger', type:'button', 'aria-label':'Remove rule', onclick:() => { rules.splice(i, 1); drawRules(); }}, icon('close', 16)))),
h('button', {class:'ghost', type:'button', style:'align-self:flex-start', onclick:() => { rules.push({action:'allow', match:'path', value:''}); drawRules(); }}, icon('add', 16), 'Add a rule'));
const drawWeb = () => {
const p = protoSel.value, web = p === 'http' || p === 'https';
hostLbl.style.display = ['http', 'https', 'tls'].includes(p) ? '' : 'none';
webBox.style.display = web ? '' : 'none';
redir.style.display = p === 'https' ? '' : 'none';
proxyBox.style.display = ['tcp', 'both', 'tls'].includes(p) ? '' : 'none';
drawTargets();
};
webBox.append(
h('fieldset', {}, h('legend', {}, 'Who can open this?'), h('div', {class:'stack'},
h('div', {class:'sub'}, 'Leave everything off for a public site. Turn on one or more methods to put a login in front.'),
ssoC, h('div', {class:'stack', style:'margin-left:26px'}, h('div', {class:'sub'}, 'Limit to these roles. If none are ticked, every member of the organization can open it.'), h('div', {class:'pillrow'}, roleChecks.map(x => x.c))),
pwC, field('Password', pwIn), pinC, field('PIN', pinIn), mailC, field('Allowed email addresses', mails, 'Needs email set up under Server → Settings'),
field('Stay signed in for (hours)', hours))),
h('fieldset', {}, h('legend', {}, 'Rules (checked before login, first match wins)'), ruleBox),
h('fieldset', {}, h('legend', {}, 'Advanced'), h('div', {class:'stack'}, redir, bhttps, field('Host header sent to targets', rewrite))));
const adv = h('fieldset', {}, h('legend', {}, 'Health checks'), h('div', {class:'grid'}, field('Check path (web ports)', healthPath), field('Every (seconds)', healthSec), healthOff));
edgeSel.onchange = () => { refreshIPs(); drawTargets(); }; protoSel.onchange = drawWeb; refreshIPs(); drawRules(); drawWeb();
const save = act(async () => {
const pr = (port.value || '').trim().match(/^(\d+)(?:\s*-\s*(\d+))?$/);
if (!pr) throw new Error('Enter a port like 25565, or a range like 2456-2458');
const roles = roleChecks.filter(x => x.c.box.checked).map(x => x.r.id);
const base = {
name:name.value, edgeId:edgeSel.value, bindIp:ipIn.value || '0.0.0.0', port:+pr[1], portEnd:pr[2] ? +pr[2] : 0, hostname:host.value, targets,
enabled:m.enabled !== false, rules:rules.filter(r => r.value.trim()),
auth:{sso:ssoC.box.checked, password:pwC.box.checked, pin:pinC.box.checked, email:mailC.box.checked, allowedRoles:roles, allowedUsers:a.allowedUsers || [],
allowedMails:mails.value.split(',').map(s => s.trim()).filter(Boolean), sessionHours:+hours.value || 0},
redirectHttp:redir.box.checked, backendHttps:bhttps.box.checked, rewriteHost:rewrite.value, healthPath:healthPath.value,
healthSeconds:healthOff.box.checked ? -1 : (+healthSec.value || 0),
};
if (pwIn.value) base.newPassword = pwIn.value; if (pinIn.value) base.newPin = pinIn.value;
if (!pwC.box.checked) base.newPassword = ''; if (!pinC.box.checked) base.newPin = '';
const protos = protoSel.value === 'both' ? ['tcp', 'udp'] : [protoSel.value];
if (existing) await api('PUT', O('/mappings/' + m.id), {...base, proto:m.proto, proxyProtocol:['tcp', 'tls'].includes(m.proto) ? proxySel.value : ''});
else for (const p of protos) await api('POST', O('/mappings'), {...base, proto:p, targets:p === 'udp' ? targets.slice(0, 1) : targets, proxyProtocol:['tcp', 'tls'].includes(p) ? proxySel.value : ''});
close(); toast(existing ? `Saved ${base.name || 'the resource'}` : `Added ${base.name || 'the resource'}`); refresh();
});
const close = existing ? modal('Edit ' + (m.name || 'resource'), h('div', {class:'stack'},
h('div', {class:'grid'}, field('Name', name), field('Edge', edgeSel), field('Public IP', ipIn, 'Domains pointed at an address are shown next to it'), field('Protocol', protoSel), field('Public port', port, 'One port, or a range like 2456-2458 for TCP and UDP')),
ipList, domList, hostBox, proxyBox, h('fieldset', {}, h('legend', {}, 'Where does the traffic go?'), tgtBox), adv, webBox,
h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => close()}, 'Cancel'), h('button', {class:'btn', onclick:save}, existing ? 'Save changes' : 'Open port'))), {sticky:true}) : wizardModal();
// The add flow for a new resource: a few short steps that reuse the controls above.
function wizardModal() {
const TYPES = [
['https', 'Website', 'A site or web app, with an automatic HTTPS certificate.'],
['tcp', 'Game or app port (TCP)', 'Minecraft, SSH, databases: anything over TCP.'],
['udp', 'UDP port', 'Voice chat, some games, DNS.'],
['both', 'TCP + UDP', 'The same port for both, as many games want.'],
['tls', 'TLS passthrough', 'Route encrypted traffic by hostname without decrypting it.'],
['http', 'Website, plain HTTP', 'No certificate: for internal use or behind another proxy.'],
];
const [accessFs, rulesFs, advFs] = [...webBox.children];
let step = 0, chosen = false;
const isWeb = () => protoSel.value === 'http' || protoSel.value === 'https';
const flow = () => isWeb() ? ['type', 'target', 'reach', 'access', 'finish'] : ['type', 'target', 'reach', 'finish'];
const titles = {type:'What are you sharing?', target:'Where does it point?', reach:'How do people reach it?', access:'Who can open it?', finish:'Name it and review'};
const body = h('div', {class:'stack'}), crumbs = h('div', {class:'small dim'}), err = h('div', {class:'err small'});
const back = h('button', {class:'ghost', type:'button', onclick:() => go(-1)}, 'Back');
const next = h('button', {class:'btn', type:'button', onclick:() => go(1)}, 'Next');
const portRe = /^(\d+)(?:\s*-\s*(\d+))?$/;
const summary = () => {
const t = targets.map(x => ((byId(st.sites, x.siteId) || {name:'?'}).name) + ' → ' + x.host + ':' + x.port).join(', '), pr = (port.value || '').trim();
const protoName = {both:'TCP + UDP', tcp:'TCP', udp:'UDP', tls:'TLS'}[protoSel.value];
const where = isWeb() ? protoSel.value + '://' + (host.value.trim() || '*') + (pr && pr !== '443' && pr !== '80' ? ':' + pr : '')
: protoName + ' port ' + pr + (protoSel.value === 'tls' && host.value.trim() ? ' for ' + host.value.trim() : '') + ' on ' + (ipIn.value === '0.0.0.0' ? 'every address' : ipIn.selectedOptions[0].textContent);
const login = ssoC.box.checked || pwC.box.checked || pinC.box.checked || mailC.box.checked;
return h('div', {class:'stack'}, h('div', {}, h('b', {class:'mono'}, where)), h('div', {class:'small'}, '→ ' + t),
isWeb() ? h('div', {class:'small dim'}, login ? 'Visitors have to sign in.' : 'Anyone can open it.') : null);
};
const validate = cur => {
if (cur === 'type') return chosen ? '' : 'Choose what you are sharing.';
if (cur === 'target') return targets.every(t => t.siteId && String(t.host).trim() && t.port > 0) ? '' : 'Choose a site and enter the host and port for every target.';
if (cur === 'reach') {
if ((isWeb() || protoSel.value === 'tls') && !host.value.trim()) return 'Enter the hostname people will visit.';
return portRe.test((port.value || '').trim()) ? '' : 'Enter a public port like 25565, or a range like 2456-2458.';
}
if (cur === 'access') {
if (pwC.box.checked && !pwIn.value) return 'Enter the shared password, or turn that option off.';
if (pinC.box.checked && !pinIn.value) return 'Enter a PIN, or turn that option off.';
}
return '';
};
const prefill = () => {
if (port.value) return;
const t = protoSel.value;
port.value = t === 'https' || t === 'tls' ? '443' : t === 'http' ? '80' : (targets[0] && targets[0].port ? String(targets[0].port) : '');
};
const pick = v => {
protoSel.value = v; chosen = true; protoSel.onchange();
if (v === 'https') redir.box.checked = true;
port.value = ''; step = 1; draw();
};
const go = dir => {
const cur = flow()[step];
if (dir > 0) {
const msg = validate(cur);
if (msg) { err.textContent = msg; return; }
if (cur === 'finish') {
if (!name.value.trim()) name.value = host.value.trim() || (protoSel.value.toUpperCase() + ' ' + port.value.trim());
save(); return;
}
step++;
} else step = Math.max(0, step - 1);
draw();
};
function draw() {
const f = flow(), cur = f[step];
err.textContent = '';
crumbs.textContent = 'Step ' + (step + 1) + ' of ' + f.length;
next.textContent = cur === 'finish' ? (isWeb() ? 'Add website' : 'Open port') : 'Next';
next.style.display = cur === 'type' ? 'none' : '';
back.style.display = step ? '' : 'none';
const kids = [h('h3', {style:'margin:0'}, titles[cur])];
if (cur === 'type') {
kids.push(h('div', {class:'choices'}, TYPES.map(([v, t, d]) => h('button', {type:'button', class:'choice' + (chosen && protoSel.value === v ? ' on' : ''), onclick:() => pick(v)},
h('div', {class:'choice-t'}, t), h('div', {class:'small dim'}, d)))));
} else if (cur === 'target') {
if (st.edges.length > 1) kids.push(field('Public server (edge)', edgeSel, 'The server that listens for visitors.'));
kids.push(h('fieldset', {}, h('legend', {}, 'Where does the traffic go?'), tgtBox), proxyBox);
} else if (cur === 'reach') {
prefill();
const web = isWeb();
kids.push(h('div', {class:'grid'}, web || protoSel.value === 'tls' ? hostBox : null,
field('Public port', port, web ? 'Websites normally use ' + (protoSel.value === 'https' ? '443' : '80') + '.' : 'One port, or a range like 2456-2458: each port goes to the target port plus the same offset.'),
field('Public IP', ipIn, (byId(st.edges, edgeSel.value) || {}).ips && byId(st.edges, edgeSel.value).ips.length ? 'Domains pointed at an address are shown next to it.' : 'The edge has not reported its addresses yet. Start it to pick a specific one.')), ipList, domList);
} else if (cur === 'access') {
kids.push(accessFs);
} else {
kids.push(field('Name', name, 'Optional. Shown in the list.'), h('div', {class:'card', style:'background:var(--surface-2); margin:0'}, summary()),
h('details', {}, h('summary', {class:'small dim', style:'cursor:pointer'}, 'More options (health checks' + (isWeb() ? ', rules, redirects' : '') + ')'),
h('div', {class:'stack', style:'margin-top:10px'}, adv, ...(isWeb() ? [rulesFs, advFs] : []))));
}
body.replaceChildren(...kids);
}
draw();
return modal('Add a resource', h('div', {class:'stack'}, crumbs, body, err,
h('div', {class:'actions form-actions'}, h('button', {class:'ghost', type:'button', onclick:() => close()}, 'Cancel'), back, next)), {sticky:true});
}
}
async function shareDialog(m) {
const body = h('div', {class:'stack'});
const draw = async () => {
const links = await api('GET', O(`/mappings/${m.id}/shares`));
const name = h('input', {placeholder:'e.g. For Sam'}), hrs = h('input', {type:'number', placeholder:'Hours, or leave blank for no expiry'}), uses = h('input', {type:'number', placeholder:'Leave blank for no limit'});
const out = h('div');
body.replaceChildren(
h('p', {class:'dim'}, 'A share link lets someone in without an account or password. Anyone with the link gets access, so keep it private.'),
h('div', {class:'grid'}, field('Label', name), field('Expires after', hrs), field('Max uses', uses), h('button', {class:'btn', onclick: act(async () => {
const r = await api('POST', O(`/mappings/${m.id}/shares`), {name:name.value, hours:+hrs.value || 0, maxUses:+uses.value || 0});
out.replaceChildren(h('p', {class:'small'}, 'Copy this now; it is not shown again:'), cmdBox(r.url)); }
)}, 'Create link')), out,
links.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Label', 'Used', 'Expires', ''].map(t => h('th', {}, t)))),
h('tbody', {}, links.map(l => h('tr', {}, h('td', {}, l.name || 'Unnamed'), h('td', {}, l.uses + (l.maxUses ? ' / ' + l.maxUses : '')), h('td', {}, l.expires.startsWith('0001') ? 'never' : fmtTime(l.expires)),
h('td', {}, h('button', {class:'ghost danger', onclick: ask({title:`Revoke ${l.name || 'this share link'}?`, body:'Anyone using the link loses access straight away. This cannot be undone.', confirm:'Revoke link'}, async () => { await api('DELETE', O(`/mappings/${m.id}/shares/${l.id}`)); draw(); })}, 'Revoke')))))) : h('div', {class:'dim'}, 'No links yet.'));
};
await draw(); modal('Share links for ' + m.name, body);
}
// ----- sites -----
views.sites = async () => {
const st = S.st, name = h('input', {placeholder:'e.g. Home lab'});
const add = act(async () => { const s = await api('POST', O('/sites'), {name:name.value}); name.value = ''; showCmd(s); refresh(); });
const showCmd = s => modal('Run Bamboo at ' + s.name, h('div', {}, h('p', {class:'dim'}, 'Run this on a machine inside that network. It only makes outbound connections. Add --docker to list its containers here.'),
cmdBox(`bamboo -hammerhead ${dashURL()} -token ${s.token} -docker`)));
S.headerAction = ['Add site', () => { const close = modal('Add a site', h('div', {class:'stack'},
h('p', {class:'dim'}, 'A site is a private network that holds your services. Every edge also has a built-in "this machine" site for services on the same box as Blacktip.'), field('Name', name),
h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => close()}, 'Cancel'), h('button', {class:'btn', onclick:() => { close(); add(); }}, 'Add site')))); }];
const latest = await latestVersions();
return h('div', {},
card('', h('table', {}, h('thead', {}, h('tr', {}, ['Status', 'Name', 'Kind', 'Tunnels to', 'Version', 'Containers', ''].map(t => h('th', {}, t)))),
h('tbody', {}, st.sites.map(s => h('tr', {},
h('td', {}, st.online[s.id] ? badge('Online', 'success') : badge('Offline', 'danger')), h('td', {}, s.name),
h('td', {}, s.local ? 'This machine' : 'Bamboo agent'),
h('td', {}, s.local ? 'Direct' : ((s.connected || []).map(id => (byId(st.edges, id) || {name:id}).name).join(', ') || 'nothing connected')),
s.local ? h('td', {class:'dim'}, 'Same as its edge') : versionCell(s, latest.bamboo, !!s.updateId, O(`/sites/${s.id}/update`), O(`/sites/${s.id}/update/log`)),
h('td', {}, h('button', {class:'ghost', onclick: act(async () => {
const l = await api('GET', O(`/sites/${s.id}/containers`));
modal('Containers on ' + s.name, l.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Image', 'Ports'].map(t => h('th', {}, t)))), h('tbody', {}, l.map(c => h('tr', {}, h('td', {}, c.name), h('td', {class:'dim'}, c.image), h('td', {class:'mono small'}, c.ports.map(p => (p.public ? p.public + '→' : '') + p.private + '/' + p.type).join(', ')))))) :
h('p', {class:'dim'}, 'None reported. Start the agent with --docker (or BLACKTIP_DOCKER=1 / BAMBOO_DOCKER=1) on a machine where Docker is running.')); })}, 'View')),
h('td', {}, s.local ? null : h('div', {class:'actions'},
h('button', {class:'ghost', onclick:() => showCmd(s)}, 'Show command'),
h('button', {class:'ghost danger', onclick: ask({title:`Delete ${s.name}?`, body:'Every port that uses only this site is deleted with it, and its agent loses its connection. This cannot be undone.', confirm:'Delete site'}, async () => { await api('DELETE', O('/sites/' + s.id)); refresh(); })}, 'Delete')))))))));
};
// ----- domains -----
views.domains = async () => {
const st = S.st, name = h('input', {placeholder:'example.com'}), edgeSel = h('select', {}, st.edges.map(e => h('option', {value:e.id}, e.name))), ip = h('input', {placeholder:'public IP it points at'});
S.headerAction = ['Add domain', () => { const close = modal('Add a domain', h('div', {class:'stack'},
h('p', {class:'dim'}, 'Hostnames on http, https and tls ports must be under one of your domains. Create the DNS record at your DNS provider, then use Check DNS.'),
field('Domain', name), field('Edge', edgeSel), field('IP', ip, 'One of the edge\'s addresses'),
h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => close()}, 'Cancel'), h('button', {class:'btn', onclick: act(async () => { await api('POST', O('/domains'), {name:name.value, edgeId:edgeSel.value, ip:ip.value}); close(); refresh(); })}, 'Add domain')))); }];
return h('div', {},
card('', st.domains.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Domain', 'Edge', 'DNS record to create', ''].map(t => h('th', {}, t)))),
h('tbody', {}, st.domains.map(d => h('tr', {}, h('td', {}, d.name), h('td', {}, (byId(st.edges, d.edgeId) || {name:'?'}).name),
h('td', {class:'mono small'}, d.ip ? `A ${d.name} ${d.ip}` : 'No IP set', h('br'), d.ip ? `A *.${d.name} ${d.ip} (for subdomains)` : ''),
h('td', {}, h('div', {class:'actions'}, h('button', {class:'ghost', onclick: act(async () => { const r = await api('POST', O(`/domains/${d.id}/check`)); toast(r.matches ? `Resolves to ${r.resolved.join(', ')}` : r.error || `Resolves to ${r.resolved.join(', ')}, which isn't this edge`, !r.matches); })}, 'Check DNS'),
h('button', {class:'ghost danger', onclick: ask({title:`Remove ${d.name}?`, body:'The domain is removed from this organization. You can add it again later.', confirm:'Remove domain'}, async () => { await api('DELETE', O('/domains/' + d.id)); refresh(); })}, 'Remove'))))))) : emptyState('domains', 'No domains yet', 'Add a domain you own, then point its DNS at an edge.')));
};
// ----- mail egress -----
const spfFor = ip => ip.includes(':') ? `v=spf1 ip6:${ip} ~all` : `v=spf1 ip4:${ip} ~all`;
function checklist(e) {
const c = e.check, ip = e.sourceIp, helo = e.heloName;
const item = (ok, title, fix) => h('div', {style:'display:flex;gap:10px;align-items:flex-start;margin-bottom:12px'},
h('span', {style:'margin-top:1px;flex:none'}, ok === true ? badge('Pass', 'success') : ok === false ? badge('Fix', 'danger') : badge('Check', 'warning')),
h('div', {}, h('div', {}, title), fix ? h('div', {class:'dim small'}, fix) : null));
const body = h('div', {},
c ? h('p', {class:'dim small'}, 'Checked ' + ago(c.checkedAt) + ' from the edge, using ' + ip + '.') : h('p', {class:'dim'}, 'No report yet. The edge sends one shortly after it picks up this egress.'),
c ? item(c.ptrMatchesHelo, 'Reverse DNS of ' + ip + (c.ptr.length ? ' is ' + c.ptr.join(', ') : ' is not set'),
c.ptrMatchesHelo ? null : `Receiving servers check this. Ask your VPS provider to set the reverse DNS (PTR) of ${ip} to ${helo}.`) : null,
c ? item(c.heloResolves, helo + (c.heloResolves ? ' resolves to ' + ip : ' does not resolve to ' + ip),
c.heloResolves ? null : `Create an A record: ${helo} → ${ip} (it must match the reverse DNS).`) : null,
c ? item(c.port25 === 'open', 'Outbound port 25 from ' + ip + ': ' + (c.port25 === 'open' ? 'open' : 'blocked'),
c.port25 === 'open' ? null : c.port25) : null,
item(null, 'SPF', 'On each domain you send from, publish a TXT record, or add this address to your existing one:'),
cmdBox(spfFor(ip)),
item(null, 'DKIM and DMARC', 'Keep signing mail with DKIM in your mail server, as before. This relay passes the message through untouched, so signatures stay valid.'));
return body;
}
views.egress = async () => {
const st = S.st;
const remote = st.sites.filter(s => !s.local);
const rows = st.mailEgress || [];
const table = rows.length ? h('div', {class:'scroll'}, h('table', {},
h('thead', {}, h('tr', {}, ['Status', 'Name', 'Relay (on the site\'s machine)', 'Mail leaves from', 'Deliverability', ''].map(t => h('th', {}, t)))),
h('tbody', {}, rows.map(e => {
const site = byId(st.sites, e.siteId), edge = byId(st.edges, e.edgeId), c = e.check;
let tone = '', label = 'Paused', why = 'paused';
if (e.enabled) {
if (!e.statusKnown) { tone = 'warning'; label = 'Waiting'; why = 'waiting for the site'; }
else if (e.status) { tone = 'danger'; label = 'Error'; why = e.status; }
else { tone = 'success'; label = 'Listening'; why = 'listening'; }
}
const good = c && c.ptrMatchesHelo && c.heloResolves && c.port25 === 'open';
return h('tr', {},
h('td', {title:why}, badge(label, tone)),
h('td', {}, e.name, e.statusKnown && e.status ? h('div', {class:'err small mono'}, e.status) : null,
e.username ? h('span', {style:'margin-left:6px'}, badge('Sign-in required', 'accent')) : null, e.requireTls ? h('span', {style:'margin-left:4px'}, badge('TLS required')) : null),
h('td', {class:'mono'}, e.listen, h('div', {class:'dim small'}, site ? site.name : '?')),
h('td', {class:'mono'}, e.sourceIp, h('div', {class:'dim small'}, e.heloName + (e.smarthost ? ' → ' + e.smarthost : ' → recipient MX'))),
h('td', {}, h('button', {class:'ghost', onclick:() => modal('Deliverability for ' + e.sourceIp, checklist(e))},
!c ? 'Checking' : good ? 'Looks good' : 'Needs attention')),
h('td', {}, h('div', {class:'actions'},
h('button', {class:'ghost', onclick:() => mailForm(e)}, 'Edit'),
h('button', {class:'ghost', onclick: act(async () => { await api('PUT', O('/mail-egress/' + e.id), {...e, enabled:!e.enabled}); refresh(); })}, e.enabled ? 'Pause' : 'Enable'),
h('button', {class:'ghost danger', onclick: ask({title:`Remove ${e.name}?`, body:'Mail from your server stops leaving through this edge. You can set it up again later.', confirm:'Remove mail egress'}, async () => { await api('DELETE', O('/mail-egress/' + e.id)); refresh(); })}, 'Remove'))));
})))) : emptyState('egress', 'No mail egress yet', 'Add one so your mail server sends from the edge\'s IP address.');
S.headerAction = ['Add mail egress', () => mailForm()];
return h('div', {},
!remote.length ? h('div', {class:'notice'}, 'This runs on a Bamboo site: add the network your mail server lives in on the Sites page and start Bamboo there first.') : null,
h('div', {class:'card flush'}, table),
await dkimCard(),
card('How it works', h('p', {class:'dim'}, 'Bamboo opens a small SMTP relay on your mail server\'s network. Point your mail server at it, and each outgoing message is carried through the WireGuard tunnel to the edge, which delivers it straight to the recipient\'s mail server from the edge\'s IP, announcing itself with the HELO name you set. The recipient\'s replies come straight back, so your server\'s queue and retries work as usual.'),
cmdBox('# Postfix (main.cf)\nrelayhost = [127.0.0.1]:2525\n\n# Exim: use a smarthost router pointing at 127.0.0.1 port 2525')));
};
async function dkimCard() {
const keys = await api('GET', O('/dkim'));
const dom = h('input', {placeholder:'example.com'}), sel = h('input', {placeholder:'Leave blank to pick one automatically'});
const chip = k => ({published:['success', 'Published'], missing:['warning', 'Not in DNS yet'], mismatch:['danger', 'DNS has a different key'], unknown:['', 'Waiting for a check']}[k.status] || ['', k.status]);
const show = k => modal('DNS record for ' + k.domain, h('div', {}, h('p', {class:'dim'}, 'Add this TXT record at your DNS provider. Some providers want the name without the domain part, or the value split into quoted pieces: both are fine.'),
h('div', {class:'sub'}, 'Name'), cmdBox(k.recordName), h('div', {class:'sub', style:'margin-top:10px'}, 'Value'), cmdBox(k.record)));
return card('DKIM signing', h('p', {class:'dim'}, 'The edge signs your outgoing mail so receiving servers can prove it came from you. Make a key for each domain you send from, then publish its record. If your mail server already signs, it is left alone.'),
keys.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Domain', 'Selector', 'DNS', ''].map(x => h('th', {}, x)))), h('tbody', {}, keys.map(k => h('tr', {},
h('td', {}, k.domain), h('td', {class:'mono'}, k.selector), h('td', {}, badge(chip(k)[1], chip(k)[0])),
h('td', {}, h('div', {class:'actions'}, h('button', {class:'ghost', onclick:() => show(k)}, 'Show DNS record'),
h('button', {class:'ghost danger', onclick: ask({title:`Stop signing mail for ${k.domain}?`, body:'Outgoing mail for this domain is no longer signed, which can hurt delivery. The key is deleted and cannot be recovered.', confirm:'Remove key'}, async () => { await api('DELETE', O('/dkim/' + k.id)); refresh(); })}, 'Remove'))))))) : null,
h('div', {class:'row-h'}, h('button', {class:'ghost', onclick:() => dialogForm('Create a DKIM key', 'Make a key for each domain you send mail from, then publish its DNS record.', [field('Domain', dom), field('Selector (optional)', sel)], 'Create key',
async () => { const k = await api('POST', O('/dkim'), {domain:dom.value, selector:sel.value}); show(k); refresh(); })}, icon('add', 16), 'Create key')));
}
function mailForm(existing) {
const st = S.st, e = existing || {enabled:true, listen:'', ratePerMinute:60, requireTls:false};
const sites = st.sites.filter(s => !s.local);
const siteSel = h('select', {}, sites.map(s => h('option', {value:s.id, selected:s.id === e.siteId}, s.name)));
const edgeSel = h('select', {}, st.edges.map(x => h('option', {value:x.id, selected:x.id === e.edgeId}, x.name + (x.online ? '' : ' (offline)'))));
const name = h('input', {value:e.name || '', placeholder:'e.g. Mail out'});
const src = h('input', {list:'srcips', value:e.sourceIp || '', placeholder:'an IP address of the edge'});
const ipList = h('datalist', {id:'srcips'});
const helo = h('input', {value:e.heloName || '', placeholder:'mail.example.com'});
const listen = h('input', {value:e.listen || '127.0.0.1:2525'});
const smart = h('input', {value:e.smarthost || '', placeholder:'Leave blank to deliver to each recipient\'s own server'});
const user = h('input', {value:e.username || '', autocomplete:'off', placeholder:'needed if you listen beyond 127.0.0.1'});
const pass = h('input', {type:'password', autocomplete:'new-password', placeholder:e.passwordSet ? 'unchanged' : ''});
const rate = h('input', {type:'number', min:1, max:6000, value:e.ratePerMinute || 60});
const tls = chk('Require verified TLS (refuse to send to servers without it)', e.requireTls);
const refreshIPs = () => { const x = byId(st.edges, edgeSel.value); ipList.replaceChildren(...((x && x.ips) || []).map(ip => h('option', {value:ip}))); };
edgeSel.onchange = refreshIPs; refreshIPs();
const save = act(async () => {
await api(existing ? 'PUT' : 'POST', O('/mail-egress' + (existing ? '/' + existing.id : '')), {name:name.value, siteId:siteSel.value, edgeId:edgeSel.value, sourceIp:src.value,
heloName:helo.value, listen:listen.value, smarthost:smart.value, requireTls:tls.box.checked, ratePerMinute:+rate.value || 0, username:user.value, password:pass.value, enabled:e.enabled !== false});
close(); toast('Saved. Check the deliverability column in a moment.'); refresh();
});
const close = modal(existing ? 'Edit mail egress' : 'Add mail egress', h('div', {class:'stack'},
h('div', {class:'grid'}, field('Name', name), field('Site (your mail server\'s network)', siteSel), field('Edge (where mail leaves)', edgeSel)),
h('div', {class:'grid'}, field('Send from this edge IP', src, 'Its reverse DNS should be your HELO name'), field('HELO hostname', helo, 'What your server announces, e.g. mail.example.com')),
ipList,
h('div', {class:'grid'}, field('Relay listens on (on the site)', listen, '127.0.0.1 keeps it private to that machine'), field('Messages per minute (limit)', rate, 'Caps what a compromised server could send from your IP')),
h('div', {class:'grid'}, field('Username', user), field('Password', pass)),
field('Smarthost (optional)', smart, 'Use this if you relay through a provider that allows your edge IP. Leave blank for direct delivery.'), tls,
h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => close()}, 'Cancel'), h('button', {class:'btn', onclick:save}, existing ? 'Save changes' : 'Add mail egress'))), {sticky:true});
}
// ----- alerts -----
const FORMATS = [['json', 'JSON'], ['slack', 'Slack'], ['discord', 'Discord'], ['ntfy', 'ntfy']];
function channelForm(base, existing, trusted) {
const c = existing || {kind:'email', format:'json', enabled:true};
const name = h('input', {value:c.name || '', placeholder:'e.g. On-call'});
const kind = h('select', {}, [['email', 'Email'], ['webhook', 'Webhook']].map(([v, t]) => h('option', {value:v, selected:v === c.kind}, t)));
const target = h('input', {value:c.kind === 'email' ? c.target || '' : '', placeholder:c.kind === 'webhook' && existing ? 'unchanged' : ''});
const fmt = h('select', {}, FORMATS.map(([v, t]) => h('option', {value:v, selected:v === (c.format || 'json')}, t)));
const priv = chk('This address is on a private network (like a home server)', c.allowPrivate);
const en = chk('Enabled', c.enabled !== false);
const fmtBox = field('Webhook format', fmt, 'Slack and Discord incoming-webhook URLs work as they are. ntfy sends a push notification.');
const targetBox = field('Where', target);
const redraw = () => {
const w = kind.value === 'webhook';
fmtBox.style.display = w ? '' : 'none';
priv.style.display = w && trusted ? '' : 'none';
target.placeholder = w ? (existing && c.kind === 'webhook' ? 'unchanged (it may contain a secret token)' : 'https://hooks.example.com/…') : 'you@example.com, oncall@example.com';
};
kind.onchange = redraw; redraw();
const save = act(async () => {
await api(existing ? 'PUT' : 'POST', base + '/channels' + (existing ? '/' + existing.id : ''), {name:name.value, kind:kind.value, target:target.value, format:kind.value === 'webhook' ? fmt.value : '', allowPrivate:trusted && priv.box.checked, enabled:en.box.checked});
close(); toast(existing ? 'Alert channel saved' : 'Alert channel added'); refresh();
});
const close = modal(existing ? 'Edit channel' : 'Add an alert channel', h('div', {class:'stack'},
h('div', {class:'grid'}, field('Name', name), field('Type', kind)), targetBox, fmtBox, priv, en,
h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => close()}, 'Cancel'), h('button', {class:'btn', onclick:save}, 'Save'))), {sticky:true});
}
async function alertsView(base, scoped, trusted) {
const d = await api('GET', base);
const channels = h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Type', 'Sends to', ''].map(x => h('th', {}, x)))),
h('tbody', {}, d.channels.map(c => h('tr', {}, h('td', {}, c.name, c.enabled ? null : h('span', {class:'tag', style:'margin-left:6px'}, 'off')),
h('td', {}, c.kind === 'email' ? 'Email' : 'Webhook (' + c.format + ')'), h('td', {class:'mono small'}, c.preview),
h('td', {}, h('div', {class:'actions'},
h('button', {class:'ghost', onclick: act(async () => { await api('POST', base + '/channels/' + c.id + '/test'); toast('Test sent'); })}, 'Send test'),
h('button', {class:'ghost', onclick:() => channelForm(base, c, trusted)}, 'Edit'),
h('button', {class:'ghost danger', onclick: ask({title:'Remove this alert channel?', body:'You will no longer be told here when something stops working. You can add it again later.', confirm:'Remove channel'}, async () => { await api('DELETE', base + '/channels/' + c.id); refresh(); })}, 'Remove')))))));
const debounce = h('input', {type:'number', min:1, max:120, value:d.settings.debounceMin}), repeat = h('input', {type:'number', min:0, max:168, value:d.settings.repeatHours});
S.headerAction = ['Add channel', () => channelForm(base, null, trusted)];
return h('div', {},
!d.emailConfigured ? h('div', {class:'notice'}, 'Email alerts need an email server. Set one up under Server → Settings, or use a webhook.') : null,
card('Right now', d.active.length ? h('table', {}, h('tbody', {}, d.active.map(a => h('tr', {}, h('td', {}, a.fired ? badge('Firing', 'danger') : badge('Waiting', 'warning')), h('td', {}, a.message), h('td', {class:'dim small'}, 'since ' + ago(a.since)))))) :
emptyState('check', 'Nothing is wrong', 'Problems that are firing or about to fire appear here.')),
card('Where alerts go',
d.channels.length ? channels : emptyState('alerts', 'No channels yet', 'Add an email address or a webhook to be told when something stops working.')),
scoped ? card('Timing', h('p', {class:'dim'}, 'A problem has to last this long before you are told, so a restart or a brief blip stays quiet. You get one message when it starts and one when it clears.'),
h('div', {class:'grid'}, field('Wait this many minutes first', debounce), field('Remind me every (hours)', repeat, 'Use 0 to send no reminders'),
h('button', {class:'ghost', onclick: act(async () => { await api('PUT', base + '/settings', {debounceMin:+debounce.value, repeatHours:+repeat.value}); toast('Alert timing saved'); })}, 'Save timing'))) : null,
card('History', d.history.length ? h('table', {}, h('tbody', {}, d.history.map(e => h('tr', {}, h('td', {class:'dim small'}, fmtTime(e.time)),
h('td', {}, h('span', {class:'tag'}, e.status)), h('td', {}, e.message))))) : emptyState('alerts', 'No alerts yet', 'Alerts that fired and recovered are listed here.')));
}
views.alerts = () => alertsView(O('/alerts'), true, !!(S.me.serverAdmin || (S.st.org && S.st.org.allowLocal)));
// ----- access -----
views.access = async () => {
const [members, invites, keys] = await Promise.all([api('GET', O('/members')), api('GET', O('/invites')), api('GET', O('/apikeys'))]);
const roles = S.st.roles;
const email = h('input', {type:'email', placeholder:'person@example.com'}), roleSel = h('select', {}, roles.map(r => h('option', {value:r.id, selected:r.name === 'Member'}, r.name)));
const rname = h('input', {placeholder:'e.g. Staff'}), radmin = chk('Can manage the organization', false);
const kname = h('input', {placeholder:'e.g. CI deploy'});
S.headerAction = ['Invite someone', () => dialogForm('Invite someone', 'They get a link that works for 7 days.', [field('Email', email), field('Role', roleSel)], 'Create invitation', async () => {
const r = await api('POST', O('/invites'), {email:email.value, roleId:roleSel.value}); email.value = '';
modal('Invitation created', h('div', {}, h('p', {class:'dim'}, r.emailed ? 'An email was sent. You can also share this link:' : 'Send them this link (it works for 7 days):'), cmdBox(r.link))); refresh(); })];
return h('div', {},
card('Members', h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Email', 'Role', 'Two-factor', ''].map(t => h('th', {}, t)))),
h('tbody', {}, members.map(mm => h('tr', {}, h('td', {}, mm.name), h('td', {}, mm.email, mm.sso ? h('span', {class:'tag', style:'margin-left:6px'}, 'sso') : null),
h('td', {}, h('select', {style:'width:auto', onchange: act(async e => { await api('PUT', O('/members/' + mm.userId), {roleId:e.target.value}); toast('Role changed'); })}, roles.map(r => h('option', {value:r.id, selected:r.id === mm.roleId}, r.name)))),
h('td', {}, mm.totp ? badge('On', 'success') : badge('Off')),
h('td', {}, mm.userId === S.me.id ? null : h('button', {class:'ghost danger', onclick: ask({title:`Remove ${mm.name}?`, body:'They lose access to this organization straight away. You can invite them again later.', confirm:'Remove member'}, async () => { await api('DELETE', O('/members/' + mm.userId)); refresh(); })}, 'Remove'))))))),
invites.length ? card('Pending invitations', h('table', {}, h('tbody', {}, invites.map(i => h('tr', {}, h('td', {}, i.email), h('td', {class:'dim'}, (byId(roles, i.roleId) || {name:'?'}).name), h('td', {class:'dim'}, 'Expires ' + fmtDate(i.expires)),
h('td', {}, h('button', {class:'ghost danger', onclick: ask({title:`Cancel the invitation for ${i.email}?`, body:'The invitation link stops working. You can invite them again.', confirm:'Cancel invitation', cancel:'Keep invitation'}, async () => { await api('DELETE', O('/invites/' + i.id)); refresh(); })}, 'Cancel'))))))) : null,
card('Roles', h('p', {class:'dim'}, 'Admin roles manage the organization. Other roles are used to decide who may open protected apps.'),
h('table', {}, h('tbody', {}, roles.map(r => h('tr', {}, h('td', {}, r.name, r.builtin ? h('span', {class:'tag', style:'margin-left:6px'}, 'built-in') : null), h('td', {class:'dim'}, r.admin ? 'Can manage the organization' : 'Member'),
h('td', {}, r.builtin ? null : h('button', {class:'ghost danger', onclick: ask({title:`Delete the ${r.name} role?`, body:'The role is deleted. This cannot be undone.', confirm:'Delete role'}, async () => { await api('DELETE', O('/roles/' + r.id)); refresh(); })}, 'Delete')))))),
h('div', {class:'grid', style:'margin-top:12px'}, field('New role', rname), radmin, h('button', {class:'ghost', onclick: act(async () => { await api('POST', O('/roles'), {name:rname.value, admin:radmin.box.checked}); refresh(); })}, 'Add role'))),
card('API keys', h('p', {class:'dim'}, 'Keys act as an administrator of this organization only. Send as "Authorization: Bearer <key>" to /api/orgs/' + S.org + '/…'),
keys.length ? h('table', {}, h('tbody', {}, keys.map(k => h('tr', {}, h('td', {}, k.name), h('td', {class:'dim'}, (ago(k.lastUsed) === 'never' ? 'Never used' : 'Last used ' + ago(k.lastUsed))), h('td', {}, h('button', {class:'ghost danger', onclick: ask({title:`Revoke ${k.name}?`, body:'Anything using this key stops working straight away. This cannot be undone.', confirm:'Revoke key'}, async () => { await api('DELETE', O('/apikeys/' + k.id)); refresh(); })}, 'Revoke'))))) ) : null,
h('div', {class:'grid', style:'margin-top:12px'}, field('Name', kname), h('button', {class:'ghost', onclick: act(async () => { const r = await api('POST', O('/apikeys'), {name:kname.value}); modal('New API key', h('div', {}, h('p', {class:'dim'}, 'Copy it now; it is not shown again.'), cmdBox(r.key))); refresh(); })}, 'Create key'))));
};
// ----- logs -----
let logKind = 'access', logMap = '', logQ = '', logStatus = '', logResult = '', logPaged = false;
views.logs = async () => {
const st = S.st;
const params = extra => new URLSearchParams({kind:logKind, limit:200, mapping:logMap, q:logQ, status:logStatus, result:logResult, ...extra});
const rows = await api('GET', O('/logs?' + params()));
const set = fn => e => { fn(e.target.value); logPaged = false; refresh(); };
const named = [...st.mappings, ...(st.mailEgress || [])];
const mapSel = h('select', {style:'width:auto', onchange:set(v => logMap = v)}, h('option', {value:''}, 'All ports'), named.map(m => h('option', {value:m.id, selected:m.id === logMap}, m.name)));
const statusSel = h('select', {style:'width:auto', onchange:set(v => logStatus = v)}, [['', 'Any status'], ['2', '2xx'], ['3', '3xx'], ['4', '4xx'], ['5', '5xx']].map(([v, t]) => h('option', {value:v, selected:v === logStatus}, t)));
const resultSel = h('select', {style:'width:auto', onchange:set(v => logResult = v)}, [['', 'Any result'], ['denied', 'Denied'], ['auth-required', 'Login required'], ['login', 'Logins'], ['mail', 'Mail sent'], ['mail-failed', 'Mail failed'], ['mail-refused', 'Mail refused'], ['no-target', 'No target']].map(([v, t]) => h('option', {value:v, selected:v === logResult}, t)));
const search = h('input', {placeholder:'Search host, path, address, user…', value:logQ, style:'width:260px'});
search.addEventListener('keydown', e => { if (e.key === 'Enter') { logQ = search.value.trim(); logPaged = false; refresh(); } });
const t = fmtTime;
const tbody = h('tbody');
const addRows = list => list.forEach(r => {
if (logKind === 'audit') { tbody.append(h('tr', {}, h('td', {class:'dim'}, t(r.time)), h('td', {}, r.actor), h('td', {}, r.action), h('td', {class:'dim'}, r.detail))); return; }
const m = byId(st.mappings, r.mapping) || byId(st.mailEgress, r.mapping);
tbody.append(h('tr', {}, h('td', {class:'dim'}, t(r.time)), h('td', {}, m ? m.name : r.mapping), h('td', {class:'mono small'}, r.remote), h('td', {class:'mono small'}, r.method ? `${r.method} ${r.host}${r.path}` : (r.host || '(connection)')),
h('td', {}, r.status || ''), h('td', {}, r.user || ''), h('td', {class:r.result && !['allowed', 'connection', 'bypass', 'login', 'logout', 'mail'].includes(r.result) ? 'err' : 'dim'}, r.result), h('td', {class:'small dim'}, `↑${bytes(r.bytesIn)} ↓${bytes(r.bytesOut)}`), h('td', {class:'dim'}, r.ms)));
});
addRows(rows);
let last = rows.length ? rows[rows.length - 1].id : 0;
const more = h('button', {class:'ghost', style:'margin:12px auto;display:block', onclick: act(async () => {
const next = await api('GET', O('/logs?' + params({before:last})));
if (!next.length) { more.remove(); toast('That is everything'); return; }
logPaged = true; addRows(next); last = next[next.length - 1].id;
})}, 'Load older entries');
const head = logKind === 'audit' ? ['Time', 'Who', 'What', 'Detail'] : ['Time', 'Port', 'From', 'Request', 'Status', 'User', 'Result', 'Bytes', 'ms'];
return h('div', {}, h('div', {class:'row-h', style:'justify-content:space-between'},
h('div', {class:'tabs2', style:'margin:0'}, [['access', 'Access'], ['audit', 'Audit']].map(([k, l]) => h('button', {class:logKind === k ? 'on' : '', onclick:() => { logKind = k; logPaged = false; refresh(); }}, l))),
logKind === 'access' ? h('div', {class:'actions'}, search, mapSel, statusSel, resultSel) : null),
card('', rows.length ? h('div', {class:'scroll'}, h('table', {}, h('thead', {}, h('tr', {}, head.map(x => h('th', {}, x)))), tbody), rows.length >= 200 ? more : null) : ((logQ || logMap || logStatus || logResult) ? emptyState('logs', 'No entries match', 'Nothing was logged for these filters. Try a wider search.')
: emptyState('logs', logKind === 'audit' ? 'No changes yet' : 'No requests yet', logKind === 'audit' ? 'Changes to this organization are listed here.' : 'Requests to your resources are listed here as they happen.'))));
};
// ----- server administration -----
let serverTab = 'edges';
views.server = async () => {
return await serverViews[serverTab]();
};
const serverViews = {};
serverViews.alerts = () => alertsView('/api/admin/alerts', false, true);
serverViews.edges = async () => {
const [edges, me] = [await api('GET', '/api/edges'), S.me];
const orgs = me.orgs, name = h('input', {placeholder:'e.g. VPS Frankfurt'}), addr = h('input', {placeholder:'203.0.113.10:51820'});
const orgChecks = orgs.map(o => ({o, c:chk(o.name, false)}));
const showCmd = e => modal('Run Blacktip on ' + e.name, h('div', {}, h('p', {class:'dim'}, 'Run this on the public server. The WireGuard UDP port (and any ports you open) must be reachable from the internet. Add -traefik /path/to/traefik to serve web resources with Traefik. Add -docker to list local containers.'),
cmdBox(`blacktip -hammerhead ${S.me.dashboardUrl || location.origin} -token ${e.token} -tunnel :${e.tunnelAddr.split(':').pop()} -docker`)));
S.headerAction = ['Add edge', () => { const close = modal('Add an edge', h('div', {class:'stack'},
h('p', {class:'dim'}, 'An edge (Blacktip) is a public server that owns IP addresses and opens ports.'),
field('Name', name), field('WireGuard endpoint (public host:UDP port)', addr, 'host:port'),
orgs.length > 1 ? h('div', {class:'stack'}, h('div', {class:'sub'}, 'Restrict to these organizations. If none are ticked, every organization can use it.'), h('div', {class:'pillrow'}, orgChecks.map(x => x.c))) : null,
h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => close()}, 'Cancel'), h('button', {class:'btn', onclick: act(async () => {
const e = await api('POST', '/api/edges', {name:name.value, tunnelAddr:addr.value, orgIds:orgChecks.filter(x => x.c.box.checked).map(x => x.o.id)}); close(); showCmd(e); refresh(); })}, 'Add edge')))); }];
// rename an edge, move its endpoint, or change which organizations may use it
const editEdge = e => {
const n = h('input', {value:e.name}), a = h('input', {value:e.tunnelAddr});
const checks = orgs.map(o => ({o, c:chk(o.name, (e.orgIds || []).includes(o.id))}));
dialogForm('Edit ' + e.name, 'Sites use the WireGuard endpoint to reach this edge, so change it only when the address or port has changed.',
[field('Name', n), field('WireGuard endpoint (public host:UDP port)', a, 'host:port'),
orgs.length > 1 ? h('div', {class:'stack'}, h('div', {class:'sub'}, 'Restrict to these organizations. If none are ticked, every organization can use it.'), h('div', {class:'pillrow'}, checks.map(x => x.c))) : null],
'Save edge', async () => {
await api('PUT', '/api/edges/' + e.id, {name:n.value, tunnelAddr:a.value, orgIds:orgs.length > 1 ? checks.filter(x => x.c.box.checked).map(x => x.o.id) : (e.orgIds || [])});
toast('Saved ' + n.value.trim()); refresh();
});
};
const latest = await latestVersions();
return h('div', {},
card('', edges.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Status', 'Name', 'WireGuard endpoint', 'IP addresses', 'Version', 'Organizations', ''].map(t => h('th', {}, t)))),
h('tbody', {}, edges.map(e => h('tr', {}, h('td', {}, e.online ? badge('Online', 'success') : badge('Offline', 'danger')), h('td', {}, e.name), h('td', {class:'mono'}, e.tunnelAddr),
h('td', {class:'mono small'}, (e.ips || []).join(', ') || 'None reported yet'), versionCell(e, latest.blacktip, e.updatePending, '/api/edges/' + e.id + '/update', '/api/edges/' + e.id + '/update/log'), h('td', {class:'small dim'}, (e.orgIds || []).length ? e.orgIds.map(id => (byId(orgs, id) || {name:id}).name).join(', ') : 'all'),
h('td', {}, h('div', {class:'actions'}, h('button', {class:'ghost', onclick:() => editEdge(e)}, 'Edit'), h('button', {class:'ghost', onclick:() => showCmd(e)}, 'Show command'),
h('button', {class:'ghost danger', onclick: ask({title:`Delete ${e.name}?`, body:'Every port on this edge is deleted and its tunnel closes. This cannot be undone.', confirm:'Delete edge'}, async () => { await api('DELETE', '/api/edges/' + e.id); refresh(); })}, 'Delete'))))))) : emptyState('edges', 'No edges yet', 'An edge is a public server running Blacktip. Add one to start opening ports.')));
};
serverViews.orgs = async () => {
const name = h('input', {placeholder:'Organization name'}), local = chk('Allow targeting the edge machine itself', false);
await boot0();
S.headerAction = ['New organization', () => dialogForm('New organization', null, [field('Name', name), local], 'Create', async () => { await api('POST', '/api/orgs', {name:name.value, allowLocal:local.box.checked}); S.me = await api('GET', '/api/me'); refresh(); })];
const states = await Promise.all(S.me.orgs.map(o => api('GET', `/api/orgs/${o.id}/state`).catch(() => null)));
const setLocal = (o, on) => async () => { await api('PUT', `/api/orgs/${o.id}`, {allowLocal:on}); toast(`Local access turned ${on ? 'on' : 'off'} for ${o.name}`); refresh(); };
return h('div', {},
card('', h('p', {class:'dim'}, 'Local access lets an organization point ports at services on the edge machine itself (127.0.0.1), which includes Hammerhead. Only turn it on for organizations you fully trust.'),
h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Local access', '', ''].map(t => h('th', {}, t)))), h('tbody', {}, S.me.orgs.map((o, i) => {
const on = !!(states[i] && states[i].org.allowLocal);
return h('tr', {}, h('td', {}, o.name), h('td', {}, on ? badge('On', 'warning') : badge('Off')),
h('td', {}, h('button', {class:'ghost', onclick:on ? act(setLocal(o, false)) : ask({title:`Allow local access for ${o.name}?`, body:'Its administrators can point ports at services on the edge machine itself, including Hammerhead. Only do this for an organization you fully trust.', confirm:'Allow local access', danger:false}, setLocal(o, true))}, on ? 'Turn off local access' : 'Allow local access')),
h('td', {}, h('button', {class:'ghost danger', onclick: ask({title:`Delete ${o.name}?`, body:'Everything in it is deleted: resources, sites, domains, members and logs. This cannot be undone.', confirm:'Delete organization'}, async () => { await api('DELETE', '/api/orgs/' + o.id); S.me = await api('GET', '/api/me'); if (S.org === o.id) S.org = (S.me.orgs[0] || {}).id; refresh(); })}, 'Delete')));
})))));
};
async function boot0() { S.me = await api('GET', '/api/me'); }
serverViews.users = async () => {
const users = await api('GET', '/api/admin/users');
const f = {e:h('input', {type:'email'}), n:h('input'), p:h('input', {type:'password'})}, adm = chk('Server administrator', false);
S.headerAction = ['Add user', () => dialogForm('Add a user', 'To add someone to an organization by email, use an invitation on the Access page instead.', [field('Email', f.e), field('Name', f.n), field('Password', f.p), adm], 'Create',
async () => { await api('POST', '/api/admin/users', {email:f.e.value, name:f.n.value, password:f.p.value, serverAdmin:adm.box.checked}); refresh(); })];
return h('div', {},
card('', h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Email', 'Organizations', 'Access', ''].map(t => h('th', {}, t)))),
h('tbody', {}, users.map(u => h('tr', {}, h('td', {}, u.name), h('td', {}, u.email), h('td', {class:'dim'}, u.orgs.join(', ') || 'None'),
h('td', {}, h('div', {class:'actions'}, u.serverAdmin ? badge('Server administrator', 'accent') : null, u.totp ? badge('Two-factor on', 'success') : null, u.sso ? badge('Single sign-on') : null)),
h('td', {}, u.id === S.me.id ? null : h('div', {class:'actions'},
h('button', {class:'ghost', onclick: act(async () => { await api('PUT', '/api/admin/users/' + u.id, {serverAdmin:!u.serverAdmin}); refresh(); })}, u.serverAdmin ? 'Remove admin' : 'Make admin'),
u.totp ? h('button', {class:'ghost', onclick: ask({title:`Turn off two-factor for ${u.email}?`, body:'They can sign in with their password alone until they set it up again. Use this when someone has lost their authenticator.', confirm:'Turn off two-factor'}, async () => { await api('PUT', '/api/admin/users/' + u.id, {resetTotp:true}); toast('Two-factor authentication turned off for ' + u.email); refresh(); })}, 'Turn off two-factor') : null,
h('button', {class:'ghost', onclick: act(async () => { await api('PUT', '/api/admin/users/' + u.id, {signOut:true}); toast(u.email + ' was signed out on every device'); })}, 'Sign out everywhere'),
h('button', {class:'ghost', onclick:() => { const pw = h('input', {type:'password', autocomplete:'new-password'}); dialogForm('Set a password for ' + u.email, 'They are signed out everywhere and use this password from now on.', [field('New password', pw, 'At least 8 characters')], 'Set password', async () => { await api('PUT', '/api/admin/users/' + u.id, {password:pw.value}); toast('Password set for ' + u.email); }); }}, 'Set password'),
h('button', {class:'ghost danger', onclick: ask({title:`Delete ${u.email}?`, body:'This person can no longer sign in. This cannot be undone.', confirm:'Delete user'}, async () => { await api('DELETE', '/api/admin/users/' + u.id); refresh(); })}, 'Delete')))))))));
};
serverViews.idps = async () => {
const idps = await api('GET', '/api/admin/idps'), orgs = S.me.orgs;
const form = (e = {}) => {
const f = {name:h('input', {value:e.name || ''}), issuer:h('input', {value:e.issuer || '', placeholder:'https://accounts.google.com'}), cid:h('input', {value:e.clientId || ''}),
sec:h('input', {type:'password', placeholder:e.id ? 'unchanged' : ''}), scopes:h('input', {value:e.scopes || 'openid email profile'}), dom:h('input', {value:(e.allowedDomains || []).join(', '), placeholder:'company.com (blank = any)'})};
const auto = chk('Create accounts for new users automatically', e.autoProvision);
const orgSel = h('select', {}, orgs.map(o => h('option', {value:o.id, selected:o.id === e.orgId}, o.name)));
const close = modal(e.id ? 'Edit provider' : 'Add an OpenID Connect provider', h('div', {class:'stack'},
h('p', {class:'dim small'}, 'Redirect URI to register at the provider: ' + (S.me.dashboardUrl || location.origin) + '/auth/oidc/callback'),
field('Name shown on the button', f.name), field('Issuer URL', f.issuer), field('Client ID', f.cid), field('Client secret', f.sec), field('Scopes', f.scopes), field('Allowed email domains', f.dom),
auto, field('New users join', orgSel, 'as a Member'),
h('button', {class:'btn', onclick: act(async () => {
const body = {name:f.name.value, issuer:f.issuer.value, clientId:f.cid.value, clientSecret:f.sec.value, scopes:f.scopes.value, autoProvision:auto.box.checked, orgId:orgSel.value, allowedDomains:f.dom.value.split(',').map(s => s.trim()).filter(Boolean)};
await api(e.id ? 'PUT' : 'POST', '/api/admin/idps' + (e.id ? '/' + e.id : ''), body); close(); refresh(); })}, 'Save')), {sticky:true});
};
S.headerAction = ['Add provider', () => form()];
return card('', h('p', {class:'dim'}, 'Let people sign in with Google, Authentik, Keycloak, Entra ID or any OpenID Connect provider.'),
idps.length ? h('table', {}, h('tbody', {}, idps.map(i => h('tr', {}, h('td', {}, i.name), h('td', {class:'mono small dim'}, i.issuer), h('td', {}, h('div', {class:'actions'}, h('button', {class:'ghost', onclick:() => form(i)}, 'Edit'),
h('button', {class:'ghost danger', onclick: ask({title:`Delete ${i.name}?`, body:'People can no longer sign in with this provider. This cannot be undone.', confirm:'Delete provider'}, async () => { await api('DELETE', '/api/admin/idps/' + i.id); refresh(); })}, 'Delete')))))) ) : emptyState('idps', 'No sign-in providers yet', 'Add an OpenID Connect provider so people can sign in with it.'));
};
serverViews.settings = async () => {
const { settings: s, smtpPassSet } = await api('GET', '/api/admin/settings');
const f = {url:h('input', {value:s.dashboardUrl, placeholder:'https://hammerhead.example.com'}), acme:h('input', {type:'email', value:s.acmeEmail, placeholder:'you@example.com'}),
host:h('input', {value:s.smtp.host}), port:h('input', {type:'number', value:s.smtp.port || ''}), user:h('input', {value:s.smtp.user}), pass:h('input', {type:'password', placeholder:smtpPassSet ? 'unchanged' : ''}), from:h('input', {value:s.smtp.from, placeholder:'Hammerhead <no-reply@example.com>'}),
mode:h('select', {}, [['starttls', 'STARTTLS'], ['tls', 'TLS'], ['none', 'None']].map(([m, l]) => h('option', {value:m, selected:(s.smtp.mode || 'starttls') === m}, l)))};
const staging = chk('Use the Let\'s Encrypt staging server (untrusted test certificates)', s.acmeStaging);
const save = act(async () => { await api('PUT', '/api/admin/settings', {dashboardUrl:f.url.value, acmeEmail:f.acme.value, acmeStaging:staging.box.checked,
smtp:{host:f.host.value, port:+f.port.value, user:f.user.value, pass:f.pass.value, from:f.from.value, mode:f.mode.value}}); S.me = await api('GET', '/api/me'); toast('Settings saved'); });
const updOut = h('div', {class:'dim'}, 'Not checked yet.');
const updNow = h('button', {class:'ghost', style:'display:none', onclick: act(async () => {
await api('POST', '/api/admin/update/apply');
updNow.style.display = 'none';
showPanel();
})}, 'Update now');
const updBox = h('div', {style:'margin-top:12px'});
const showPanel = () => { updOut.textContent = 'Updating Hammerhead. The page reconnects by itself after the restart.'; updBox.replaceChildren(updatePanel(() => api('GET', '/api/admin/update/status'), async s => { if (s.stage === 'done') updOut.textContent = 'Updated: Hammerhead is now running ' + s.version + '.'; })); };
api('GET', '/api/admin/update/status').then(s => {
if (s.running) showPanel();
else if ((s.lines || []).length) updBox.append(h('button', {class:'ghost', onclick:() => { updBox.replaceChildren(updatePanel(() => api('GET', '/api/admin/update/status'))); }}, 'Show the last update log'));
}).catch(() => {});
const checkUpdate = act(async () => {
updOut.textContent = 'Checking…'; updNow.style.display = 'none';
const u = await api('GET', '/api/admin/update');
const dev = !/^v\d+\.\d+\.\d+$/.test(u.current || '');
if (u.error) updOut.textContent = 'Running ' + (u.current || 'an unknown version') + '. Could not check for updates: ' + u.error;
else if (dev) updOut.textContent = 'This is a development build (' + (u.current || 'unversioned') + '). The newest release is ' + u.latest + '.';
else if (u.newer) { updOut.textContent = 'Running ' + u.current + '. ' + u.latest + ' is available. ' + (u.autoUpdate ? 'Auto-update is on, so it installs by itself within about six hours, or you can install it now.' : 'Auto-update is off: install it yourself, or set HAMMERHEAD_AUTO_UPDATE=1 and restart.'); if (u.autoUpdate) updNow.style.display = ''; }
else updOut.textContent = 'Running ' + u.current + ', which is the newest release.';
});
return h('div', {},
card('General', h('div', {class:'stack'}, field('Dashboard URL', f.url, 'The address people use to reach Hammerhead. Used for login redirects and SSO. Include https:// if you serve it over TLS.'),
field('Let\'s Encrypt contact email', f.acme, 'Used for certificate expiry notices'), staging)),
card('Email', h('p', {class:'dim'}, 'Needed for invitation emails and one-time sign-in codes.'),
h('div', {class:'grid'}, field('SMTP host', f.host), field('Port', f.port), field('Security', f.mode), field('Username', f.user), field('Password', f.pass), field('From', f.from)),
h('div', {class:'actions', style:'margin-top:10px'}, h('button', {class:'ghost', onclick: act(async () => { await save(); await api('POST', '/api/admin/settings/test-mail'); toast('Test email sent to ' + S.me.email); })}, 'Save and send a test email'))),
h('div', {class:'actions form-actions', style:'margin-bottom:24px'}, h('button', {class:'btn', onclick:save}, 'Save settings')),
card('Updates', h('p', {class:'dim'}, 'Hammerhead, Blacktip and Bamboo can update themselves from new releases when auto-update is on (HAMMERHEAD_AUTO_UPDATE=1). This checks the newest release for Hammerhead; it installs nothing.'),
updOut, h('div', {class:'actions', style:'margin-top:10px'}, h('button', {class:'ghost', onclick:checkUpdate}, 'Check for updates'), updNow), updBox));
};
serverViews.audit = async () => {
const rows = await api('GET', '/api/admin/audit');
return card('', rows.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Time', 'Who', 'What', 'Detail'].map(x => h('th', {}, x)))), h('tbody', {}, rows.map(r => h('tr', {}, h('td', {class:'dim'}, fmtTime(r.time)), h('td', {}, r.actor), h('td', {}, r.action), h('td', {class:'dim'}, r.detail))))) : emptyState('audit', 'No activity yet', 'Server level changes appear here.'));
};
// ----- account -----
views.account = async () => {
S.me = await api('GET', '/api/me');
const name = h('input', {value:S.me.name}), cur = h('input', {type:'password', autocomplete:'current-password'}), nw = h('input', {type:'password', autocomplete:'new-password'});
const twofa = h('div', {class:'stack'});
const draw2fa = () => {
if (S.me.totpEnabled) {
const pw = h('input', {type:'password', placeholder:'Password'}), code = h('input', {placeholder:'Authenticator code'});
twofa.replaceChildren(h('p', {}, 'Two-factor authentication is on. ' + S.me.backupCodes + ' backup codes left.'), h('div', {class:'grid'}, field('Password', pw), field('Code', code),
h('button', {class:'ghost danger', onclick: act(async () => { await api('POST', '/api/me/2fa/disable', {password:pw.value, code:code.value}); S.me = await api('GET', '/api/me'); draw2fa(); })}, 'Turn off')));
} else twofa.replaceChildren(h('p', {class:'dim'}, 'Add an authenticator app (Google Authenticator, 1Password, Aegis…) for a second step at sign-in.'),
h('button', {class:'btn', style:'align-self:flex-start', onclick: act(async () => {
const r = await api('POST', '/api/me/2fa/setup'), code = h('input', {placeholder:'6-digit code', inputmode:'numeric'});
const close = modal('Set up two-factor authentication', h('div', {class:'stack'}, h('p', {class:'dim'}, 'In your authenticator app choose "enter a setup key" and paste this key (or open the link on your phone):'), cmdBox(r.secret), h('a', {href:r.uri}, 'Open in authenticator app'),
field('Then enter the code it shows', code), h('button', {class:'btn', onclick: act(async () => {
const e = await api('POST', '/api/me/2fa/enable', {code:code.value}); close(); S.me = await api('GET', '/api/me'); draw2fa();
modal('Save your backup codes', h('div', {}, h('p', {class:'dim'}, 'Each code works once if you lose your device. They are not shown again.'), cmdBox(e.backupCodes.join('\n')))); })}, 'Turn on')), {sticky:true});
})}, 'Set up two-factor'));
};
draw2fa();
const sessions = await api('GET', '/api/me/sessions');
const devices = card('Signed-in devices', sessions.length > 1 ? h('div', {class:'row-h'}, h('button', {class:'ghost', onclick: act(async () => { await api('POST', '/api/me/sessions/revoke-others'); toast('Signed out everywhere else'); refresh(); })}, 'Sign out everywhere else')) : null,
h('table', {}, h('tbody', {}, sessions.map(x => h('tr', {}, h('td', {}, x.current ? badge('This device', 'accent') : null),
h('td', {class:'small'}, (x.ua || 'Unknown browser').slice(0, 80), h('div', {class:'dim'}, x.ip + ' · signed in ' + ago(x.created))),
h('td', {}, x.current ? null : h('button', {class:'ghost danger', onclick: ask({title:'Sign out this device?', body:'That session ends and its device has to sign in again.', confirm:'Sign out', danger:false}, async () => { await api('DELETE', '/api/me/sessions/' + x.id); refresh(); })}, 'Sign out')))))));
return h('div', {}, card('Profile', h('div', {class:'grid'}, field('Name', name), field('Email', h('input', {value:S.me.email, disabled:true})), h('button', {class:'ghost', onclick: act(async () => { await api('PUT', '/api/me', {name:name.value}); toast('Profile saved'); })}, 'Save profile'))),
card('Password', h('div', {class:'grid'}, S.me.hasPassword ? field('Current password', cur) : null, field('New password', nw, 'At least 8 characters'),
h('button', {class:'ghost', onclick: act(async () => { await api('POST', '/api/me/password', {current:cur.value, new:nw.value}); cur.value = nw.value = ''; toast('Password changed. Your other devices were signed out.'); })}, 'Change password'))),
card('Two-factor authentication', twofa), devices);
};
boot();
</script>
</body>
</html>