Update notice: a newer release shows in the web interface

Once a day the server asks Gitea or GitHub, as picked under Settings ->
Updates, for the latest release. A newer one shows as a pill in the
sidebar, a banner on the Dashboard and in the Updates card with its
release notes and the commands to update this server. Drafts and
pre-releases are ignored, nothing about the server is sent, and the check
can be switched off. POST /updates/check checks now.
This commit is contained in:
Daniel Redetzke
2026-10-05 11:59:27 +03:00
parent 6662adf0b8
commit ae95781427
9 changed files with 587 additions and 17 deletions
+142 -6
View File
@@ -530,7 +530,7 @@
const NAV = [['#/', 'dashboard', 'Dashboard'], ['#/peers', 'peers', 'Peers'], ['#/server', 'server', 'Server'], ['#/settings', 'settings', 'Settings']];
let navLinks = {};
let srvBox, peerCount;
let srvBox, peerCount, verRow;
function buildShell() {
srvBox = h('div', { class: 'srv' }, h('span', { class: 'dot' }), h('span', null, 'Loading…'));
@@ -546,13 +546,25 @@
h('span', { class: 'avatar', 'aria-hidden': 'true' }, me.name.slice(0, 1).toUpperCase()),
h('span', null, h('strong', null, me.name), h('span', null, 'My account')))),
h('button', { type: 'button', class: 'signout', 'aria-label': 'Sign out', onClick: logout }, icon('logout'), h('span', { class: 'tip', 'aria-hidden': 'true' }, 'Sign out'))),
h('div', { class: 'footrow' },
h('span', null, 'v' + me.version.replace(/^v/, '')))));
(verRow = h('div', { class: 'footrow' }))));
main = h('main', { class: 'main', id: 'main' });
app.replaceChildren(h('div', { class: 'shell' }, nav, main));
drawUpdateHint();
refreshSide();
}
// drawUpdateHint shows a newer release next to the version in the sidebar
// and as a dot on Settings.
function drawUpdateHint() {
if (!verRow) return;
const v = me.updateAvailable;
fill(verRow, h('span', null, 'v' + me.version.replace(/^v/, '')),
v ? h('a', { class: 'upd', href: '#/settings#updates' }, v + ' available') : null);
const set = navLinks['#/settings'];
set.querySelectorAll('.pip, .sr').forEach((e) => e.remove());
if (v) set.append(h('span', { class: 'pip', title: 'Update available' }), h('span', { class: 'sr' }, ', update available'));
}
async function refreshSide() {
try {
const s = await api('GET', '/status');
@@ -582,7 +594,7 @@
[/^#\/peers\/new$/, '#/peers', viewPeerNew],
[/^#\/peers\/([\w-]+)$/, '#/peers', viewPeer],
[/^#\/server$/, '#/server', viewServer],
[/^#\/settings$/, '#/settings', viewSettings],
[/^#\/settings(#updates)?$/, '#/settings', viewSettings],
[/^#\/account$/, '#/account', viewAccount],
];
@@ -1005,6 +1017,8 @@
h('div', null, h('strong', null, 'Needs attention: '), failing.map((c) => c.name + ' (' + c.detail + ')').join(' · ')),
h('a', { class: 'btn small', href: '#/server' }, 'Health')) : null,
updateBanner(),
h('div', { class: 'tiles' },
h('div', { class: 'card tile' }, h('div', { class: 'k' }, 'Peers online'),
h('div', { class: 'v' }, String(st.peers.online), h('small', null, '/ ' + st.peers.total)),
@@ -1693,6 +1707,125 @@
bar);
}
// ---------- updates ----------
const HIDE_UPDATE = 'GHOSTWIRE.hideUpdate';
// updateBanner tells the Dashboard about a newer release until it is
// hidden for that version.
function updateBanner() {
const v = me.updateAvailable;
let hidden = null;
try { hidden = localStorage.getItem(HIDE_UPDATE); } catch { /* storage blocked */ }
if (!v || hidden === v) return null;
const box = h('div', { class: 'notice new' },
h('div', null, h('strong', null, 'GHOSTWIRE ' + v + ' is available. '), 'You\'re on v' + me.version.replace(/^v/, '') + '.'),
h('div', { class: 'actions' },
h('a', { class: 'btn small', href: '#/settings#updates' }, 'How to update'),
h('button', { type: 'button', class: 'btn small ghost', onClick: () => {
try { localStorage.setItem(HIDE_UPDATE, v); } catch { /* storage blocked */ }
box.remove();
} }, 'Hide until the next version')));
return box;
}
// mdInline turns **bold** and `code` into elements; everything else stays
// text.
const mdInline = (text) => text.split(/(\*\*[^*]+\*\*|`[^`]+`)/).filter(Boolean).map((t) =>
t.startsWith('**') ? h('strong', null, t.slice(2, -2)) : t.startsWith('`') ? h('code', null, t.slice(1, -1)) : t);
// releaseSummary picks the opening paragraph and the first bullet list out
// of the release notes; the full notes are a link away.
function releaseSummary(md) {
const lines = md.replace(/\r/g, '').split('\n');
const para = [];
for (const l of lines) {
if (!l.trim()) { if (para.length) break; continue; }
if (/^(#|- |\* |```|\|)/.test(l)) break;
para.push(l.trim());
}
const items = [];
let started = false;
for (const l of lines) {
const m = /^[-*] (.+)$/.exec(l);
if (m) { started = true; items.push(m[1]); } else if (started && l.trim()) break;
}
return { summary: para.join(' '), items };
}
function updatesCard(initial) {
let st = initial;
const card = h('section', { class: 'card', id: 'updates', 'aria-labelledby': 'upd' });
const setStatus = (next) => {
st = next;
me.updateAvailable = st.enabled && st.available ? st.latest.version : undefined;
drawUpdateHint();
draw();
};
const checkNow = async (btn) => {
if (btn) { btn.disabled = true; btn.textContent = 'Checking…'; }
try { setStatus(await api('POST', '/updates/check')); } catch (x) { toast(x.message, true); draw(); }
};
const save = async (updates) => {
try {
await api('PATCH', '/settings', { updates });
const s = await api('GET', '/settings');
if (s.updates.enabled) await checkNow(); else setStatus(s.updates);
} catch (x) { toast(x.message, true); draw(); }
};
const SOURCES = [['gitea', 'Gitea', 'git.redetzke.aero/Redetzke/GHOSTWIRE'], ['github', 'GitHub', 'github.com/danielredetzke/GHOSTWIRE']];
const srcName = () => SOURCES.find(([k]) => k === st.source)[1];
function draw() {
const cur = 'v' + st.current.replace(/^v/, '');
const rel = st.latest;
const notes = rel && st.available ? releaseSummary(rel.notes || '') : null;
const cmds = st.available && st.file ? [
'curl -fLO ' + st.fileUrl,
'curl -fLO ' + st.sumsUrl,
'sha256sum -c --ignore-missing SHA256SUMS',
'chmod +x ' + st.file,
'sudo ./' + st.file + ' update',
].join('\n') : null;
fill(card,
h('div', { class: 'cardhead' },
h('h2', { id: 'upd' }, 'Updates'),
st.enabled ? h('span', { class: 'muted' }, st.checked ? 'Last checked ' + ago(st.checked) : 'Not checked yet') : null),
h('div', { class: 'upvers' },
h('div', { class: 'upbox' }, h('span', null, 'Running'), h('strong', { class: 'mono' }, cur)),
rel ? h('div', { class: st.available ? 'upbox new' : 'upbox' }, h('span', null, 'Latest release'), h('strong', { class: 'mono' }, rel.version)) : null,
h('div', { class: 'upbox' }, h('span', null, 'This server'), h('strong', null, st.arch ? 'Linux · ' + st.arch : 'No release file for this platform'))),
st.enabled && st.error ? h('div', { class: 'notice err', role: 'alert' },
h('div', null, 'The last check failed: ' + st.error + '. ' + (st.lastOk ? 'Last worked ' + ago(st.lastOk) + '. ' : '') + 'Try the other source.')) : null,
rel && !st.available ? h('p', { class: 'uptodate' }, h('span', { class: 'dot ok' }), 'GHOSTWIRE is up to date.') : null,
notes ? h('div', { class: 'upnotes' },
h('div', { class: 'hd' }, h('strong', null, 'What\'s new in ' + rel.version),
h('span', { class: 'muted' }, 'Released ' + fmtDate(rel.published) + ' · from ' + srcName()),
h('a', { href: rel.url, target: '_blank', rel: 'noopener' }, 'Full notes on ' + srcName())),
/security/i.test(notes.summary) ? h('p', { class: 'notice' }, 'Includes security fixes.') : null,
notes.summary ? h('p', null, mdInline(notes.summary)) : null,
notes.items.length ? h('ul', null, notes.items.map((t) => h('li', null, mdInline(t)))) : null) : null,
cmds ? h('div', { class: 'upcmd' },
h('div', { class: 'hd' }, h('strong', null, 'Update this server'), h('span', { class: 'muted' }, 'Run on the server. VPN connections stay up.')),
h('pre', { class: 'code' }, cmds),
h('div', null, h('button', { type: 'button', class: 'btn small', onClick: () => copy(cmds) }, 'Copy commands'))) : null,
st.available && !st.file ? h('p', null, 'No release file is built for this platform. ', h('a', { href: rel.url, target: '_blank', rel: 'noopener' }, 'See the release')) : null,
h('fieldset', { class: 'section' }, h('legend', { class: 'legend' }, 'Release source'),
h('div', { class: 'grid' }, SOURCES.map(([k, name, where]) => h('label', { class: 'opt' },
h('input', { type: 'radio', name: 'upsrc', value: k, checked: st.source === k, onChange: () => save({ source: k }) }),
h('span', null, h('strong', null, name), h('br'), h('span', { class: 'hint mono' }, where))))),
h('span', { class: 'hint' }, 'Both carry the same releases and files. The check, the release notes and the download links use the source you pick.')),
h('div', { class: 'uprow' },
h('label', { class: 'check' },
h('input', { type: 'checkbox', checked: st.enabled, onChange: (e) => save({ check: e.target.checked }) }),
h('span', null, 'Check for updates once a day', h('br'),
h('span', { class: 'hint' }, 'Asks ' + new URL(st.sourceUrl).host + ' for the latest release. Nothing about this server is sent.'))),
st.enabled ? h('button', { type: 'button', class: 'btn small', onClick: (e) => checkNow(e.currentTarget) }, 'Check now') : null));
}
draw();
return card;
}
// ---------- settings ----------
// ---------- my account ----------
@@ -2071,7 +2204,7 @@
} });
fill(wrap,
h('div', null, h('h1', null, 'Settings'), h('p', { class: 'sub' }, 'Users, web interface, API access for the iOS app, logs, data retention and backups')),
h('div', null, h('h1', null, 'Settings'), h('p', { class: 'sub' }, 'Users, web interface, API access for the iOS app, logs, data retention, backups and updates')),
restartBox,
h('section', { class: 'card flush', 'aria-labelledby': 'usr' },
@@ -2146,8 +2279,11 @@
h('div', { class: 'actions' },
h('a', { class: 'btn', href: '/api/v1/backup' }, 'Download backup'),
h('button', { type: 'button', class: 'btn', onClick: () => restoreInput.click() }, 'Restore from file…'),
restoreInput)));
restoreInput)),
updatesCard(s.updates));
await drawLogs();
if (location.hash.endsWith('#updates')) document.getElementById('updates').scrollIntoView();
}
render();