Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8bf5df89f | ||
|
|
e64b0a1727 | ||
|
|
f3441ae718 | ||
|
|
70fcf86283 |
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
+3
-3
@@ -63,8 +63,8 @@ const GROUPS: Group[] = [
|
|||||||
{ id: 'usage', label: 'Usage', icon: '▧' },
|
{ id: 'usage', label: 'Usage', icon: '▧' },
|
||||||
{ id: 'files', label: 'Files', icon: FolderIcon },
|
{ id: 'files', label: 'Files', icon: FolderIcon },
|
||||||
{ id: 'alerts', label: 'Alerts', icon: BellIcon },
|
{ id: 'alerts', label: 'Alerts', icon: BellIcon },
|
||||||
{ id: 'machines', label: 'Machines', icon: '⌂' },
|
|
||||||
{ id: 'kvm', label: 'KVM', icon: '⌨' },
|
{ id: 'kvm', label: 'KVM', icon: '⌨' },
|
||||||
|
{ id: 'machines', label: 'Machines', icon: '⌂' },
|
||||||
{ id: 'users', label: 'Users', icon: '⚇' },
|
{ id: 'users', label: 'Users', icon: '⚇' },
|
||||||
{ id: 'widgets', label: 'Widgets', icon: '▦' },
|
{ id: 'widgets', label: 'Widgets', icon: '▦' },
|
||||||
{ id: 'settings', label: 'Settings', icon: '⚙' },
|
{ id: 'settings', label: 'Settings', icon: '⚙' },
|
||||||
@@ -72,7 +72,7 @@ const GROUPS: Group[] = [
|
|||||||
|
|
||||||
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
@@ -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),
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user