Add resources through a short step-by-step flow; list websites and ports separately
CI / test (push) Successful in 3m19s
Release / release (push) Successful in 5m13s

A new resource starts by choosing what is being shared (website, TCP, UDP, TCP+UDP, TLS passthrough,
plain HTTP), then where it points, how people reach it, who can open it (websites only) and a final
name-and-review step. Each step shows only its own fields; health checks, rules and redirects sit
under More options. The existing form is still used for editing.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-10-26 17:45:49 -06:00
1 parent 579ad1521a
commit 9bd48917c7
1 file changed
+116 -9
+116 -9
View File
@@ -70,6 +70,12 @@
.row-h h2 { display:none; } .row-h h2 { display:none; }
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(170px,1fr)); gap:var(--s4); align-items:end; } .grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(170px,1fr)); gap:var(--s4); align-items:end; }
.stack { display:flex; flex-direction:column; gap:var(--s3); } .stack { display:flex; flex-direction:column; gap:var(--s3); }
.choices { display:grid; grid-template-columns:repeat(auto-fill,minmax(210px,1fr)); gap:var(--s3); }
.choice { text-align:left; background:var(--surface); border:1px solid var(--border); 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 { border-color:var(--border-strong); background:var(--surface-2); }
.choice.on { border-color:var(--accent); background:var(--accent-soft); }
.choice-t { font-weight:600; }
.sect-h { padding:var(--s3) var(--s4); font-weight:600; border-bottom:1px solid var(--border); }
/* forms */ /* forms */
label { display:flex; flex-direction:column; gap:var(--s1); font-size:var(--text-sm); font-weight:600; color:var(--text); } label { display:flex; flex-direction:column; gap:var(--s1); font-size:var(--text-sm); font-weight:600; color:var(--text); }
@@ -415,13 +421,15 @@ views.ports = async () => {
const unsetDash = !st.dashboardUrl && st.mappings.some(m => m.auth && (m.auth.sso || m.auth.password || m.auth.pin || m.auth.email)); 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 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 rows = st.mappings.slice().sort((a, b) => a.edgeId.localeCompare(b.edgeId) || a.bindIp.localeCompare(b.bindIp) || a.port - b.port);
const table = rows.length ? h('div', {class:'scroll'}, h('table', {}, const isWebRow = m => m.proto === 'http' || m.proto === 'https' || m.proto === 'tls';
h('thead', {}, h('tr', {}, ['', 'Name', 'Public address', 'Targets', 'Traffic', ''].map(t => h('th', {}, t)))), 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('tbody', {}, rows.map(m => mappingRow(m, st, traffic[m.id]))))) : h('div', {class:'empty'}, 'No ports open yet.'); h('thead', {}, h('tr', {}, ['', 'Name', title === 'Websites' ? 'Address' : 'Public address', 'Targets', 'Traffic', ''].map(t => h('th', {}, t)))),
S.headerAction = ['Open a port', () => portForm()]; 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'}, h('div', {class:'empty'}, 'Nothing here yet. Use Add resource to share a website or open a port.'));
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'; }); }}); 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:'banner'}, '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, return h('div', {}, unsetDash ? h('div', {class:'banner'}, '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, h('div', {class:'card flush'}, table)); rows.length > 5 ? h('div', {class:'toolbar'}, q) : null, table);
}; };
function spark(series) { function spark(series) {
@@ -450,13 +458,13 @@ function mappingRow(m, st, series) {
} }
const web = m.proto === 'http' || m.proto === 'https'; 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 prot = m.auth && (m.auth.sso || m.auth.password || m.auth.pin || m.auth.email);
const pubAddr = (m.hostname ? m.hostname + ' @ ' : '') + m.bindIp + ':' + portText(m); 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', {}, return h('tr', {},
h('td', {title:why}, h('span', {class:dot})), h('td', {title:why}, h('span', {class:dot})),
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', {class:'tag', style:'margin-left:4px', title:'login required'}, '🔒') : null), 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', {class:'tag', style:'margin-left:4px', title:'login required'}, '🔒') : null),
s.error ? h('div', {class:'err small mono'}, s.error) : null, s.error ? h('div', {class:'err small mono'}, s.error) : null,
s.cert && m.proto === 'https' ? h('div', {class:'small ' + (s.cert.startsWith('error') ? 'err' : 'dim')}, '🔐 ' + s.cert) : null), s.cert && m.proto === 'https' ? h('div', {class:'small ' + (s.cert.startsWith('error') ? 'err' : 'dim')}, '🔐 ' + s.cert) : null),
h('td', {class:'mono'}, pubAddr, h('div', {class:'dim small'}, edge ? edge.name : '?')), h('td', {class:'mono'}, pubAddr, h('div', {class:'dim small'}, (edge ? edge.name : '?') + (web && m.bindIp !== '0.0.0.0' ? ' · ' + m.bindIp : ''))),
h('td', {class:'mono small'}, m.targets.map((t, i) => { h('td', {class:'mono small'}, m.targets.map((t, i) => {
const th = (s.targets || [])[i], site = byId(st.sites, t.siteId); const th = (s.targets || [])[i], site = byId(st.sites, t.siteId);
return h('div', {title:th && !th.healthy ? th.detail : ''}, h('span', {class:'dot ' + (th ? (th.healthy ? 'ok' : 'bad') : '')}), (site ? site.name : '?') + ' → ' + t.host + ':' + t.port); return h('div', {title:th && !th.healthy ? th.detail : ''}, h('span', {class:'dot ' + (th ? (th.healthy ? 'ok' : 'bad') : '')}), (site ? site.name : '?') + ' → ' + t.host + ':' + t.port);
@@ -571,10 +579,109 @@ function portForm(existing) {
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 : ''}); 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('Saved'); refresh(); close(); toast('Saved'); refresh();
}); });
const close = modal(existing ? 'Edit port' : 'Open a port', h('div', {class:'stack'}, const close = existing ? modal('Edit port', h('div', {class:'stack'},
h('div', {class:'grid'}, field('Name', name), field('Edge', edgeSel), field('Public IP', ipIn), field('Protocol', protoSel), field('Public port', port, 'One port, or a range like 2456-2458 (tcp/udp): each port goes to the target port plus the same offset')), h('div', {class:'grid'}, field('Name', name), field('Edge', edgeSel), field('Public IP', ipIn), field('Protocol', protoSel), field('Public port', port, 'One port, or a range like 2456-2458 (tcp/udp): each port goes to the target port plus the same offset')),
ipList, domList, hostBox, proxyBox, h('fieldset', {}, h('legend', {}, 'Where does the traffic go?'), tgtBox), adv, webBox, ipList, domList, hostBox, proxyBox, h('fieldset', {}, h('legend', {}, 'Where does the traffic go?'), tgtBox), adv, webBox,
h('div', {class:'actions'}, h('button', {class:'btn', onclick:save}, existing ? 'Save changes' : 'Open port'), h('button', {class:'ghost', onclick:() => close()}, 'Cancel'))), {sticky:true}); h('div', {class:'actions'}, h('button', {class:'btn', onclick:save}, existing ? 'Save changes' : 'Open port'), h('button', {class:'ghost', onclick:() => close()}, 'Cancel'))), {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.trim() || 'every address');
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, icon, t, d]) => h('button', {type:'button', class:'choice' + (chosen && protoSel.value === v ? ' on' : ''), onclick:() => pick(v)},
h('div', {class:'choice-t'}, icon + ' ' + 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, '0.0.0.0 means every address on the edge.')), 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'}, back, next, h('button', {class:'ghost', type:'button', onclick:() => close()}, 'Cancel'))), {sticky:true});
}
} }
async function shareDialog(m) { async function shareDialog(m) {