Add Sandbar alert channel, tags for resources and sites, use Reef's secretbox
Sandbar channels raise and resolve alerts through its API, with the API key sealed at rest and never returned to the browser. Resources and sites can be tagged and filtered by tag; sites can be renamed. The secretbox package now comes from Reef v0.1.9 instead of a private copy. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
38ab4f7db2
commit
227c890409
17 files changed
+386
-196
No files matched your search
+60
-17
@@ -159,6 +159,10 @@
|
||||
.badge-warning { background:var(--warning-bg); color:var(--warning); }
|
||||
.badge-danger { background:var(--danger-bg); color:var(--danger); }
|
||||
.badge-accent { background:var(--accent-soft); color:var(--accent-text); }
|
||||
.tags { display:flex; flex-wrap:wrap; gap:var(--s1); margin-top:var(--s1); }
|
||||
.tagbar { display:flex; flex-wrap:wrap; gap:var(--s2); margin-bottom:var(--s3); }
|
||||
.chip { min-height:44px; padding:0 var(--s3); border:var(--border-w) solid var(--border); border-radius:var(--radius); background:var(--surface); color:var(--text); font:inherit; cursor:pointer; }
|
||||
.chip.on { background:var(--accent); border-color:var(--accent); color:var(--accent-contrast); }
|
||||
.tag { display:inline-block; padding:1px var(--s2); border:var(--border-w) solid var(--border); border-radius:var(--radius); font-size:var(--text-xs); color:var(--text-muted); }
|
||||
pre { background:var(--surface-2); border:var(--border-w) solid var(--border); border-radius:var(--radius); padding:var(--s3); overflow:auto; margin:var(--s2) 0 0; user-select:all; white-space:pre-wrap; word-break:break-all; }
|
||||
|
||||
@@ -271,6 +275,25 @@ const fmtTime = d => new Date(d).toLocaleString(undefined, {month:'short', day:'
|
||||
const byId = (list, id) => (list || []).find(x => x.id === id);
|
||||
const field = (label, el, hint) => h('label', {}, label, el, hint ? h('span', {class:'sub'}, hint) : null);
|
||||
const chk = (label, checked, onchange) => { const i = h('input', {type:'checkbox', checked: !!checked}); if (onchange) i.onchange = () => onchange(i.checked); return Object.assign(h('label', {class:'chk'}, i, label), {box:i}); };
|
||||
// ----- tags -----
|
||||
// Tags are free-form labels on resources and sites. The filter choice is remembered per list until the page reloads.
|
||||
const TAGSEL = {ports:'', sites:''};
|
||||
const allTags = (...lists) => [...new Set(lists.flat().flatMap(x => x.tags || []))].sort((a, b) => a.localeCompare(b));
|
||||
const hasTag = (x, t) => !t || (x.tags || []).some(v => v.toLowerCase() === t.toLowerCase());
|
||||
const tagList = x => (x.tags || []).length ? h('div', {class:'tags'}, x.tags.map(t => h('span', {class:'tag'}, t))) : null;
|
||||
function tagInput(tags, known) {
|
||||
const id = 'tags-' + Math.random().toString(36).slice(2, 8);
|
||||
const i = h('input', {value:(tags || []).join(', '), placeholder:'e.g. minecraft, production', list:id});
|
||||
const el = field('Tags', h('div', {}, i, h('datalist', {id}, known.map(t => h('option', {value:t})))), 'Optional. Separate with commas. Use them to filter this list.');
|
||||
return {el, get:() => i.value.split(',').map(t => t.trim()).filter(Boolean)};
|
||||
}
|
||||
// chips that narrow a list to one tag; pressing the active chip, or "All", clears it
|
||||
function tagBar(key, tags) {
|
||||
if (!tags.length) return null;
|
||||
const chip = (label, val) => h('button', {class:'chip' + (TAGSEL[key] === val ? ' on' : ''), type:'button', 'aria-pressed':String(TAGSEL[key] === val),
|
||||
onclick:() => { TAGSEL[key] = TAGSEL[key] === val ? '' : val; refresh(); }}, label);
|
||||
return h('div', {class:'tagbar', role:'group', 'aria-label':'Filter by tag'}, chip('All', ''), tags.map(t => chip(t, t)));
|
||||
}
|
||||
// ----- versions and updates -----
|
||||
// A progress bar and live log for an update. load() returns {running, stage, pct, lines}; the panel polls it
|
||||
// until the update is over, and keeps going through the gap while a program restarts into its new version.
|
||||
@@ -600,7 +623,9 @@ views.ports = async () => {
|
||||
S.me.serverAdmin ? h('button', {class:'btn', onclick:() => { selectTab('edges'); history.pushState(null, '', TAB_PATH.edges); render(); }}, 'Go to Edges') : null);
|
||||
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 tags = allTags(st.mappings);
|
||||
if (TAGSEL.ports && !tags.some(t => t.toLowerCase() === TAGSEL.ports.toLowerCase())) TAGSEL.ports = ''; // the last resource with that tag is gone
|
||||
const rows = st.mappings.filter(m => hasTag(m, TAGSEL.ports)).sort((a, b) => a.edgeId.localeCompare(b.edgeId) || a.bindIp.localeCompare(b.bindIp) || a.port - b.port);
|
||||
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', {}, ['Status', 'Name', title === 'Websites' ? 'Address' : 'Public address', 'Targets', 'Traffic (24 hours)', ''].map(t => h('th', {}, t)))),
|
||||
@@ -609,7 +634,7 @@ views.ports = async () => {
|
||||
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:'notice'}, '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, table);
|
||||
tagBar('ports', tags), rows.length > 5 ? h('div', {class:'toolbar'}, q) : null, table);
|
||||
};
|
||||
|
||||
function spark(series) {
|
||||
@@ -642,6 +667,7 @@ function mappingRow(m, st, series) {
|
||||
return h('tr', {},
|
||||
h('td', {title:why}, badge(label, tone)),
|
||||
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', {style:'margin-left:4px', title:'Visitors have to sign in'}, badge('Login', 'accent')) : null),
|
||||
tagList(m),
|
||||
s.error ? h('div', {class:'err small mono clip', title:s.error}, s.error) : null,
|
||||
s.cert && m.proto === 'https' ? h('div', {class:'small clip ' + (s.cert.startsWith('error') ? 'err' : 'dim'), title:s.cert}, 'Certificate: ' + s.cert) : null),
|
||||
h('td', {}, h('span', {class:'mono'}, pubAddr), h('div', {class:'dim small'}, (edge ? edge.name : '?') + (web && m.bindIp !== '0.0.0.0' ? ' · ' + m.bindIp : ''))),
|
||||
@@ -665,7 +691,8 @@ function portForm(existing) {
|
||||
// the public address is picked from the edge's own addresses, each shown with the domains pointed at it
|
||||
const ipIn = h('select', {}), ipList = document.createTextNode('');
|
||||
const protoSel = h('select', {disabled:!!existing}, PROTOS.filter(p => !existing || p[0] !== 'both').map(([v, t]) => h('option', {value:v, selected:v === m.proto}, t)));
|
||||
const name = h('input', {value:m.name || '', placeholder:'optional'}), port = h('input', {value:m.port ? portText(m) : '', placeholder:'25565 or 2456-2458'});
|
||||
const tagsIn = tagInput(m.tags, allTags(st.mappings));
|
||||
const name = h('input', {value:m.name || '', placeholder:'optional'}), port =h('input', {value:m.port ? portText(m) : '', placeholder:'25565 or 2456-2458'});
|
||||
const host = h('input', {value:m.hostname || '', list:'doms', placeholder:'app.example.com or *.example.com'});
|
||||
const domList = h('datalist', {id:'doms'}, st.domains.map(d => h('option', {value:d.name})));
|
||||
const hostBox = field('Hostname', host, st.domains.length ? 'Must be under one of your domains' : 'Add a domain on the Domains tab first');
|
||||
@@ -762,7 +789,7 @@ function portForm(existing) {
|
||||
if (!pr) throw new Error('Enter a port like 25565, or a range like 2456-2458');
|
||||
const roles = roleChecks.filter(x => x.c.box.checked).map(x => x.r.id);
|
||||
const base = {
|
||||
name:name.value, edgeId:edgeSel.value, bindIp:ipIn.value || '0.0.0.0', port:+pr[1], portEnd:pr[2] ? +pr[2] : 0, hostname:host.value, targets,
|
||||
name:name.value, edgeId:edgeSel.value, bindIp:ipIn.value || '0.0.0.0', port:+pr[1], portEnd:pr[2] ? +pr[2] : 0, hostname:host.value, targets, tags:tagsIn.get(),
|
||||
enabled:m.enabled !== false, rules:rules.filter(r => r.value.trim()),
|
||||
auth:{sso:ssoC.box.checked, password:pwC.box.checked, pin:pinC.box.checked, email:mailC.box.checked, allowedRoles:roles, allowedUsers:a.allowedUsers || [],
|
||||
allowedMails:mails.value.split(',').map(s => s.trim()).filter(Boolean), sessionHours:+hours.value || 0},
|
||||
@@ -777,7 +804,7 @@ function portForm(existing) {
|
||||
close(); toast(existing ? `Saved ${base.name || 'the resource'}` : `Added ${base.name || 'the resource'}`); refresh();
|
||||
});
|
||||
const close = existing ? modal('Edit ' + (m.name || 'resource'), h('div', {class:'stack'},
|
||||
h('div', {class:'grid'}, field('Name', name), field('Edge', edgeSel), field('Public IP', ipIn, 'Domains pointed at an address are shown next to it'), field('Protocol', protoSel), field('Public port', port, 'One port, or a range like 2456-2458 for TCP and UDP')),
|
||||
h('div', {class:'grid'}, field('Name', name), field('Edge', edgeSel), field('Public IP', ipIn, 'Domains pointed at an address are shown next to it'), field('Protocol', protoSel), field('Public port', port, 'One port, or a range like 2456-2458 for TCP and UDP'), tagsIn.el),
|
||||
ipList, domList, hostBox, proxyBox, h('fieldset', {}, h('legend', {}, 'Where does the traffic go?'), tgtBox), adv, webBox,
|
||||
h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => close()}, 'Cancel'), h('button', {class:'btn', onclick:save}, existing ? 'Save changes' : 'Open port'))), {sticky:true}) : wizardModal();
|
||||
|
||||
@@ -869,7 +896,7 @@ function portForm(existing) {
|
||||
} 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()),
|
||||
kids.push(field('Name', name, 'Optional. Shown in the list.'), tagsIn.el, 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] : []))));
|
||||
}
|
||||
@@ -910,10 +937,19 @@ views.sites = async () => {
|
||||
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 form-actions'}, h('button', {class:'ghost', onclick:() => close()}, 'Cancel'), h('button', {class:'btn', onclick:() => { close(); add(); }}, 'Add site')))); }];
|
||||
const latest = await latestVersions();
|
||||
const tags = allTags(st.sites);
|
||||
if (TAGSEL.sites && !tags.some(t => t.toLowerCase() === TAGSEL.sites.toLowerCase())) TAGSEL.sites = '';
|
||||
const editSite = s => { // rename a site and set its tags
|
||||
const nm = h('input', {value:s.name}), tg = tagInput(s.tags, tags);
|
||||
const close = modal('Edit ' + s.name, h('div', {class:'stack'}, field('Name', nm), tg.el,
|
||||
h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => close()}, 'Cancel'),
|
||||
h('button', {class:'btn', onclick: act(async () => { await api('PUT', O('/sites/' + s.id), {name:nm.value, tags:tg.get()}); close(); toast('Saved ' + nm.value.trim()); refresh(); })}, 'Save changes'))));
|
||||
};
|
||||
return h('div', {},
|
||||
tagBar('sites', tags),
|
||||
card('', h('table', {}, h('thead', {}, h('tr', {}, ['Status', 'Name', 'Kind', 'Tunnels to', 'Version', 'Containers', ''].map(t => h('th', {}, t)))),
|
||||
h('tbody', {}, st.sites.map(s => h('tr', {},
|
||||
h('td', {}, st.online[s.id] ? badge('Online', 'success') : badge('Offline', 'danger')), h('td', {}, s.name),
|
||||
h('tbody', {}, st.sites.filter(s => hasTag(s, TAGSEL.sites)).map(s => h('tr', {},
|
||||
h('td', {}, st.online[s.id] ? badge('Online', 'success') : badge('Offline', 'danger')), h('td', {}, s.name, tagList(s)),
|
||||
h('td', {}, s.local ? 'This machine' : 'Bamboo agent'),
|
||||
h('td', {}, s.local ? 'Direct' : ((s.connected || []).map(id => (byId(st.edges, id) || {name:id}).name).join(', ') || 'nothing connected')),
|
||||
s.local ? h('td', {class:'dim'}, 'Same as its edge') : versionCell(s, latest.bamboo, !!s.updateId, O(`/sites/${s.id}/update`), O(`/sites/${s.id}/update/log`)),
|
||||
@@ -922,6 +958,7 @@ views.sites = async () => {
|
||||
modal('Containers on ' + s.name, l.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Image', 'Ports'].map(t => h('th', {}, t)))), h('tbody', {}, l.map(c => h('tr', {}, h('td', {}, c.name), h('td', {class:'dim'}, c.image), h('td', {class:'mono small'}, c.ports.map(p => (p.public ? p.public + '→' : '') + p.private + '/' + p.type).join(', ')))))) :
|
||||
h('p', {class:'dim'}, 'None reported. Start the agent with --docker (or BLACKTIP_DOCKER=1 / BAMBOO_DOCKER=1) on a machine where Docker is running.')); })}, 'View')),
|
||||
h('td', {}, s.local ? null : h('div', {class:'actions'},
|
||||
h('button', {class:'ghost', onclick:() => editSite(s)}, 'Edit'),
|
||||
h('button', {class:'ghost', onclick:() => showCmd(s)}, 'Show command'),
|
||||
h('button', {class:'ghost danger', onclick: ask({title:`Delete ${s.name}?`, body:'Every port that uses only this site is deleted with it, and its agent loses its connection. This cannot be undone.', confirm:'Delete site'}, async () => { await api('DELETE', O('/sites/' + s.id)); refresh(); })}, 'Delete')))))))));
|
||||
};
|
||||
@@ -1056,26 +1093,32 @@ const FORMATS = [['json', 'JSON'], ['slack', 'Slack'], ['discord', 'Discord'], [
|
||||
function channelForm(base, existing, trusted) {
|
||||
const c = existing || {kind:'email', format:'json', enabled:true};
|
||||
const name = h('input', {value:c.name || '', placeholder:'e.g. On-call'});
|
||||
const kind = h('select', {}, [['email', 'Email'], ['webhook', 'Webhook']].map(([v, t]) => h('option', {value:v, selected:v === c.kind}, t)));
|
||||
const kind = h('select', {}, [['email', 'Email'], ['sandbar', 'Sandbar'], ['webhook', 'Webhook']].map(([v, t]) => h('option', {value:v, selected:v === c.kind}, t)));
|
||||
const target = h('input', {value:c.kind === 'email' ? c.target || '' : '', placeholder:c.kind === 'webhook' && existing ? 'unchanged' : ''});
|
||||
const token = h('input', {type:'password', autocomplete:'off', placeholder:existing && c.kind === 'sandbar' ? 'unchanged' : 'sb_…'});
|
||||
const policy = h('input', {value:c.policy || '', placeholder:'Optional'});
|
||||
const sandbarBox = h('div', {class:'grid'}, field('API key', token, 'Create one on Sandbar\'s Account page.'), field('Escalation policy ID', policy, 'Who gets paged. Leave blank to send alerts without paging anyone.'));
|
||||
const fmt = h('select', {}, FORMATS.map(([v, t]) => h('option', {value:v, selected:v === (c.format || 'json')}, t)));
|
||||
const priv = chk('This address is on a private network (like a home server)', c.allowPrivate);
|
||||
const en = chk('Enabled', c.enabled !== false);
|
||||
const fmtBox = field('Webhook format', fmt, 'Slack and Discord incoming-webhook URLs work as they are. ntfy sends a push notification.');
|
||||
const targetBox = field('Where', target);
|
||||
const redraw = () => {
|
||||
const w = kind.value === 'webhook';
|
||||
const w = kind.value === 'webhook', sb = kind.value === 'sandbar';
|
||||
fmtBox.style.display = w ? '' : 'none';
|
||||
priv.style.display = w && trusted ? '' : 'none';
|
||||
target.placeholder = w ? (existing && c.kind === 'webhook' ? 'unchanged (it may contain a secret token)' : 'https://hooks.example.com/…') : 'you@example.com, oncall@example.com';
|
||||
sandbarBox.style.display = sb ? '' : 'none';
|
||||
priv.style.display = (w || sb) && trusted ? '' : 'none';
|
||||
targetBox.firstChild.textContent =sb ? 'Sandbar address' : 'Where';
|
||||
target.placeholder = sb ? (existing && c.kind === 'sandbar' ? 'unchanged' : 'https://sandbar.example.com') :
|
||||
w ? (existing && c.kind === 'webhook' ? 'unchanged (it may contain a secret token)' : 'https://hooks.example.com/…') : 'you@example.com, oncall@example.com';
|
||||
};
|
||||
kind.onchange = redraw; redraw();
|
||||
const save = act(async () => {
|
||||
await api(existing ? 'PUT' : 'POST', base + '/channels' + (existing ? '/' + existing.id : ''), {name:name.value, kind:kind.value, target:target.value, format:kind.value === 'webhook' ? fmt.value : '', allowPrivate:trusted && priv.box.checked, enabled:en.box.checked});
|
||||
await api(existing ? 'PUT' : 'POST', base + '/channels' + (existing ? '/' + existing.id : ''), {name:name.value, kind:kind.value, target:target.value, format:kind.value === 'webhook' ? fmt.value : '', token:token.value, policy:policy.value, allowPrivate:trusted && priv.box.checked, enabled:en.box.checked});
|
||||
close(); toast(existing ? 'Alert channel saved' : 'Alert channel added'); refresh();
|
||||
});
|
||||
const close = modal(existing ? 'Edit channel' : 'Add an alert channel', h('div', {class:'stack'},
|
||||
h('div', {class:'grid'}, field('Name', name), field('Type', kind)), targetBox, fmtBox, priv, en,
|
||||
h('div', {class:'grid'}, field('Name', name), field('Type', kind)), targetBox, sandbarBox, fmtBox, priv, en,
|
||||
h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => close()}, 'Cancel'), h('button', {class:'btn', onclick:save}, 'Save'))), {sticky:true});
|
||||
}
|
||||
|
||||
@@ -1083,7 +1126,7 @@ async function alertsView(base, scoped, trusted) {
|
||||
const d = await api('GET', base);
|
||||
const channels = h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Type', 'Sends to', ''].map(x => h('th', {}, x)))),
|
||||
h('tbody', {}, d.channels.map(c => h('tr', {}, h('td', {}, c.name, c.enabled ? null : h('span', {class:'tag', style:'margin-left:6px'}, 'off')),
|
||||
h('td', {}, c.kind === 'email' ? 'Email' : 'Webhook (' + c.format + ')'), h('td', {class:'mono small'}, c.preview),
|
||||
h('td', {}, c.kind === 'email' ? 'Email' : c.kind === 'sandbar' ? 'Sandbar' : 'Webhook (' + c.format + ')'), h('td', {class:'mono small'}, c.preview),
|
||||
h('td', {}, h('div', {class:'actions'},
|
||||
h('button', {class:'ghost', onclick: act(async () => { await api('POST', base + '/channels/' + c.id + '/test'); toast('Test sent'); })}, 'Send test'),
|
||||
h('button', {class:'ghost', onclick:() => channelForm(base, c, trusted)}, 'Edit'),
|
||||
@@ -1091,11 +1134,11 @@ async function alertsView(base, scoped, trusted) {
|
||||
const debounce = h('input', {type:'number', min:1, max:120, value:d.settings.debounceMin}), repeat = h('input', {type:'number', min:0, max:168, value:d.settings.repeatHours});
|
||||
S.headerAction = ['Add channel', () => channelForm(base, null, trusted)];
|
||||
return h('div', {},
|
||||
!d.emailConfigured ? h('div', {class:'notice'}, 'Email alerts need an email server. Set one up under Server → Settings, or use a webhook.') : null,
|
||||
!d.emailConfigured ? h('div', {class:'notice'}, 'Email alerts need an email server. Set one up under Server → Settings, or use Sandbar or a webhook.') : null,
|
||||
card('Right now', d.active.length ? h('table', {}, h('tbody', {}, d.active.map(a => h('tr', {}, h('td', {}, a.fired ? badge('Firing', 'danger') : badge('Waiting', 'warning')), h('td', {}, a.message), h('td', {class:'dim small'}, 'since ' + ago(a.since)))))) :
|
||||
emptyState('check', 'Nothing is wrong', 'Problems that are firing or about to fire appear here.')),
|
||||
card('Where alerts go',
|
||||
d.channels.length ? channels : emptyState('alerts', 'No channels yet', 'Add an email address or a webhook to be told when something stops working.')),
|
||||
d.channels.length ? channels : emptyState('alerts', 'No channels yet', 'Add an email address, a Sandbar server or a webhook to be told when something stops working.')),
|
||||
scoped ? card('Timing', h('p', {class:'dim'}, 'A problem has to last this long before you are told, so a restart or a brief blip stays quiet. You get one message when it starts and one when it clears.'),
|
||||
h('div', {class:'grid'}, field('Wait this many minutes first', debounce), field('Remind me every (hours)', repeat, 'Use 0 to send no reminders'),
|
||||
h('button', {class:'ghost', onclick: act(async () => { await api('PUT', base + '/settings', {debounceMin:+debounce.value, repeatHours:+repeat.value}); toast('Alert timing saved'); })}, 'Save timing'))) : null,
|
||||
|
||||
Reference in new issue
Block a user