Show edge and site versions with Update buttons; Update now for Hammerhead itself
CI / test (push) Successful in 4m36s
Release / release (push) Successful in 5m15s

Edges and sites report their version and whether auto-update is on. Hammerhead can ask a machine that
has auto-update on to install the newest release, and shows the outcome. Requests carry an ID so a
stale result can't answer a new one.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-10-26 17:04:56 -06:00
1 parent abf352e6c9
commit 579ad1521a
11 files changed
+326 -23

No files matched your search

+38 -6
View File
@@ -184,6 +184,30 @@ const ago = d => { if (!d || d.startsWith('0001')) return 'never'; const s = (Da
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}); };
// ----- versions and updates -----
// compares vX.Y.Z strings: -1 if a is older than b, 1 if newer, 0 if equal or not comparable
const cmpVer = (a, b) => {
const p = v => (/^v(\d+)\.(\d+)\.(\d+)$/.exec(v || '') || []).slice(1).map(Number), x = p(a), y = p(b);
if (x.length < 3 || y.length < 3) return 0;
for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] < y[i] ? -1 : 1;
return 0;
};
const latestVersions = async () => { try { return await api('GET', '/api/versions'); } catch (e) { return {}; } };
// A table cell with a machine's version, a note when a newer release exists, and an Update button
// when that machine allows being updated from here (it must run with auto-update on).
const versionCell = (m, latest, pending, updateUrl) => {
const behind = latest && m.version && cmpVer(m.version, latest) < 0, kids = [h('span', {class:'mono small'}, m.version || 'unknown')];
if (behind) kids.push(h('div', {class:'small'}, latest + ' is available'));
if (pending) {
kids.push(h('div', {class:'small dim'}, 'Updating…'));
clearTimeout(S.updTimer); S.updTimer = setTimeout(() => refresh(), 4000); // look again for the result
} else if (m.updateMsg) kids.push(h('div', {class:'small dim'}, m.updateMsg));
if (behind && !pending) kids.push(h('button', {class:'ghost', disabled:!m.autoUpdate,
title:m.autoUpdate ? 'Install ' + latest + ' now' : 'Turn on auto-update on that machine (…_AUTO_UPDATE=1) to update it from here',
onclick: act(async () => { await api('POST', updateUrl); toast('Update requested'); refresh(); })}, 'Update'));
return h('td', {}, h('div', {class:'stack'}, ...kids));
};
const card = (title, ...kids) => h('div', {class:'card'}, title ? h('h2', {}, title) : null, ...kids);
function dialogForm(title, intro, fields, label, submit) {
const close = modal(title, h('div', {class:'stack'}, intro ? h('p', {class:'dim'}, intro) : null, ...fields,
@@ -581,12 +605,14 @@ views.sites = async () => {
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')))); }];
const latest = await latestVersions();
return h('div', {},
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', 'Version', '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),
h('td', {}, s.local ? 'this machine (direct)' : 'bamboo agent'),
h('td', {}, s.local ? '—' : ((s.connected || []).map(id => (byId(st.edges, id) || {name:id}).name).join(', ') || 'nothing connected')),
s.local ? h('td', {class:'dim'}, '—') : versionCell(s, latest.bamboo, !!s.updateId, O(`/sites/${s.id}/update`)),
h('td', {}, h('button', {class:'ghost', onclick: act(async () => {
const l = await api('GET', O(`/sites/${s.id}/containers`));
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(', ')))))) :
@@ -857,10 +883,11 @@ serverViews.edges = async () => {
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')))); }];
const latest = await latestVersions();
return h('div', {},
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', 'Version', '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'),
h('td', {class:'mono small'}, (e.ips || []).join(', ') || '—'), versionCell(e, latest.blacktip, e.updatePending, '/api/edges/' + e.id + '/update'), h('td', {class:'small dim'}, (e.orgIds || []).length ? e.orgIds.map(id => (byId(orgs, id) || {name:id}).name).join(', ') : 'all'),
h('td', {}, h('div', {class:'actions'}, h('button', {class:'ghost', onclick:() => showCmd(e)}, 'Show command'),
h('button', {class:'ghost danger', onclick: act(async () => { if (confirm('Delete this edge and all its ports?')) { await api('DELETE', '/api/edges/' + e.id); refresh(); } })}, 'Delete'))))))) : h('div', {class:'empty'}, 'None yet.')));
};
@@ -926,13 +953,18 @@ serverViews.settings = async () => {
const save = act(async () => { await api('PUT', '/api/admin/settings', {dashboardUrl:f.url.value, acmeEmail:f.acme.value, acmeStaging:staging.box.checked,
smtp:{host:f.host.value, port:+f.port.value, user:f.user.value, pass:f.pass.value, from:f.from.value, mode:f.mode.value}}); S.me = await api('GET', '/api/me'); toast('Saved'); });
const updOut = h('div', {class:'dim'}, 'Not checked yet.');
const updNow = h('button', {class:'btn', style:'display:none', onclick: act(async () => {
await api('POST', '/api/admin/update/apply');
updNow.style.display = 'none';
updOut.textContent = 'Updating. Hammerhead restarts in a few seconds; reload this page after that.';
})}, 'Update now');
const checkUpdate = act(async () => {
updOut.textContent = 'Checking…';
updOut.textContent = 'Checking…'; updNow.style.display = 'none';
const u = await api('GET', '/api/admin/update');
const dev = !/^v\d+\.\d+\.\d+$/.test(u.current || '');
if (u.error) updOut.textContent = 'Running ' + (u.current || 'an unknown version') + '. Could not check for updates: ' + u.error;
else if (dev) updOut.textContent = 'This is a development build (' + (u.current || 'unversioned') + '). The newest release is ' + u.latest + '.';
else if (u.newer) updOut.textContent = 'Running ' + u.current + '. ' + u.latest + ' is available. ' + (u.autoUpdate ? 'Auto-update is on, so it installs by itself within about six hours.' : 'Auto-update is off: install it yourself, or set HAMMERHEAD_AUTO_UPDATE=1 and restart.');
else if (u.newer) { updOut.textContent = 'Running ' + u.current + '. ' + u.latest + ' is available. ' + (u.autoUpdate ? 'Auto-update is on, so it installs by itself within about six hours, or you can install it now.' : 'Auto-update is off: install it yourself, or set HAMMERHEAD_AUTO_UPDATE=1 and restart.'); if (u.autoUpdate) updNow.style.display = ''; }
else updOut.textContent = 'Running ' + u.current + ', which is the newest release.';
});
return h('div', {},
@@ -942,7 +974,7 @@ serverViews.settings = async () => {
h('div', {class:'grid'}, field('SMTP host', f.host), field('Port', f.port), field('Security', f.mode), field('Username', f.user), field('Password', f.pass), field('From', f.from)),
h('div', {class:'actions', style:'margin-top:10px'}, h('button', {class:'ghost', onclick: act(async () => { await save(); await api('POST', '/api/admin/settings/test-mail'); toast('Test email sent to ' + S.me.email); })}, 'Save and send a test email'))),
card('Updates', h('p', {class:'dim'}, 'Hammerhead, Blacktip and Bamboo can update themselves from new releases when auto-update is on (HAMMERHEAD_AUTO_UPDATE=1). This checks the newest release for Hammerhead; it installs nothing.'),
updOut, h('div', {class:'actions', style:'margin-top:10px'}, h('button', {class:'ghost', onclick:checkUpdate}, 'Check for updates'))),
updOut, h('div', {class:'actions', style:'margin-top:10px'}, h('button', {class:'ghost', onclick:checkUpdate}, 'Check for updates'), updNow)),
h('button', {class:'btn', onclick:save}, 'Save settings'));
};