Bug bash fixes: hash routing + back button, hub-only pages reset on machine switch, exited terminals reap after 10 min, self-update refuses while busy unless forced, touch-sized controls, power confirm by machine name, down interfaces collapsed and empty modules hidden, load label, offline backoff, cron log newest first, procs empty message, timer timestamp parsing
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
27f9665606
commit
f093b322ee
@@ -36,5 +36,9 @@ deploy: build cross
|
|||||||
push-nodes:
|
push-nodes:
|
||||||
$(PREFIX)/hamlab push-nodes
|
$(PREFIX)/hamlab push-nodes
|
||||||
|
|
||||||
|
# same, interrupting running tasks and attached terminals on the nodes
|
||||||
|
push-nodes-force:
|
||||||
|
$(PREFIX)/hamlab push-nodes --force
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf bin internal/web/dist web/dist
|
rm -rf bin internal/web/dist web/dist
|
||||||
|
|||||||
+3
-2
@@ -46,7 +46,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
cfgPath := flag.String("config", defaultCfg, "path to config.json")
|
cfgPath := flag.String("config", defaultCfg, "path to config.json")
|
||||||
flag.Usage = func() {
|
flag.Usage = func() {
|
||||||
fmt.Fprintf(os.Stderr, "usage: hamlab [-config path] [serve|passwd|version|push-nodes|fs …]\n")
|
fmt.Fprintf(os.Stderr, "usage: hamlab [-config path] [serve|passwd|version|push-nodes [--force]|fs …]\n")
|
||||||
flag.PrintDefaults()
|
flag.PrintDefaults()
|
||||||
}
|
}
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
@@ -73,7 +73,8 @@ func main() {
|
|||||||
exe, _ := os.Executable()
|
exe, _ := os.Executable()
|
||||||
host, _ := os.Hostname()
|
host, _ := os.Hostname()
|
||||||
nm := nodes.New(cfg, host)
|
nm := nodes.New(cfg, host)
|
||||||
results := nm.PushAll(context.Background(), exe, runtime.GOARCH)
|
force := flag.NArg() > 1 && flag.Arg(1) == "--force"
|
||||||
|
results := nm.PushAll(context.Background(), exe, runtime.GOARCH, force)
|
||||||
if len(results) == 0 {
|
if len(results) == 0 {
|
||||||
fmt.Println("no managed machines in", cfg.Path())
|
fmt.Println("no managed machines in", cfg.Path())
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-3
@@ -5,6 +5,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
@@ -328,6 +329,20 @@ func (s *Server) removeNode(w http.ResponseWriter, r *http.Request) {
|
|||||||
// selfUpdate receives a new binary from a hub, checks it runs, installs it over the
|
// selfUpdate receives a new binary from a hub, checks it runs, installs it over the
|
||||||
// current executable (keeping a .prev copy) and restarts the service.
|
// current executable (keeping a .prev copy) and restarts the service.
|
||||||
func (s *Server) selfUpdate(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) selfUpdate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Query().Get("force") != "1" {
|
||||||
|
if t, ok := s.tasks.RunningOf("updates"); ok {
|
||||||
|
jsonErr(w, 409, "busy: a package task is running ("+t.Title+"); retry with force to interrupt it")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
attached := 0
|
||||||
|
for _, t := range s.mgr.List() {
|
||||||
|
attached += t.Clients
|
||||||
|
}
|
||||||
|
if attached > 0 {
|
||||||
|
jsonErr(w, 409, fmt.Sprintf("busy: %d terminal client(s) attached; retry with force to drop them", attached))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
exe, err := os.Executable()
|
exe, err := os.Executable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonErr(w, 500, err.Error())
|
jsonErr(w, 500, err.Error())
|
||||||
@@ -396,10 +411,14 @@ func copyFile(src, dst string) error {
|
|||||||
|
|
||||||
func (s *Server) updateMachine(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) updateMachine(w http.ResponseWriter, r *http.Request) {
|
||||||
exe, _ := os.Executable()
|
exe, _ := os.Executable()
|
||||||
res := s.nodes.Push(r.Context(), r.PathValue("name"), exe, runtime.GOARCH)
|
res := s.nodes.Push(r.Context(), r.PathValue("name"), exe, runtime.GOARCH, r.URL.Query().Get("force") == "1")
|
||||||
log.Printf("machines: update %s -> ok=%v %s %s (from %s)", res.Name, res.OK, res.Version, res.Error, clientIP(r))
|
log.Printf("machines: update %s -> ok=%v %s %s (from %s)", res.Name, res.OK, res.Version, res.Error, clientIP(r))
|
||||||
if !res.OK {
|
if !res.OK {
|
||||||
jsonErr(w, 400, res.Error)
|
code := 400
|
||||||
|
if strings.HasPrefix(res.Error, "busy:") {
|
||||||
|
code = 409
|
||||||
|
}
|
||||||
|
jsonErr(w, code, res.Error)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
jsonOK(w, 200, res)
|
jsonOK(w, 200, res)
|
||||||
@@ -407,7 +426,7 @@ func (s *Server) updateMachine(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (s *Server) updateAllMachines(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) updateAllMachines(w http.ResponseWriter, r *http.Request) {
|
||||||
exe, _ := os.Executable()
|
exe, _ := os.Executable()
|
||||||
res := s.nodes.PushAll(r.Context(), exe, runtime.GOARCH)
|
res := s.nodes.PushAll(r.Context(), exe, runtime.GOARCH, r.URL.Query().Get("force") == "1")
|
||||||
for _, x := range res {
|
for _, x := range res {
|
||||||
log.Printf("machines: update %s -> ok=%v %s %s (from %s)", x.Name, x.OK, x.Version, x.Error, clientIP(r))
|
log.Printf("machines: update %s -> ok=%v %s %s (from %s)", x.Name, x.OK, x.Version, x.Error, clientIP(r))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -499,15 +499,16 @@ func (m *Manager) OutputTail(ctx context.Context, j Job, n int) (string, error)
|
|||||||
|
|
||||||
// timers: one `systemctl show` over every loaded timer instead of three list commands.
|
// timers: one `systemctl show` over every loaded timer instead of three list commands.
|
||||||
func (m *Manager) timers(ctx context.Context) []Timer {
|
func (m *Manager) timers(ctx context.Context) []Timer {
|
||||||
out, err := run(ctx, "", "systemctl", "show", "*.timer", "--timestamp=unix", "--no-pager",
|
out, err := run(ctx, "", "systemctl", "show", "*.timer", "--no-pager",
|
||||||
"-p", "Id,Description,ActiveState,SubState,UnitFileState,NextElapseUSecRealtime,LastTriggerUSec,Triggers")
|
"-p", "Id,Description,ActiveState,SubState,UnitFileState,NextElapseUSecRealtime,LastTriggerUSec,Triggers")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return []Timer{}
|
return []Timer{}
|
||||||
}
|
}
|
||||||
unix := func(v string) int64 { // "@1789711405" (seconds) or "n/a"
|
unix := func(v string) int64 { // "Sat 2026-09-19 05:47:13 PDT", "n/a" or ""
|
||||||
if strings.HasPrefix(v, "@") {
|
for _, layout := range []string{"Mon 2006-01-02 15:04:05 MST", "2006-01-02 15:04:05 MST", "Mon 2006-01-02 15:04:05"} {
|
||||||
s, _ := strconv.ParseInt(strings.TrimPrefix(v, "@"), 10, 64)
|
if t, err := time.ParseInLocation(layout, v, time.Local); err == nil {
|
||||||
return s * 1000
|
return t.UnixMilli()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -291,7 +291,7 @@ type PushResult struct {
|
|||||||
// Push uploads binPath (normally the hub's own executable) to the node's self-update
|
// Push uploads binPath (normally the hub's own executable) to the node's self-update
|
||||||
// endpoint. The node verifies it, installs it and restarts itself. Refused when the
|
// endpoint. The node verifies it, installs it and restarts itself. Refused when the
|
||||||
// node's architecture differs from ours.
|
// node's architecture differs from ours.
|
||||||
func (m *Manager) Push(ctx context.Context, name, binPath, ourArch string) PushResult {
|
func (m *Manager) Push(ctx context.Context, name, binPath, ourArch string, force bool) PushResult {
|
||||||
res := PushResult{Name: name}
|
res := PushResult{Name: name}
|
||||||
n, ok := m.node(name)
|
n, ok := m.node(name)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -328,7 +328,11 @@ func (m *Manager) Push(ctx context.Context, name, binPath, ourArch string) PushR
|
|||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
st, _ := f.Stat()
|
st, _ := f.Stat()
|
||||||
up, _ := http.NewRequestWithContext(ctx, "POST", n.URL+"/api/self-update", f)
|
target := n.URL + "/api/self-update"
|
||||||
|
if force {
|
||||||
|
target += "?force=1"
|
||||||
|
}
|
||||||
|
up, _ := http.NewRequestWithContext(ctx, "POST", target, f)
|
||||||
up.ContentLength = st.Size()
|
up.ContentLength = st.Size()
|
||||||
up.Header.Set("Authorization", "Bearer "+n.Token)
|
up.Header.Set("Authorization", "Bearer "+n.Token)
|
||||||
up.Header.Set("Content-Type", "application/octet-stream")
|
up.Header.Set("Content-Type", "application/octet-stream")
|
||||||
@@ -356,7 +360,7 @@ func (m *Manager) Push(ctx context.Context, name, binPath, ourArch string) PushR
|
|||||||
}
|
}
|
||||||
|
|
||||||
// PushAll updates every node, in parallel.
|
// PushAll updates every node, in parallel.
|
||||||
func (m *Manager) PushAll(ctx context.Context, binPath, ourArch string) []PushResult {
|
func (m *Manager) PushAll(ctx context.Context, binPath, ourArch string, force bool) []PushResult {
|
||||||
var list []config.Node
|
var list []config.Node
|
||||||
m.cfg.View(func(c *config.Config) { list = append(list, c.Nodes...) })
|
m.cfg.View(func(c *config.Config) { list = append(list, c.Nodes...) })
|
||||||
out := make([]PushResult, len(list))
|
out := make([]PushResult, len(list))
|
||||||
@@ -365,7 +369,7 @@ func (m *Manager) PushAll(ctx context.Context, binPath, ourArch string) []PushRe
|
|||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func(i int, name string) {
|
go func(i int, name string) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
out[i] = m.Push(ctx, name, binPath, ourArch)
|
out[i] = m.Push(ctx, name, binPath, ourArch, force)
|
||||||
}(i, n.Name)
|
}(i, n.Name)
|
||||||
}
|
}
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|||||||
@@ -130,6 +130,15 @@ func (m *Manager) pump(s *Session) {
|
|||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
_ = s.ptmx.Close()
|
_ = s.ptmx.Close()
|
||||||
m.changed()
|
m.changed()
|
||||||
|
// An exited shell stays listed for a while so the exit is visible, then reaps itself.
|
||||||
|
time.AfterFunc(10*time.Minute, func() {
|
||||||
|
m.mu.Lock()
|
||||||
|
cur, ok := m.sessions[s.ID]
|
||||||
|
m.mu.Unlock()
|
||||||
|
if ok && cur == s && !s.running {
|
||||||
|
_ = m.Close(s.ID)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// broadcast requires s.mu. Slow clients are dropped rather than blocking the PTY.
|
// broadcast requires s.mu. Slow clients are dropped rather than blocking the PTY.
|
||||||
|
|||||||
+1
-1
@@ -122,7 +122,7 @@ function JobDetail({ job, logging, onChanged, onBack, onEdit }: { job: CronJob;
|
|||||||
{tab === 'logs' && (
|
{tab === 'logs' && (
|
||||||
<div className="logs"><div className="log-lines">
|
<div className="logs"><div className="log-lines">
|
||||||
{logs === null ? <div className="muted"><Spinner /> loading…</div> : logs.length === 0 ? <div className="muted">{logging ? 'No runs of this exact command in the journal.' : 'cron isn’t writing to the journal on this host, so there is no run history.'}</div>
|
{logs === null ? <div className="muted"><Spinner /> loading…</div> : logs.length === 0 ? <div className="muted">{logging ? 'No runs of this exact command in the journal.' : 'cron isn’t writing to the journal on this host, so there is no run history.'}</div>
|
||||||
: logs.map((l, i) => <div key={i} className="log"><span className="lt">{fmtTime(l.t)}</span><span className="lm">{l.msg}</span></div>)}
|
: [...logs].reverse().map((l, i) => <div key={i} className="log"><span className="lt">{fmtTime(l.t)}</span><span className="lm">{l.msg}</span></div>)}
|
||||||
</div></div>
|
</div></div>
|
||||||
)}
|
)}
|
||||||
{tab === 'output' && <pre className="run-out">{out ? out.text || '(empty)' : <span className="muted"><Spinner /> reading…</span>}</pre>}
|
{tab === 'output' && <pre className="run-out">{out ? out.text || '(empty)' : <span className="muted"><Spinner /> reading…</span>}</pre>}
|
||||||
|
|||||||
@@ -176,17 +176,18 @@ function body(id: string, { s, h, edit }: Ctx): ReactNode {
|
|||||||
<div className="sub-head">
|
<div className="sub-head">
|
||||||
<span className={'dot ' + (x.up ? 'ok' : '')} />
|
<span className={'dot ' + (x.up ? 'ok' : '')} />
|
||||||
<span>{x.iface}</span>
|
<span>{x.iface}</span>
|
||||||
|
{!x.up && <span className="muted small">down</span>}
|
||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
<span className="num">↓ {fmtRate(x.rx_bps)} ↑ {fmtRate(x.tx_bps)}</span>
|
{x.up && <span className="num">↓ {fmtRate(x.rx_bps)} ↑ {fmtRate(x.tx_bps)}</span>}
|
||||||
<HideBtn k={`net:${x.iface}`} edit={edit} />
|
<HideBtn k={`net:${x.iface}`} edit={edit} />
|
||||||
</div>
|
</div>
|
||||||
{h?.net[x.iface] && (
|
{x.up && h?.net[x.iface] && (
|
||||||
<Sparkline series={[{ name: 'rx', values: h.net[x.iface].rx, color: S1 }, { name: 'tx', values: h.net[x.iface].tx, color: S2 }]} format={fmtRate} />
|
<Sparkline series={[{ name: 'rx', values: h.net[x.iface].rx, color: S1 }, { name: 'tx', values: h.net[x.iface].tx, color: S2 }]} format={fmtRate} />
|
||||||
)}
|
)}
|
||||||
<div className="legend">
|
{x.up && <div className="legend">
|
||||||
<span><span className="sw" style={{ background: S1 }} />rx · {fmtBytes(x.rx_total)} total</span>
|
<span><span className="sw" style={{ background: S1 }} />rx · {fmtBytes(x.rx_total)} total</span>
|
||||||
<span><span className="sw" style={{ background: S2 }} />tx · {fmtBytes(x.tx_total)} total</span>
|
<span><span className="sw" style={{ background: S2 }} />tx · {fmtBytes(x.tx_total)} total</span>
|
||||||
</div>
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<HiddenChips prefix="net" edit={edit} label={(k) => k} />
|
<HiddenChips prefix="net" edit={edit} label={(k) => k} />
|
||||||
@@ -345,7 +346,9 @@ export default function Dashboard() {
|
|||||||
unhideItem: (k) => update((l) => { const s = new Set(l.hiddenItems); s.delete(k); return { ...l, hiddenItems: s } }),
|
unhideItem: (k) => update((l) => { const s = new Set(l.hiddenItems); s.delete(k); return { ...l, hiddenItems: s } }),
|
||||||
}
|
}
|
||||||
const ctx: Ctx = { s: latest, h: hist.current, edit }
|
const ctx: Ctx = { s: latest, h: hist.current, edit }
|
||||||
const visible = (kind: Kind) => layout.order.filter((id) => byId(id)?.kind === kind && !layout.hidden.has(id))
|
// Modules with nothing to show on this host (no fans, no UPS…) stay out of the way unless customizing.
|
||||||
|
const empty = (id: string) => (id === 'fans' && latest.fans.length === 0) || (id === 'ups' && !latest.ups) || (id === 'temps' && latest.temps.length === 0) || (id === 'disks' && latest.disks.length === 0)
|
||||||
|
const visible = (kind: Kind) => layout.order.filter((id) => byId(id)?.kind === kind && !layout.hidden.has(id) && (editing || !empty(id)))
|
||||||
const hiddenMods = layout.order.filter((id) => layout.hidden.has(id))
|
const hiddenMods = layout.order.filter((id) => layout.hidden.has(id))
|
||||||
|
|
||||||
// Move a module past its nearest same-kind neighbour (tiles stay with tiles, cards with cards).
|
// Move a module past its nearest same-kind neighbour (tiles stay with tiles, cards with cards).
|
||||||
|
|||||||
+10
-2
@@ -38,13 +38,21 @@ export default function Machines({ hub, onChanged, onOpen }: { hub: Me; onChange
|
|||||||
}
|
}
|
||||||
async function update(m: MachineStatus) {
|
async function update(m: MachineStatus) {
|
||||||
if (!window.confirm(`Update ${m.name} to ${norm(hub.version)}? Its Ham Lab restarts; open terminals there are lost.`)) return
|
if (!window.confirm(`Update ${m.name} to ${norm(hub.version)}? Its Ham Lab restarts; open terminals there are lost.`)) return
|
||||||
try { const r = await api.updateMachine(m.name); toast(`${m.name} updated to ${r.version}, restarting`); setTimeout(load, 4000) } catch (e) { toast(`${m.name}: ${e instanceof Error ? e.message : 'failed'}`, 'err') }
|
try { const r = await api.updateMachine(m.name); toast(`${m.name} updated to ${r.version}, restarting`); setTimeout(load, 4000) }
|
||||||
|
catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : 'failed'
|
||||||
|
if (msg.startsWith('busy:') && window.confirm(`${m.name} is ${msg.slice(6)}\n\nForce the update anyway?`)) {
|
||||||
|
try { const r = await api.updateMachine(m.name, true); toast(`${m.name} updated to ${r.version}, restarting`); setTimeout(load, 4000) } catch (e2) { toast(`${m.name}: ${e2 instanceof Error ? e2.message : 'failed'}`, 'err') }
|
||||||
|
} else toast(`${m.name}: ${msg}`, 'err')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
async function updateAll() {
|
async function updateAll() {
|
||||||
const n = list?.filter(outdated).length ?? 0
|
const n = list?.filter(outdated).length ?? 0
|
||||||
if (!window.confirm(`Update ${n} machine${n === 1 ? '' : 's'} to ${norm(hub.version)}?`)) return
|
if (!window.confirm(`Update ${n} machine${n === 1 ? '' : 's'} to ${norm(hub.version)}?`)) return
|
||||||
try {
|
try {
|
||||||
const res = await api.updateAllMachines()
|
let res = await api.updateAllMachines()
|
||||||
|
const busy = res.filter((r) => !r.ok && (r.error ?? '').startsWith('busy:'))
|
||||||
|
if (busy.length && window.confirm(`${busy.map((r) => r.name).join(', ')}: ${busy[0].error?.slice(6)}\n\nForce those too?`)) res = res.map((r) => r).concat(await api.updateAllMachines(true))
|
||||||
for (const r of res) toast(r.ok ? `${r.name} → ${r.version}` : `${r.name}: ${r.error}`, r.ok ? 'ok' : 'err')
|
for (const r of res) toast(r.ok ? `${r.name} → ${r.version}` : `${r.name}: ${r.error}`, r.ok ? 'ok' : 'err')
|
||||||
setTimeout(load, 4000)
|
setTimeout(load, 4000)
|
||||||
} catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
} catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||||
|
|||||||
+7
-6
@@ -3,16 +3,17 @@ import { api } from './api'
|
|||||||
import { Btn, Spinner } from './ui'
|
import { Btn, Spinner } from './ui'
|
||||||
|
|
||||||
// Header power control: a small button, a dialog that needs the hostname typed back.
|
// Header power control: a small button, a dialog that needs the hostname typed back.
|
||||||
export default function Power({ host, rebootNeeded }: { host: string; rebootNeeded?: boolean }) {
|
// `label` is what the user types (the machine name on a remote); `host` is what the node checks.
|
||||||
|
export default function Power({ host, label, rebootNeeded }: { host: string; label: string; rebootNeeded?: boolean }) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [typed, setTyped] = useState('')
|
const [typed, setTyped] = useState('')
|
||||||
const [sent, setSent] = useState<'' | 'reboot' | 'poweroff'>('')
|
const [sent, setSent] = useState<'' | 'reboot' | 'poweroff'>('')
|
||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
const ok = typed.trim() === host
|
const ok = typed.trim() === label
|
||||||
|
|
||||||
async function go(action: 'reboot' | 'poweroff') {
|
async function go(action: 'reboot' | 'poweroff') {
|
||||||
setErr('')
|
setErr('')
|
||||||
try { await api.power(action, typed.trim()); setSent(action) }
|
try { await api.power(action, host); setSent(action) }
|
||||||
catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,14 +23,14 @@ export default function Power({ host, rebootNeeded }: { host: string; rebootNeed
|
|||||||
{open && (
|
{open && (
|
||||||
<div className="modal-back" onClick={() => !sent && setOpen(false)}>
|
<div className="modal-back" onClick={() => !sent && setOpen(false)}>
|
||||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||||
<h3>Power · {host}</h3>
|
<h3>Power · {label}</h3>
|
||||||
{rebootNeeded && <div className="notice">A reboot is pending from package updates.</div>}
|
{rebootNeeded && <div className="notice">A reboot is pending from package updates.</div>}
|
||||||
{sent ? (
|
{sent ? (
|
||||||
<div className="muted"><Spinner /> {sent === 'reboot' ? 'Rebooting. The console will reconnect when the host is back.' : 'Shutting down. Power it back on by hand.'}</div>
|
<div className="muted"><Spinner /> {sent === 'reboot' ? 'Rebooting. The console will reconnect when the host is back.' : 'Shutting down. Power it back on by hand.'}</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<label className="muted">Type the hostname to confirm
|
<label className="muted">Type <b>{label}</b> to confirm
|
||||||
<input autoFocus value={typed} onChange={(e) => setTyped(e.target.value)} placeholder={host} />
|
<input autoFocus value={typed} onChange={(e) => setTyped(e.target.value)} placeholder={label} />
|
||||||
</label>
|
</label>
|
||||||
{err && <div className="error">{err}</div>}
|
{err && <div className="error">{err}</div>}
|
||||||
<div className="svc-actions">
|
<div className="svc-actions">
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ export default function Procs() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ptable">
|
<div className="ptable">
|
||||||
|
{rows.length === 0 && <div className="muted" style={{ padding: 12 }}>Nothing matches.</div>}
|
||||||
{rows.map((p) => (
|
{rows.map((p) => (
|
||||||
<div key={p.pid} className={'prow' + (p.pid === sel ? ' active' : '') + (p.state === 'Z' ? ' zombie' : p.state === 'R' ? ' running' : '')} onClick={() => setSel(p.pid === sel ? null : p.pid)} title={p.cmd || p.name}>
|
<div key={p.pid} className={'prow' + (p.pid === sel ? ' active' : '') + (p.state === 'Z' ? ' zombie' : p.state === 'R' ? ' running' : '')} onClick={() => setSel(p.pid === sel ? null : p.pid)} title={p.cmd || p.name}>
|
||||||
<span className="num muted">{p.pid}</span>
|
<span className="num muted">{p.pid}</span>
|
||||||
|
|||||||
+26
-5
@@ -39,6 +39,16 @@ const GROUPS: Group[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
type View = 'terminal' | 'dashboard' | 'services' | 'cron' | 'updates' | 'disks' | 'files' | 'procs' | 'machines' | 'settings'
|
type View = 'terminal' | 'dashboard' | 'services' | 'cron' | 'updates' | 'disks' | 'files' | 'procs' | 'machines' | 'settings'
|
||||||
|
const VIEWS: View[] = ['terminal', 'dashboard', 'services', 'cron', 'updates', 'disks', 'files', 'procs', 'machines', 'settings']
|
||||||
|
const HUB_ONLY: View[] = ['machines', 'settings']
|
||||||
|
|
||||||
|
// URL hash is the source of truth for where you are: #/<machine|hub>/<view>. Back and
|
||||||
|
// forward work, reload restores the tab, and links can point at a page on a machine.
|
||||||
|
function parseHash(): { machine: string | null; view: View } | null {
|
||||||
|
const m = location.hash.match(/^#\/([a-z0-9-]+)\/([a-z]+)$/)
|
||||||
|
if (!m || !VIEWS.includes(m[2] as View)) return null
|
||||||
|
return { machine: m[1] === 'hub' ? null : m[1], view: m[2] as View }
|
||||||
|
}
|
||||||
|
|
||||||
function fmtUptime(s: number): string {
|
function fmtUptime(s: number): string {
|
||||||
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60)
|
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60)
|
||||||
@@ -48,7 +58,7 @@ function fmtUptime(s: number): string {
|
|||||||
export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut: () => void }) {
|
export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut: () => void }) {
|
||||||
// Which machine we're looking at. null = the hub itself. Set before anything renders so
|
// Which machine we're looking at. null = the hub itself. Set before anything renders so
|
||||||
// every page's first request already goes to the right place.
|
// every page's first request already goes to the right place.
|
||||||
const [machine, setMachineState] = useState<string | null>(() => { const m = sessionStorage.getItem('hamlab.machine'); setMachine(m); return m })
|
const [machine, setMachineState] = useState<string | null>(() => { const m = parseHash()?.machine ?? sessionStorage.getItem('hamlab.machine'); setMachine(m); return m })
|
||||||
const [machines, setMachines] = useState<MachineStatus[]>([])
|
const [machines, setMachines] = useState<MachineStatus[]>([])
|
||||||
const [remoteMe, setRemoteMe] = useState<Me | null>(null)
|
const [remoteMe, setRemoteMe] = useState<Me | null>(null)
|
||||||
const [remoteErr, setRemoteErr] = useState('')
|
const [remoteErr, setRemoteErr] = useState('')
|
||||||
@@ -56,7 +66,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
|||||||
const listRate = useRefreshMs(LIST_FLOOR)
|
const listRate = useRefreshMs(LIST_FLOOR)
|
||||||
const [terms, setTerms] = useState<Term[]>([])
|
const [terms, setTerms] = useState<Term[]>([])
|
||||||
const [active, setActive] = useState<string | null>(null)
|
const [active, setActive] = useState<string | null>(null)
|
||||||
const [view, setView] = useState<View>('dashboard')
|
const [view, setView] = useState<View>(() => parseHash()?.view ?? 'dashboard')
|
||||||
const [drawer, setDrawer] = useState(false)
|
const [drawer, setDrawer] = useState(false)
|
||||||
const [online, setOnline] = useState(true)
|
const [online, setOnline] = useState(true)
|
||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
@@ -81,8 +91,19 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
|||||||
setMachine(name); sessionStorage.setItem('hamlab.machine', name ?? ''); if (!name) sessionStorage.removeItem('hamlab.machine')
|
setMachine(name); sessionStorage.setItem('hamlab.machine', name ?? ''); if (!name) sessionStorage.removeItem('hamlab.machine')
|
||||||
handles.current = {}; setTerms([]); setActive(null); setRemoteMe(null); setRemoteErr(''); setDrawer(false); setOnline(true)
|
handles.current = {}; setTerms([]); setActive(null); setRemoteMe(null); setRemoteErr(''); setDrawer(false); setOnline(true)
|
||||||
setMachineState(name)
|
setMachineState(name)
|
||||||
if (view === 'machines') setView('dashboard')
|
if (name && HUB_ONLY.includes(view)) setView('dashboard') // hub-only pages make no sense on a remote machine
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// keep the hash in step, and follow it when the browser navigates
|
||||||
|
useEffect(() => {
|
||||||
|
const want = `#/${machine ?? 'hub'}/${view}`
|
||||||
|
if (location.hash !== want) history.pushState(null, '', want)
|
||||||
|
}, [machine, view])
|
||||||
|
useEffect(() => {
|
||||||
|
const h = () => { const p = parseHash(); if (!p) return; if (p.machine !== machine) switchMachine(p.machine); if (p.view !== view) setView(p.view) }
|
||||||
|
window.addEventListener('popstate', h)
|
||||||
|
return () => window.removeEventListener('popstate', h)
|
||||||
|
}) // no deps: needs the latest switchMachine/view each time
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!machine) { setRemoteMe(null); return }
|
if (!machine) { setRemoteMe(null); return }
|
||||||
let stop = false
|
let stop = false
|
||||||
@@ -139,9 +160,9 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
|||||||
<span className="host">{me.user}@{me.host}</span>
|
<span className="host">{me.user}@{me.host}</span>
|
||||||
{machine && remoteErr && <span className="badge bad" title={remoteErr}>offline</span>}
|
{machine && remoteErr && <span className="badge bad" title={remoteErr}>offline</span>}
|
||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
<span className="stat" title="load average">{me.load}</span>
|
<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'} />
|
<span className={'dot ' + (online && !remoteErr ? 'ok' : 'bad')} title={remoteErr ? `${machine} unreachable` : online ? 'connected' : 'disconnected'} />
|
||||||
<Power host={me.host} rebootNeeded={me.reboot_needed} />
|
<Power host={me.host} label={machine ?? me.host} rebootNeeded={me.reboot_needed} />
|
||||||
<Btn className="link" onClick={logout}>logout</Btn>
|
<Btn className="link" onClick={logout}>logout</Btn>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
+12
-3
@@ -7,6 +7,11 @@ let base = ''
|
|||||||
export function setMachine(name: string | null) { base = name ? `/api/nodes/${name}` : '' }
|
export function setMachine(name: string | null) { base = name ? `/api/nodes/${name}` : '' }
|
||||||
export const apiPath = (p: string) => (base && p.startsWith('/api/') ? base + p.slice(4) : p)
|
export const apiPath = (p: string) => (base && p.startsWith('/api/') ? base + p.slice(4) : p)
|
||||||
|
|
||||||
|
// A machine that just failed to answer is left alone for a few seconds instead of
|
||||||
|
// being hit again on every tick of every page.
|
||||||
|
const downUntil: Record<string, number> = {}
|
||||||
|
function machineOf(p: string): string | null { const m = p.match(/^\/api\/nodes\/([a-z0-9-]+)\//); return m ? m[1] : null }
|
||||||
|
|
||||||
let inflight = 0
|
let inflight = 0
|
||||||
function track(delta: number) {
|
function track(delta: number) {
|
||||||
inflight += delta
|
inflight += delta
|
||||||
@@ -14,10 +19,13 @@ function track(delta: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function req<T>(method: string, path: string, body?: unknown, local = false): Promise<T> {
|
async function req<T>(method: string, path: string, body?: unknown, local = false): Promise<T> {
|
||||||
|
const target = local ? path : apiPath(path)
|
||||||
|
const mach = machineOf(target)
|
||||||
|
if (mach && downUntil[mach] > Date.now()) throw new Error(`${mach} unreachable (retrying shortly)`)
|
||||||
track(1)
|
track(1)
|
||||||
let r: Response
|
let r: Response
|
||||||
try {
|
try {
|
||||||
r = await fetch(local ? path : apiPath(path), {
|
r = await fetch(target, {
|
||||||
method,
|
method,
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
|
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
|
||||||
@@ -28,6 +36,7 @@ async function req<T>(method: string, path: string, body?: unknown, local = fals
|
|||||||
throw new Error('network error')
|
throw new Error('network error')
|
||||||
}
|
}
|
||||||
track(-1)
|
track(-1)
|
||||||
|
if (r.status === 502 && mach) downUntil[mach] = Date.now() + 5000
|
||||||
if (r.status === 401) {
|
if (r.status === 401) {
|
||||||
let msg = 'unauthenticated'
|
let msg = 'unauthenticated'
|
||||||
try { msg = (await r.json()).error ?? msg } catch { /* ignore */ }
|
try { msg = (await r.json()).error ?? msg } catch { /* ignore */ }
|
||||||
@@ -167,8 +176,8 @@ export const api = {
|
|||||||
me: () => req<Me>('GET', '/api/me'),
|
me: () => req<Me>('GET', '/api/me'),
|
||||||
machines: () => req<{ nodes: MachineStatus[]; host: string; fingerprint?: string }>('GET', '/api/nodes', undefined, true),
|
machines: () => req<{ nodes: MachineStatus[]; host: string; fingerprint?: string }>('GET', '/api/nodes', undefined, true),
|
||||||
addMachine: (name: string, url: string, password: string) => req<MachineStatus>('POST', '/api/nodes', { name, url, password }, true),
|
addMachine: (name: string, url: string, password: string) => req<MachineStatus>('POST', '/api/nodes', { name, url, password }, true),
|
||||||
updateMachine: (name: string) => req<PushResult>('POST', `/api/machines/${encodeURIComponent(name)}/update`, undefined, true),
|
updateMachine: (name: string, force = false) => req<PushResult>('POST', `/api/machines/${encodeURIComponent(name)}/update${force ? '?force=1' : ''}`, undefined, true),
|
||||||
updateAllMachines: () => req<PushResult[]>('POST', '/api/machines-update-all', 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),
|
changePassword: (current: string, password: string) => req<void>('POST', '/api/password', { current, password }, true),
|
||||||
tokens: () => req<{ name: string; created: string }[]>('GET', '/api/tokens', undefined, true),
|
tokens: () => req<{ name: string; created: string }[]>('GET', '/api/tokens', undefined, true),
|
||||||
revokeToken: (name: string) => req<void>('DELETE', `/api/tokens/${encodeURIComponent(name)}`, undefined, true),
|
revokeToken: (name: string) => req<void>('DELETE', `/api/tokens/${encodeURIComponent(name)}`, undefined, true),
|
||||||
|
|||||||
@@ -379,3 +379,12 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted);
|
|||||||
[data-theme="light"] .modal-back { background: rgba(0,0,0,.35); }
|
[data-theme="light"] .modal-back { background: rgba(0,0,0,.35); }
|
||||||
|
|
||||||
.disk-summary { display: flex; flex-wrap: wrap; gap: 4px 14px; margin: 2px 0 6px; }
|
.disk-summary { display: flex; flex-wrap: wrap; gap: 4px 14px; margin: 2px 0 6px; }
|
||||||
|
|
||||||
|
/* touch: every control at least 36px tall (bug 22) */
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
.btn, .chip, .icon-btn, .link, .th, .mod-tools button, .hide-item, .crumb, .files-tools select, .procs-tools select, .dash-bar select { min-height: 36px; padding-top: 6px; padding-bottom: 6px; }
|
||||||
|
.icon-btn { min-width: 36px; }
|
||||||
|
.leaf-actions .icon-btn { min-width: 34px; }
|
||||||
|
.header .link, .header .icon-btn { min-height: 36px; display: inline-flex; align-items: center; }
|
||||||
|
.mod-tools { margin-top: 0; }
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user