Pangolin-style layout: header action buttons, add-dialogs, table search, neutral palette
CI / test (push) Successful in 2m41s
CI / test (push) Successful in 2m41s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b105c974a8
commit
6f204e71c9
1 file changed
+35
-18
+35
-18
@@ -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'),
|
||||||
|
|||||||
Reference in new issue
Block a user