From 9bd48917c77d8aabb0fcf5e7553a1abd4c03d05c Mon Sep 17 00:00:00 2001 From: Veillax <126523099+Veillax@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:45:49 -0600 Subject: [PATCH] Add resources through a short step-by-step flow; list websites and ports separately 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 --- web/index.html | 125 +++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 116 insertions(+), 9 deletions(-) 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) {