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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
70fcf86283
commit
f3441ae718
+49
-53
@@ -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<void>; unlock?: () => void }
|
||||
const keyboardAPI = (): KeyboardLock | undefined => (navigator as unknown as { keyboard?: KeyboardLock }).keyboard
|
||||
|
||||
export default function KVM({ machines }: { machines: MachineStatus[] }) {
|
||||
const [list, setList] = useState<Device[] | null>(null)
|
||||
const [sel, setSel] = useState<string>('')
|
||||
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 [err, setErr] = useState('')
|
||||
const [full, setFull] = useState(false)
|
||||
const box = useRef<HTMLDivElement>(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 () => {
|
||||
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 <div className="dash"><div className="error">{err}</div></div>
|
||||
if (!list) return <div className="dash"><Loading label="Reading the list…" /></div>
|
||||
if (err && kvm === undefined) return <div className="dash"><div className="error">{err}</div></div>
|
||||
if (kvm === undefined) return <div className="dash"><Loading label="Looking…" /></div>
|
||||
|
||||
const cur = list.find((k) => k.id === sel)
|
||||
const locks = !!keyboardAPI()?.lock
|
||||
|
||||
return (
|
||||
<div className="kvm">
|
||||
<div className="kvm-bar">
|
||||
{list.length > 0 && (
|
||||
<span className="chips">
|
||||
{list.map((k) => (
|
||||
<button key={k.id} className={'chip' + (k.id === sel ? ' on' : '')} onClick={() => setSel(k.id)}
|
||||
title={`${k.url}${k.machine ? ` — attached to ${k.machine}` : ''}`}>{k.name}</button>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
<span className="dname">{title}</span>
|
||||
{kvm
|
||||
? <span className="muted small mono kvm-url" title={kvm.url}>{kvm.name !== kvm.url ? `${kvm.name} · ` : ''}{kvm.url}</span>
|
||||
: <span className="muted small">no KVM</span>}
|
||||
<span className="spacer" />
|
||||
{cur && <>
|
||||
<span className="muted small mono kvm-url">{cur.url}</span>
|
||||
{kvm && <>
|
||||
<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={cur.url} target="_blank" rel="noreferrer" title="the device directly, outside Ham Lab">Open ↗</a>
|
||||
<Btn className="btn danger" onClick={() => remove(cur)}>Remove</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>
|
||||
<Btn className="btn" onClick={() => setAdding(true)}>Change</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>
|
||||
|
||||
{cur ? (
|
||||
{kvm ? (
|
||||
<div className={'kvm-frame' + (full ? ' full' : '')} 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" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="svc-empty muted">
|
||||
{list.length === 0
|
||||
? 'No KVM yet. Add one with its address and its web page appears here.'
|
||||
: 'Pick a KVM.'}
|
||||
<div className="svc-empty muted kvm-none">
|
||||
<div>
|
||||
<p>No KVM for <b>{title}</b>.</p>
|
||||
<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>
|
||||
)}
|
||||
{cur && !full && (
|
||||
{kvm && !full && (
|
||||
<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.'}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
function AddDialog({ machines, onClose, onAdded }: { machines: MachineStatus[]; onClose: () => void; onAdded: (k: Device) => void }) {
|
||||
const [url, setUrl] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [machine, setMachine] = useState('')
|
||||
function AddDialog({ machine, title, existing, onClose, onAdded }: { machine: string; title: string; existing: Device | null; onClose: () => void; onAdded: () => void }) {
|
||||
const [url, setUrl] = useState(existing?.url ?? '')
|
||||
const [name, setName] = useState(existing?.name ?? '')
|
||||
const [err, setErr] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
useEscape(onClose)
|
||||
const save = async (e: FormEvent) => {
|
||||
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) }
|
||||
}
|
||||
return (
|
||||
<div className="modal-back" onClick={onClose}>
|
||||
<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
|
||||
<input autoFocus className="mono" value={url} onChange={(e) => setUrl(e.target.value)} placeholder="10.0.0.217" spellCheck={false} autoCapitalize="off" required />
|
||||
</label>
|
||||
<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} />
|
||||
</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>
|
||||
<input value={name} onChange={(e) => setName(e.target.value)} placeholder={`${title} KVM`} maxLength={64} />
|
||||
</label>
|
||||
<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
|
||||
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.
|
||||
{existing && <> Saving replaces the one there now — a machine has one KVM.</>}
|
||||
</div>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<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" />
|
||||
<button type="button" className="btn" onClick={onClose}>Cancel</button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user