Files
hamlab/web/src/VgaConsole.tsx
T
Mitchell-JohnsonandClaude Opus 5.5 d5680e8e41 VGA console: stop importing spice-html5's demo stylesheet, which restyled every button
spice.css sets body, *, button and canvas for the whole document. Bundled
into the App chunk since 43ebacd (deployed as v0.1.0-188), it turned every
button into a gradient pill, shifted 15% right and 8% up with a 150px
minimum width, broke the rail and header layout, and bordered every graph
canvas. The console is already styled by the .vga rules in styles.css.

The file is removed from the vendored copy, and a new web test fails on any
stylesheet import outside an allow-list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:19:20 -07:00

87 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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'
// No spice.css: upstream's stylesheet is for its own demo page and sets body, *, button and
// canvas for the whole document. Imported here it restyled every button in Ham Lab. The
// console is styled by the .vga rules in styles.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>
)
}