Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
faf444d8ce | ||
|
|
ae72e45bf5 |
+24
-21
@@ -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`,
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
|||||||
@@ -106,8 +106,8 @@ func TestValidateRefusesNonsense(t *testing.T) {
|
|||||||
{Dataset: "tank", Every: "custom", KeepFor: "6w"},
|
{Dataset: "tank", Every: "custom", KeepFor: "6w"},
|
||||||
{Dataset: "tank", Every: "custom", Cron: "not a schedule", KeepFor: "6w"},
|
{Dataset: "tank", Every: "custom", Cron: "not a schedule", KeepFor: "6w"},
|
||||||
{Dataset: "tank", Every: "daily", At: "25:00", KeepFor: "6w"},
|
{Dataset: "tank", Every: "daily", At: "25:00", KeepFor: "6w"},
|
||||||
{Dataset: "tank", Every: "daily", At: "02:30"}, // no retention
|
{Dataset: "tank", Every: "daily", At: "02:30"}, // no retention
|
||||||
{Dataset: "tank", Every: "daily", At: "02:30", KeepFor: "6 weeks"}, // unparseable
|
{Dataset: "tank", Every: "daily", At: "02:30", KeepFor: "6 weeks"}, // unparseable
|
||||||
{Dataset: "tank", Every: "monthly", At: "02:30", Day: 31, KeepFor: "6w"}, // not every month has one
|
{Dataset: "tank", Every: "monthly", At: "02:30", Day: 31, KeepFor: "6w"}, // not every month has one
|
||||||
{Dataset: "tank", Every: "daily", At: "02:30", KeepFor: "6w", Prefix: "a--b"},
|
{Dataset: "tank", Every: "daily", At: "02:30", KeepFor: "6w", Prefix: "a--b"},
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-11
@@ -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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user