Show a progress bar and live log while Hammerhead, edges and sites update
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:
1 parent
9bd48917c7
commit
2b7cb5dd35
11 files changed
+306
-13
No files matched your search
+46
-6
@@ -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'));
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user