diff --git a/web/index.html b/web/index.html
index 79742df..5f3607c 100644
--- a/web/index.html
+++ b/web/index.html
@@ -70,6 +70,12 @@
.row-h h2 { display:none; }
.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); }
+ .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 */
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 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 table = rows.length ? h('div', {class:'scroll'}, h('table', {},
- h('thead', {}, h('tr', {}, ['', 'Name', 'Public address', 'Targets', 'Traffic', ''].map(t => h('th', {}, t)))),
- h('tbody', {}, rows.map(m => mappingRow(m, st, traffic[m.id]))))) : h('div', {class:'empty'}, 'No ports open yet.');
- S.headerAction = ['Open a port', () => portForm()];
+ 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', {}, ['', 'Name', title === 'Websites' ? 'Address' : 'Public address', 'Targets', 'Traffic', ''].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'}, 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'; }); }});
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) {
@@ -450,13 +458,13 @@ function mappingRow(m, st, series) {
}
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 = (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', {},
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),
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),
- 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) => {
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);
@@ -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 : ''});
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')),
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) {