Show a progress bar and live log while Hammerhead, edges and sites update
CI / test (push) Successful in 5m21s
Release / release (push) Successful in 6m9s

The updater reports each step (checking, downloading with percent, verifying, installing, restarting).
Edges and sites send that progress and their log with their reports; Hammerhead serves it to a dialog
that opens when an update starts, and keeps its own last update log on disk across the restart.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-10-26 18:05:42 -06:00
1 parent 9bd48917c7
commit 2b7cb5dd35
11 files changed
+306 -13

No files matched your search

+46 -6
View File
@@ -76,6 +76,10 @@
.choice.on { border-color:var(--accent); background:var(--accent-soft); }
.choice-t { font-weight:600; }
.sect-h { padding:var(--s3) var(--s4); font-weight:600; border-bottom:1px solid var(--border); }
.progress { height:8px; background:var(--surface-2); border-radius:999px; overflow:hidden; }
.progress > div { height:100%; background:var(--accent); transition:width .4s ease; }
.progress > div.bad { background:var(--danger); }
.logbox { margin:0; background:var(--bg); border:1px solid var(--border); border-radius:var(--radius); padding:var(--s3); font:var(--text-xs)/1.6 var(--font-mono); max-height:260px; overflow:auto; white-space:pre-wrap; word-break:break-word; }
/* forms */
label { display:flex; flex-direction:column; gap:var(--s1); font-size:var(--text-sm); font-weight:600; color:var(--text); }
@@ -191,6 +195,34 @@ 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 -----
// 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.
function updatePanel(load, onDone) {
const bar = h('div', {style:'width:0%'}), stageEl = h('div', {class:'small'}, 'Starting…'), logEl = h('pre', {class:'logbox'}, 'Waiting for the first message…');
const el = h('div', {class:'stack'}, stageEl, h('div', {class:'progress'}, bar), logEl);
const label = {starting:'Starting', checking:'Checking for the release', downloading:'Downloading', verifying:'Verifying', installing:'Installing', restarting:'Restarting', done:'Done', failed:'Failed'};
let misses = 0;
const tick = async () => {
if (!el.isConnected) return; // the dialog or card was closed
let again = true;
try {
const s = await load(); misses = 0;
const pct = Math.max(0, Math.min(100, s.pct || 0)), lines = s.lines || [];
bar.style.width = pct + '%'; bar.className = s.stage === 'failed' ? 'bad' : '';
stageEl.textContent = (label[s.stage] || s.stage || 'Waiting') + (pct ? ' · ' + pct + '%' : '');
if (lines.length) { const atEnd = logEl.scrollTop + logEl.clientHeight >= logEl.scrollHeight - 4; logEl.textContent = lines.join('\n'); if (atEnd) logEl.scrollTop = logEl.scrollHeight; }
if (!s.running && (s.stage === 'done' || s.stage === 'failed')) { again = false; if (onDone) onDone(s); }
} catch (e) {
misses++; // the server is probably restarting into the new version
if (misses > 2) stageEl.textContent = 'Restarting… waiting for it to come back';
if (misses > 150) { again = false; stageEl.textContent = 'It has not come back after a few minutes. Check its log on the server.'; }
}
if (again) setTimeout(tick, 800);
};
setTimeout(tick, 100);
return el;
}
// 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);
@@ -201,7 +233,8 @@ const cmpVer = (a, b) => {
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 updateDialog = (m, logUrl) => modal('Updating ' + m.name, h('div', {class:'stack'}, updatePanel(() => api('GET', logUrl), () => refresh()), h('p', {class:'small dim'}, 'You can close this; the update carries on.')));
const versionCell = (m, latest, pending, updateUrl, logUrl) => {
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) {
@@ -210,7 +243,8 @@ const versionCell = (m, latest, pending, updateUrl) => {
} 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'));
onclick: act(async () => { await api('POST', updateUrl); updateDialog(m, logUrl); refresh(); })}, 'Update'));
if (pending || m.updateMsg) kids.push(h('button', {class:'ghost', onclick:() => updateDialog(m, logUrl)}, 'Log'));
return h('td', {}, h('div', {class:'stack'}, ...kids));
};
@@ -719,7 +753,7 @@ views.sites = async () => {
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`)),
s.local ? h('td', {class:'dim'}, '—') : versionCell(s, latest.bamboo, !!s.updateId, O(`/sites/${s.id}/update`), O(`/sites/${s.id}/update/log`)),
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(', ')))))) :
@@ -994,7 +1028,7 @@ serverViews.edges = async () => {
return h('div', {},
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(', ') || '—'), 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', {class:'mono small'}, (e.ips || []).join(', ') || '—'), versionCell(e, latest.blacktip, e.updatePending, '/api/edges/' + e.id + '/update', '/api/edges/' + e.id + '/update/log'), 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.')));
};
@@ -1063,8 +1097,14 @@ serverViews.settings = async () => {
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.';
showPanel();
})}, 'Update now');
const updBox = h('div', {style:'margin-top:12px'});
const showPanel = () => { updOut.textContent = 'Updating Hammerhead. The page reconnects by itself after the restart.'; updBox.replaceChildren(updatePanel(() => api('GET', '/api/admin/update/status'), async s => { if (s.stage === 'done') updOut.textContent = 'Updated: Hammerhead is now running ' + s.version + '.'; })); };
api('GET', '/api/admin/update/status').then(s => {
if (s.running) showPanel();
else if ((s.lines || []).length) updBox.append(h('button', {class:'ghost', onclick:() => { updBox.replaceChildren(updatePanel(() => api('GET', '/api/admin/update/status'))); }}, 'Show the last update log'));
}).catch(() => {});
const checkUpdate = act(async () => {
updOut.textContent = 'Checking…'; updNow.style.display = 'none';
const u = await api('GET', '/api/admin/update');
@@ -1081,7 +1121,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'), updNow)),
updOut, h('div', {class:'actions', style:'margin-top:10px'}, h('button', {class:'ghost', onclick:checkUpdate}, 'Check for updates'), updNow), updBox),
h('button', {class:'btn', onclick:save}, 'Save settings'));
};