Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c129f655a7 | ||
|
|
d8bf5df89f | ||
|
|
e64b0a1727 |
@@ -344,10 +344,15 @@ something long in one, switch away and back, refresh — it's all still there.
|
|||||||
does not need to, since one with focus receives them directly. Fullscreen plus
|
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;
|
`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
|
that is Chromium-only, and the tab says which version you are getting rather than
|
||||||
pretending. The list is **hub-side** on purpose — a KVM earns its keep when the machine is
|
pretending.
|
||||||
down, which is when that machine's own Ham Lab cannot answer — and a KVM need not belong to
|
**One KVM per machine, and the tab follows the machine switcher** (corrected the same day:
|
||||||
a machine Ham Lab manages at all. Not done: holding the device's own login on the hub, so
|
the first cut was a hub-side list you pointed at machines, which had it backwards). Switch
|
||||||
its page does not ask for a second password.
|
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;
|
||||||
|
|||||||
+47
-9
@@ -22,11 +22,35 @@ 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
|
||||||
|
|
||||||
|
// Fullscreen, across the three ways browsers spell it. 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 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 }) {
|
export default function KVM({ machine, title }: { machine: string | null; title: string }) {
|
||||||
const [kvm, setKvm] = useState<Device | null | undefined>(undefined)
|
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 key = machine ?? '' // the hub is a machine too, and its key is the empty one
|
||||||
@@ -41,21 +65,33 @@ export default function KVM({ machine, title }: { machine: string | null; title:
|
|||||||
// 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 () => {
|
const remove = async () => {
|
||||||
if (!kvm || !await askYes(`Forget ${title}'s KVM? The device itself is not touched — this only removes its address.`, 'Remove')) return
|
if (!kvm || !await askYes(`Forget ${title}'s KVM? The device itself is not touched — this only removes its address.`, 'Remove')) return
|
||||||
@@ -66,6 +102,7 @@ export default function KVM({ machine, title }: { machine: string | null; title:
|
|||||||
if (kvm === undefined) return <div className="dash"><Loading label="Looking…" /></div>
|
if (kvm === undefined) return <div className="dash"><Loading label="Looking…" /></div>
|
||||||
|
|
||||||
const locks = !!keyboardAPI()?.lock
|
const locks = !!keyboardAPI()?.lock
|
||||||
|
const big = full || expanded
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="kvm">
|
<div className="kvm">
|
||||||
@@ -76,7 +113,7 @@ export default function KVM({ machine, title }: { machine: string | null; title:
|
|||||||
: <span className="muted small">no KVM</span>}
|
: <span className="muted small">no KVM</span>}
|
||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
{kvm && <>
|
{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>
|
<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>
|
||||||
<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={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" onClick={() => setAdding(true)}>Change</Btn>
|
||||||
<Btn className="btn danger" onClick={remove}>Remove</Btn>
|
<Btn className="btn danger" onClick={remove}>Remove</Btn>
|
||||||
@@ -85,9 +122,10 @@ export default function KVM({ machine, title }: { machine: string | null; title:
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{kvm ? (
|
{kvm ? (
|
||||||
<div className={'kvm-frame' + (full ? ' full' : '')} ref={box}>
|
<div className={'kvm-frame' + (full ? ' full' : '') + (expanded ? ' expanded' : '')} ref={box}>
|
||||||
<iframe ref={frame} key={kvm.id} src={`/kvm/${kvm.id}/`} title={kvm.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 kvm-none">
|
<div className="svc-empty muted kvm-none">
|
||||||
@@ -98,7 +136,7 @@ export default function KVM({ machine, title }: { machine: string | null; title:
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{kvm && !full && (
|
{kvm && !big && (
|
||||||
<div className="kvm-hint muted small">
|
<div className="kvm-hint muted small">
|
||||||
Click the picture, then type — the keys go to {title}.
|
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.'}
|
||||||
|
|||||||
+6
-6
@@ -55,18 +55,18 @@ 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: 'alerts', label: 'Alerts', icon: BellIcon },
|
{ id: 'usage', label: 'Usage', icon: '▧' },
|
||||||
|
{ id: 'zfs', label: 'ZFS', icon: '⛁' },
|
||||||
|
{ id: 'docker', label: 'Docker', icon: DockerIcon },
|
||||||
{ id: 'kvm', label: 'KVM', icon: '⌨' },
|
{ id: 'kvm', label: 'KVM', icon: '⌨' },
|
||||||
|
{ id: 'updates', label: 'Updates', icon: '⇪' },
|
||||||
|
{ id: 'alerts', label: 'Alerts', icon: BellIcon },
|
||||||
{ id: 'machines', label: 'Machines', icon: '⌂' },
|
{ id: 'machines', label: 'Machines', 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: '⚙' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -751,3 +751,8 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%,
|
|||||||
.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; }
|
.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