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 <noreply@anthropic.com>
This commit is contained in:
1 parent
579ad1521a
commit
9bd48917c7
1 file changed
+116
-9
+116
-9
@@ -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) {
|
||||||
|
|||||||
Reference in new issue
Block a user