An updater: what is published on Gitea, and installing it from Settings

A new selfupdate package asks one URL what the newest build is — a Gitea/Forgejo repo
API base, or any directory holding VERSION, SHA256SUMS and hamlab-linux-<arch>, which is
what make release already writes and what install.sh already downloads. The binary is
checked against SHA256SUMS before it goes near the running one, and a token is sent when
the source is behind a login, so a private Gitea serves its own fleet without opening the
instance to everyone. Version ordering is install.sh's vnum rule, so the daemon and the
installer never disagree about which build is newer.

Settings gains an Updates card: installed, published, when it last looked, the source,
release notes, Check for updates, and Install. The rail badges Settings when a newer
build is out. Nothing installs unasked — it restarts the daemon and drops terminals.

scripts/gitea-release.sh (make gitea-release) publishes dist/ as a Gitea release.
Deliberate, and not part of deploy: deploy pushes to the machines this hub owns,
publishing offers the build to every install anywhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-23 14:31:32 -07:00
co-authored by Claude Opus 5
parent 98a8de8091
commit 68bf3bb218
10 changed files with 876 additions and 36 deletions
+74 -2
View File
@@ -1,9 +1,9 @@
import { useCallback, useEffect, useState } from 'react'
import { fetchCatalog } from './sensors'
import { api, Me, TokenInfo } from './api'
import { api, Me, TokenInfo, UpdateStatus } from './api'
import { savePrefs } from './accents'
import { applyTheme, currentThemeId, THEMES } from './themes'
import { Btn, Loading, askYes, toast, useEscape } from './ui'
import { Btn, Loading, Spinner, askYes, toast, useEscape } from './ui'
import { fmtMs, fmtWindowSec, getHistorySec, getRefreshMs, HISTORY_OPTIONS, HISTORY_POINTS, REFRESH_OPTIONS, setHistorySec, setRefreshMs, strideMs } from './prefs'
const fmtUptime = (s: number) => { const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60); return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m` }
@@ -133,6 +133,8 @@ export default function Settings({ me }: { me: Me }) {
{making && <TokenDialog onClose={() => setMaking(false)} onMade={(t) => { setMaking(false); setFresh(t); loadTokens() }} />}
<Updates me={me} />
<div className="dash-bar"><h2 className="section">About</h2></div>
<section className="card">
<div className="props">
@@ -147,6 +149,76 @@ export default function Settings({ me }: { me: Me }) {
)
}
// What is published, and installing it. An update replaces the binary and restarts the
// daemon, which drops open terminals — so it only ever happens when it is asked for.
function Updates({ me }: { me: Me }) {
const [st, setSt] = useState<UpdateStatus | null>(null)
const [busy, setBusy] = useState('')
const [err, setErr] = useState('')
const load = useCallback(async () => { try { setSt(await api.update()) } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } }, [])
useEffect(() => { load() }, [load])
const check = async () => {
setBusy('check'); setErr('')
try { setSt(await api.checkUpdate()) } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
finally { setBusy('') }
}
const install = async (force = false) => {
if (!st) return
if (!await askYes(`Install ${st.latest} on ${me.host}? Ham Lab restarts, which closes open terminals on this machine.`, 'Install')) return
setBusy('install'); setErr('')
try {
await api.installUpdate(st.latest, force)
toast(`${st.latest} installed — reconnecting`)
setTimeout(() => window.location.reload(), 4000)
} catch (e) {
const m = e instanceof Error ? e.message : 'failed'
setBusy('')
if (/^busy:/.test(m) && await askYes(m.replace(/^busy: /, '') + '. Install anyway?', 'Install anyway')) { install(true); return }
setErr(m)
}
}
return (
<>
<div className="dash-bar"><h2 className="section">Updates</h2><span className="spacer" />
<span className="muted small">{st?.auto ? 'checked daily' : st?.enabled ? 'checked when you ask' : 'switched off'}</span>
</div>
<section className="card">
<div className="props">
<div><span className="k">Installed</span><span className="mono">{st?.current ?? me.version}</span></div>
<div><span className="k">Published</span><span className="mono">{st?.checking ? <Spinner /> : st?.latest || '—'}</span></div>
<div><span className="k">Checked</span><span>{st?.checked ? new Date(st.checked).toLocaleString() : 'not yet'}</span></div>
<div><span className="k">Source</span><span className="mono small" title={st?.url}>{st?.enabled ? sourceName(st.url) : 'off'}</span></div>
</div>
{st?.behind && (
<div className="notice attn" style={{ marginTop: 10 }}>
<b>{st.latest}</b> is published; this machine runs <b>{st.current}</b>.
{st.published && <span className="muted"> Released {new Date(st.published).toLocaleDateString()}.</span>}
{st.notes && <div className="mono small" style={{ whiteSpace: 'pre-wrap', marginTop: 6, maxHeight: 140, overflow: 'auto' }}>{st.notes.trim()}</div>}
</div>
)}
{st && !st.behind && st.latest && <div className="muted small" style={{ marginTop: 8 }}>This is the newest published build.</div>}
{(err || st?.error) && <div className="error" style={{ marginTop: 8 }}>{err || st?.error}</div>}
<div className="svc-actions" style={{ marginTop: 10 }}>
<Btn className="btn" busy={busy === 'check'} disabled={!st?.enabled || !!busy} onClick={check}>Check for updates</Btn>
{st?.behind && <Btn className="btn on" busy={busy === 'install'} disabled={!!busy} onClick={() => install()}>Install {st.latest}</Btn>}
<span className="muted small">Installing restarts Ham Lab on this machine. Terminals here close; the shells do not survive it.</span>
</div>
</section>
</>
)
}
// A source shown as something readable: the repo it is, or the host it is on.
function sourceName(url: string): string {
try {
const u = new URL(url)
const m = u.pathname.match(/\/api\/v1\/repos\/(.+)$/)
return m ? `${u.host}/${m[1]}` : u.host + u.pathname
} catch { return url }
}
// One dialog for a read-only token: what will use it, and (optionally) which sensor
// families it may see — picked from this machine's own catalog, or typed as patterns.
function TokenDialog({ onClose, onMade }: { onClose: () => void; onMade: (t: { name: string; token: string }) => void }) {
+1 -1
View File
@@ -258,7 +258,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
</header>
<div className="body">
<Rail groups={machine ? GROUPS.filter((g) => !HUB_ONLY.includes(g.id as View)) : GROUPS} view={view} badges={{ alerts: firing }}
<Rail groups={machine ? GROUPS.filter((g) => !HUB_ONLY.includes(g.id as View)) : GROUPS} view={view} badges={{ alerts: firing, settings: hubMe.update_available ? 1 : 0 }}
header={<MachineSwitcher hub={hubMe.host} hubAddr={hubMe.addr} current={machine} machines={machines} onChange={switchMachine} onManage={() => { setView('machines'); setDrawer(false) }} />}
onView={async (id) => { if (id !== view && !(await okToLeave())) return; setView(id as View); setDrawer(false) }} />
<div className="backdrop" onClick={() => setDrawer(false)} />
+5 -1
View File
@@ -54,7 +54,8 @@ async function req<T>(method: string, path: string, body?: unknown, local = fals
return r.json()
}
export interface Me { demo_url?: string; addr?: string; host: string; user: string; account?: string; version: string; load: string; uptime: number; reboot_needed?: boolean; fingerprint?: string; arch?: string }
export interface UpdateStatus { current: string; latest: string; arch: string; behind: boolean; checking: boolean; installing: boolean; enabled: boolean; auto: boolean; url: string; notes?: string; published?: string; checked?: string; error?: string }
export interface Me { demo_url?: string; addr?: string; host: string; user: string; account?: string; update_available?: string; version: string; load: string; uptime: number; reboot_needed?: boolean; fingerprint?: string; arch?: string }
export interface PushResult { name: string; ok: boolean; version?: string; error?: string }
// What a machine is called on screen: the hostname it reports, which follows a rename within
// a poll. The name it was added under stays its identity — the URL, its accent colour, and
@@ -225,6 +226,9 @@ export const api = {
updateMachine: (name: string, force = false) => req<PushResult>('POST', `/api/machines/${encodeURIComponent(name)}/update${force ? '?force=1' : ''}`, undefined, true),
updateAllMachines: (force = false) => req<PushResult[]>('POST', `/api/machines-update-all${force ? '?force=1' : ''}`, undefined, true),
changePassword: (current: string, password: string) => req<void>('POST', '/api/password', { current, password }, true),
update: () => req<UpdateStatus>('GET', '/api/update'),
checkUpdate: () => req<UpdateStatus>('POST', '/api/update/check'),
installUpdate: (version: string, force = false) => req<{ version: string; previous: string }>('POST', `/api/update/install${force ? '?force=1' : ''}`, { version }),
tokens: () => req<TokenInfo[]>('GET', '/api/tokens', undefined, true),
createReadToken: (name: string, sensors: string[] = []) => req<{ token: string; name: string }>('POST', '/api/tokens/read', { name, sensors }, true),
docker: () => req<DockerInfo>('GET', '/api/docker'),