2 Commits
Author SHA1 Message Date
Mitchell-JohnsonandClaude Fable 5.1 faf444d8ce VIRTUALIZATION.md: storage decision revised — a Disk location picker, plain files on grid's root disk
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 14:36:06 -07:00
Mitchell-JohnsonandClaude Fable 5.1 ae72e45bf5 Virtualization: a disk-location picker on both create forms, locations by place, add or remove a location
Pools are shown by where their bytes land (plain files on this disk, a ZFS pool inside one
file, a dataset…) rather than by driver name, which read as the NAS pool. A location can be
added as a plain-files pool at any folder on the machine, and removed while nothing uses it.
The driver order at Install is as it was; the labels say what each one really is.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 14:33:10 -07:00
9 changed files with 215 additions and 48 deletions
+24 -21
View File
@@ -1,20 +1,17 @@
# Virtualization tab — plan (2026-10-09; phase 1 built the same day) # Virtualization tab — plan (2026-10-09; phase 1 built the same day)
**Status:** phases 1 and 2 are **deployed to all five machines as v0.1.0-188** (2026-10-09). **Status:** phases 1 and 2 are **deployed to all five machines as v0.1.0-196** (2026-10-11).
Incus runs on `grid` (installed from the tab); the other machines show Install or, on `gate`, Incus runs on `grid` with **plain-files storage on its root disk** (`local`, see decision 1,
unavailable. Phase 2 verified live on `grid` through the hub: ISO fetched by URL with its revised 2026-10-11 — the file-backed ZFS pool and the test VM are gone); the other machines
sha256 checked, **New VM from ISO** booted Alpine with Secure Boot off, **the VGA console show Install or, on `gate`, unavailable. A **Disk location** picker is on both create forms,
drew it in a real browser** (headless Chromium, 1280×800 canvas, keyboard and mouse live), with add/remove of locations. Phase 2 verified live on `grid` through the hub on 2026-10-09:
a virtiofs mount appeared in a running VM, an export tarball landed in `virt/exports/` and the ISO fetched by URL with its sha256 checked, New VM from ISO booted Alpine with Secure Boot
nightly cron line is written, `instance.*` sensors flow and the "VM or container down" alert off, the VGA console drew it in a real browser (headless Chromium; needed the `binary`
default is in. Two things found and fixed on the way: spice-html5 wants the `binary` websocket subprotocol and one console operation per VM with a data websocket per SPICE
websocket subprotocol agreed; and it opens one websocket per SPICE channel, so the bridge channel), a virtiofs mount appeared in a running VM, an export tarball landed and the nightly
holds **one console operation per VM** and gives each channel its own data websocket on it cron line is written, `instance.*` sensors flow and the "VM or container down" alert default
(Incus refuses a second operation as "already connected"). **Left from phase 2:** the LAN is in. **Left:** the LAN bridge step (his call, needs him near the NanoKVM), cloud-init
bridge step (`br0` on the uplink — needs a live test on a machine that can afford to drop presets beyond "Use my SSH keys", a backup line for `grid`'s `virt/exports/`.
off the network for a moment, which is a decision, not a build), cloud-init presets beyond
"Use my SSH keys", and a backup line for `grid`'s `virt/exports/` (see `hamlet/docs/backups.md`).
`t-alpine` on `grid` is the live example; delete it from the tab when done.
A tab for creating, watching, consoling into and deleting **LXC containers and KVM virtual A tab for creating, watching, consoling into and deleting **LXC containers and KVM virtual
machines**, the way Proxmox does with `pct` and `qm` — **per machine, like every other machines**, the way Proxmox does with `pct` and `qm` — **per machine, like every other
@@ -236,12 +233,18 @@ like everything else.
## Layout decisions Ham Lab makes per machine (overridable in the Install dialog) ## Layout decisions Ham Lab makes per machine (overridable in the Install dialog)
1. **Storage.** The instance pool's backing lives in Ham Lab's folder: Install creates a 1. **Storage — revised 2026-10-11.** The create forms carry a **Disk location** picker: a
sparse `virt/pool.img` and hands it to Incus as the pool's `source` (zfs or btrfs by the list of the machine's Incus storage pools, each described by where its bytes land, with
detection above), or `virt/pool/` as a `dir` pool where neither is available. Sized from "+ add a location" (any folder on the machine becomes a plain-files pool). On `grid` the
the disk's free space, shown and editable. Bulk data is **bind-mounted in**, not stored in one location is `local`: plain files under `virt/pool/` on the root NVMe, each VM's disk
the pool. The driver, path and size are recorded in `virt.json` so the page can say where its own sparse file sized on its own, each container a directory, nothing pre-sized.
the data is and Install can rebuild the pool from the file after a reinstall. *The first design* (a ZFS pool inside one sparse file, handed to Incus as the pool's
source, for instant snapshots and clones) was built, ran for two days, and was taken out
at Mitchell's instruction: one pre-sized file holding every disk is an opaque middle
layer with a number on it that reads as a reservation, and "zfs · hamlab-virt" on the page
read as the NAS pool. The Install dialog still offers zfs/btrfs-in-a-file for a machine
that wants instant snapshots; the labels now say exactly what each is. Bulk data is still
**bind-mounted in**, not stored in the pool.
2. **Bridge subnet, computed.** Incus's `auto` picks a random `10.x.y.0/24`, and **this 2. **Bridge subnet, computed.** Incus's `auto` picks a random `10.x.y.0/24`, and **this
LAN is `10.0.0.0/8`** — every host has a /8 on its NIC, so any 10.x bridge collides. LAN is `10.0.0.0/8`** — every host has a /8 on its NIC, so any 10.x bridge collides.
Ham Lab reads the machine's routes and picks the first free /24 from `192.168.0.0/16`, Ham Lab reads the machine's routes and picks the first free /24 from `192.168.0.0/16`,
+2
View File
@@ -259,6 +259,8 @@ func (s *Server) routes() *http.ServeMux {
m.HandleFunc("POST /api/virt/exports/run", s.auth(s.virtExportsRun)) m.HandleFunc("POST /api/virt/exports/run", s.auth(s.virtExportsRun))
m.HandleFunc("POST /api/virt/exports/delete", s.auth(s.virtExportDelete)) m.HandleFunc("POST /api/virt/exports/delete", s.auth(s.virtExportDelete))
m.HandleFunc("GET /api/virt/sshkeys", s.auth(s.virtSSHKeys)) m.HandleFunc("GET /api/virt/sshkeys", s.auth(s.virtSSHKeys))
m.HandleFunc("POST /api/virt/pools", s.auth(s.virtPoolCreate))
m.HandleFunc("POST /api/virt/pools/delete", s.auth(s.virtPoolDelete))
m.HandleFunc("GET /api/alerts", s.auth(s.alertsGet)) m.HandleFunc("GET /api/alerts", s.auth(s.alertsGet))
m.HandleFunc("PUT /api/alerts", s.auth(s.alertsPut)) m.HandleFunc("PUT /api/alerts", s.auth(s.alertsPut))
m.HandleFunc("POST /api/alerts/test", s.auth(s.alertsTest)) m.HandleFunc("POST /api/alerts/test", s.auth(s.alertsTest))
+68
View File
@@ -48,6 +48,7 @@ func (s *Server) virtOverview(w http.ResponseWriter, r *http.Request) {
imgs, _ := s.virt.Images(ctx) imgs, _ := s.virt.Images(ctx)
out["images"] = imgs out["images"] = imgs
out["pools"] = s.virt.Pools(ctx) out["pools"] = s.virt.Pools(ctx)
out["default_pool"] = s.virt.DefaultPool(ctx)
out["networks"] = s.virt.Networks(ctx) out["networks"] = s.virt.Networks(ctx)
} }
isos := s.virt.Library(s.virt.ISOsDir()) isos := s.virt.Library(s.virt.ISOsDir())
@@ -1094,3 +1095,70 @@ func (s *Server) virtSSHKeys(w http.ResponseWriter, _ *http.Request) {
} }
jsonOK(w, 200, map[string]any{"keys": keys, "user": s.user}) jsonOK(w, 200, map[string]any{"keys": keys, "user": s.user})
} }
// ---- locations: storage pools, added as plain-files pools at a path of his choosing ----
func (s *Server) virtPoolCreate(w http.ResponseWriter, r *http.Request) {
var body struct {
Name string `json:"name"`
Path string `json:"path"`
}
_ = readBody(r, &body, 8192)
p := filepath.Clean(body.Path)
name := strings.TrimSpace(body.Name)
if name == "" {
name = sensors.Seg(filepath.Base(p))
}
if !incus.ValidName(name) {
jsonErr(w, 400, "name: letters, digits and dashes, starting with a letter")
return
}
if !filepath.IsAbs(p) || p == "/" {
jsonErr(w, 400, "path: an absolute path to a folder on this machine")
return
}
if !s.virtReady(w, r) {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
defer cancel()
if st, err := os.Stat(p); err == nil && !st.IsDir() {
jsonErr(w, 400, p+" is a file, not a folder")
return
} else if err != nil {
mk := priv.Wrap("mkdir", "-p", p) // Incus wants the folder there; it fills it as root
if out, err := exec.CommandContext(ctx, mk[0], mk[1:]...).CombinedOutput(); err != nil {
jsonErr(w, 500, "could not make "+p+": "+strings.TrimSpace(string(out)))
return
}
}
if out, err := s.virt.Cmd(ctx, "storage", "create", name, "dir", "source="+p).CombinedOutput(); err != nil {
jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: "))
return
}
log.Printf("virt: location %s added at %s (from %s)", name, p, clientIP(r))
jsonOK(w, 200, map[string]string{"name": name, "path": p})
}
func (s *Server) virtPoolDelete(w http.ResponseWriter, r *http.Request) {
var body struct {
Name string `json:"name"`
}
_ = readBody(r, &body, 4096)
if !incus.ValidName(body.Name) {
jsonErr(w, 400, "bad name")
return
}
if !s.virtReady(w, r) {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
defer cancel()
// Incus refuses a pool anything still uses; that refusal is the answer
if out, err := s.virt.Cmd(ctx, "storage", "delete", body.Name).CombinedOutput(); err != nil {
jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: "))
return
}
log.Printf("virt: location %s removed (from %s)", body.Name, clientIP(r))
w.WriteHeader(204)
}
+66 -6
View File
@@ -28,7 +28,7 @@ import (
) )
const ( const (
PoolName = "default" PoolName = "local" // plain files on the machine's own disk, in Ham Lab's folder
BridgeName = "incusbr0" BridgeName = "incusbr0"
ZPoolName = "hamlab-virt" // the ZFS pool made on the backing file; Incus is handed it by name ZPoolName = "hamlab-virt" // the ZFS pool made on the backing file; Incus is handed it by name
AdminGroup = "incus-admin" AdminGroup = "incus-admin"
@@ -615,11 +615,38 @@ type Pool struct {
Name string `json:"name"` Name string `json:"name"`
Driver string `json:"driver"` Driver string `json:"driver"`
Source string `json:"source"` Source string `json:"source"`
Where string `json:"where"` // in words: what disk this is, so a pool is picked by place, not by driver
Used uint64 `json:"used"` Used uint64 `json:"used"`
Total uint64 `json:"total"` Total uint64 `json:"total"`
UsedBy int `json:"used_by"` UsedBy int `json:"used_by"`
} }
// poolWhere describes a pool by where its bytes land.
func (m *Manager) poolWhere(driver, source string) string {
inHam := strings.HasPrefix(source, m.dir+"/") || source == m.dir
switch driver {
case "dir":
if inHam {
return "this machine's own disk — plain files, each disk sized on its own"
}
return "plain files at " + source
case "zfs":
if strings.HasSuffix(source, ".img") || source == ZPoolName {
return "a ZFS pool inside one file on this machine's disk"
}
if strings.Contains(source, "/") {
return "ZFS dataset " + source
}
return "ZFS pool " + source
case "btrfs":
if inHam {
return "btrfs inside one file on this machine's disk"
}
return "btrfs at " + source
}
return driver + " at " + source
}
type Network struct { type Network struct {
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type"` Type string `json:"type"`
@@ -647,7 +674,7 @@ func (m *Manager) Pools(ctx context.Context) []Pool {
} }
var out2 []Pool var out2 []Pool
for _, p := range raw { for _, p := range raw {
pool := Pool{Name: p.Name, Driver: p.Driver, Source: p.Config["source"], UsedBy: len(p.UsedBy)} pool := Pool{Name: p.Name, Driver: p.Driver, Source: p.Config["source"], Where: m.poolWhere(p.Driver, p.Config["source"]), UsedBy: len(p.UsedBy)}
if b, err := m.run(c, "query", "/1.0/storage-pools/"+p.Name+"/resources"); err == nil { if b, err := m.run(c, "query", "/1.0/storage-pools/"+p.Name+"/resources"); err == nil {
var res struct { var res struct {
Space struct { Space struct {
@@ -664,6 +691,24 @@ func (m *Manager) Pools(ctx context.Context) []Pool {
return out2 return out2
} }
// DefaultPool is where a new instance's disk goes unless the form says otherwise: the pool
// on the default profile's root device.
func (m *Manager) DefaultPool(ctx context.Context) string {
c, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
out, err := m.run(c, "query", "/1.0/profiles/default")
if err != nil {
return ""
}
var p struct {
Devices map[string]map[string]string `json:"devices"`
}
if json.Unmarshal(out, &p) != nil {
return ""
}
return p.Devices["root"]["pool"]
}
func (m *Manager) Networks(ctx context.Context) []Network { func (m *Manager) Networks(ctx context.Context) []Network {
c, cancel := context.WithTimeout(ctx, 20*time.Second) c, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel() defer cancel()
@@ -869,6 +914,7 @@ type CreateSpec struct {
CPU string `json:"cpu"` CPU string `json:"cpu"`
Memory string `json:"memory"` Memory string `json:"memory"`
Disk string `json:"disk"` Disk string `json:"disk"`
Pool string `json:"pool"` // where the root disk goes; blank is the default profile's
Network string `json:"network"` Network string `json:"network"`
CloudInit string `json:"cloud_init"` CloudInit string `json:"cloud_init"`
Profiles []string `json:"profiles"` Profiles []string `json:"profiles"`
@@ -891,6 +937,8 @@ func CreateArgs(sp CreateSpec, kvm bool) ([]string, error) {
return nil, errors.New("memory: a size, like 2GiB") return nil, errors.New("memory: a size, like 2GiB")
case !ValidSize(sp.Disk): case !ValidSize(sp.Disk):
return nil, errors.New("disk: a size, like 20GiB") return nil, errors.New("disk: a size, like 20GiB")
case sp.Pool != "" && !ValidName(sp.Pool):
return nil, errors.New("pool: a storage pool's name")
case !ValidNetwork(sp.Network): case !ValidNetwork(sp.Network):
return nil, errors.New("network: a bridge name") return nil, errors.New("network: a bridge name")
case len(sp.CloudInit) > 256<<10: case len(sp.CloudInit) > 256<<10:
@@ -912,8 +960,16 @@ func CreateArgs(sp CreateSpec, kvm bool) ([]string, error) {
if sp.Memory != "" { if sp.Memory != "" {
args = append(args, "-c", "limits.memory="+sp.Memory) args = append(args, "-c", "limits.memory="+sp.Memory)
} }
// the root disk: the profile's, with the pool and size overridden where given
var root []string
if sp.Pool != "" {
root = append(root, "pool="+sp.Pool)
}
if sp.Disk != "" { if sp.Disk != "" {
args = append(args, "-d", "root,size="+sp.Disk) root = append(root, "size="+sp.Disk)
}
if len(root) > 0 {
args = append(args, "-d", "root,"+strings.Join(root, ","))
} }
if sp.Network != "" { if sp.Network != "" {
args = append(args, "-n", sp.Network) args = append(args, "-n", sp.Network)
@@ -1104,6 +1160,7 @@ type FromISOSpec struct {
CPU string `json:"cpu"` CPU string `json:"cpu"`
Memory string `json:"memory"` Memory string `json:"memory"`
Disk string `json:"disk"` Disk string `json:"disk"`
Pool string `json:"pool"`
Network string `json:"network"` Network string `json:"network"`
SecureBoot bool `json:"secureboot"` SecureBoot bool `json:"secureboot"`
Start bool `json:"start"` Start bool `json:"start"`
@@ -1121,6 +1178,8 @@ func FromISOScript(sp FromISOSpec, kvm bool) (script string, params []string, er
return "", nil, errors.New("iso: pick one from the library") return "", nil, errors.New("iso: pick one from the library")
case !ValidCPU(sp.CPU) || !ValidSize(sp.Memory) || !ValidSize(sp.Disk) || !ValidNetwork(sp.Network): case !ValidCPU(sp.CPU) || !ValidSize(sp.Memory) || !ValidSize(sp.Disk) || !ValidNetwork(sp.Network):
return "", nil, errors.New("cpu, memory, disk: a count and two sizes, like 2, 2GiB, 20GiB") return "", nil, errors.New("cpu, memory, disk: a count and two sizes, like 2, 2GiB, 20GiB")
case sp.Pool != "" && !ValidName(sp.Pool):
return "", nil, errors.New("pool: a storage pool's name")
} }
cpu, mem, disk := sp.CPU, sp.Memory, sp.Disk cpu, mem, disk := sp.CPU, sp.Memory, sp.Disk
if cpu == "" { if cpu == "" {
@@ -1140,13 +1199,14 @@ func FromISOScript(sp FromISOSpec, kvm bool) (script string, params []string, er
if sp.Start { if sp.Start {
start = "1" start = "1"
} }
// $1 name $2 iso path $3 cpu $4 memory $5 disk $6 network $7 secureboot $8 start // $1 name $2 iso path $3 cpu $4 memory $5 disk $6 network $7 secureboot $8 start $9 pool
script = `set -e script = `set -e
NET=""; [ -n "$6" ] && NET="-n $6" NET=""; [ -n "$6" ] && NET="-n $6"
incus init --empty --vm "$1" -c limits.cpu="$3" -c limits.memory="$4" -c security.secureboot="$7" -d root,size="$5" $NET ROOT="root,size=$5"; [ -n "$9" ] && ROOT="root,pool=$9,size=$5"
incus init --empty --vm "$1" -c limits.cpu="$3" -c limits.memory="$4" -c security.secureboot="$7" -d "$ROOT" $NET
incus config device add "$1" iso0 disk source="$2" boot.priority=10 incus config device add "$1" iso0 disk source="$2" boot.priority=10
echo "made $1 with $2 as its first boot device" echo "made $1 with $2 as its first boot device"
[ "$8" = 1 ] && incus start "$1" && echo "started; open the VGA console to run the installer" [ "$8" = 1 ] && incus start "$1" && echo "started; open the VGA console to run the installer"
exit 0` exit 0`
return script, []string{sp.Name, sp.ISO, cpu, mem, disk, sp.Network, sb, start}, nil return script, []string{sp.Name, sp.ISO, cpu, mem, disk, sp.Network, sb, start, sp.Pool}, nil
} }
+6 -3
View File
@@ -21,13 +21,13 @@ func TestInstallScriptShapes(t *testing.T) {
}{ }{
{"grid: zfs, apt, docker", Status{State: "installable", PM: "apt", Arch: "x86_64", Drivers: []string{"zfs", "btrfs", "dir"}, Docker: true, ZFSHost: true, DiskFree: 700 << 30, MemTotal: 62 << 30}, {"grid: zfs, apt, docker", Status{State: "installable", PM: "apt", Arch: "x86_64", Drivers: []string{"zfs", "btrfs", "dir"}, Docker: true, ZFSHost: true, DiskFree: 700 << 30, MemTotal: 62 << 30},
InstallOpts{Driver: "zfs", PoolSizeGiB: 300, Subnet: "172.16.100.1/24", DockerRules: true, ARCMaxGiB: 16}, InstallOpts{Driver: "zfs", PoolSizeGiB: 300, Subnet: "172.16.100.1/24", DockerRules: true, ARCMaxGiB: 16},
[]string{"apt-get", "zpool create", "incus storage create default zfs source=hamlab-virt", "ipv4.address='172.16.100.1/24'", "DOCKER-USER", "zfs_arc_max", "usermod -aG incus-admin 'core'", "SupplementaryGroups=incus-admin", "systemd-run"}}, []string{"apt-get", "zpool create", "incus storage create local zfs source=hamlab-virt", "ipv4.address='172.16.100.1/24'", "DOCKER-USER", "zfs_arc_max", "usermod -aG incus-admin 'core'", "SupplementaryGroups=incus-admin", "systemd-run"}},
{"core: btrfs, apt, docker, no tools yet", Status{State: "installable", PM: "apt", Arch: "x86_64", Drivers: []string{"btrfs", "dir"}, Docker: true, DiskFree: 170 << 30, MemTotal: 8 << 30}, {"core: btrfs, apt, docker, no tools yet", Status{State: "installable", PM: "apt", Arch: "x86_64", Drivers: []string{"btrfs", "dir"}, Docker: true, DiskFree: 170 << 30, MemTotal: 8 << 30},
InstallOpts{Driver: "btrfs", PoolSizeGiB: 40, Subnet: "172.16.100.1/24", DockerRules: true}, InstallOpts{Driver: "btrfs", PoolSizeGiB: 40, Subnet: "172.16.100.1/24", DockerRules: true},
[]string{"btrfs-progs", "mkfs.btrfs", "/etc/fstab", "incus storage create default btrfs source="}}, []string{"btrfs-progs", "mkfs.btrfs", "/etc/fstab", "incus storage create local btrfs source="}},
{"mac: dir, dnf, arm64", Status{State: "installable", PM: "dnf", Arch: "aarch64", Drivers: []string{"dir"}, DiskFree: 200 << 30, MemTotal: 15 << 30}, {"mac: dir, dnf, arm64", Status{State: "installable", PM: "dnf", Arch: "aarch64", Drivers: []string{"dir"}, DiskFree: 200 << 30, MemTotal: 15 << 30},
InstallOpts{Driver: "dir", Subnet: "172.16.101.1/24"}, InstallOpts{Driver: "dir", Subnet: "172.16.101.1/24"},
[]string{"dnf -y install incus qemu-system-aarch64-core edk2-aarch64", "incus storage create default dir source="}}, []string{"dnf -y install incus qemu-system-aarch64-core edk2-aarch64", "incus storage create local dir source="}},
{"repair: already installed", Status{State: "repair", PM: "apt", Arch: "x86_64", Drivers: []string{"dir"}, DiskFree: 10 << 30, MemTotal: 4 << 30}, {"repair: already installed", Status{State: "repair", PM: "apt", Arch: "x86_64", Drivers: []string{"dir"}, DiskFree: 10 << 30, MemTotal: 4 << 30},
InstallOpts{Driver: "dir", Subnet: "172.16.100.1/24"}, InstallOpts{Driver: "dir", Subnet: "172.16.100.1/24"},
[]string{"already installed"}}, []string{"already installed"}},
@@ -75,6 +75,9 @@ func TestCreateArgs(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
want := "launch images:debian/13 web-1 --vm -c limits.cpu=2 -c limits.memory=2GiB -d root,size=20GiB -n incusbr0" want := "launch images:debian/13 web-1 --vm -c limits.cpu=2 -c limits.memory=2GiB -d root,size=20GiB -n incusbr0"
if a, err := CreateArgs(CreateSpec{Name: "x", Image: "images:alpine/3.22", Pool: "local"}, true); err != nil || strings.Join(a, " ") != "init images:alpine/3.22 x -d root,pool=local" {
t.Fatalf("pool override: %v %v", a, err)
}
if got := strings.Join(args, " "); got != want { if got := strings.Join(args, " "); got != want {
t.Fatalf("got %q", got) t.Fatalf("got %q", got)
} }
-1
View File
@@ -66,7 +66,6 @@ type Policy struct {
KeepMin int `json:"keep_min"` KeepMin int `json:"keep_min"`
Prefix string `json:"prefix"` Prefix string `json:"prefix"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
} }
func (p *Policy) prefix() string { return strings.TrimSpace(p.Prefix) } func (p *Policy) prefix() string { return strings.TrimSpace(p.Prefix) }
+41 -11
View File
@@ -81,7 +81,7 @@ export default function Virtualization() {
{tab === 'images' && <Images st={st} images={images} instances={instances} onTask={startTask} onNew={(image, vm) => { setTab('instances'); setMaking({ image, vm }) }} onChanged={load} />} {tab === 'images' && <Images st={st} images={images} instances={instances} onTask={startTask} onNew={(image, vm) => { setTab('instances'); setMaking({ image, vm }) }} onChanged={load} />}
{tab === 'library' && <Library st={st} ov={ov} onTask={startTask} onChanged={load} onNewVM={(iso) => setFromIso(iso)} />} {tab === 'library' && <Library st={st} ov={ov} onTask={startTask} onChanged={load} onNewVM={(iso) => setFromIso(iso)} />}
</div> </div>
{tab === 'instances' && (cur ? <Detail i={cur} st={st} onBack={() => setSel('')} onChanged={load} onTask={startTask} /> : <Summary ov={ov} />)} {tab === 'instances' && (cur ? <Detail i={cur} st={st} onBack={() => setSel('')} onChanged={load} onTask={startTask} /> : <Summary ov={ov} onChanged={load} />)}
{making && <NewInstance st={st} ov={ov} preset={making} onClose={() => setMaking(null)} onStarted={(t) => { setMaking(null); startTask(t) }} />} {making && <NewInstance st={st} ov={ov} preset={making} onClose={() => setMaking(null)} onStarted={(t) => { setMaking(null); startTask(t) }} />}
{fromIso !== null && <NewVMFromISO st={st} ov={ov} iso={fromIso} onClose={() => setFromIso(null)} onStarted={(t) => { setFromIso(null); startTask(t); setTab('instances') }} />} {fromIso !== null && <NewVMFromISO st={st} ov={ov} iso={fromIso} onClose={() => setFromIso(null)} onStarted={(t) => { setFromIso(null); startTask(t); setTab('instances') }} />}
</div> </div>
@@ -151,11 +151,11 @@ function InstallDialog({ st, onClose, onStarted }: { st: VirtStatus; onClose: ()
<div className="notice">Everything goes under <span className="mono">{st.dir}</span>: the pool's backing file, ISOs, images, exports. Where is not a choice; how big is.</div> <div className="notice">Everything goes under <span className="mono">{st.dir}</span>: the pool's backing file, ISOs, images, exports. Where is not a choice; how big is.</div>
<label className="muted">Pool driver <label className="muted">Pool driver
<select value={driver} onChange={(e) => setDriver(e.target.value)}> <select value={driver} onChange={(e) => setDriver(e.target.value)}>
{st.drivers.map((d) => <option key={d} value={d}>{d}{d === 'zfs' ? ' — snapshots and clones are instant' : d === 'btrfs' ? ' — snapshots, on a loop file' : ' — plain files, no snapshots to speak of'}</option>)} {st.drivers.map((d) => <option key={d} value={d}>{d === 'dir' ? 'plain files on this disk — each VM\u2019s disk is its own file, sized on its own; snapshots are copies' : d === 'zfs' ? 'zfs — one pre-sized file holding every disk; snapshots and clones are instant' : 'btrfs — one pre-sized file holding every disk; snapshots are instant'}</option>)}
</select> </select>
</label> </label>
{driver !== 'dir' && ( {driver !== 'dir' && (
<label className="muted">Pool size, GiB — a sparse file, grows as used · {fmtBytes(st.disk_free)} free <label className="muted">Ceiling of that file, GiB — sparse, it grows only as disks fill it · {fmtBytes(st.disk_free)} free
<input type="number" min={5} max={Math.floor(st.disk_free / GiB)} value={size} onChange={(e) => setSize(e.target.value)} required /> <input type="number" min={5} max={Math.floor(st.disk_free / GiB)} value={size} onChange={(e) => setSize(e.target.value)} required />
</label> </label>
)} )}
@@ -187,7 +187,17 @@ function InstallDialog({ st, onClose, onStarted }: { st: VirtStatus; onClose: ()
// ---- the right-hand pane when nothing is selected: what this machine's Incus is ---- // ---- the right-hand pane when nothing is selected: what this machine's Incus is ----
function Summary({ ov }: { ov: VirtOverview }) { // A location is a plain-files storage pool at a folder of his choosing.
async function addLocation(onDone: (name: string) => void) {
const path = await askText('Folder on this machine for instance disks', '/', 'Next')
if (!path) return
const base = path.split('/').filter(Boolean).pop() ?? 'disks'
const name = await askText(`A name for ${path}`, base.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-|-$/g, ''), 'Add')
if (!name) return
try { const r = await api.virtPoolCreate(name, path); toast(`${r.name} added at ${r.path}`); onDone(r.name) } catch (e) { toast(errMsg(e), 'err') }
}
function Summary({ ov, onChanged }: { ov: VirtOverview; onChanged: () => void }) {
const st = ov.status const st = ov.status
return ( return (
<div className="svc-detail"> <div className="svc-detail">
@@ -198,9 +208,11 @@ function Summary({ ov }: { ov: VirtOverview }) {
{st.layout && <div><span className="k">Installed</span><span>{when(st.layout.attempted)} by Ham Lab {st.layout.hamlab_version ?? ''}</span></div>} {st.layout && <div><span className="k">Installed</span><span>{when(st.layout.attempted)} by Ham Lab {st.layout.hamlab_version ?? ''}</span></div>}
{!st.layout && <div><span className="k">Installed</span><span>outside Ham Lab — pools and networks below are as found</span></div>} {!st.layout && <div><span className="k">Installed</span><span>outside Ham Lab — pools and networks below are as found</span></div>}
</div> </div>
<h3 className="stack-head">Storage pools</h3> <h3 className="stack-head">Disk locations <span className="muted">· where an instance\u2019s disk can go</span></h3>
<table className="virt-table"><thead><tr><th>Pool</th><th>Driver</th><th>Source</th><th className="num">Used</th><th className="num">Of</th><th className="num">Users</th></tr></thead> <table className="virt-table"><thead><tr><th>Location</th><th>Where</th><th className="num">Used</th><th className="num">Free</th><th className="num">Disks</th><th /></tr></thead>
<tbody>{(ov.pools ?? []).map((p) => <tr key={p.name}><td>{p.name}</td><td>{p.driver}</td><td className="mono small">{p.source}</td><td className="num">{p.total ? fmtBytes(p.used) : '—'}</td><td className="num">{p.total ? fmtBytes(p.total) : '—'}</td><td className="num">{p.used_by}</td></tr>)}</tbody></table> <tbody>{(ov.pools ?? []).map((p) => <tr key={p.name}><td>{p.name}{p.name === ov.default_pool && <span className="badge">default</span>}</td><td>{p.where}<div className="muted small mono">{p.driver} · {p.source}</div></td><td className="num">{p.total ? fmtBytes(p.used) : '—'}</td><td className="num">{p.total ? fmtBytes(p.total - p.used) : '—'}</td><td className="num">{p.used_by}</td>
<td className="num">{p.used_by === 0 && p.name !== ov.default_pool && <Btn className="btn danger" confirm={`Remove the location ${p.name}? Nothing uses it; the folder itself is left alone.`} onClick={async () => { try { await api.virtPoolDelete(p.name); toast('removed'); onChanged() } catch (e) { toast(errMsg(e), 'err') } }}>Remove</Btn>}</td></tr>)}</tbody></table>
<div className="svc-actions"><Btn className="btn" onClick={() => addLocation(onChanged)}>+ Add a location…</Btn><span className="muted small">a folder on this machine; VM disks become files in it, containers folders</span></div>
<h3 className="stack-head">Networks</h3> <h3 className="stack-head">Networks</h3>
<table className="virt-table"><thead><tr><th>Network</th><th>Type</th><th>Address</th><th>NAT</th><th className="num">Users</th></tr></thead> <table className="virt-table"><thead><tr><th>Network</th><th>Type</th><th>Address</th><th>NAT</th><th className="num">Users</th></tr></thead>
<tbody>{(ov.networks ?? []).filter((n) => n.managed).map((n) => <tr key={n.name}><td>{n.name}</td><td>{n.type}</td><td className="mono small">{n.address ?? '—'}</td><td>{n.nat ? 'yes' : 'no'}</td><td className="num">{n.used_by}</td></tr>)}</tbody></table> <tbody>{(ov.networks ?? []).filter((n) => n.managed).map((n) => <tr key={n.name}><td>{n.name}</td><td>{n.type}</td><td className="mono small">{n.address ?? '—'}</td><td>{n.nat ? 'yes' : 'no'}</td><td className="num">{n.used_by}</td></tr>)}</tbody></table>
@@ -248,7 +260,7 @@ function Detail({ i, st, onBack, onChanged, onTask }: { i: VirtInstance; st: Vir
{i.description && <div><span className="k">About</span><span>{i.description}</span></div>} {i.description && <div><span className="k">About</span><span>{i.description}</span></div>}
{running && <div><span className="k">Using</span><span>{i.cpu_pct.toFixed(0)}% of a core · {fmtBytes(i.mem_usage)}{i.mem_peak ? ` (peak ${fmtBytes(i.mem_peak)})` : ''}{i.processes > 0 ? ` · ${i.processes} processes` : ''}</span></div>} {running && <div><span className="k">Using</span><span>{i.cpu_pct.toFixed(0)}% of a core · {fmtBytes(i.mem_usage)}{i.mem_peak ? ` (peak ${fmtBytes(i.mem_peak)})` : ''}{i.processes > 0 ? ` · ${i.processes} processes` : ''}</span></div>}
<div><span className="k">Limits</span><span>{i.cpu ? `${i.cpu} CPU` : 'all CPUs'} · {i.memory || 'unlimited memory'}</span></div> <div><span className="k">Limits</span><span>{i.cpu ? `${i.cpu} CPU` : 'all CPUs'} · {i.memory || 'unlimited memory'}</span></div>
{i.disk_total > 0 && <div><span className="k">Disk</span><span>{fmtBytes(i.disk_usage)} of {fmtBytes(i.disk_total)}</span></div>} <div><span className="k">Disk</span><span>{i.disk_total > 0 ? `${fmtBytes(i.disk_usage)} of ${fmtBytes(i.disk_total)}` : i.disk_usage > 0 ? fmtBytes(i.disk_usage) : 'no limit'}{i.devices.root?.pool ? <span className="muted"> · on {i.devices.root.pool}</span> : ''}</span></div>
<div><span className="k">Network</span><span className="mono small">{i.ipv4.length ? i.ipv4.join(' ') : running ? 'no address yet' : '—'}</span></div> <div><span className="k">Network</span><span className="mono small">{i.ipv4.length ? i.ipv4.join(' ') : running ? 'no address yet' : '—'}</span></div>
<div><span className="k">Created</span><span>{when(i.created)}</span></div> <div><span className="k">Created</span><span>{when(i.created)}</span></div>
</div> </div>
@@ -348,6 +360,20 @@ function Snapshots({ i, onChanged }: { i: VirtInstance; onChanged: () => void })
// ---- making one ---- // ---- making one ----
// Where an instance's disk goes. One entry is still a choice shown; a new one can be added here.
function PoolPick({ ov, value, onChange }: { ov: VirtOverview; value: string; onChange: (v: string) => void }) {
const [extra, setExtra] = useState<{ name: string; where: string }[]>([])
const pools = [...(ov.pools ?? []).map((p) => ({ name: p.name, where: p.where + (p.total ? ` · ${fmtBytes(p.total - p.used)} free` : '') })), ...extra.filter((e) => !(ov.pools ?? []).some((p) => p.name === e.name))]
return (
<label className="muted">Disk location
<select value={value} onChange={(e) => { if (e.target.value === '+') addLocation((n) => { setExtra((x) => [...x, { name: n, where: 'just added' }]); onChange(n) }); else onChange(e.target.value) }}>
{pools.map((p) => <option key={p.name} value={p.name}>{p.name} — {p.where}</option>)}
<option value="+">+ add a location…</option>
</select>
</label>
)
}
function NewInstance({ st, ov, preset, onClose, onStarted }: { st: VirtStatus; ov: VirtOverview; preset: { image?: string; vm?: boolean }; onClose: () => void; onStarted: (t: TaskInfo) => void }) { function NewInstance({ st, ov, preset, onClose, onStarted }: { st: VirtStatus; ov: VirtOverview; preset: { image?: string; vm?: boolean }; onClose: () => void; onStarted: (t: TaskInfo) => void }) {
const [vm, setVm] = useState(!!preset.vm && st.kvm) const [vm, setVm] = useState(!!preset.vm && st.kvm)
const [image, setImage] = useState(preset.image ?? '') const [image, setImage] = useState(preset.image ?? '')
@@ -355,6 +381,7 @@ function NewInstance({ st, ov, preset, onClose, onStarted }: { st: VirtStatus; o
const [cpu, setCpu] = useState('') const [cpu, setCpu] = useState('')
const [mem, setMem] = useState('') const [mem, setMem] = useState('')
const [disk, setDisk] = useState('') const [disk, setDisk] = useState('')
const [pool, setPool] = useState(ov.default_pool || ov.pools?.[0]?.name || '')
const nets = (ov.networks ?? []).filter((n) => n.managed || n.type === 'bridge') const nets = (ov.networks ?? []).filter((n) => n.managed || n.type === 'bridge')
const [net, setNet] = useState(nets.find((n) => n.name === 'incusbr0')?.name ?? nets[0]?.name ?? '') const [net, setNet] = useState(nets.find((n) => n.name === 'incusbr0')?.name ?? nets[0]?.name ?? '')
const [cloud, setCloud] = useState('') const [cloud, setCloud] = useState('')
@@ -387,7 +414,7 @@ function NewInstance({ st, ov, preset, onClose, onStarted }: { st: VirtStatus; o
}, [q, local, remote]) }, [q, local, remote])
const submit = async (e: FormEvent) => { const submit = async (e: FormEvent) => {
e.preventDefault(); setErr(''); setBusy(true) e.preventDefault(); setErr(''); setBusy(true)
try { onStarted(await api.virtCreate({ name: name.trim(), image: image.trim(), vm, cpu: cpu.trim(), memory: mem.trim(), disk: disk.trim(), network: net, cloud_init: showCloud ? cloud : '', profiles: [], description: desc.trim(), start })) } try { onStarted(await api.virtCreate({ name: name.trim(), image: image.trim(), vm, cpu: cpu.trim(), memory: mem.trim(), disk: disk.trim(), pool, network: net, cloud_init: showCloud ? cloud : '', profiles: [], description: desc.trim(), start })) }
catch (e) { setErr(errMsg(e)); setBusy(false) } catch (e) { setErr(errMsg(e)); setBusy(false) }
} }
return ( return (
@@ -418,8 +445,9 @@ function NewInstance({ st, ov, preset, onClose, onStarted }: { st: VirtStatus; o
<div className="virt-form-row"> <div className="virt-form-row">
<label className="muted">CPUs<input className="mono" value={cpu} onChange={(e) => setCpu(e.target.value)} placeholder={vm ? '2' : 'all'} /></label> <label className="muted">CPUs<input className="mono" value={cpu} onChange={(e) => setCpu(e.target.value)} placeholder={vm ? '2' : 'all'} /></label>
<label className="muted">Memory<input className="mono" value={mem} onChange={(e) => setMem(e.target.value)} placeholder={vm ? '2GiB' : 'unlimited'} /></label> <label className="muted">Memory<input className="mono" value={mem} onChange={(e) => setMem(e.target.value)} placeholder={vm ? '2GiB' : 'unlimited'} /></label>
<label className="muted">Root disk<input className="mono" value={disk} onChange={(e) => setDisk(e.target.value)} placeholder={vm ? '20GiB' : 'pool default'} /></label> <label className="muted">Root disk<input className="mono" value={disk} onChange={(e) => setDisk(e.target.value)} placeholder={vm ? '20GiB' : 'no limit'} /></label>
</div> </div>
<PoolPick ov={ov} value={pool} onChange={setPool} />
<div className="muted small">{fmtBytes(st.mem_avail)} free on this machine.{vm ? ' A VM’s memory is a reservation.' : ''}</div> <div className="muted small">{fmtBytes(st.mem_avail)} free on this machine.{vm ? ' A VM’s memory is a reservation.' : ''}</div>
<label className="muted">Description — optional<input value={desc} onChange={(e) => setDesc(e.target.value)} maxLength={200} /></label> <label className="muted">Description — optional<input value={desc} onChange={(e) => setDesc(e.target.value)} maxLength={200} /></label>
<label className="muted" style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}> <label className="muted" style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
@@ -556,6 +584,7 @@ function NewVMFromISO({ st, ov, iso, onClose, onStarted }: { st: VirtStatus; ov:
const [cpu, setCpu] = useState('2') const [cpu, setCpu] = useState('2')
const [mem, setMem] = useState('2GiB') const [mem, setMem] = useState('2GiB')
const [disk, setDisk] = useState('20GiB') const [disk, setDisk] = useState('20GiB')
const [pool, setPool] = useState(ov.default_pool || ov.pools?.[0]?.name || '')
const nets = (ov.networks ?? []).filter((n) => n.managed || n.type === 'bridge') const nets = (ov.networks ?? []).filter((n) => n.managed || n.type === 'bridge')
const [net, setNet] = useState(nets.find((n) => n.name === 'incusbr0')?.name ?? nets[0]?.name ?? '') const [net, setNet] = useState(nets.find((n) => n.name === 'incusbr0')?.name ?? nets[0]?.name ?? '')
const [sb, setSb] = useState(true) const [sb, setSb] = useState(true)
@@ -564,7 +593,7 @@ function NewVMFromISO({ st, ov, iso, onClose, onStarted }: { st: VirtStatus; ov:
useEscape(onClose) useEscape(onClose)
const submit = async (e: FormEvent) => { const submit = async (e: FormEvent) => {
e.preventDefault(); setErr(''); setBusy(true) e.preventDefault(); setErr(''); setBusy(true)
try { onStarted(await api.virtFromISO({ name: name.trim(), iso: pick, cpu: cpu.trim(), memory: mem.trim(), disk: disk.trim(), network: net, secureboot: sb, start: true })) } try { onStarted(await api.virtFromISO({ name: name.trim(), iso: pick, cpu: cpu.trim(), memory: mem.trim(), disk: disk.trim(), pool, network: net, secureboot: sb, start: true })) }
catch (e) { setErr(errMsg(e)); setBusy(false) } catch (e) { setErr(errMsg(e)); setBusy(false) }
} }
return ( return (
@@ -581,6 +610,7 @@ function NewVMFromISO({ st, ov, iso, onClose, onStarted }: { st: VirtStatus; ov:
<label className="muted">Memory<input className="mono" value={mem} onChange={(e) => setMem(e.target.value)} /></label> <label className="muted">Memory<input className="mono" value={mem} onChange={(e) => setMem(e.target.value)} /></label>
<label className="muted">Disk<input className="mono" value={disk} onChange={(e) => setDisk(e.target.value)} /></label> <label className="muted">Disk<input className="mono" value={disk} onChange={(e) => setDisk(e.target.value)} /></label>
</div> </div>
<PoolPick ov={ov} value={pool} onChange={setPool} />
<label className="muted" style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}> <label className="muted" style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<input type="checkbox" checked={sb} onChange={(e) => setSb(e.target.checked)} style={{ width: 'auto' }} />Secure Boot — Windows, Debian, Ubuntu and Fedora boot with it; Alpine, Arch, TrueNAS and most others need it off <input type="checkbox" checked={sb} onChange={(e) => setSb(e.target.checked)} style={{ width: 'auto' }} />Secure Boot — Windows, Debian, Ubuntu and Fedora boot with it; Alpine, Arch, TrueNAS and most others need it off
</label> </label>
+6 -4
View File
@@ -260,17 +260,17 @@ export interface VirtStatus { state: 'ready' | 'repair' | 'installable' | 'unava
export interface VirtSnapshot { name: string; created: string; expires?: string; size?: number } export interface VirtSnapshot { name: string; created: string; expires?: string; size?: number }
export interface VirtInstance { name: string; type: 'container' | 'virtual-machine'; status: string; arch: string; description?: string; image: string; os?: string; release?: string; cpu?: string; memory?: string; created: string; last_used: string; ipv4: string[]; snapshots: VirtSnapshot[]; profiles: string[]; ephemeral: boolean; cpu_pct: number; mem_usage: number; mem_peak: number; disk_usage: number; disk_total: number; pid: number; processes: number; config: Record<string, string>; devices: Record<string, Record<string, string>>; schedule?: string; expiry?: string } export interface VirtInstance { name: string; type: 'container' | 'virtual-machine'; status: string; arch: string; description?: string; image: string; os?: string; release?: string; cpu?: string; memory?: string; created: string; last_used: string; ipv4: string[]; snapshots: VirtSnapshot[]; profiles: string[]; ephemeral: boolean; cpu_pct: number; mem_usage: number; mem_peak: number; disk_usage: number; disk_total: number; pid: number; processes: number; config: Record<string, string>; devices: Record<string, Record<string, string>>; schedule?: string; expiry?: string }
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 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 VirtPool { name: string; driver: string; source: string; where: 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 VirtNetwork { name: string; type: string; managed: boolean; address?: string; nat: boolean; used_by: number }
export interface LibraryFile { name: string; size: number; modified: string; attached_to?: 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 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 ExportRun { at: string; exported: string[]; pruned: string[]; errors?: string[]; seconds: number }
export interface ExportFile { instance: string; name: string; size: number; modified: string } 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 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 FromISO { name: string; iso: string; cpu: string; memory: string; disk: string; pool: 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 VirtOverview { status: VirtStatus; error?: string; instances?: VirtInstance[]; images?: VirtImage[]; pools?: VirtPool[]; default_pool?: string; 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 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 } export interface VirtCreate { name: string; image: string; vm: boolean; cpu: string; memory: string; disk: string; pool: string; network: string; cloud_init: string; profiles: string[]; description: string; start: boolean }
export const api = { export const api = {
procs: () => req<ProcTable>('GET', '/api/procs'), procs: () => req<ProcTable>('GET', '/api/procs'),
@@ -456,6 +456,8 @@ export const api = {
virtExportsRun: (instance = '') => req<TaskInfo>('POST', '/api/virt/exports/run', { instance }), virtExportsRun: (instance = '') => req<TaskInfo>('POST', '/api/virt/exports/run', { instance }),
virtExportDelete: (instance: string, name: string) => req<void>('POST', '/api/virt/exports/delete', { instance, name }), virtExportDelete: (instance: string, name: string) => req<void>('POST', '/api/virt/exports/delete', { instance, name }),
virtSSHKeys: () => req<{ keys: string[]; user: string }>('GET', '/api/virt/sshkeys'), virtSSHKeys: () => req<{ keys: string[]; user: string }>('GET', '/api/virt/sshkeys'),
virtPoolCreate: (name: string, path: string) => req<{ name: string; path: string }>('POST', '/api/virt/pools', { name, path }),
virtPoolDelete: (name: string) => req<void>('POST', '/api/virt/pools/delete', { name }),
} }
export function wsURLPath(path: string): string { export function wsURLPath(path: string): string {