Pangolin-style layout: header action buttons, add-dialogs, table search, neutral palette
CI / test (push) Successful in 2m41s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-10-26 13:34:59 -06:00
1 parent b105c974a8
commit 6f204e71c9
1 file changed
+35 -18
+35 -18
View File
@@ -8,8 +8,8 @@
/* Design tokens: the only place raw colors, spacing and sizes are defined (same system as Lightwell). */ /* Design tokens: the only place raw colors, spacing and sizes are defined (same system as Lightwell). */
:root { :root {
--accent:#0891b2; --accent-contrast:#fff; --accent:#0891b2; --accent-contrast:#fff;
--bg:#f7f8f9; --surface:#fff; --surface-2:#f0f2f4; --border:#e2e5e9; --border-strong:#c6ccd3; --bg:#fafafa; --surface:#fff; --surface-2:#f4f4f5; --border:#e4e4e7; --border-strong:#d4d4d8;
--text:#16191d; --text-muted:#566069; --text-faint:#75808b; --text:#09090b; --text-muted:#52525b; --text-faint:#71717a;
--accent-text:color-mix(in srgb, var(--accent) 85%, #000); --accent-text:color-mix(in srgb, var(--accent) 85%, #000);
--accent-soft:color-mix(in srgb, var(--accent) 10%, var(--surface)); --accent-soft:color-mix(in srgb, var(--accent) 10%, var(--surface));
--accent-hover:color-mix(in srgb, var(--accent) 86%, #000); --accent-hover:color-mix(in srgb, var(--accent) 86%, #000);
@@ -17,7 +17,7 @@
--success:#1c6b3a; --success-bg:#e3f1e8; --warning:#85560a; --warning-bg:#f9eed6; --danger:#a3271e; --danger-bg:#f9e6e3; --success:#1c6b3a; --success-bg:#e3f1e8; --warning:#85560a; --warning-bg:#f9eed6; --danger:#a3271e; --danger-bg:#f9e6e3;
--overlay:rgb(15 18 22 / .5); --shadow:0 8px 24px rgb(15 18 22 / .14); --overlay:rgb(15 18 22 / .5); --shadow:0 8px 24px rgb(15 18 22 / .14);
--s1:4px; --s2:8px; --s3:12px; --s4:16px; --s5:24px; --s6:32px; --s7:48px; --s8:64px; --s1:4px; --s2:8px; --s3:12px; --s4:16px; --s5:24px; --s6:32px; --s7:48px; --s8:64px;
--radius:6px; --radius-lg:10px; --radius:8px; --radius-lg:12px;
--font:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif; --font:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
--font-mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace; --font-mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
--text-xs:12px; --text-sm:13px; --text-base:14px; --text-lg:16px; --text-xl:20px; --text-2xl:26px; --text-xs:12px; --text-sm:13px; --text-base:14px; --text-lg:16px; --text-xl:20px; --text-2xl:26px;
@@ -26,8 +26,8 @@
} }
@media (prefers-color-scheme: dark) { :root { @media (prefers-color-scheme: dark) { :root {
--accent:#22b8d4; --accent-contrast:#04222a; --accent:#22b8d4; --accent-contrast:#04222a;
--bg:#0c0d0f; --surface:#141517; --surface-2:#1d1f22; --border:#2a2d31; --border-strong:#41454b; --bg:#09090b; --surface:#0f0f11; --surface-2:#18181b; --border:#27272a; --border-strong:#3f3f46;
--text:#eceef0; --text-muted:#aab1b9; --text-faint:#8a929b; --text:#fafafa; --text-muted:#a1a1aa; --text-faint:#71717a;
--accent-text:color-mix(in srgb, var(--accent) 80%, #fff); --accent-soft:color-mix(in srgb, var(--accent) 18%, var(--surface)); --accent-hover:color-mix(in srgb, var(--accent) 85%, #fff); --accent-text:color-mix(in srgb, var(--accent) 80%, #fff); --accent-soft:color-mix(in srgb, var(--accent) 18%, var(--surface)); --accent-hover:color-mix(in srgb, var(--accent) 85%, #fff);
--success:#86d3a0; --success-bg:#16301f; --warning:#e8bd6b; --warning-bg:#33280f; --danger:#f19a92; --danger-bg:#3a1b18; --success:#86d3a0; --success-bg:#16301f; --warning:#e8bd6b; --warning-bg:#33280f; --danger:#f19a92; --danger-bg:#3a1b18;
--overlay:rgb(0 0 0 / .65); --shadow:0 8px 24px rgb(0 0 0 / .5); color-scheme:dark; --overlay:rgb(0 0 0 / .65); --shadow:0 8px 24px rgb(0 0 0 / .5); color-scheme:dark;
@@ -41,14 +41,16 @@
/* shell */ /* shell */
.app { display:flex; min-height:100vh; } .app { display:flex; min-height:100vh; }
.side { width:var(--sidebar-w); flex:none; background:var(--surface); border-right:1px solid var(--border); display:flex; flex-direction:column; position:sticky; top:0; height:100vh; padding:var(--s5) var(--s3); gap:2px; overflow-y:auto; } .side { width:var(--sidebar-w); flex:none; background:var(--bg); border-right:1px solid var(--border); display:flex; flex-direction:column; position:sticky; top:0; height:100vh; padding:var(--s5) var(--s3); gap:2px; overflow-y:auto; }
.brand { display:flex; align-items:center; gap:var(--s2); font-weight:700; font-size:var(--text-lg); letter-spacing:-.02em; padding:0 var(--s3) var(--s4); } .brand { display:flex; align-items:center; gap:var(--s2); font-weight:700; font-size:var(--text-lg); letter-spacing:-.02em; padding:0 var(--s3) var(--s4); }
.brand svg { color:var(--accent); } .brand svg { color:var(--accent); }
.orgsel { margin:0 0 var(--s2); } .orgsel { margin:0 0 var(--s2); }
.navsec { font-size:var(--text-xs); font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--text-faint); padding:var(--s4) var(--s3) var(--s1); } .navsec { font-size:var(--text-xs); font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--text-faint); padding:var(--s4) var(--s3) var(--s1); }
.navitem { display:flex; align-items:center; gap:var(--s3); width:100%; background:none; border:0; color:var(--text-muted); padding:var(--s2) var(--s3); min-height:34px; border-radius:var(--radius); cursor:pointer; font:inherit; font-weight:500; text-align:left; transition:background var(--ease), color var(--ease); } .navitem { display:flex; align-items:center; gap:var(--s3); width:100%; background:none; border:0; color:var(--text-muted); padding:var(--s2) var(--s3); min-height:34px; border-radius:var(--radius); cursor:pointer; font:inherit; font-weight:500; text-align:left; transition:background var(--ease), color var(--ease); }
.navitem:hover { background:var(--surface-2); color:var(--text); } .navitem:hover { background:var(--surface-2); color:var(--text); }
.navitem.on { background:var(--accent-soft); color:var(--accent-text); } .navitem { border-radius:var(--radius); }
.navitem.on { background:var(--surface-2); color:var(--text); }
.navitem.on svg { color:var(--accent); }
.navitem svg { flex:none; } .navitem svg { flex:none; }
.grow { flex:1; } .grow { flex:1; }
.userbox { border-top:1px solid var(--border); padding-top:var(--s3); margin-top:var(--s2); } .userbox { border-top:1px solid var(--border); padding-top:var(--s3); margin-top:var(--s2); }
@@ -88,6 +90,11 @@
td button.ghost, .actions button.ghost, .tgt button.ghost { min-height:30px; padding:0 var(--s3); font-size:var(--text-sm); } 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; } button:disabled { opacity:.55; cursor:not-allowed; }
/* toolbar above a table */
.toolbar { display:flex; align-items:center; gap:var(--s3); margin-bottom:var(--s3); }
.toolbar input[type=search] { max-width:320px; }
.page-h .btn { white-space:nowrap; }
/* tables */ /* tables */
table { width:100%; border-collapse:collapse; } 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:1px solid var(--border); white-space:nowrap; background:var(--surface); } th { text-align:left; font-size:var(--text-sm); color:var(--text-muted); font-weight:600; padding:var(--s3) var(--s4); border-bottom:1px solid var(--border); white-space:nowrap; background:var(--surface); }
@@ -323,7 +330,8 @@ async function render() {
function pageHeader(tab) { function pageHeader(tab) {
const [t, d] = PAGES[tab] || ['', '']; const [t, d] = PAGES[tab] || ['', ''];
return h('div', {class:'page-h'}, h('div', {}, h('h1', {}, t), d ? h('p', {}, d) : null)); 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]}, '+ ' + a[0]) : null);
} }
let seq = 0; let seq = 0;
@@ -331,6 +339,7 @@ async function refresh() {
const my = ++seq; const my = ++seq;
try { try {
if (S.org && !SRV[S.tab] && S.tab !== 'account') await loadOrg(); if (S.org && !SRV[S.tab] && S.tab !== 'account') await loadOrg();
S.headerAction = null;
const view = await (SRV[S.tab] ? views.server() : views[S.tab]()); const view = await (SRV[S.tab] ? views.server() : views[S.tab]());
if (my !== seq) return; if (my !== seq) return;
const v = document.getElementById('view'); const v = document.getElementById('view');
@@ -368,8 +377,10 @@ views.ports = async () => {
const table = rows.length ? h('div', {class:'scroll'}, h('table', {}, 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('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.'); h('tbody', {}, rows.map(m => mappingRow(m, st, traffic[m.id]))))) : h('div', {class:'empty'}, 'No ports open yet.');
return h('div', {}, h('div', {class:'row-h'}, h('button', {class:'btn', onclick:() => portForm()}, '+ Open a port')), 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, S.headerAction = ['Open a port', () => portForm()];
h('div', {class:'card flush'}, table)); 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));
}; };
function spark(series) { function spark(series) {
@@ -550,9 +561,10 @@ views.sites = async () => {
const add = act(async () => { const s = await api('POST', O('/sites'), {name:name.value}); name.value = ''; showCmd(s); refresh(); }); 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.'), 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`))); 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'}, h('button', {class:'btn', onclick:() => { close(); add(); }}, 'Add site'), h('button', {class:'ghost', onclick:() => close()}, 'Cancel')))); }];
return h('div', {}, return h('div', {},
card('Add a site', 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.'),
h('div', {class:'grid'}, field('Name', name), h('button', {class:'btn', onclick:add}, 'Add site'))),
card('Sites', h('table', {}, h('thead', {}, h('tr', {}, ['', 'Name', 'Kind', 'Tunnels to', 'Containers', ''].map(t => h('th', {}, t)))), card('Sites', h('table', {}, h('thead', {}, h('tr', {}, ['', 'Name', 'Kind', 'Tunnels to', 'Containers', ''].map(t => h('th', {}, t)))),
h('tbody', {}, st.sites.map(s => h('tr', {}, h('tbody', {}, st.sites.map(s => h('tr', {},
h('td', {}, h('span', {class:'dot ' + (st.online[s.id] ? 'ok' : 'bad')})), h('td', {}, s.name), h('td', {}, h('span', {class:'dot ' + (st.online[s.id] ? 'ok' : 'bad')})), h('td', {}, s.name),
@@ -570,9 +582,11 @@ views.sites = async () => {
// ----- domains ----- // ----- domains -----
views.domains = async () => { 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'}); 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'}, 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'), h('button', {class:'ghost', onclick:() => close()}, 'Cancel')))); }];
return h('div', {}, return h('div', {},
card('Add a domain', 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.'),
h('div', {class:'grid'}, field('Domain', name), field('Edge', edgeSel), field('IP', ip, 'One of the edge\'s addresses'), h('button', {class:'btn', onclick: act(async () => { await api('POST', O('/domains'), {name:name.value, edgeId:edgeSel.value, ip:ip.value}); refresh(); })}, 'Add'))),
card('Domains', st.domains.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Domain', 'Edge', 'DNS record to create', ''].map(t => h('th', {}, t)))), card('Domains', 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('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}` : '—', h('br'), d.ip ? `A *.${d.name} ${d.ip} (for subdomains)` : ''), h('td', {class:'mono small'}, d.ip ? `A ${d.name} ${d.ip}` : '—', h('br'), d.ip ? `A *.${d.name} ${d.ip} (for subdomains)` : ''),
@@ -818,11 +832,14 @@ serverViews.edges = async () => {
const orgChecks = orgs.map(o => ({o, c:chk(o.name, false)})); 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.'), 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`))); 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 (none ticked = all):'), h('div', {class:'pillrow'}, orgChecks.map(x => x.c))) : null,
h('div', {class:'actions'}, 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'),
h('button', {class:'ghost', onclick:() => close()}, 'Cancel')))); }];
return h('div', {}, return h('div', {},
card('Add an edge', h('p', {class:'dim'}, 'An edge (Blacktip) is a public server that owns IP addresses and opens ports.'),
h('div', {class:'grid'}, field('Name', name), field('WireGuard endpoint (public host:UDP port)', addr, 'host:port'), 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)}); showCmd(e); refresh(); })}, 'Add edge')),
orgs.length > 1 ? h('div', {style:'margin-top:10px'}, h('div', {class:'sub'}, 'Restrict to these organizations (none ticked = all):'), h('div', {class:'pillrow'}, orgChecks.map(x => x.c))) : null),
card('Edges', edges.length ? h('table', {}, h('thead', {}, h('tr', {}, ['', 'Name', 'WireGuard endpoint', 'IP addresses', 'Orgs', ''].map(t => h('th', {}, t)))), card('Edges', edges.length ? h('table', {}, h('thead', {}, h('tr', {}, ['', 'Name', 'WireGuard endpoint', 'IP addresses', 'Orgs', ''].map(t => h('th', {}, t)))),
h('tbody', {}, edges.map(e => h('tr', {}, h('td', {}, h('span', {class:'dot ' + (e.online ? 'ok' : 'bad'), title:e.online ? 'online' : 'offline'})), h('td', {}, e.name), h('td', {class:'mono'}, e.tunnelAddr), h('tbody', {}, edges.map(e => h('tr', {}, h('td', {}, h('span', {class:'dot ' + (e.online ? 'ok' : 'bad'), title:e.online ? 'online' : 'offline'})), h('td', {}, e.name), h('td', {class:'mono'}, e.tunnelAddr),
h('td', {class:'mono small'}, (e.ips || []).join(', ') || '—'), h('td', {class:'small dim'}, (e.orgIds || []).length ? e.orgIds.map(id => (byId(orgs, id) || {name:id}).name).join(', ') : 'all'), h('td', {class:'mono small'}, (e.ips || []).join(', ') || '—'), h('td', {class:'small dim'}, (e.orgIds || []).length ? e.orgIds.map(id => (byId(orgs, id) || {name:id}).name).join(', ') : 'all'),