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). */
|
||||
:root {
|
||||
--accent:#0891b2; --accent-contrast:#fff;
|
||||
--bg:#f7f8f9; --surface:#fff; --surface-2:#f0f2f4; --border:#e2e5e9; --border-strong:#c6ccd3;
|
||||
--text:#16191d; --text-muted:#566069; --text-faint:#75808b;
|
||||
--bg:#fafafa; --surface:#fff; --surface-2:#f4f4f5; --border:#e4e4e7; --border-strong:#d4d4d8;
|
||||
--text:#09090b; --text-muted:#52525b; --text-faint:#71717a;
|
||||
--accent-text:color-mix(in srgb, var(--accent) 85%, #000);
|
||||
--accent-soft:color-mix(in srgb, var(--accent) 10%, var(--surface));
|
||||
--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;
|
||||
--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;
|
||||
--radius:6px; --radius-lg:10px;
|
||||
--radius:8px; --radius-lg:12px;
|
||||
--font:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
|
||||
--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;
|
||||
@@ -26,8 +26,8 @@
|
||||
}
|
||||
@media (prefers-color-scheme: dark) { :root {
|
||||
--accent:#22b8d4; --accent-contrast:#04222a;
|
||||
--bg:#0c0d0f; --surface:#141517; --surface-2:#1d1f22; --border:#2a2d31; --border-strong:#41454b;
|
||||
--text:#eceef0; --text-muted:#aab1b9; --text-faint:#8a929b;
|
||||
--bg:#09090b; --surface:#0f0f11; --surface-2:#18181b; --border:#27272a; --border-strong:#3f3f46;
|
||||
--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);
|
||||
--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;
|
||||
@@ -41,14 +41,16 @@
|
||||
|
||||
/* shell */
|
||||
.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 svg { color:var(--accent); }
|
||||
.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); }
|
||||
.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.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; }
|
||||
.grow { flex:1; }
|
||||
.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); }
|
||||
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 */
|
||||
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); }
|
||||
@@ -323,7 +330,8 @@ async function render() {
|
||||
|
||||
function pageHeader(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;
|
||||
@@ -331,6 +339,7 @@ async function refresh() {
|
||||
const my = ++seq;
|
||||
try {
|
||||
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]());
|
||||
if (my !== seq) return;
|
||||
const v = document.getElementById('view');
|
||||
@@ -368,8 +377,10 @@ views.ports = async () => {
|
||||
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.');
|
||||
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,
|
||||
h('div', {class:'card flush'}, table));
|
||||
S.headerAction = ['Open a port', () => 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));
|
||||
};
|
||||
|
||||
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 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`)));
|
||||
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', {},
|
||||
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)))),
|
||||
h('tbody', {}, st.sites.map(s => h('tr', {},
|
||||
h('td', {}, h('span', {class:'dot ' + (st.online[s.id] ? 'ok' : 'bad')})), h('td', {}, s.name),
|
||||
@@ -570,9 +582,11 @@ views.sites = async () => {
|
||||
// ----- domains -----
|
||||
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'});
|
||||
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', {},
|
||||
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)))),
|
||||
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)` : ''),
|
||||
@@ -818,11 +832,14 @@ serverViews.edges = async () => {
|
||||
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.'),
|
||||
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', {},
|
||||
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)))),
|
||||
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'),
|
||||
|
||||
Reference in new issue
Block a user