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
+20 -1
View File
@@ -262,7 +262,12 @@ export interface VirtInstance { name: string; type: 'container' | 'virtual-machi
export interface VirtImage { fingerprint: string; aliases: string[]; arch: string; type: string; size: number; created: string; last_used: string; description: string; os?: string; release?: string; variant?: string; cached: boolean; source?: string; public: boolean }
export interface VirtPool { name: string; driver: string; source: string; used: number; total: number; used_by: number }
export interface VirtNetwork { name: string; type: string; managed: boolean; address?: string; nat: boolean; used_by: number }
export interface LibraryFile { name: string; size: number; modified: string }
export interface LibraryFile { name: string; size: number; modified: string; attached_to?: string[] }
export interface ExportConfig { enabled: boolean; hour: number; minute: number; keep: number; instance_only: boolean; instances?: string[]; updated?: string }
export interface ExportRun { at: string; exported: string[]; pruned: string[]; errors?: string[]; seconds: number }
export interface ExportFile { instance: string; name: string; size: number; modified: string }
export interface ExportsView { config: ExportConfig; cron: string; next?: string; last: ExportRun | null; files: ExportFile[]; dir: string; cron_file: string }
export interface FromISO { name: string; iso: string; cpu: string; memory: string; disk: string; network: string; secureboot: boolean; start: boolean }
export interface VirtOverview { status: VirtStatus; error?: string; instances?: VirtInstance[]; images?: VirtImage[]; pools?: VirtPool[]; networks?: VirtNetwork[]; library: { images: LibraryFile[]; isos: LibraryFile[] } }
export interface VirtInstallOpts { driver: string; pool_size_gib: number; subnet: string; docker_rules: boolean; arc_max_gib: number }
export interface VirtCreate { name: string; image: string; vm: boolean; cpu: string; memory: string; disk: string; network: string; cloud_init: string; profiles: string[]; description: string; start: boolean }
@@ -437,6 +442,20 @@ export const api = {
virtConfig: (name: string, cfg: Record<string, string>) => req<void>('PUT', `/api/virt/instances/${encodeURIComponent(name)}/config`, cfg),
virtSnapshot: (name: string, snap: string) => req<{ name: string }>('POST', `/api/virt/instances/${encodeURIComponent(name)}/snapshots`, { name: snap }),
virtSnapshotAction: (name: string, snap: string, action: 'restore' | 'delete') => req<void>('POST', `/api/virt/instances/${encodeURIComponent(name)}/snapshots/${encodeURIComponent(snap)}/${action}`),
virtISOFetch: (url: string, sha256: string, name: string) => req<TaskInfo>('POST', '/api/virt/isos/fetch', { url, sha256, name }),
virtISOImport: (path: string) => req<TaskInfo>('POST', '/api/virt/isos/import', { path }),
virtISODelete: (name: string) => req<void>('POST', '/api/virt/isos/delete', { name }),
virtISORename: (name: string, to: string) => req<void>('POST', '/api/virt/isos/rename', { name, to }),
virtISOAttach: (inst: string, iso: string, bootFirst: boolean) => req<{ device: string }>('POST', `/api/virt/instances/${encodeURIComponent(inst)}/iso`, { iso, boot_first: bootFirst }),
virtISODetach: (inst: string, device: string) => req<void>('POST', `/api/virt/instances/${encodeURIComponent(inst)}/iso/detach`, { device }),
virtFromISO: (f: FromISO) => req<TaskInfo>('POST', '/api/virt/instances/from-iso', f),
virtMountAdd: (inst: string, m: { device: string; source: string; path: string; readonly: boolean; shift: boolean }) => req<{ device: string }>('POST', `/api/virt/instances/${encodeURIComponent(inst)}/mounts`, m),
virtMountRemove: (inst: string, device: string) => req<void>('POST', `/api/virt/instances/${encodeURIComponent(inst)}/mounts/remove`, { device }),
virtExports: () => req<ExportsView>('GET', '/api/virt/exports'),
virtExportsPut: (c: ExportConfig) => req<void>('PUT', '/api/virt/exports', c),
virtExportsRun: (instance = '') => req<TaskInfo>('POST', '/api/virt/exports/run', { instance }),
virtExportDelete: (instance: string, name: string) => req<void>('POST', '/api/virt/exports/delete', { instance, name }),
virtSSHKeys: () => req<{ keys: string[]; user: string }>('GET', '/api/virt/sshkeys'),
}
export function wsURLPath(path: string): string {