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:
Mitchell-Johnson
2026-10-09 22:21:57 -07:00
co-authored by Claude Fable 5.1
parent 66498a58c5
commit 43ebacdd92
46 changed files with 14493 additions and 25 deletions
+84
View File
@@ -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>
)
}