Author SHA1 Message Date
Mitchell-JohnsonandClaude Opus 5 c129f655a7 Reorder the default rail: the machine, then its storage, then what runs on it
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 19:12:14 -07:00
Mitchell-JohnsonandClaude Opus 5 d8bf5df89f Fullscreen on Safari, and a fallback where there is none
Safari on a Mac wants the webkit prefix; Safari on iOS has no fullscreen for an ordinary
element at all, only for a <video>, so requestFullscreen was simply undefined and the tab
threw. It now uses whichever spelling the browser has, and where there is none the frame
fills the viewport in-page instead — the same result minus the browser's own chrome — with
a Done button and Escape to come back. The button reads Expand rather than Fullscreen when
that is what it will do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 17:51:41 -07:00
Mitchell-JohnsonandClaude Opus 5 e64b0a1727 PLAN: the KVM tab is per machine
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 17:01:05 -07:00
Mitchell-JohnsonandClaude Opus 5 f3441ae718 The KVM tab is per machine, and follows the machine switcher
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 <noreply@anthropic.com>
2026-09-24 16:58:08 -07:00
Mitchell-JohnsonandClaude Opus 5 70fcf86283 PLAN: the KVM tab shipped
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 16:43:44 -07:00
7 changed files with 192 additions and 111 deletions
+33 -33
View File
@@ -320,6 +320,39 @@ something long in one, switch away and back, refresh — it's all still there.
snapshot, the package is purged and the old cron line is gone, with the 515 snapshots it snapshot, the package is purged and the old cron line is gone, with the 515 snapshots it
had taken left untouched and fully readable here — which is what adopting its naming had taken left untouched and fully readable here — which is what adopting its naming
bought. See `hamlet/docs/hosts.md`. bought. See `hamlet/docs/hosts.md`.
- **KVM tab** — **shipped 2026-09-24** (`internal/api/kvm.go`, `KVM.tsx`, `config.KVM`).
Paste a device's address; its own web page appears in the tab. **Nothing in it knows
anything about any particular KVM** — the first device here is a Sipeed NanoKVM on `grid`
at `10.0.0.217`, but none of that is in the code.
**Why it proxies rather than framing the device directly.** Ham Lab is served over TLS and
a KVM usually is not, so a bare `http://…` iframe is blocked as mixed content before it
loads. Serving the device from Ham Lab's own origin (`/kvm/<id>/…`) fixes that, puts it
behind **Ham Lab's** login rather than a second one, and makes the iframe same-origin.
**The general problem, and the answer.** A device UI asks for its assets by absolute path
— `/assets/app.js`, `/api/whatever` — which no path prefix can rewrite away, and rewriting
bodies per device would not be general. So the prefix is only the entry point: any request
Ham Lab does not serve itself is routed to whichever KVM the asking page belongs to, read
from the **Referer**, falling back to a **cookie** when there is none (a WebSocket upgrade).
Ham Lab's own routes always match first, so a device can never shadow the console, and the
fallback is gated on a live session. The device never receives Ham Lab's session cookie,
and its `X-Frame-Options`/CSP/HSTS are dropped on the way out since Ham Lab is the origin
now. Redirects to the device's root are rewritten back through the prefix.
**One catch found in testing:** Ham Lab's own `frame-ancestors 'none'` forbids framing even
from the same origin, so `/kvm/` paths send `'self'` instead. Without that the iframe was
refused before it loaded.
**The keyboard** needs less than it sounds: a page cannot forward keys into an iframe and
does not need to, since one with focus receives them directly. Fullscreen plus
`navigator.keyboard.lock()` is what takes Escape, Tab and Ctrl-W back from the browser;
that is Chromium-only, and the tab says which version you are getting rather than
pretending.
**One KVM per machine, and the tab follows the machine switcher** (corrected the same day:
the first cut was a hub-side list you pointed at machines, which had it backwards). Switch
to `grid` and you get grid's; adding a second for a machine replaces the first. The
addresses are still kept **on the hub** rather than on each machine, because a KVM earns
its keep exactly when the machine is down and its own Ham Lab cannot answer — the hub
proxies the device either way — and a KVM need not belong to a machine Ham Lab manages at
all. Not done: holding the device's own login on the hub, so its page does not ask for a
second password.
- **Skins** — 13 since 2026-09-19 (`themes.ts`: a palette is one `mk(…)` line): Dark, - **Skins** — 13 since 2026-09-19 (`themes.ts`: a palette is one `mk(…)` line): Dark,
Light, Cyberpunk, Retro, **HUD** (hairlines of cyan on near-black, corner ticks, thin Light, Cyberpunk, Retro, **HUD** (hairlines of cyan on near-black, corner ticks, thin
type), Nord, Gruvbox, Catppuccin + Latte, Dracula, Tokyo Night, Solarized Dark + Light; type), Nord, Gruvbox, Catppuccin + Latte, Dracula, Tokyo Night, Solarized Dark + Light;
@@ -585,39 +618,6 @@ something long in one, switch away and back, refresh — it's all still there.
`internal/term`); snapshots and clones; and what it does on a host with no virtualisation `internal/term`); snapshots and clones; and what it does on a host with no virtualisation
at all, which is every host here today. Worth scoping as its own plan before starting. at all, which is every host here today. Worth scoping as its own plan before starting.
- **A KVM tab** — asked for 2026-09-24, not started. **Dead simple is the brief**: paste one
HTTP link, the tab shows whatever UI that KVM serves, and the keyboard goes to it. **This
is KVM the keyboard-video-mouse switch, not KVM the hypervisor** — the Virtualization item
above means the other one.
- **The device**: a **Sipeed NanoKVM** attached to `grid` at **`10.0.0.217`** (MAC
`48:da:35:6f:d0:31`), web UI on port 80. The only one so far, so store a list, not one URL.
- **Framing works.** Probed 2026-09-24: it sends **no `X-Frame-Options` and no
`frame-ancestors`**, so it does not refuse to be embedded. No fallback needed.
- **Proxy it through the daemon — that is what makes the simple version possible.** The
KVM is **HTTP only**, and Ham Lab is served over TLS, so a bare `http://…` iframe is
blocked as mixed content and the "just paste a link" idea dies there. Serving it from Ham
Lab's own origin (`/api/kvm/<id>/…`, on the `proxyNode` machinery, which already carries
WebSockets — exactly what the video and input streams are) fixes three things at once:
no mixed content, the KVM sits behind **Ham Lab's** login rather than a second one
(`/api/vm/info` answers 401, so it has its own, which the hub can hold once), and the
iframe becomes **same-origin**, which matters for the keyboard below.
- **The keyboard needs less than it sounds.** A parent page *cannot* capture keys and
forward them into a **cross-origin** iframe — the browser forbids synthetic events across
origins — but it does not need to: an iframe with focus receives keystrokes directly, and
the NanoKVM's own page already handles them. So "capture and give it to the iframe" is
really: click-to-focus, keep focus there, and stop the console's own shortcuts from
eating keys while it is focused. What is genuinely worth adding is **fullscreen** plus
`navigator.keyboard.lock()`, which is what stops the *browser* swallowing Escape, Tab and
Ctrl-W so they reach the guest — note it is Chromium-only, so Firefox and iOS Safari get
the plain version. Proxying makes the iframe same-origin anyway, so real forwarding stays
available if some KVM ever needs it.
- **Where the list lives is the one real design decision.** A KVM earns its keep when the
machine is *down* — no OS, a bad kernel, a BIOS screen — which is when that host's Ham Lab
cannot answer. So it is **hub-side** and must work for machines running no Ham Lab at all.
A `kvm_url` in each node's own `config.json` would fail in exactly the case it is for.
- **Do not give it an NPM host** without deciding to: `*.bigham.dev` is a wildcard to the
public IP, so that would put BIOS-level control of `grid` on the internet behind only the
KVM's own login. Behind Ham Lab's proxy it inherits Ham Lab's auth instead.
## Open questions ## Open questions
+41 -3
View File
@@ -200,6 +200,13 @@ func (s *Server) kvmFallback(w http.ResponseWriter, r *http.Request) bool {
return true return true
} }
func machineLabel(m string) string {
if m == "" {
return "this host"
}
return m
}
// ---- the list ---- // ---- the list ----
func (s *Server) listKVMs(w http.ResponseWriter, _ *http.Request) { 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) 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) { func (s *Server) addKVM(w http.ResponseWriter, r *http.Request) {
var body struct{ Name, URL, Machine string } var body struct{ Name, URL, Machine string }
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil { 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()) jsonErr(w, 500, err.Error())
return 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()} machine := strings.TrimSpace(body.Machine)
if err := s.cfg.Update(func(c *config.Config) { c.KVMs = append(c.KVMs, k) }); err != nil { 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()) jsonErr(w, 500, err.Error())
return 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) jsonOK(w, 201, k)
} }
+5 -3
View File
@@ -70,9 +70,11 @@ type KVM struct {
Name string `json:"name"` Name string `json:"name"`
// URL is the device's own address, as you would type it into a browser on the LAN. // URL is the device's own address, as you would type it into a browser on the LAN.
URL string `json:"url"` URL string `json:"url"`
// Machine is which host it is attached to, for display only — it need not be a machine // Machine is the host this KVM is attached to, and is the key: one KVM per machine, shown
// Ham Lab manages, and usually the point is that it is not answering. // on that machine's KVM tab. Empty means the hub itself. The list lives on the hub even
Machine string `json:"machine,omitempty"` // 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"` Added time.Time `json:"added"`
} }
+97 -63
View File
@@ -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 // One per machine, and it follows the machine switcher — switch to `grid` and you get
// particular KVM — it is an iframe pointed at Ham Lab's own proxy, so whatever web UI the // grid's KVM. The address is held on the **hub**, not on the machine, because a KVM earns
// device serves is what you get. // 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 // 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 — // 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 // 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. // 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 { 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' import { Btn, Loading, askYes, toast, useEscape } from './ui'
// lock() releases the keys the browser would otherwise keep for itself. Chromium only; a // lock() releases the keys the browser would otherwise keep for itself. Chromium only; a
@@ -18,132 +22,162 @@ import { Btn, Loading, askYes, toast, useEscape } from './ui'
type KeyboardLock = { lock?: (keys?: string[]) => Promise<void>; unlock?: () => void } type KeyboardLock = { lock?: (keys?: string[]) => Promise<void>; unlock?: () => void }
const keyboardAPI = (): KeyboardLock | undefined => (navigator as unknown as { keyboard?: KeyboardLock }).keyboard const keyboardAPI = (): KeyboardLock | undefined => (navigator as unknown as { keyboard?: KeyboardLock }).keyboard
export default function KVM({ machines }: { machines: MachineStatus[] }) { // Fullscreen, across the three ways browsers spell it. Safari on a Mac wants the webkit
const [list, setList] = useState<Device[] | null>(null) // prefix; Safari on iOS has no fullscreen for an ordinary element at all — only for a
const [sel, setSel] = useState<string>('') // <video> — so there the page expands inside the viewport instead, which is the same thing
// minus the browser's own chrome.
type FSElement = HTMLElement & { webkitRequestFullscreen?: () => Promise<void> | void }
type FSDocument = Document & { webkitFullscreenElement?: Element | null; webkitExitFullscreen?: () => Promise<void> | void }
const nativeFullscreen = () => {
if (typeof document === 'undefined') return false
const el = document.documentElement as FSElement
return typeof el.requestFullscreen === 'function' || typeof el.webkitRequestFullscreen === 'function'
}
const fullscreenElement = () => (document as FSDocument).fullscreenElement ?? (document as FSDocument).webkitFullscreenElement ?? null
async function enterFullscreen(el: FSElement) {
if (typeof el.requestFullscreen === 'function') return el.requestFullscreen()
if (typeof el.webkitRequestFullscreen === 'function') return el.webkitRequestFullscreen()
throw new Error('this browser has no fullscreen for a page element')
}
function exitFullscreen() {
const d = document as FSDocument
if (typeof d.exitFullscreen === 'function') return d.exitFullscreen()
if (typeof d.webkitExitFullscreen === 'function') return d.webkitExitFullscreen()
}
export default function KVM({ machine, title }: { machine: string | null; title: string }) {
const [kvm, setKvm] = useState<Device | null | undefined>(undefined)
const [adding, setAdding] = useState(false) const [adding, setAdding] = useState(false)
const [err, setErr] = useState('') const [err, setErr] = useState('')
const [full, setFull] = useState(false) const [full, setFull] = useState(false) // native fullscreen is on
const [expanded, setExpanded] = useState(false) // the fallback: filling the viewport in-page
const box = useRef<HTMLDivElement>(null) const box = useRef<HTMLDivElement>(null)
const frame = useRef<HTMLIFrameElement>(null) const frame = useRef<HTMLIFrameElement>(null)
const key = machine ?? '' // the hub is a machine too, and its key is the empty one
const load = useCallback(async () => { 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') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
}, []) }, [key])
useEffect(() => { load() }, [load]) useEffect(() => { load() }, [load])
// Fullscreen plus a keyboard lock is what makes Escape and Tab reach the machine rather // Fullscreen plus a keyboard lock is what makes Escape and Tab reach the machine rather
// than the browser. Both are undone together when fullscreen ends, however it ends. // than the browser. Both are undone together when fullscreen ends, however it ends.
useEffect(() => { useEffect(() => {
const onChange = () => { const onChange = () => {
const on = !!document.fullscreenElement const on = !!fullscreenElement()
setFull(on) setFull(on)
if (!on) keyboardAPI()?.unlock?.() if (!on) keyboardAPI()?.unlock?.()
} }
document.addEventListener('fullscreenchange', onChange) document.addEventListener('fullscreenchange', onChange)
return () => { document.removeEventListener('fullscreenchange', onChange); keyboardAPI()?.unlock?.() } document.addEventListener('webkitfullscreenchange', onChange)
return () => {
document.removeEventListener('fullscreenchange', onChange)
document.removeEventListener('webkitfullscreenchange', onChange)
keyboardAPI()?.unlock?.()
}
}, []) }, [])
// Expanding is in-page, so Escape is ours to handle.
useEscape(() => setExpanded(false), expanded)
const goFullscreen = async () => { const goBig = async () => {
if (!nativeFullscreen()) { setExpanded(true); frame.current?.focus(); return }
try { try {
await box.current?.requestFullscreen() if (box.current) await enterFullscreen(box.current)
await keyboardAPI()?.lock?.() await keyboardAPI()?.lock?.()
frame.current?.focus() frame.current?.focus()
} catch (e) { toast(e instanceof Error ? e.message : 'fullscreen refused', 'err') } } catch {
setExpanded(true) // refused (an iframe policy, a permissions policy): fill the page instead
frame.current?.focus()
}
}
const goSmall = () => { setExpanded(false); if (fullscreenElement()) exitFullscreen() }
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') }
} }
const remove = async (k: Device) => { if (err && kvm === undefined) return <div className="dash"><div className="error">{err}</div></div>
if (!await askYes(`Remove ${k.name}? The device is not touched — this only forgets its address.`, 'Remove')) return if (kvm === undefined) return <div className="dash"><Loading label="Looking…" /></div>
try { await api.removeKVM(k.id); toast(`${k.name} removed`); await load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
}
if (err && !list) return <div className="dash"><div className="error">{err}</div></div>
if (!list) return <div className="dash"><Loading label="Reading the list…" /></div>
const cur = list.find((k) => k.id === sel)
const locks = !!keyboardAPI()?.lock const locks = !!keyboardAPI()?.lock
const big = full || expanded
return ( return (
<div className="kvm"> <div className="kvm">
<div className="kvm-bar"> <div className="kvm-bar">
{list.length > 0 && ( <span className="dname">{title}</span>
<span className="chips"> {kvm
{list.map((k) => ( ? <span className="muted small mono kvm-url" title={kvm.url}>{kvm.name !== kvm.url ? `${kvm.name} · ` : ''}{kvm.url}</span>
<button key={k.id} className={'chip' + (k.id === sel ? ' on' : '')} onClick={() => setSel(k.id)} : <span className="muted small">no KVM</span>}
title={`${k.url}${k.machine ? ` — attached to ${k.machine}` : ''}`}>{k.name}</button>
))}
</span>
)}
<span className="spacer" /> <span className="spacer" />
{cur && <> {kvm && <>
<span className="muted small mono kvm-url">{cur.url}</span> <Btn className="btn" onClick={goBig} title={!nativeFullscreen() ? 'fill the screen — this browser has no true fullscreen for a page element' : locks ? 'fullscreen, and the browser stops keeping Escape, Tab and Ctrl-W for itself' : 'fullscreen — this browser will still keep some keys for itself'}>{nativeFullscreen() ? 'Fullscreen' : 'Expand'}</Btn>
<Btn className="btn" onClick={goFullscreen} title={locks ? 'fullscreen, and the browser stops keeping Escape, Tab and Ctrl-W for itself' : 'fullscreen — this browser will still keep some keys for itself'}>Fullscreen</Btn> <a className="btn" href={kvm.url} target="_blank" rel="noreferrer" title="the device directly, outside Ham Lab — only works on the LAN">Open ↗</a>
<a className="btn" href={cur.url} target="_blank" rel="noreferrer" title="the device directly, outside Ham Lab">Open ↗</a> <Btn className="btn" onClick={() => setAdding(true)}>Change</Btn>
<Btn className="btn danger" onClick={() => remove(cur)}>Remove</Btn> <Btn className="btn danger" onClick={remove}>Remove</Btn>
</>} </>}
<Btn className="btn on" onClick={() => setAdding(true)}>+ Add</Btn> {!kvm && <Btn className="btn on" onClick={() => setAdding(true)}>+ Add a KVM for {title}</Btn>}
</div> </div>
{cur ? ( {kvm ? (
<div className={'kvm-frame' + (full ? ' full' : '')} ref={box}> <div className={'kvm-frame' + (full ? ' full' : '') + (expanded ? ' expanded' : '')} ref={box}>
<iframe ref={frame} key={cur.id} src={`/kvm/${cur.id}/`} title={cur.name} <iframe ref={frame} key={kvm.id} src={`/kvm/${kvm.id}/`} title={kvm.name}
allow="fullscreen; clipboard-read; clipboard-write" /> allow="fullscreen; clipboard-read; clipboard-write" />
{expanded && <button className="btn kvm-shrink" onClick={goSmall} title="back to the console">Done</button>}
</div> </div>
) : ( ) : (
<div className="svc-empty muted"> <div className="svc-empty muted kvm-none">
{list.length === 0 <div>
? 'No KVM yet. Add one with its address and its web page appears here.' <p>No KVM for <b>{title}</b>.</p>
: 'Pick a KVM.'} <p className="small">Add one with its address and its page appears here. The address is kept on the hub,
so it still works when {title} does not — which is when you will want it.</p>
</div>
</div> </div>
)} )}
{cur && !full && ( {kvm && !big && (
<div className="kvm-hint muted small"> <div className="kvm-hint muted small">
Click the picture, then type — the keys go to the machine. Click the picture, then type — the keys go to {title}.
{locks ? ' Fullscreen also takes Escape, Tab and Ctrl-W back from the browser.' : ' This browser keeps Escape, Tab and Ctrl-W for itself; use the device’s own on-screen keys for those, or a Chromium browser.'} {locks ? ' Fullscreen also takes Escape, Tab and Ctrl-W back from the browser.' : ' This browser keeps Escape, Tab and Ctrl-W for itself; use the device’s own on-screen keys for those, or a Chromium browser.'}
</div> </div>
)} )}
{adding && <AddDialog machines={machines} onClose={() => setAdding(false)} onAdded={(k) => { setAdding(false); setSel(k.id); load() }} />} {adding && <AddDialog machine={key} title={title} existing={kvm} onClose={() => setAdding(false)} onAdded={() => { setAdding(false); load() }} />}
</div> </div>
) )
} }
function AddDialog({ machines, onClose, onAdded }: { machines: MachineStatus[]; onClose: () => void; onAdded: (k: Device) => void }) { function AddDialog({ machine, title, existing, onClose, onAdded }: { machine: string; title: string; existing: Device | null; onClose: () => void; onAdded: () => void }) {
const [url, setUrl] = useState('') const [url, setUrl] = useState(existing?.url ?? '')
const [name, setName] = useState('') const [name, setName] = useState(existing?.name ?? '')
const [machine, setMachine] = useState('')
const [err, setErr] = useState('') const [err, setErr] = useState('')
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
useEscape(onClose) useEscape(onClose)
const save = async (e: FormEvent) => { const save = async (e: FormEvent) => {
e.preventDefault(); setErr(''); setBusy(true) e.preventDefault(); setErr(''); setBusy(true)
try { onAdded(await api.addKVM(name.trim(), url.trim(), machine)) } try { await api.addKVM(name.trim(), url.trim(), machine); onAdded() }
catch (e) { setErr(e instanceof Error ? e.message : 'failed'); setBusy(false) } catch (e) { setErr(e instanceof Error ? e.message : 'failed'); setBusy(false) }
} }
return ( return (
<div className="modal-back" onClick={onClose}> <div className="modal-back" onClick={onClose}>
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={save}> <form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={save}>
<h3>Add a KVM</h3> <h3>{existing ? `Change ${title}'s KVM` : `A KVM for ${title}`}</h3>
<label className="muted">Address — whatever you would type into a browser <label className="muted">Address — whatever you would type into a browser
<input autoFocus className="mono" value={url} onChange={(e) => setUrl(e.target.value)} placeholder="10.0.0.217" spellCheck={false} autoCapitalize="off" required /> <input autoFocus className="mono" value={url} onChange={(e) => setUrl(e.target.value)} placeholder="10.0.0.217" spellCheck={false} autoCapitalize="off" required />
</label> </label>
<label className="muted">Name — optional; the address is used otherwise <label className="muted">Name — optional; the address is used otherwise
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="mac mini KVM" maxLength={64} /> <input value={name} onChange={(e) => setName(e.target.value)} placeholder={`${title} KVM`} maxLength={64} />
</label>
<label className="muted">Attached to — optional, and only a label
<select value={machine} onChange={(e) => setMachine(e.target.value)}>
<option value="">—</option>
{machines.map((m) => <option key={m.name} value={m.name}>{m.host || m.name}</option>)}
</select>
</label> </label>
<div className="muted small"> <div className="muted small">
Ham Lab serves the device from its own address, so a KVM that only speaks plain HTTP Ham Lab serves the device from its own address, so a KVM that speaks only plain HTTP
still works here, and it is behind this login rather than needing a second one. The still works here, and it is behind this login rather than needing a second one. The
address is kept on the hub, so it still reaches the device when {title} is down. The
device itself is not changed and is never reachable from outside. device itself is not changed and is never reachable from outside.
{existing && <> Saving replaces the one there now — a machine has one KVM.</>}
</div> </div>
{err && <div className="error">{err}</div>} {err && <div className="error">{err}</div>}
<div className="svc-actions"> <div className="svc-actions">
<button type="submit" className="btn on" disabled={busy || !url.trim()}>Add</button> <button type="submit" className="btn on" disabled={busy || !url.trim()}>{existing ? 'Save' : 'Add'}</button>
<span className="spacer" /> <span className="spacer" />
<button type="button" className="btn" onClick={onClose}>Cancel</button> <button type="button" className="btn" onClick={onClose}>Cancel</button>
</div> </div>
+8 -8
View File
@@ -55,24 +55,24 @@ const GROUPS: Group[] = [
{ id: 'procs', label: 'Processes', icon: '▚' }, { id: 'procs', label: 'Processes', icon: '▚' },
{ id: 'terminal', label: 'Terminal', icon: '>_' }, { id: 'terminal', label: 'Terminal', icon: '>_' },
{ id: 'services', label: 'Services', icon: '◈' }, { id: 'services', label: 'Services', icon: '◈' },
{ id: 'docker', label: 'Docker', icon: DockerIcon },
{ id: 'jobs', label: 'Jobs', icon: ClockIcon }, { id: 'jobs', label: 'Jobs', icon: ClockIcon },
{ id: 'updates', label: 'Updates', icon: '⇪' },
{ id: 'disks', label: 'Disks', icon: '▤' }, { id: 'disks', label: 'Disks', icon: '▤' },
{ id: 'zfs', label: 'ZFS', icon: '⛁' },
{ id: 'usage', label: 'Usage', icon: '▧' },
{ id: 'files', label: 'Files', icon: FolderIcon }, { id: 'files', label: 'Files', icon: FolderIcon },
{ id: 'usage', label: 'Usage', icon: '▧' },
{ id: 'zfs', label: 'ZFS', icon: '⛁' },
{ id: 'docker', label: 'Docker', icon: DockerIcon },
{ id: 'kvm', label: 'KVM', icon: '⌨' },
{ id: 'updates', label: 'Updates', icon: '⇪' },
{ id: 'alerts', label: 'Alerts', icon: BellIcon }, { id: 'alerts', label: 'Alerts', icon: BellIcon },
{ id: 'machines', label: 'Machines', icon: '⌂' }, { id: 'machines', label: 'Machines', icon: '⌂' },
{ id: 'kvm', label: 'KVM', icon: '⌨' },
{ id: 'users', label: 'Users', icon: '⚇' },
{ id: 'widgets', label: 'Widgets', icon: '▦' }, { id: 'widgets', label: 'Widgets', icon: '▦' },
{ id: 'users', label: 'Users', icon: '⚇' },
{ id: 'settings', label: 'Settings', icon: '⚙' }, { id: 'settings', label: 'Settings', icon: '⚙' },
] ]
type View = 'terminal' | 'widgets' | 'docker' | 'alerts' | 'services' | 'jobs' | 'updates' | 'disks' | 'zfs' | 'usage' | 'files' | 'procs' | 'machines' | 'kvm' | 'users' | 'settings' type View = 'terminal' | 'widgets' | 'docker' | 'alerts' | 'services' | 'jobs' | 'updates' | 'disks' | 'zfs' | 'usage' | 'files' | 'procs' | 'machines' | 'kvm' | 'users' | 'settings'
const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'alerts', 'services', 'jobs', 'updates', 'disks', 'zfs', 'usage', 'files', 'procs', 'machines', 'kvm', 'users', 'settings'] const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'alerts', 'services', 'jobs', 'updates', 'disks', 'zfs', 'usage', 'files', 'procs', 'machines', 'kvm', 'users', 'settings']
const HUB_ONLY: View[] = ['machines', 'kvm', 'users', 'settings', 'alerts'] const HUB_ONLY: View[] = ['machines', 'users', 'settings', 'alerts']
// URL hash is the source of truth for where you are: #/<machine|hub>/<view>. Back and // 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. // forward work, reload restores the tab, and links can point at a page on a machine.
@@ -271,7 +271,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
<main className="main" key={machine ?? 'hub'}> <main className="main" key={machine ?? 'hub'}>
{view === 'settings' && <Settings me={hubMe} />} {view === 'settings' && <Settings me={hubMe} />}
{view === 'users' && <Users me={hubMe} />} {view === 'users' && <Users me={hubMe} />}
{view === 'kvm' && <KVM machines={machines} />} {view === 'kvm' && <KVM key={machine ?? 'hub'} machine={machine} title={title} />}
{view === 'usage' && <Usage key={machine ?? ''} />} {view === 'usage' && <Usage key={machine ?? ''} />}
{view === 'machines' && <Machines hub={hubMe} onChanged={loadMachines} onOpen={(n) => { switchMachine(n); setView('procs') }} />} {view === 'machines' && <Machines hub={hubMe} onChanged={loadMachines} onOpen={(n) => { switchMachine(n); setView('procs') }} />}
{view === 'widgets' && <Gallery />} {view === 'widgets' && <Gallery />}
+2 -1
View File
@@ -133,7 +133,7 @@ export interface SpaceNode { name: string; dir: boolean; size: number; apparent:
export interface SpaceTree extends SpaceNode { path: string; children?: SpaceTree[]; more_n?: number; more_size?: number; total: number } export interface SpaceTree extends SpaceNode { path: string; children?: SpaceTree[]; more_n?: number; more_size?: number; total: number }
export interface SpaceFile { path: string; size: number; apparent: number; mtime: number } export interface SpaceFile { path: string; size: number; apparent: number; mtime: number }
export interface KVM { id: string; name: string; url: string; machine?: string; added: string } export interface KVM { id: string; name: string; url: string; machine: string; added: string }
export interface ZFSPolicy { id: string; name: string; dataset: string; recursive: boolean; every: 'hourly' | 'daily' | 'weekly' | 'monthly' | 'custom'; at: string; weekday: number; day: number; cron?: string; keep_for: string; keep_min: number; prefix?: string; enabled: boolean } export interface ZFSPolicy { id: string; name: string; dataset: string; recursive: boolean; every: 'hourly' | 'daily' | 'weekly' | 'monthly' | 'custom'; at: string; weekday: number; day: number; cron?: string; keep_for: string; keep_min: number; prefix?: string; enabled: boolean }
export interface ZFSRun { at?: string; taken?: string; pruned?: string[]; error?: string } export interface ZFSRun { at?: string; taken?: string; pruned?: string[]; error?: string }
@@ -186,6 +186,7 @@ export const api = {
storage: () => req<{ devices: StorageDevice[]; zfs: ZFSInfo; smartctl: boolean }>('GET', '/api/storage'), storage: () => req<{ devices: StorageDevice[]; zfs: ZFSInfo; smartctl: boolean }>('GET', '/api/storage'),
smart: (dev: string, force = false) => req<SmartInfo>('GET', `/api/storage/smart/${dev}${force ? '?force=1' : ''}`), smart: (dev: string, force = false) => req<SmartInfo>('GET', `/api/storage/smart/${dev}${force ? '?force=1' : ''}`),
smartTest: (dev: string, kind: 'short' | 'long') => req<void>('POST', `/api/storage/smart/${dev}/test/${kind}`), smartTest: (dev: string, kind: 'short' | 'long') => req<void>('POST', `/api/storage/smart/${dev}/test/${kind}`),
// Always the hub's: it holds every machine's KVM, so the tab works when the machine does not.
kvms: () => req<KVM[]>('GET', '/api/kvms', undefined, true), kvms: () => req<KVM[]>('GET', '/api/kvms', undefined, true),
addKVM: (name: string, url: string, machine: string) => req<KVM>('POST', '/api/kvms', { name, url, machine }, true), addKVM: (name: string, url: string, machine: string) => req<KVM>('POST', '/api/kvms', { name, url, machine }, true),
removeKVM: (id: string) => req<void>('DELETE', `/api/kvms/${encodeURIComponent(id)}`, undefined, true), removeKVM: (id: string) => req<void>('DELETE', `/api/kvms/${encodeURIComponent(id)}`, undefined, true),
+6
View File
@@ -750,3 +750,9 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%,
.kvm-frame.full { background: #000; } .kvm-frame.full { background: #000; }
.kvm-hint { padding: 6px 12px; border-top: 1px solid var(--border); } .kvm-hint { padding: 6px 12px; border-top: 1px solid var(--border); }
@media (max-width: 800px) { .kvm-bar .kvm-url { display: none; } } @media (max-width: 800px) { .kvm-bar .kvm-url { display: none; } }
.kvm-none { text-align: center; } .kvm-none p { margin: 4px 0; }
/* No true fullscreen for a page element (Safari on iOS): fill the viewport in-page instead. */
.kvm-frame.expanded { position: fixed; inset: 0; z-index: 60; }
.kvm-frame.expanded iframe { height: 100%; }
.kvm-shrink { position: absolute; right: max(10px, env(safe-area-inset-right)); bottom: max(10px, env(safe-area-inset-bottom)); z-index: 61; background: var(--bg2); border: 1px solid var(--border); opacity: .75; }
.kvm-shrink:hover, .kvm-shrink:active { opacity: 1; }