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>
This commit is contained in:
Mitchell-Johnson
2026-10-11 14:33:10 -07:00
co-authored by Claude Fable 5.1
parent a9b4d7969e
commit ae72e45bf5
8 changed files with 191 additions and 27 deletions
+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) }
+2 -2
View File
@@ -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
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 {