Virtualization phase 2: ISOs, New VM from ISO, the VGA console in the browser, nightly exports, mounts, instance sensors
- Library: an ISO shelf in virt/isos: upload (the Files uploader), fetch by URL with an optional sha256, import a file from the machine by copying it in, rename, delete (refused while attached), attach to a VM as a CD-ROM live or stopped with boot-first, eject, and New VM from ISO: an empty VM with the ISO as first boot device, Secure Boot on or off. - The VGA console: Ham Lab asks Incus for the VM's SPICE console over its unix socket and bridges the display websocket to the browser, where spice-html5 (vendored from upstream b372aab, LGPL, loaded on demand) draws it; Ctrl+Alt+Del, fit-to-window. - Exports: every instance as a tarball in virt/exports/<name>/, kept to a count, as a cron line in /etc/cron.d/hamlab-virt calling hamlab virt-export, settings and the last run as files beside the data; Export now per instance or for all. - Disks tab per instance: the root disk, attached ISOs with Eject, bind mounts in and out (shift for containers, virtiofs for VMs). - instance.* sensors (running, down, state, cpu, mem) and the VM-or-container-down alert default, offered once to installs seeded before it existed. - cloud-init: Use my SSH keys writes a cloud-config from the account's public keys. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
66498a58c5
commit
43ebacdd92
@@ -0,0 +1,84 @@
|
||||
// The graphical console of a VM: spice-html5 (vendored, loaded on demand) drawing the SPICE
|
||||
// stream that Ham Lab bridges from Incus into a canvas. Used for installers from an ISO, and
|
||||
// anything else with a screen rather than a serial line.
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { wsURLPath } from './api'
|
||||
import { Btn, useEscape } from './ui'
|
||||
import './vendor/spice-html5/spice.css'
|
||||
|
||||
type Spice = typeof import('./vendor/spice-html5/src/main.js')
|
||||
|
||||
export default function VgaConsole({ name, onClose }: { name: string; onClose: () => void }) {
|
||||
const [state, setState] = useState<'loading' | 'connecting' | 'connected' | 'error'>('loading')
|
||||
const [err, setErr] = useState('')
|
||||
const [fit, setFit] = useState(true)
|
||||
const [size, setSize] = useState('')
|
||||
const conn = useRef<InstanceType<Spice['SpiceMainConn']> | null>(null)
|
||||
const mod = useRef<Spice | null>(null)
|
||||
const area = useRef<HTMLDivElement>(null)
|
||||
const screen = useRef<HTMLDivElement>(null)
|
||||
useEscape(onClose)
|
||||
|
||||
useEffect(() => {
|
||||
let dead = false
|
||||
import('./vendor/spice-html5/src/main.js').then((m) => {
|
||||
if (dead) return
|
||||
mod.current = m
|
||||
setState('connecting')
|
||||
try {
|
||||
conn.current = new m.SpiceMainConn({
|
||||
uri: wsURLPath(`/api/virt/instances/${encodeURIComponent(name)}/vga`),
|
||||
screen_id: 'spice-screen', message_id: 'spice-message',
|
||||
onerror: (e) => { setState('error'); setErr(e instanceof Error ? e.message : (e as Event & { reason?: string })?.reason || 'the console closed') },
|
||||
onsuccess: () => setState('connected'),
|
||||
onagent: () => { try { m.handle_resize() } catch { /* no agent in the guest yet */ } },
|
||||
})
|
||||
} catch (e) { setState('error'); setErr(String(e)) }
|
||||
}).catch((e) => { setState('error'); setErr('could not load the SPICE viewer: ' + String(e)) })
|
||||
return () => { dead = true; try { conn.current?.stop() } catch { /* already gone */ } conn.current = null }
|
||||
}, [name])
|
||||
|
||||
// The guest draws at its own resolution; scale the canvas to the pane unless asked not to.
|
||||
useEffect(() => {
|
||||
const a = area.current, s = screen.current
|
||||
if (!a || !s) return
|
||||
const apply = () => {
|
||||
const c = s.querySelector('canvas')
|
||||
if (!c) return
|
||||
setSize(`${c.width}×${c.height}`)
|
||||
c.tabIndex = 1
|
||||
if (!fit) { s.style.transform = ''; s.style.width = ''; s.style.height = ''; return }
|
||||
const k = Math.min(1, (a.clientWidth - 8) / c.width, (a.clientHeight - 8) / c.height)
|
||||
s.style.transformOrigin = 'top left'
|
||||
s.style.transform = `scale(${k})`
|
||||
s.style.width = `${c.width}px`; s.style.height = `${c.height}px`
|
||||
}
|
||||
apply()
|
||||
const ro = new ResizeObserver(apply); ro.observe(a)
|
||||
const mo = new MutationObserver(apply); mo.observe(s, { childList: true, attributes: true, subtree: true, attributeFilter: ['width', 'height'] })
|
||||
return () => { ro.disconnect(); mo.disconnect() }
|
||||
}, [fit, state])
|
||||
|
||||
const cad = () => { if (conn.current && mod.current) mod.current.sendCtrlAltDel(conn.current) }
|
||||
const focus = () => screen.current?.querySelector('canvas')?.focus()
|
||||
return (
|
||||
<div className="modal-back vga-back" onClick={onClose}>
|
||||
<div className="vga" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="logs-bar vga-bar">
|
||||
<span className={'dot ' + (state === 'connected' ? 'ok' : state === 'error' ? 'bad' : 'warn')} />
|
||||
<span className="dname">{name}</span>
|
||||
<span className="muted small">{state === 'connected' ? `VGA · ${size || 'waiting for the first frame'}` : state === 'error' ? err : state === 'loading' ? 'loading the viewer…' : 'connecting…'}</span>
|
||||
<span className="spacer" />
|
||||
<Btn className="btn" disabled={state !== 'connected'} onClick={cad} title="send Ctrl+Alt+Del to the guest">Ctrl+Alt+Del</Btn>
|
||||
<button className={'btn' + (fit ? ' on' : '')} onClick={() => setFit(!fit)} title="scale the screen to this window, or show it pixel for pixel">Fit</button>
|
||||
<button className="btn" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
<div className="vga-area" ref={area} onClick={focus}>
|
||||
<div id="spice-area"><div id="spice-screen" ref={screen} /></div>
|
||||
<div id="spice-message" className="spice-message vga-msg" />
|
||||
</div>
|
||||
<div className="muted small vga-hint">Click the screen to type into it. Keys go to the guest while it has focus; Escape with focus elsewhere closes this.</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user