A machine is titled by its own hostname, and follows a rename

The name a machine was added under stays its identity — the URL, its accent, what the hub's
API calls it — and is what the Machines page shows as 'Added as'. The hostname is read live
rather than at start-up, so renaming a machine reaches the console without a restart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-21 17:04:17 -07:00
co-authored by Claude Opus 5
parent ec94cb8bff
commit dbfe163a75
8 changed files with 66 additions and 31 deletions
+3 -3
View File
@@ -210,7 +210,7 @@ func (e *alertEngine) evaluate() {
}
}
qb, _ := json.Marshal(query)
machines := append([]string{e.s.host}, e.s.nodes.Names()...)
machines := append([]string{e.s.hostName()}, e.s.nodes.Names()...)
results := map[string]map[string]any{} // machine → ruleID → value | {id: value}
reach := map[string]bool{}
var wg sync.WaitGroup
@@ -221,7 +221,7 @@ func (e *alertEngine) evaluate() {
defer wg.Done()
var res map[string]any
ok := false
if m == e.s.host {
if m == e.s.hostName() {
if out, err := sensors.Shape(e.s.sensors.All(), qb, false); err == nil {
res, ok = out.(map[string]any)
}
@@ -515,6 +515,6 @@ func (s *Server) alertsTest(w http.ResponseWriter, _ *http.Request) {
jsonErr(w, 400, "no channels to test")
return
}
e.notify(channels, AlertEvent{T: time.Now().UnixMilli(), Kind: "firing", Rule: "Test alert", Machine: s.host, Sensor: "hamlab.test", Value: "this is only a test", Severity: "info"})
e.notify(channels, AlertEvent{T: time.Now().UnixMilli(), Kind: "firing", Rule: "Test alert", Machine: s.hostName(), Sensor: "hamlab.test", Value: "this is only a test", Severity: "info"})
w.WriteHeader(204)
}
+29 -8
View File
@@ -73,15 +73,17 @@ type Server struct {
version string
started time.Time
user string
host string
host string // the machine's hostname as last read; use hostName(), it can change under us
hostMu sync.Mutex
hostAt time.Time
ui fs.FS
mux *http.ServeMux
}
func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *metrics.Collector, st *store.Store, svc *services.Manager, cr *cron.Manager, tm *tasks.Manager, up *updates.Manager, sto *storage.Manager, fsLocal, fsSudo files.FS, nm *nodes.Manager, fingerprint string, ui fs.FS, version string) *Server {
host, _ := os.Hostname()
s := &Server{cfg: cfg, sessions: sessions, mgr: mgr, metrics: col, sensors: sensors.NewRegistry(col), store: st, svc: svc, cron: cr, tasks: tm, updates: up, storage: sto, fsLocal: fsLocal, fsSudo: fsSudo, nodes: nm, fingerprint: fingerprint, ui: ui, version: version,
started: time.Now(), host: host, user: os.Getenv("USER")}
started: time.Now(), user: os.Getenv("USER")}
s.hostName() // read it once now so the first request doesn't have to
if u, err := user.Current(); err == nil && u.Username != "" {
s.user = u.Username
}
@@ -572,7 +574,7 @@ func (s *Server) revokeToken(w http.ResponseWriter, r *http.Request) {
// ---- nodes: the machines this hub manages ----
func (s *Server) listNodes(w http.ResponseWriter, _ *http.Request) {
jsonOK(w, 200, map[string]any{"nodes": s.nodes.List(), "host": s.host, "fingerprint": s.fingerprint})
jsonOK(w, 200, map[string]any{"nodes": s.nodes.List(), "host": s.hostName(), "fingerprint": s.fingerprint})
}
func (s *Server) addNode(w http.ResponseWriter, r *http.Request) {
@@ -797,6 +799,25 @@ func clientIP(r *http.Request) string {
// ---- me ----
// hostName is this machine's hostname now, not when the daemon started: rename a machine
// (hostnamectl, or a DHCP lease) and the console says so within a few seconds — in its header,
// in the machine menu, in alerts and in the fleet's sensor queries — without a restart. Read
// through a short cache because it is asked for on most requests and on every alert pass.
func (s *Server) hostName() string {
s.hostMu.Lock()
defer s.hostMu.Unlock()
if time.Since(s.hostAt) > 5*time.Second {
if h, err := os.Hostname(); err == nil && h != "" {
if s.host != "" && h != s.host {
log.Printf("hostname: %s is now %s", s.host, h)
}
s.host = h
}
s.hostAt = time.Now()
}
return s.host
}
func (s *Server) me(w http.ResponseWriter, _ *http.Request) {
load := ""
if b, err := os.ReadFile("/proc/loadavg"); err == nil {
@@ -820,7 +841,7 @@ func (s *Server) me(w http.ResponseWriter, _ *http.Request) {
}
})
jsonOK(w, 200, map[string]any{
"host": s.host, "user": s.user, "version": s.version,
"host": s.hostName(), "user": s.user, "version": s.version,
"load": load, "uptime": uptime, "service_started": s.started,
"reboot_needed": rebootErr == nil, "fingerprint": s.fingerprint, "arch": runtime.GOARCH, "demo_url": demoURL, "addr": addr,
})
@@ -1086,7 +1107,7 @@ func (s *Server) fleetQuery(w http.ResponseWriter, r *http.Request) {
jsonErr(w, 400, err.Error())
return
}
names := append([]string{s.host}, s.nodes.Names()...)
names := append([]string{s.hostName()}, s.nodes.Names()...)
if len(want) > 0 {
names = names[:0]
for _, n := range want {
@@ -1107,7 +1128,7 @@ func (s *Server) fleetQuery(w http.ResponseWriter, r *http.Request) {
var mu sync.Mutex
var wg sync.WaitGroup
for _, name := range names {
if name == s.host {
if name == s.hostName() {
out[name] = local
continue
}
@@ -1478,7 +1499,7 @@ func (s *Server) power(w http.ResponseWriter, r *http.Request) {
Confirm string `json:"confirm"`
}
_ = json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body)
if body.Confirm != s.host {
if body.Confirm != s.hostName() {
jsonErr(w, 400, "confirmation must be the hostname")
return
}
+1 -1
View File
@@ -363,7 +363,7 @@ func (s *Server) hubStacks(ctx context.Context, node string) ([]stack, error) {
st.Note = note
continue
}
st.Remote, st.HeldBy = remote, s.host
st.Remote, st.HeldBy = remote, s.hostName()
}
var held []stack
for _, st := range res.Stacks {
+8 -6
View File
@@ -10,7 +10,9 @@ const patience = (s: number) => (s === 0 ? 'at once' : s < 120 ? `for ${s} s` :
const show = (v: unknown) => (typeof v === 'number' ? String(Math.round(v * 10) / 10) : String(v))
const parse = (raw: string): number | boolean | string => (raw === 'true' ? true : raw === 'false' ? false : raw.trim() !== '' && !isNaN(Number(raw)) ? Number(raw) : raw)
function RuleDialog({ rule, machines, onSave, onClose }: { rule: AlertRule; machines: string[]; onSave: (r: AlertRule) => void; onClose: () => void }) {
// A rule names the machines it watches by the name the hub knows them by; on screen each is
// titled by its own hostname, like everywhere else.
function RuleDialog({ rule, machines, title, onSave, onClose }: { rule: AlertRule; machines: string[]; title: (n: string) => string; onSave: (r: AlertRule) => void; onClose: () => void }) {
const [r, setR] = useState({ ...rule, value: String(rule.value) })
const [hits, setHits] = useState<string>('')
useEscape(onClose)
@@ -47,7 +49,7 @@ function RuleDialog({ rule, machines, onSave, onClose }: { rule: AlertRule; mach
<div className="chips">{(['info', 'warn', 'crit'] as const).map((s) => <button type="button" key={s} className={'chip' + (r.severity === s ? ' on' : '')} onClick={() => setR({ ...r, severity: s })}>{s === 'crit' ? 'critical' : s === 'warn' ? 'warning' : 'info'}</button>)}</div>
<div className="chips">
<button type="button" className={'chip' + (all ? ' on' : '')} onClick={() => setR({ ...r, machines: '*' })}>every machine</button>
{machines.map((m) => <button type="button" key={m} className={'chip' + (picked.includes(m) ? ' on' : '')} onClick={() => { const next = picked.includes(m) ? picked.filter((x) => x !== m) : [...picked, m]; setR({ ...r, machines: next.length ? next.join(',') : '*' }) }}>{m}</button>)}
{machines.map((m) => <button type="button" key={m} className={'chip' + (picked.includes(m) ? ' on' : '')} onClick={() => { const next = picked.includes(m) ? picked.filter((x) => x !== m) : [...picked, m]; setR({ ...r, machines: next.length ? next.join(',') : '*' }) }}>{title(m)}</button>)}
</div>
<div className="svc-actions"><button type="submit" className="btn on" disabled={!r.name.trim() || !r.sensor.trim()}>Save</button><span className="spacer" /><button type="button" className="btn" onClick={onClose}>Cancel</button></div>
</form>
@@ -55,7 +57,7 @@ function RuleDialog({ rule, machines, onSave, onClose }: { rule: AlertRule; mach
)
}
export default function Alerts({ machines }: { machines: string[] }) {
export default function Alerts({ machines, titleOf: title }: { machines: string[]; titleOf: (n: string) => string }) {
const [a, setA] = useState<AlertsInfo | null>(null)
const [err, setErr] = useState('')
const [editing, setEditing] = useState<AlertRule | null>(null)
@@ -77,7 +79,7 @@ export default function Alerts({ machines }: { machines: string[] }) {
<div className="cards">
{a.firing.map((f) => (
<section className={'card firing ' + sevClass(f.severity)} key={f.rule_id + f.machine + f.sensor}>
<div className="sub-head"><span className={'sdot ' + (f.severity === 'crit' ? 'bad' : 'warn')} /><span className="dname">{f.rule}</span><span className="spacer" /><span className="badge" style={{ color: accentFor(f.machine), borderColor: accentFor(f.machine) }}>{f.machine}</span></div>
<div className="sub-head"><span className={'sdot ' + (f.severity === 'crit' ? 'bad' : 'warn')} /><span className="dname">{f.rule}</span><span className="spacer" /><span className="badge" style={{ color: accentFor(f.machine), borderColor: accentFor(f.machine) }}>{title(f.machine)}</span></div>
<div className="mono small">{f.sensor} = {show(f.value)}</div>
<div className="muted small">for {ago(f.since)}</div>
</section>
@@ -119,12 +121,12 @@ export default function Alerts({ machines }: { machines: string[] }) {
{a.events.map((e: AlertEvent, i) => (
<div className="ev" key={i}>
<span className={e.kind === 'firing' ? (e.severity === 'crit' ? 'warn-text bad-text' : 'warn-text') : 'good'}>{e.kind === 'firing' ? '▲' : '✓'}</span>
<span className="ev-main">{e.rule} <span className="muted">· {e.machine} · </span><span className="mono small muted">{e.sensor} = {show(e.value)}</span></span>
<span className="ev-main">{e.rule} <span className="muted">· {title(e.machine)} · </span><span className="mono small muted">{e.sensor} = {show(e.value)}</span></span>
<span className="muted small num" title={new Date(e.t).toLocaleString()}>{ago(e.t)} ago</span>
</div>
))}
</section>
{editing && <RuleDialog rule={editing} machines={machines} onSave={saveRule} onClose={() => setEditing(null)} />}
{editing && <RuleDialog rule={editing} machines={machines} title={title} onSave={saveRule} onClose={() => setEditing(null)} />}
</div>
)
}
+5 -4
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { MachineStatus } from './api'
import { MachineStatus, titleOf } from './api'
import { useEscape } from './ui'
import { accentFor } from './accents'
@@ -19,12 +19,13 @@ export default function MachineSwitcher({ hub, hubAddr, current, machines, onCha
}, [])
useEscape(() => setOpen(false), open)
const cur = current ? machines.find((m) => m.name === current) : null
const title = (name: string | null) => titleOf(name, machines, hub)
const dotFor = (m?: MachineStatus | null) => (!m ? 'ok' : m.online ? 'ok' : 'bad')
return (
<div className="mswitch" ref={box}>
<button className="mswitch-btn" onClick={() => setOpen(!open)} title="switch machine">
<span className={'dot ' + (current ? dotFor(cur) : 'ok')} />
<span className="mname">{current ?? hub}</span>
<span className="mname">{title(current)}</span>
<span className="muted small">{current ? (cur?.online ? 'remote' : 'offline') : 'this host'}</span>
<span className="chev">▾</span>
</button>
@@ -34,8 +35,8 @@ export default function MachineSwitcher({ hub, hubAddr, current, machines, onCha
<span className="dot ok" /><span className="mname" style={{ color: accentFor(hub) }}>{hub}</span><span className="muted small mono">{hubAddr || 'this host'}</span>
</button>
{machines.map((m) => (
<button key={m.name} className={'mitem' + (current === m.name ? ' on' : '')} onClick={() => { setOpen(false); onChange(m.name) }} title={m.error || m.url}>
<span className={'dot ' + dotFor(m)} /><span className="mname" style={{ color: accentFor(m.name) }}>{m.name}</span><span className="muted small mono">{hostOf(m.url)}{m.online ? '' : ' · offline'}</span>
<button key={m.name} className={'mitem' + (current === m.name ? ' on' : '')} onClick={() => { setOpen(false); onChange(m.name) }} title={m.error || (m.host && m.host !== m.name ? `${m.url} · added as ${m.name}` : m.url)}>
<span className={'dot ' + dotFor(m)} /><span className="mname" style={{ color: accentFor(m.name) }}>{m.host || m.name}</span><span className="muted small mono">{hostOf(m.url)}{m.online ? '' : ' · offline'}</span>
</button>
))}
<button className="mitem manage" onClick={() => { setOpen(false); onManage() }}>+ manage machines…</button>
+4 -2
View File
@@ -81,7 +81,7 @@ export default function Machines({ hub, onChanged, onOpen }: { hub: Me; onChange
<section className="card" key={m.name}>
<div className="sub-head">
<span className={'dot ' + (m.online ? 'ok' : 'bad')} />
<span className="dname" style={{ color: accentFor(m.name) }}>{m.name}</span>
<span className="dname" style={{ color: accentFor(m.name) }}>{m.host || m.name}</span>
<span className="muted small mono">{m.url}</span>
<span className="spacer" />
{m.reboot_needed && <span className="badge" title="reboot required">⟳</span>}
@@ -89,7 +89,9 @@ export default function Machines({ hub, onChanged, onOpen }: { hub: Me; onChange
</div>
<div className="props">
<div><span className="k">Status</span><span className={m.online ? 'good' : 'warn-text'}>{m.online ? 'online' : m.error || 'offline'}</span></div>
{m.host && <div><span className="k">Host</span><span>{m.host} · v{(m.version ?? '').replace(/^v/, '')}</span></div>}
{/* the name it was added under is its identity here: it is what Remove, Update and the URL use */}
<div><span className="k">Added as</span><span className="mono">{m.name}{m.host && m.host !== m.name ? ' · renamed itself since' : ''}</span></div>
{m.version && <div><span className="k">Version</span><span>v{m.version.replace(/^v/, '')}</span></div>}
{m.online && <div><span className="k">Load</span><span>{m.load} · up {fmtUptime(m.uptime ?? 0)}</span></div>}
<div><span className="k">Seen</span><span>{ago(m.last_seen)}</span></div>
{m.fingerprint && <div><span className="k">Cert</span><span className="mono" title={m.fingerprint}>{shortFp(m.fingerprint)}</span></div>}
+10 -7
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { api, Me, Term } from './api'
import { api, Me, Term, titleOf } from './api'
import Rail, { Group } from './Rail'
import TerminalPane, { TermHandle } from './TerminalPane'
import TermTabs from './TermTabs'
@@ -90,7 +90,10 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
const [machines, setMachines] = useState<MachineStatus[]>([])
const [remoteMe, setRemoteMe] = useState<Me | null>(null)
const [remoteErr, setRemoteErr] = useState('')
const me: Me = machine ? (remoteMe ?? { ...hubMe, host: machine, load: '', version: '…', uptime: 0, reboot_needed: false }) : hubMe
// A machine is titled by its own hostname; the name it was added under stays its identity
// (the URL, its accent, what the hub's API calls it). See titleOf.
const title = titleOf(machine, machines, hubMe.host)
const me: Me = machine ? (remoteMe ?? { ...hubMe, host: title, load: '', version: '…', uptime: 0, reboot_needed: false }) : hubMe
const listRate = useRefreshMs(LIST_FLOOR)
const [terms, setTerms] = useState<Term[]>([])
const [active, setActive] = useState<string | null>(null)
@@ -133,7 +136,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
// The machine's colour: set on <html>, so the header stripe and the switcher pick it up.
useEffect(() => { const paint = () => applyAccent(machine ?? hubMe.host); paint(); window.addEventListener('hamlab:accents', paint); return () => window.removeEventListener('hamlab:accents', paint) }, [machine, hubMe.host])
// The tab's title says where you are, so history entries and open tabs can be told apart.
useEffect(() => { document.title = `${GROUPS.find((g) => g.id === view)?.label ?? view} · ${machine ?? hubMe.host} — Ham Lab` }, [view, machine, hubMe.host])
useEffect(() => { document.title = `${GROUPS.find((g) => g.id === view)?.label ?? view} · ${title} — Ham Lab` }, [view, title])
useEffect(() => {
const want = `#/${machine ?? 'hub'}/${view}`
if (location.hash !== want && !location.hash.startsWith(want + '/')) history.pushState(null, '', want)
@@ -236,8 +239,8 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
{machine && remoteErr && <span className="badge bad" title={remoteErr}>offline</span>}
<span className="spacer" />
<span className="stat" title="load average, 1 · 5 · 15 min">load {me.load}</span>
<span className={'dot ' + (online && !remoteErr ? 'ok' : 'bad')} title={remoteErr ? `${machine} unreachable` : online ? 'connected' : 'disconnected'} />
<Power host={me.host} label={machine ?? me.host} rebootNeeded={me.reboot_needed} />
<span className={'dot ' + (online && !remoteErr ? 'ok' : 'bad')} title={remoteErr ? `${title} unreachable` : online ? 'connected' : 'disconnected'} />
<Power host={me.host} label={title} rebootNeeded={me.reboot_needed} />
<Btn className="link" onClick={logout}>logout</Btn>
</header>
@@ -253,7 +256,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
{view === 'dashboard' && <Dashboard />}
{view === 'widgets' && <Gallery demoUrl={hubMe.demo_url} />}
{view === 'docker' && <Docker />}
{view === 'alerts' && <Alerts machines={machines.map((m) => m.name)} />}
{view === 'alerts' && <Alerts machines={machines.map((m) => m.name)} titleOf={(n) => titleOf(n === hubMe.host ? null : n, machines, hubMe.host)} />}
{view === 'top' && <Top />}
{view === 'procs' && <Procs />}
{view === 'services' && <Services />}
@@ -283,7 +286,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
setCtrl={(v) => handles.current[activeTerm.id]?.armCtrl(v)} />}
<footer className="footer">
<span>{machine ? `${machine} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
<span>{machine ? `${title} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
<span>up {fmtUptime(me.uptime)}</span>
<span className="spacer" />
<span className="muted">{view === 'top' ? 'Top' : view === 'dashboard' ? 'Dashboard' : view === 'widgets' ? 'Widgets' : view === 'docker' ? 'Docker' : view === 'alerts' ? 'Alerts' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''}</span>
+6
View File
@@ -56,6 +56,12 @@ async function req<T>(method: string, path: string, body?: unknown, local = fals
export interface Me { demo_url?: string; addr?: string; host: string; user: 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
// what the hub's own API calls it — and is what we fall back to until it has been reached.
export const titleOf = (name: string | null, machines: MachineStatus[], hubHost: string): string =>
name ? machines.find((m) => m.name === name)?.host || name : hubHost
export interface MachineStatus { name: string; url: string; fingerprint?: string; added: string; online: boolean; host?: string; version?: string; load?: string; uptime?: number; reboot_needed?: boolean; last_seen?: string; error?: string }
export interface Term { id: string; name: string; running: boolean; exit_code: number; created: string; clients: number }