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:
co-authored by
Claude Opus 5
parent
ec94cb8bff
commit
dbfe163a75
+8
-6
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user