From f3441ae718d938d3aeca0f09c45673b969cf3828 Mon Sep 17 00:00:00 2001 From: Mitchell-Johnson Date: Thu, 24 Sep 2026 16:58:08 -0700 Subject: [PATCH] The KVM tab is per machine, and follows the machine switcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It was a hub-side list you pointed at machines, which had it backwards. A machine has one KVM; switch to grid and you get grid's. The address is still kept on the hub rather than on the machine, because a KVM earns its keep exactly when that machine is down and cannot answer for itself — so the tab reaches the device either way. Adding a second KVM for a machine replaces the first rather than sitting beside it. The tab is no longer hub-only and now sits beside Machines in the rail. Co-Authored-By: Claude Opus 5 --- internal/api/kvm.go | 44 ++++++++++++++-- internal/config/config.go | 8 +-- web/src/KVM.tsx | 102 ++++++++++++++++++-------------------- web/src/Shell.tsx | 6 +-- web/src/api.ts | 3 +- web/src/styles.css | 1 + 6 files changed, 101 insertions(+), 63 deletions(-) diff --git a/internal/api/kvm.go b/internal/api/kvm.go index 23c182f..275af9c 100644 --- a/internal/api/kvm.go +++ b/internal/api/kvm.go @@ -200,6 +200,13 @@ func (s *Server) kvmFallback(w http.ResponseWriter, r *http.Request) bool { return true } +func machineLabel(m string) string { + if m == "" { + return "this host" + } + return m +} + // ---- the list ---- func (s *Server) listKVMs(w http.ResponseWriter, _ *http.Request) { @@ -208,6 +215,20 @@ func (s *Server) listKVMs(w http.ResponseWriter, _ *http.Request) { jsonOK(w, 200, out) } +// kvmForMachine is the KVM attached to a machine, "" being the hub itself. +func (s *Server) kvmForMachine(machine string) (config.KVM, bool) { + var found config.KVM + ok := false + s.cfg.View(func(c *config.Config) { + for _, k := range c.KVMs { + if k.Machine == machine { + found, ok = k, true + } + } + }) + return found, ok +} + func (s *Server) addKVM(w http.ResponseWriter, r *http.Request) { var body struct{ Name, URL, Machine string } if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil { @@ -236,12 +257,29 @@ func (s *Server) addKVM(w http.ResponseWriter, r *http.Request) { jsonErr(w, 500, err.Error()) return } - k := config.KVM{ID: hex.EncodeToString(b), Name: name, URL: u.Scheme + "://" + u.Host + strings.TrimSuffix(u.Path, "/"), Machine: strings.TrimSpace(body.Machine), Added: time.Now()} - if err := s.cfg.Update(func(c *config.Config) { c.KVMs = append(c.KVMs, k) }); err != nil { + machine := strings.TrimSpace(body.Machine) + k := config.KVM{ID: hex.EncodeToString(b), Name: name, URL: u.Scheme + "://" + u.Host + strings.TrimSuffix(u.Path, "/"), Machine: machine, Added: time.Now()} + // One per machine: a second one for the same host replaces the first rather than sitting + // beside it, since the tab shows the machine you are looking at. + var replaced string + if err := s.cfg.Update(func(c *config.Config) { + kept := c.KVMs[:0] + for _, old := range c.KVMs { + if old.Machine == machine { + replaced = old.ID + continue + } + kept = append(kept, old) + } + c.KVMs = append(kept, k) + }); err != nil { jsonErr(w, 500, err.Error()) return } - log.Printf("kvm %q added (%s) by %s", k.Name, k.URL, clientIP(r)) + if replaced != "" { + s.forgetKVMProxy(replaced) + } + log.Printf("kvm %q added for %s (%s) by %s", k.Name, machineLabel(machine), k.URL, clientIP(r)) jsonOK(w, 201, k) } diff --git a/internal/config/config.go b/internal/config/config.go index 5be2337..e7efd42 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -70,9 +70,11 @@ type KVM struct { Name string `json:"name"` // URL is the device's own address, as you would type it into a browser on the LAN. URL string `json:"url"` - // Machine is which host it is attached to, for display only — it need not be a machine - // Ham Lab manages, and usually the point is that it is not answering. - Machine string `json:"machine,omitempty"` + // Machine is the host this KVM is attached to, and is the key: one KVM per machine, shown + // on that machine's KVM tab. Empty means the hub itself. The list lives on the hub even + // for a node's KVM, because a KVM earns its keep when the machine is down and cannot + // answer for itself. + Machine string `json:"machine"` Added time.Time `json:"added"` } diff --git a/web/src/KVM.tsx b/web/src/KVM.tsx index 864939e..9280c11 100644 --- a/web/src/KVM.tsx +++ b/web/src/KVM.tsx @@ -1,8 +1,12 @@ -// IP-KVMs: a device's own web page, shown inside Ham Lab. +// The KVM of the machine you are looking at: the device's own web page, shown in the tab. // -// Paste the address, and the device appears. Nothing here knows anything about any -// particular KVM — it is an iframe pointed at Ham Lab's own proxy, so whatever web UI the -// device serves is what you get. +// One per machine, and it follows the machine switcher — switch to `grid` and you get +// grid's KVM. The address is held on the **hub**, not on the machine, because a KVM earns +// its keep exactly when that machine is down and cannot answer for itself; the hub proxies +// the device either way. +// +// Nothing here knows anything about any particular KVM. It is an iframe pointed at Ham +// Lab's own proxy, so whatever web UI the device serves is what you get. // // On the keyboard: a page cannot capture keys and forward them into an iframe, and does not // need to. An iframe with focus receives them directly. What the browser itself swallows — @@ -10,7 +14,7 @@ // and that needs fullscreen and only exists in Chromium. Everywhere else you get the plain // version, which is most keys, and the device's own on-screen buttons for the rest. import { FormEvent, useCallback, useEffect, useRef, useState } from 'react' -import { KVM as Device, MachineStatus, api } from './api' +import { KVM as Device, api } from './api' import { Btn, Loading, askYes, toast, useEscape } from './ui' // lock() releases the keys the browser would otherwise keep for itself. Chromium only; a @@ -18,19 +22,19 @@ import { Btn, Loading, askYes, toast, useEscape } from './ui' type KeyboardLock = { lock?: (keys?: string[]) => Promise; unlock?: () => void } const keyboardAPI = (): KeyboardLock | undefined => (navigator as unknown as { keyboard?: KeyboardLock }).keyboard -export default function KVM({ machines }: { machines: MachineStatus[] }) { - const [list, setList] = useState(null) - const [sel, setSel] = useState('') +export default function KVM({ machine, title }: { machine: string | null; title: string }) { + const [kvm, setKvm] = useState(undefined) const [adding, setAdding] = useState(false) const [err, setErr] = useState('') const [full, setFull] = useState(false) const box = useRef(null) const frame = useRef(null) + const key = machine ?? '' // the hub is a machine too, and its key is the empty one const load = useCallback(async () => { - try { const l = await api.kvms(); setList(l); setSel((s) => (l.some((k) => k.id === s) ? s : l[0]?.id ?? '')) } + try { setKvm((await api.kvms()).find((k) => k.machine === key) ?? null) } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } - }, []) + }, [key]) useEffect(() => { load() }, [load]) // Fullscreen plus a keyboard lock is what makes Escape and Tab reach the machine rather @@ -53,97 +57,89 @@ export default function KVM({ machines }: { machines: MachineStatus[] }) { } catch (e) { toast(e instanceof Error ? e.message : 'fullscreen refused', 'err') } } - const remove = async (k: Device) => { - if (!await askYes(`Remove ${k.name}? The device is not touched — this only forgets its address.`, 'Remove')) return - try { await api.removeKVM(k.id); toast(`${k.name} removed`); await load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } + const remove = async () => { + if (!kvm || !await askYes(`Forget ${title}'s KVM? The device itself is not touched — this only removes its address.`, 'Remove')) return + try { await api.removeKVM(kvm.id); toast('removed'); await load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } - if (err && !list) return
{err}
- if (!list) return
+ if (err && kvm === undefined) return
{err}
+ if (kvm === undefined) return
- const cur = list.find((k) => k.id === sel) const locks = !!keyboardAPI()?.lock return (
- {list.length > 0 && ( - - {list.map((k) => ( - - ))} - - )} + {title} + {kvm + ? {kvm.name !== kvm.url ? `${kvm.name} · ` : ''}{kvm.url} + : no KVM} - {cur && <> - {cur.url} + {kvm && <> Fullscreen - Open ↗ - remove(cur)}>Remove + Open ↗ + setAdding(true)}>Change + Remove } - setAdding(true)}>+ Add + {!kvm && setAdding(true)}>+ Add a KVM for {title}}
- {cur ? ( + {kvm ? (
-