diff --git a/internal/api/alerts.go b/internal/api/alerts.go index 384916f..c9d04c6 100644 --- a/internal/api/alerts.go +++ b/internal/api/alerts.go @@ -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) } diff --git a/internal/api/server.go b/internal/api/server.go index 183f9cd..cba89b0 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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 } diff --git a/internal/api/stacks.go b/internal/api/stacks.go index f67db27..d009d73 100644 --- a/internal/api/stacks.go +++ b/internal/api/stacks.go @@ -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 { diff --git a/web/src/Alerts.tsx b/web/src/Alerts.tsx index 8e3829c..8a75a76 100644 --- a/web/src/Alerts.tsx +++ b/web/src/Alerts.tsx @@ -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('') useEscape(onClose) @@ -47,7 +49,7 @@ function RuleDialog({ rule, machines, onSave, onClose }: { rule: AlertRule; mach
{(['info', 'warn', 'crit'] as const).map((s) => )}
- {machines.map((m) => )} + {machines.map((m) => )}
@@ -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(null) const [err, setErr] = useState('') const [editing, setEditing] = useState(null) @@ -77,7 +79,7 @@ export default function Alerts({ machines }: { machines: string[] }) {
{a.firing.map((f) => (
-
{f.rule}{f.machine}
+
{f.rule}{title(f.machine)}
{f.sensor} = {show(f.value)}
for {ago(f.since)}
@@ -119,12 +121,12 @@ export default function Alerts({ machines }: { machines: string[] }) { {a.events.map((e: AlertEvent, i) => (
{e.kind === 'firing' ? '▲' : '✓'} - {e.rule} · {e.machine} · {e.sensor} = {show(e.value)} + {e.rule} · {title(e.machine)} · {e.sensor} = {show(e.value)} {ago(e.t)} ago
))} - {editing && setEditing(null)} />} + {editing && setEditing(null)} />}
) } diff --git a/web/src/MachineSwitcher.tsx b/web/src/MachineSwitcher.tsx index ce04b7a..b7c950f 100644 --- a/web/src/MachineSwitcher.tsx +++ b/web/src/MachineSwitcher.tsx @@ -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 (
@@ -34,8 +35,8 @@ export default function MachineSwitcher({ hub, hubAddr, current, machines, onCha {hub}{hubAddr || 'this host'} {machines.map((m) => ( - ))} diff --git a/web/src/Machines.tsx b/web/src/Machines.tsx index 50a88a5..c0e6b3b 100644 --- a/web/src/Machines.tsx +++ b/web/src/Machines.tsx @@ -81,7 +81,7 @@ export default function Machines({ hub, onChanged, onOpen }: { hub: Me; onChange
- {m.name} + {m.host || m.name} {m.url} {m.reboot_needed && ⟳} @@ -89,7 +89,9 @@ export default function Machines({ hub, onChanged, onOpen }: { hub: Me; onChange
Status{m.online ? 'online' : m.error || 'offline'}
- {m.host &&
Host{m.host} · v{(m.version ?? '').replace(/^v/, '')}
} + {/* the name it was added under is its identity here: it is what Remove, Update and the URL use */} +
Added as{m.name}{m.host && m.host !== m.name ? ' · renamed itself since' : ''}
+ {m.version &&
Versionv{m.version.replace(/^v/, '')}
} {m.online &&
Load{m.load} · up {fmtUptime(m.uptime ?? 0)}
}
Seen{ago(m.last_seen)}
{m.fingerprint &&
Cert{shortFp(m.fingerprint)}
} diff --git a/web/src/Shell.tsx b/web/src/Shell.tsx index 35a5bde..0c1098b 100644 --- a/web/src/Shell.tsx +++ b/web/src/Shell.tsx @@ -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([]) const [remoteMe, setRemoteMe] = useState(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([]) const [active, setActive] = useState(null) @@ -133,7 +136,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut: // The machine's colour: set on , 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 && offline} load {me.load} - - + + logout @@ -253,7 +256,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut: {view === 'dashboard' && } {view === 'widgets' && } {view === 'docker' && } - {view === 'alerts' && m.name)} />} + {view === 'alerts' && m.name)} titleOf={(n) => titleOf(n === hubMe.host ? null : n, machines, hubMe.host)} />} {view === 'top' && } {view === 'procs' && } {view === 'services' && } @@ -283,7 +286,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut: setCtrl={(v) => handles.current[activeTerm.id]?.armCtrl(v)} />}