Virtualization tab, phase 1: Incus per machine — detect, install, instances, consoles, snapshots, images

The tab reports one of four states for the selected machine (ready, repair, installable,
unavailable) from what it finds — /dev/kvm, the package manager's incus candidate, the
pool drivers the kernel has — and acts on that machine only. Install is one followed task:
packages, a pool whose backing file sits in Ham Lab's own virt/ folder (zfs pool on a file,
btrfs on a loop mount, or a plain dir), a bridge on a subnet picked clear of the machine's
routes and Docker's pools (never 10.x on a /8 LAN), the DOCKER-USER rules that keep
Docker's FORWARD drop off the bridge, the incus-admin group for Ham Lab's account, and a
restart. Instances list with live CPU and memory, create from the images: catalogue or a
local image (container or VM, limits, cloud-init), start/stop/restart/force-stop/rename/
delete, console and shell as terminal tabs, snapshots with Incus's own schedule and expiry,
limits editing. Images: local store, catalogue search and download (exported into the
library too), import a tarball or a VM disk from a path, publish an instance.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-10-09 14:09:56 -07:00
co-authored by Claude Fable 5.1
parent af53b45b67
commit 240129c83f
9 changed files with 2311 additions and 3 deletions
+17
View File
@@ -34,6 +34,7 @@ import (
"hamlab/internal/cron" "hamlab/internal/cron"
"hamlab/internal/files" "hamlab/internal/files"
"hamlab/internal/hardware" "hamlab/internal/hardware"
"hamlab/internal/incus"
"hamlab/internal/metrics" "hamlab/internal/metrics"
"hamlab/internal/network" "hamlab/internal/network"
"hamlab/internal/nodes" "hamlab/internal/nodes"
@@ -75,6 +76,7 @@ type Server struct {
update updateState update updateState
events eventLog events eventLog
kvms kvmProxies kvms kvmProxies
virt *incus.Manager
fsLocal files.FS fsLocal files.FS
fsSudo files.FS // nil without passwordless sudo fsSudo files.FS // nil without passwordless sudo
nodes *nodes.Manager nodes *nodes.Manager
@@ -104,6 +106,7 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
if s.user == "" { if s.user == "" {
s.user = "?" s.user = "?"
} }
s.virt = incus.New(cfg.DataDir(), s.user, version)
s.migrateUsers() s.migrateUsers()
// The speed test and the outside view. Its history outlives a restart, and its last result // The speed test and the outside view. Its history outlives a restart, and its last result
// is published as sensors, so the line is graphable and alertable like anything else. // is published as sensors, so the line is graphable and alertable like anything else.
@@ -239,6 +242,20 @@ func (s *Server) routes() *http.ServeMux {
m.HandleFunc("GET /api/docker/containers/{id}/events", s.auth(s.dockerEvents)) m.HandleFunc("GET /api/docker/containers/{id}/events", s.auth(s.dockerEvents))
m.HandleFunc("POST /api/docker/containers/{id}/exec", s.auth(s.dockerExec)) m.HandleFunc("POST /api/docker/containers/{id}/exec", s.auth(s.dockerExec))
m.HandleFunc("POST /api/docker/containers/{id}/limits", s.auth(s.dockerUpdate)) m.HandleFunc("POST /api/docker/containers/{id}/limits", s.auth(s.dockerUpdate))
m.HandleFunc("GET /api/virt", s.auth(s.virtOverview))
m.HandleFunc("POST /api/virt/install", s.auth(s.virtInstall))
m.HandleFunc("GET /api/virt/images/remote", s.auth(s.virtRemoteImages))
m.HandleFunc("POST /api/virt/images/download", s.auth(s.virtImageDownload))
m.HandleFunc("POST /api/virt/images/import", s.auth(s.virtImageImport))
m.HandleFunc("POST /api/virt/images/publish", s.auth(s.virtImagePublish))
m.HandleFunc("POST /api/virt/images/delete", s.auth(s.virtImageDelete))
m.HandleFunc("POST /api/virt/instances", s.auth(s.virtCreate))
m.HandleFunc("GET /api/virt/instances/{name}", s.auth(s.virtInstance))
m.HandleFunc("POST /api/virt/instances/{name}/rename", s.auth(s.virtRename))
m.HandleFunc("PUT /api/virt/instances/{name}/config", s.auth(s.virtConfig))
m.HandleFunc("POST /api/virt/instances/{name}/snapshots", s.auth(s.virtSnapshotCreate))
m.HandleFunc("POST /api/virt/instances/{name}/snapshots/{snap}/{action}", s.auth(s.virtSnapshotAction))
m.HandleFunc("POST /api/virt/instances/{name}/{action}", s.auth(s.virtAction))
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))
+490
View File
@@ -0,0 +1,490 @@
package api
// The Virtualization tab: LXC containers and KVM virtual machines through Incus, per
// machine. Three page states — ready, repairable, installable, or unavailable with the
// reason — and every action lands on the machine that was asked. Long work (install, create,
// image downloads, publish) runs as a followed task; consoles are terminal sessions like
// any other, so they reattach and work through the hub's proxy.
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"hamlab/internal/incus"
)
func readBody(r *http.Request, v any, max int64) error {
return json.NewDecoder(io.LimitReader(r.Body, max)).Decode(v)
}
// virtOverview is the page: the machine's state, and when Incus answers, everything on it.
func (s *Server) virtOverview(w http.ResponseWriter, r *http.Request) {
st := s.virt.Detect(r.Context())
out := map[string]any{"status": st}
if st.State == "ready" {
ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second)
defer cancel()
inst, err := s.virt.Instances(ctx)
if err != nil {
out["error"] = err.Error()
}
out["instances"] = inst
imgs, _ := s.virt.Images(ctx)
out["images"] = imgs
out["pools"] = s.virt.Pools(ctx)
out["networks"] = s.virt.Networks(ctx)
}
out["library"] = map[string]any{"images": s.virt.Library(s.virt.ImagesDir()), "isos": s.virt.Library(s.virt.ISOsDir())}
jsonOK(w, 200, out)
}
func (s *Server) virtInstall(w http.ResponseWriter, r *http.Request) {
var o incus.InstallOpts
if err := readBody(r, &o, 16<<10); err != nil {
jsonErr(w, 400, "bad request")
return
}
st := s.virt.Detect(r.Context())
if st.State != "installable" && st.State != "repair" {
jsonErr(w, 409, "nothing to install here: "+st.State)
return
}
if err := s.virt.EnsureDirs(); err != nil {
jsonErr(w, 500, err.Error())
return
}
script, lay, err := s.virt.InstallScript(st, o)
if err != nil {
jsonErr(w, 400, err.Error())
return
}
if err := s.virt.SaveLayout(lay); err != nil {
jsonErr(w, 500, err.Error())
return
}
// kept beside the layout, so what was run is on record
_ = os.WriteFile(filepath.Join(s.virt.Dir(), "install.sh"), []byte(script), 0o700)
t, err := s.tasks.Start("virt:install", "install Incus", s.virt.ShellCmd(r.Context(), script))
if err != nil {
jsonErr(w, 409, err.Error())
return
}
log.Printf("virt: install started as task %s — %s pool, %s (from %s)", t.ID, lay.Driver, lay.Subnet, clientIP(r))
jsonOK(w, 202, t.Snapshot(false))
}
func (s *Server) virtReady(w http.ResponseWriter, r *http.Request) bool {
if st := s.virt.Detect(r.Context()); st.State != "ready" {
jsonErr(w, 409, "Incus isn't ready on this machine")
return false
}
return true
}
// ---- images ----
func (s *Server) virtRemoteImages(w http.ResponseWriter, r *http.Request) {
if !s.virtReady(w, r) {
return
}
list, err := s.virt.Remote(r.Context(), r.URL.Query().Get("refresh") == "1")
if err != nil {
jsonErr(w, 502, err.Error())
return
}
if t := r.URL.Query().Get("type"); t == "container" || t == "virtual-machine" {
var f []incus.Image
for _, im := range list {
if im.Type == t {
f = append(f, im)
}
}
list = f
}
if list == nil {
list = []incus.Image{}
}
jsonOK(w, 200, map[string]any{"images": list})
}
func (s *Server) virtImageDownload(w http.ResponseWriter, r *http.Request) {
var body struct {
Alias string `json:"alias"`
}
_ = readBody(r, &body, 4096)
alias := strings.TrimSpace(body.Alias)
if !incus.ValidImage(alias) || strings.Contains(alias, ":") {
jsonErr(w, 400, "alias: like debian/13 or ubuntu/24.04/cloud")
return
}
if !s.virtReady(w, r) {
return
}
local := strings.ReplaceAll(alias, "/", "-")
// copied into the local store under a flat alias, then exported to the library so the
// file is Ham Lab's whether or not Incus keeps its cache
script := `set -e; incus image copy "images:$1" local: --alias "$2"; cd "$3" && incus image export "$2" "$2" && echo "exported to $3/$2"`
t, err := s.tasks.Start("virt:image", "download "+alias, s.virt.ShellCmd(r.Context(), script, alias, local, s.virt.ImagesDir()))
if err != nil {
jsonErr(w, 409, err.Error())
return
}
log.Printf("virt: image %s download as task %s (from %s)", alias, t.ID, clientIP(r))
jsonOK(w, 202, t.Snapshot(false))
}
func (s *Server) virtImageImport(w http.ResponseWriter, r *http.Request) {
var body struct {
Path string `json:"path"`
Alias string `json:"alias"`
Type string `json:"type"` // container | virtual-machine
}
_ = readBody(r, &body, 8192)
alias := strings.TrimSpace(body.Alias)
if !incus.ValidImage(alias) || strings.Contains(alias, ":") {
jsonErr(w, 400, "alias: letters, digits, dots, dashes")
return
}
p := filepath.Clean(body.Path)
st, err := os.Stat(p)
if !filepath.IsAbs(p) || err != nil || !st.Mode().IsRegular() {
jsonErr(w, 400, "path: a file on this machine")
return
}
if !s.virtReady(w, r) {
return
}
dest := filepath.Join(s.virt.ImagesDir(), filepath.Base(p))
var script string
if body.Type == "virtual-machine" {
// a bare disk image needs the metadata Incus wants beside it; a small tarball Ham Lab writes
script = `set -e; [ "$1" = "$2" ] || cp -n "$1" "$2"; META="$3/$4.metadata.tar.gz"; T=$(mktemp -d); printf 'architecture: %s\ncreation_date: %s\nproperties:\n description: "%s"\n os: custom\n' "$5" "$(date +%s)" "$4" > "$T/metadata.yaml"; tar -C "$T" -czf "$META" metadata.yaml; rm -rf "$T"; incus image import "$META" "$2" --alias "$4"; echo "imported $4 from $2"`
} else {
script = `set -e; [ "$1" = "$2" ] || cp -n "$1" "$2"; incus image import "$2" --alias "$4"; echo "imported $4 from $2"`
}
t, err := s.tasks.Start("virt:image", "import "+alias, s.virt.ShellCmd(r.Context(), script, p, dest, s.virt.ImagesDir(), alias, incus.IncusArch()))
if err != nil {
jsonErr(w, 409, err.Error())
return
}
log.Printf("virt: image import %s from %s as task %s (from %s)", alias, p, t.ID, clientIP(r))
jsonOK(w, 202, t.Snapshot(false))
}
func (s *Server) virtImagePublish(w http.ResponseWriter, r *http.Request) {
var body struct {
Instance string `json:"instance"`
Alias string `json:"alias"`
}
_ = readBody(r, &body, 4096)
alias := strings.TrimSpace(body.Alias)
if !incus.ValidName(body.Instance) || !incus.ValidImage(alias) || strings.Contains(alias, ":") {
jsonErr(w, 400, "an instance and an alias")
return
}
if !s.virtReady(w, r) {
return
}
script := `set -e; incus publish "$1" --alias "$2"; cd "$3" && incus image export "$2" "$2" && echo "exported to $3/$2"`
t, err := s.tasks.Start("virt:image", "publish "+body.Instance+" as "+alias, s.virt.ShellCmd(r.Context(), script, body.Instance, alias, s.virt.ImagesDir()))
if err != nil {
jsonErr(w, 409, err.Error())
return
}
log.Printf("virt: publish %s as %s, task %s (from %s)", body.Instance, alias, t.ID, clientIP(r))
jsonOK(w, 202, t.Snapshot(false))
}
func (s *Server) virtImageDelete(w http.ResponseWriter, r *http.Request) {
var body struct {
Ref string `json:"ref"`
}
_ = readBody(r, &body, 4096)
if !incus.ValidImage(body.Ref) {
jsonErr(w, 400, "bad image")
return
}
if !s.virtReady(w, r) {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()
if out, err := s.virt.Cmd(ctx, "image", "delete", body.Ref).CombinedOutput(); err != nil {
jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: "))
return
}
log.Printf("virt: image %s deleted (from %s)", body.Ref, clientIP(r))
w.WriteHeader(204)
}
// ---- instances ----
func (s *Server) virtCreate(w http.ResponseWriter, r *http.Request) {
var sp incus.CreateSpec
if err := readBody(r, &sp, 512<<10); err != nil {
jsonErr(w, 400, "bad request")
return
}
st := s.virt.Detect(r.Context())
if st.State != "ready" {
jsonErr(w, 409, "Incus isn't ready on this machine")
return
}
args, err := incus.CreateArgs(sp, st.KVM)
if err != nil {
jsonErr(w, 400, err.Error())
return
}
if inst, err := s.virt.Instances(r.Context()); err == nil {
for _, in := range inst {
if in.Name == sp.Name {
jsonErr(w, 409, "there is already an instance called "+sp.Name)
return
}
}
}
kind := "container"
if sp.VM {
kind = "VM"
}
t, err := s.tasks.Start("virt:create", fmt.Sprintf("create %s %s", kind, sp.Name), s.virt.Cmd(r.Context(), args...))
if err != nil {
jsonErr(w, 409, err.Error())
return
}
log.Printf("virt: create %s %s from %s as task %s (from %s)", kind, sp.Name, sp.Image, t.ID, clientIP(r))
jsonOK(w, 202, t.Snapshot(false))
}
func (s *Server) virtInstance(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if !incus.ValidName(name) {
jsonErr(w, 400, "bad name")
return
}
list, err := s.virt.Instances(r.Context())
if err != nil {
jsonErr(w, 502, err.Error())
return
}
for _, in := range list {
if in.Name == name {
jsonOK(w, 200, in)
return
}
}
jsonErr(w, 404, "no such instance")
}
// virtAction is start / stop / restart / kill / delete, and the two consoles.
func (s *Server) virtAction(w http.ResponseWriter, r *http.Request) {
name, action := r.PathValue("name"), r.PathValue("action")
if !incus.ValidName(name) {
jsonErr(w, 400, "bad name")
return
}
if !s.virtReady(w, r) {
return
}
var args []string
switch action {
case "start":
args = []string{"start", name}
case "stop":
args = []string{"stop", name}
case "restart":
args = []string{"restart", name}
case "kill":
args = []string{"stop", "--force", name}
case "delete":
var body struct {
Confirm string `json:"confirm"`
Force bool `json:"force"`
}
_ = readBody(r, &body, 4096)
if body.Confirm != name {
jsonErr(w, 400, "type the instance's name to delete it")
return
}
args = []string{"delete", name}
if body.Force {
args = []string{"delete", "--force", name}
}
case "console", "exec":
s.virtTerminal(w, r, name, action)
return
default:
jsonErr(w, 400, "unknown action")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
defer cancel()
if out, err := s.virt.Cmd(ctx, args...).CombinedOutput(); err != nil {
jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: "))
return
}
log.Printf("virt: %s %s (from %s)", action, name, clientIP(r))
w.WriteHeader(204)
}
// virtTerminal opens the serial console or a shell as a terminal session.
func (s *Server) virtTerminal(w http.ResponseWriter, r *http.Request, name, kind string) {
var argv []string
title := ""
if kind == "console" {
argv = s.virt.Argv(r.Context(), "console", name)
title = "console " + name
} else {
argv = s.virt.Argv(r.Context(), "exec", name, "--", "sh", "-c", "if command -v bash >/dev/null 2>&1; then exec bash -l; else exec sh -l; fi")
title = "sh " + name
}
sess, err := s.mgr.CreateCmd("", title, argv)
if err != nil {
jsonErr(w, 500, err.Error())
return
}
log.Printf("virt: %s of %s as terminal %s (from %s)", kind, name, sess.ID, clientIP(r))
jsonOK(w, 200, map[string]string{"id": sess.ID, "name": title})
}
func (s *Server) virtRename(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
var body struct {
Name string `json:"name"`
}
_ = readBody(r, &body, 4096)
if !incus.ValidName(name) || !incus.ValidName(body.Name) {
jsonErr(w, 400, "bad name")
return
}
if !s.virtReady(w, r) {
return
}
ctx, cancel := context.WithTimeout(r.Context(), time.Minute)
defer cancel()
if out, err := s.virt.Cmd(ctx, "rename", name, body.Name).CombinedOutput(); err != nil {
jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: "))
return
}
log.Printf("virt: renamed %s to %s (from %s)", name, body.Name, clientIP(r))
w.WriteHeader(204)
}
// virtConfig sets limits, the snapshot schedule, or a description — the config keys the page
// edits, each validated for its shape. An empty value unsets the key.
func (s *Server) virtConfig(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
var body map[string]string
if err := readBody(r, &body, 16<<10); err != nil || !incus.ValidName(name) {
jsonErr(w, 400, "bad request")
return
}
var set, unset []string
for k, v := range body {
v = strings.TrimSpace(v)
ok := false
switch k {
case "limits.cpu":
ok = incus.ValidCPU(v)
case "limits.memory":
ok = incus.ValidSize(v)
case "snapshots.schedule":
ok = incus.ValidCron(v)
case "snapshots.expiry":
ok = incus.ValidExpiry(v)
case "description":
ok = len(v) <= 200 && !strings.ContainsAny(v, "\n\r")
case "boot.autostart":
ok = v == "" || v == "true" || v == "false"
}
if !ok {
jsonErr(w, 400, "bad value for "+k)
return
}
if v == "" {
unset = append(unset, k)
} else {
set = append(set, k+"="+v)
}
}
if !s.virtReady(w, r) {
return
}
ctx, cancel := context.WithTimeout(r.Context(), time.Minute)
defer cancel()
if len(set) > 0 {
if out, err := s.virt.Cmd(ctx, append([]string{"config", "set", name}, set...)...).CombinedOutput(); err != nil {
jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: "))
return
}
}
for _, k := range unset {
_, _ = s.virt.Cmd(ctx, "config", "unset", name, k).CombinedOutput() // unsetting what isn't set is fine
}
log.Printf("virt: config %s set %v unset %v (from %s)", name, set, unset, clientIP(r))
w.WriteHeader(204)
}
func (s *Server) virtSnapshotCreate(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
var body struct {
Name string `json:"name"`
}
_ = readBody(r, &body, 4096)
snap := strings.TrimSpace(body.Name)
if snap == "" {
snap = time.Now().Format("2006-01-02_15.04.05")
}
if !incus.ValidName(name) || !incus.ValidSnap(snap) {
jsonErr(w, 400, "bad name")
return
}
if !s.virtReady(w, r) {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
defer cancel()
if out, err := s.virt.Cmd(ctx, "snapshot", "create", name, snap).CombinedOutput(); err != nil {
jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: "))
return
}
log.Printf("virt: snapshot %s/%s (from %s)", name, snap, clientIP(r))
jsonOK(w, 200, map[string]string{"name": snap})
}
func (s *Server) virtSnapshotAction(w http.ResponseWriter, r *http.Request) {
name, snap, action := r.PathValue("name"), r.PathValue("snap"), r.PathValue("action")
if !incus.ValidName(name) || !incus.ValidSnap(snap) {
jsonErr(w, 400, "bad name")
return
}
var args []string
switch action {
case "restore":
args = []string{"snapshot", "restore", name, snap}
case "delete":
args = []string{"snapshot", "delete", name, snap}
default:
jsonErr(w, 400, "unknown action")
return
}
if !s.virtReady(w, r) {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute)
defer cancel()
if out, err := s.virt.Cmd(ctx, args...).CombinedOutput(); err != nil {
jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: "))
return
}
log.Printf("virt: snapshot %s %s/%s (from %s)", action, name, snap, clientIP(r))
w.WriteHeader(204)
}
File diff suppressed because it is too large Load Diff
+130
View File
@@ -0,0 +1,130 @@
package incus
// The bridge subnet. Incus's own `auto` picks a random 10.x.y.0/24, and on a LAN that is
// itself 10.0.0.0/8 (this one) every such pick is on-link and collides. So Ham Lab chooses:
// the first /24 that nothing on the machine already routes, is not an address it holds, and
// is outside Docker's configured pools — tried in an order that also stays clear of Docker's
// *defaults* (172.17–172.31 and 192.168.0.0/16 carved into /20s), which a Docker that has no
// daemon.json will hand out later without asking anyone.
import (
"context"
"encoding/json"
"fmt"
"net/netip"
"os"
"os/exec"
"strings"
"time"
)
// candidates in order of preference: 172.16.0.0/16 is the one RFC 1918 block Docker never
// takes by default; then the top of 192.168/16, which Docker reaches last; then the rest.
func candidates() []netip.Prefix {
var out []netip.Prefix
for i := 100; i <= 254; i++ {
out = append(out, netip.MustParsePrefix(fmt.Sprintf("172.16.%d.0/24", i)))
}
for i := 254; i >= 200; i-- {
out = append(out, netip.MustParsePrefix(fmt.Sprintf("192.168.%d.0/24", i)))
}
for i := 1; i < 100; i++ {
out = append(out, netip.MustParsePrefix(fmt.Sprintf("172.16.%d.0/24", i)))
}
return out
}
func overlaps(a, b netip.Prefix) bool {
return a.Contains(b.Addr()) || b.Contains(a.Addr())
}
// PickSubnet returns the first candidate /24 clear of every prefix in taken.
func PickSubnet(taken []netip.Prefix) netip.Prefix {
for _, c := range candidates() {
clear := true
for _, t := range taken {
if overlaps(c, t) {
clear = false
break
}
}
if clear {
return c
}
}
return netip.MustParsePrefix("172.16.100.0/24") // every candidate taken: not a machine this will run on
}
// Gateway is the bridge's own address in a subnet: its first host, as CIDR, the way
// Incus's ipv4.address wants it.
func Gateway(p netip.Prefix) string {
return fmt.Sprintf("%s/%d", p.Masked().Addr().Next(), p.Bits())
}
// TakenPrefixes reads what the machine already uses: its routes, its addresses, and the
// pools Docker is configured to carve networks from.
func TakenPrefixes(ctx context.Context) []netip.Prefix {
var out []netip.Prefix
add := func(s string) {
if p, err := netip.ParsePrefix(s); err == nil && p.Addr().Is4() {
out = append(out, p.Masked())
} else if a, err := netip.ParseAddr(s); err == nil && a.Is4() {
out = append(out, netip.PrefixFrom(a, 32))
}
}
c, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if b, err := exec.CommandContext(c, "ip", "-j", "-4", "route", "show").Output(); err == nil {
var routes []struct{ Dst string }
if json.Unmarshal(b, &routes) == nil {
for _, r := range routes {
if r.Dst != "" && r.Dst != "default" {
add(r.Dst)
}
}
}
}
if b, err := exec.CommandContext(c, "ip", "-j", "-4", "addr", "show").Output(); err == nil {
var links []struct {
AddrInfo []struct {
Local string `json:"local"`
Prefixlen int `json:"prefixlen"`
} `json:"addr_info"`
}
if json.Unmarshal(b, &links) == nil {
for _, l := range links {
for _, a := range l.AddrInfo {
add(fmt.Sprintf("%s/%d", a.Local, a.Prefixlen))
}
}
}
}
for _, p := range dockerPools() {
add(p)
}
return out
}
// dockerPools reads default-address-pools from Docker's daemon.json, and falls back to
// Docker's built-in defaults when the file names none.
func dockerPools() []string {
var cfg struct {
Pools []struct {
Base string `json:"base"`
} `json:"default-address-pools"`
}
if b, err := os.ReadFile("/etc/docker/daemon.json"); err == nil && json.Unmarshal(b, &cfg) == nil && len(cfg.Pools) > 0 {
var out []string
for _, p := range cfg.Pools {
out = append(out, p.Base)
}
return out
}
if _, err := exec.LookPath("docker"); err != nil {
return nil
}
return []string{"172.17.0.0/12", "192.168.0.0/16"}
}
// String form used in the layout file and on the page.
func prefixString(p netip.Prefix) string { return strings.TrimSpace(p.Masked().String()) }
+41
View File
@@ -0,0 +1,41 @@
package incus
import (
"net/netip"
"testing"
)
func TestPickSubnetAvoidsTaken(t *testing.T) {
taken := []netip.Prefix{
netip.MustParsePrefix("10.0.0.0/8"), // the LAN, a /8
netip.MustParsePrefix("172.16.100.0/24"), // first choice gone
netip.MustParsePrefix("172.16.101.0/24"), // and the second
netip.MustParsePrefix("172.28.0.0/14"), // Docker's configured pool
netip.MustParsePrefix("192.168.80.0/20"), // a Docker network
}
got := PickSubnet(taken)
if got.String() != "172.16.102.0/24" {
t.Fatalf("got %s", got)
}
if Gateway(got) != "172.16.102.1/24" {
t.Fatalf("gateway %s", Gateway(got))
}
}
func TestPickSubnetWholeBlockTaken(t *testing.T) {
// 172.16.0.0/12 covers every 172.16.x candidate; the picker must move to 192.168
got := PickSubnet([]netip.Prefix{netip.MustParsePrefix("172.16.0.0/12")})
if got.String() != "192.168.254.0/24" {
t.Fatalf("got %s", got)
}
}
func TestOverlapsEitherWay(t *testing.T) {
a := netip.MustParsePrefix("172.16.5.0/24")
if !overlaps(a, netip.MustParsePrefix("172.16.0.0/12")) || !overlaps(netip.MustParsePrefix("172.16.0.0/12"), a) {
t.Fatal("a /24 inside a /12 overlaps both ways")
}
if overlaps(a, netip.MustParsePrefix("172.16.6.0/24")) {
t.Fatal("neighbours do not overlap")
}
}
+6 -3
View File
@@ -15,6 +15,7 @@ import Hardware from './Hardware'
import Network from './Network' import Network from './Network'
import VPN from './VPN' import VPN from './VPN'
import ZFS from './ZFS' import ZFS from './ZFS'
import Virtualization from './Virtualization'
import KVM from './KVM' import KVM from './KVM'
import Files from './Files' import Files from './Files'
import Top from './Top' import Top from './Top'
@@ -81,6 +82,7 @@ const GROUPS: Group[] = [
{ id: 'usage', label: 'Usage', icon: '▧' }, { id: 'usage', label: 'Usage', icon: '▧' },
{ id: 'zfs', label: 'ZFS', icon: '⛁' }, { id: 'zfs', label: 'ZFS', icon: '⛁' },
{ id: 'docker', label: 'Docker', icon: DockerIcon }, { id: 'docker', label: 'Docker', icon: DockerIcon },
{ id: 'virt', label: 'Virtualization', icon: '▣' },
{ id: 'kvm', label: 'KVM', icon: '⌨' }, { id: 'kvm', label: 'KVM', icon: '⌨' },
{ id: 'hardware', label: 'Hardware', icon: '⬡' }, { id: 'hardware', label: 'Hardware', icon: '⬡' },
{ id: 'updates', label: 'Updates', icon: '⇪' }, { id: 'updates', label: 'Updates', icon: '⇪' },
@@ -91,8 +93,8 @@ const GROUPS: Group[] = [
{ id: 'settings', label: 'Settings', icon: '⚙' }, { id: 'settings', label: 'Settings', icon: '⚙' },
] ]
type View = 'terminal' | 'widgets' | 'docker' | 'alerts' | 'services' | 'jobs' | 'updates' | 'disks' | 'hardware' | 'network' | 'vpn' | 'zfs' | 'usage' | 'files' | 'procs' | 'machines' | 'kvm' | 'users' | 'settings' type View = 'terminal' | 'widgets' | 'docker' | 'virt' | 'alerts' | 'services' | 'jobs' | 'updates' | 'disks' | 'hardware' | 'network' | 'vpn' | 'zfs' | 'usage' | 'files' | 'procs' | 'machines' | 'kvm' | 'users' | 'settings'
const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'alerts', 'services', 'jobs', 'updates', 'disks', 'hardware', 'network', 'vpn', 'zfs', 'usage', 'files', 'procs', 'machines', 'kvm', 'users', 'settings'] const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'virt', 'alerts', 'services', 'jobs', 'updates', 'disks', 'hardware', 'network', 'vpn', 'zfs', 'usage', 'files', 'procs', 'machines', 'kvm', 'users', 'settings']
const HUB_ONLY: View[] = ['machines', 'users', 'settings', 'alerts'] const HUB_ONLY: View[] = ['machines', 'users', 'settings', 'alerts']
// URL hash is the source of truth for where you are: #/<machine|hub>/<view>. Back and // URL hash is the source of truth for where you are: #/<machine|hub>/<view>. Back and
@@ -329,6 +331,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
{view === 'machines' && <Machines hub={hubMe} onChanged={loadMachines} onOpen={(n) => { switchMachine(n); setView('procs') }} />} {view === 'machines' && <Machines hub={hubMe} onChanged={loadMachines} onOpen={(n) => { switchMachine(n); setView('procs') }} />}
{view === 'widgets' && <Gallery />} {view === 'widgets' && <Gallery />}
{view === 'docker' && <Docker />} {view === 'docker' && <Docker />}
{view === 'virt' && <Virtualization />}
{view === 'alerts' && <Alerts machines={machines.map((m) => m.name)} titleOf={(n) => titleOf(n === hubMe.host ? null : n, machines, hubMe.host)} />} {view === 'alerts' && <Alerts machines={machines.map((m) => m.name)} titleOf={(n) => titleOf(n === hubMe.host ? null : n, machines, hubMe.host)} />}
{view === 'procs' && <Top />} {view === 'procs' && <Top />}
{view === 'services' && <Services />} {view === 'services' && <Services />}
@@ -366,7 +369,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
<span>{machine ? `${title} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version}</span> <span>{machine ? `${title} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
<span>up {fmtUptime(me.uptime)}</span> <span>up {fmtUptime(me.uptime)}</span>
<span className="spacer" /> <span className="spacer" />
<span className="muted">{view === 'widgets' ? 'Widgets' : view === 'docker' ? 'Docker' : view === 'alerts' ? 'Alerts' : view === 'services' ? 'Services' : view === 'jobs' ? 'Jobs' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'hardware' ? 'Hardware' : view === 'network' ? 'Network' : view === 'vpn' ? 'VPN' : view === 'zfs' ? 'ZFS' : view === 'usage' ? 'Usage' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'kvm' ? 'KVM' : view === 'users' ? 'Users' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''}</span> <span className="muted">{view === 'widgets' ? 'Widgets' : view === 'docker' ? 'Docker' : view === 'virt' ? 'Virtualization' : view === 'alerts' ? 'Alerts' : view === 'services' ? 'Services' : view === 'jobs' ? 'Jobs' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'hardware' ? 'Hardware' : view === 'network' ? 'Network' : view === 'vpn' ? 'VPN' : view === 'zfs' ? 'ZFS' : view === 'usage' ? 'Usage' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'kvm' ? 'KVM' : view === 'users' ? 'Users' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''}</span>
</footer> </footer>
</div> </div>
) )
+509
View File
@@ -0,0 +1,509 @@
// Virtualization: LXC containers and KVM virtual machines through Incus, per machine. The
// page has four states — ready, repair, installable, unavailable — decided by what the
// selected machine can do, never by a rule about what should run where. Everything the
// tab makes lives under Ham Lab's own folder on that machine.
import { FormEvent, Fragment, useCallback, useEffect, useMemo, useState } from 'react'
import { api, LibraryFile, TaskInfo, VirtImage, VirtInstance, VirtOverview, VirtStatus } from './api'
import TaskOutput from './TaskOutput'
import { useHashSel } from './hashsel'
import { useRefreshMs } from './prefs'
import { fmtBytes } from './fmt'
import { askText, askYes, Btn, CloseBtn, Loading, toast, useEscape } from './ui'
type Tab = 'instances' | 'images' | 'library'
const GiB = 1 << 30
const dot = (i: VirtInstance) => (i.status === 'Running' ? 'ok' : i.status === 'Stopped' ? '' : i.status === 'Error' ? 'bad' : 'warn')
const kind = (i: VirtInstance) => (i.type === 'virtual-machine' ? 'VM' : 'LXC')
const when = (iso?: string) => { if (!iso) return '—'; const d = new Date(iso); return isNaN(d.getTime()) || d.getFullYear() < 1980 ? '—' : d.toLocaleString() }
const errMsg = (e: unknown) => (e instanceof Error ? e.message : 'failed')
const imgLabel = (im: VirtImage) => im.aliases[0] ?? im.fingerprint.slice(0, 12)
export default function Virtualization() {
const [ov, setOv] = useState<VirtOverview | null>(null)
const [err, setErr] = useState('')
const [tab, setTab] = useState<Tab>('instances')
const [sel, setSel] = useHashSel('virt')
const [making, setMaking] = useState<null | { image?: string; vm?: boolean }>(null)
const [task, setTask] = useState<{ id: string; title: string } | null>(null)
const [filter, setFilter] = useState('')
const rate = useRefreshMs()
const load = useCallback(async () => { try { setOv(await api.virt()); setErr('') } catch (e) { setErr(errMsg(e)) } }, [])
useEffect(() => { load(); const t = setInterval(() => { if (document.visibilityState === 'visible') load() }, Math.max(5000, rate)); return () => clearInterval(t) }, [load, rate])
const startTask = (t: TaskInfo) => setTask({ id: t.id, title: t.title })
if (!ov) return <div className="dash">{err ? <div className="notice">{err === 'not found' ? "This machine's Ham Lab is older than the Virtualization tab. Update it from Machines." : err}</div> : <Loading label="Asking Incus…" />}</div>
const st = ov.status
if (st.state !== 'ready') return <Gate st={st} onChanged={load} />
const instances = ov.instances ?? []
const images = ov.images ?? []
const cur = instances.find((i) => i.name === sel)
const running = instances.filter((i) => i.status === 'Running').length
const f = filter.trim().toLowerCase()
const shown = instances.filter((i) => !f || `${i.name} ${i.image} ${i.type} ${i.status}`.toLowerCase().includes(f))
const libCount = ov.library.images.length + ov.library.isos.length
return (
<div className={'svc virt' + (cur && tab === 'instances' ? ' has-sel' : '') + (tab !== 'instances' ? ' wide' : '')}>
<div className="svc-list">
<div className="svc-tools">
<div className="chips">
{(['instances', 'images', 'library'] as Tab[]).map((t) => (
<button key={t} className={'chip' + (tab === t ? ' on' : '')} onClick={() => { setTab(t); if (sel) setSel('') }}>
{t}{t === 'instances' ? ` · ${running}/${instances.length}` : t === 'images' ? ` · ${images.length}` : ` · ${libCount}`}
</button>
))}
</div>
{tab === 'instances' && (
<div className="svc-actions">
<button className="btn on" onClick={() => setMaking({})}>+ New instance</button>
<span className="spacer" />
<span className="muted small">{st.kvm ? 'containers and VMs' : 'containers only — no KVM on this machine'}</span>
</div>
)}
{tab === 'instances' && instances.length > 3 && <input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="filter instances" />}
{ov.error && <div className="error">{ov.error}</div>}
{task && <TaskOutput id={task.id} onDone={(t) => { load(); if (t.exit_code === 0 && !t.error) toast(`${task.title}: done`); else toast(`${task.title}: failed`, 'err') }} />}
{task && <div className="svc-actions"><button className="btn" onClick={() => setTask(null)}>Hide output</button></div>}
</div>
{tab === 'instances' && (
<div className="svc-rows">
{shown.map((i) => (
<div key={i.name} className={'svc-row' + (i.name === sel ? ' active' : '')} onClick={() => setSel(i.name)}>
<span className={'sdot ' + dot(i)} />
<div className="svc-name"><div className="n">{i.name} <span className="badge">{kind(i)}</span></div><div className="d">{i.image || '—'}{i.ipv4.length ? ` · ${i.ipv4.join(' ')}` : ''}</div></div>
<div className="svc-state">{i.status === 'Running' ? <>{fmtBytes(i.mem_usage)}<br />{i.cpu_pct.toFixed(0)}%</> : i.status.toLowerCase()}</div>
</div>
))}
{instances.length === 0 && <div className="muted small" style={{ padding: 12 }}>Nothing here yet. “+ New instance” makes a container{st.kvm ? ' or a VM' : ''} from the catalogue.</div>}
</div>
)}
{tab === 'images' && <Images st={st} images={images} instances={instances} onTask={startTask} onNew={(image, vm) => { setTab('instances'); setMaking({ image, vm }) }} onChanged={load} />}
{tab === 'library' && <Library lib={ov.library} dir={st.dir} />}
</div>
{tab === 'instances' && (cur ? <Detail i={cur} st={st} onBack={() => setSel('')} onChanged={load} onTask={startTask} /> : <Summary ov={ov} />)}
{making && <NewInstance st={st} ov={ov} preset={making} onClose={() => setMaking(null)} onStarted={(t) => { setMaking(null); startTask(t) }} />}
</div>
)
}
// ---- the three states before "ready" ----
function Gate({ st, onChanged }: { st: VirtStatus; onChanged: () => void }) {
const [open, setOpen] = useState(false)
const [task, setTask] = useState('')
const [waiting, setWaiting] = useState(false)
// the install ends by restarting Ham Lab, so poll until it answers again
useEffect(() => {
if (!waiting) return
const t = setInterval(async () => { try { const o = await api.virt(); if (o.status.state === 'ready') { setWaiting(false); onChanged() } } catch { /* restarting */ } }, 3000)
return () => clearInterval(t)
}, [waiting, onChanged])
const headline = st.state === 'installable'
? `Incus isn't installed on this machine. Ham Lab can install it with ${st.pm}: ${st.kvm ? 'containers and virtual machines' : 'containers only, since this kernel has no KVM'}.`
: st.reason
return (
<div className="dash">
<div className="dash-bar"><h2 className="section">Virtualization</h2></div>
<section className="card">
<div className="notice">{headline}</div>
<div className="props" style={{ marginTop: 8 }}>
<div><span className="k">KVM</span><span>{st.kvm ? 'yes — virtual machines possible' : 'no — containers only'}</span></div>
<div><span className="k">Architecture</span><span className="mono">{st.arch}</span></div>
{st.pm && <div><span className="k">Packages</span><span>{st.pm}</span></div>}
<div><span className="k">Pool drivers</span><span>{st.drivers.join(', ')}</span></div>
<div><span className="k">Memory</span><span>{fmtBytes(st.mem_avail)} available of {fmtBytes(st.mem_total)}</span></div>
<div><span className="k">Disk</span><span>{fmtBytes(st.disk_free)} free where Ham Lab's data lives</span></div>
<div><span className="k">Docker</span><span>{st.docker ? 'present — its FORWARD policy needs two rules, Install adds them' : 'not here'}</span></div>
<div><span className="k">Data</span><span className="mono small">{st.dir}</span></div>
{st.layout && <div><span className="k">Last attempt</span><span>{when(st.layout.attempted)} · {st.layout.driver} pool · {st.layout.subnet}</span></div>}
</div>
{(st.state === 'installable' || st.state === 'repair') && !task && (
<div className="svc-actions"><Btn className="btn on" onClick={() => setOpen(true)}>{st.state === 'repair' ? 'Finish setup' : 'Install Incus'}</Btn></div>
)}
{task && <TaskOutput id={task} onDone={(t) => { if (t.exit_code === 0 && !t.error) setWaiting(true); else { toast('install failed — the output says where', 'err'); setTask('') } }} />}
{waiting && <div className="muted small" style={{ marginTop: 8 }}>Ham Lab is restarting to pick up its new group. The page reconnects by itself.</div>}
</section>
{open && <InstallDialog st={st} onClose={() => setOpen(false)} onStarted={(id) => { setOpen(false); setTask(id) }} />}
</div>
)
}
function InstallDialog({ st, onClose, onStarted }: { st: VirtStatus; onClose: () => void; onStarted: (id: string) => void }) {
const [driver, setDriver] = useState(st.layout?.driver ?? st.drivers[0] ?? 'dir')
const [size, setSize] = useState(String(st.layout?.pool_size_gib || st.pool_size_gib || 20))
const [subnet, setSubnet] = useState(st.layout?.subnet || st.subnet || '172.16.100.1/24')
const [docker, setDocker] = useState(st.docker)
const [arc, setArc] = useState(st.layout?.arc_max_gib ? String(st.layout.arc_max_gib) : '')
const [err, setErr] = useState('')
const [busy, setBusy] = useState(false)
useEscape(onClose)
const submit = async (e: FormEvent) => {
e.preventDefault(); setErr(''); setBusy(true)
try { const t = await api.virtInstall({ driver, pool_size_gib: Number(size) || 0, subnet: subnet.trim(), docker_rules: docker, arc_max_gib: Number(arc) || 0 }); onStarted(t.id) }
catch (e) { setErr(errMsg(e)); setBusy(false) }
}
return (
<div className="modal-back" onClick={onClose}>
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={submit}>
<h3>{st.state === 'repair' ? 'Finish setting up Incus' : 'Install Incus'}</h3>
<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
<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>)}
</select>
</label>
{driver !== 'dir' && (
<label className="muted">Pool size, GiB — a sparse file, grows as used · {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 />
</label>
)}
<label className="muted">Bridge address — picked clear of this machine's routes and Docker's pools
<input className="mono" value={subnet} onChange={(e) => setSubnet(e.target.value)} spellCheck={false} required />
</label>
{st.docker && (
<label className="muted" style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<input type="checkbox" checked={docker} onChange={(e) => setDocker(e.target.checked)} style={{ width: 'auto' }} />
Add the two DOCKER-USER rules that let the bridge's traffic through Docker's FORWARD drop
</label>
)}
{st.zfs_host && (
<label className="muted">Cap the ZFS ARC, GiB — blank leaves it at {st.arc_max_gib} GiB; a cap makes guest memory deterministic
<input type="number" min={1} max={Math.floor(st.mem_total / GiB)} value={arc} onChange={(e) => setArc(e.target.value)} placeholder="leave as is" />
</label>
)}
<div className="muted small">Ham Lab's account ({st.user}) joins the incus-admin group and Ham Lab restarts at the end.</div>
{err && <div className="error">{err}</div>}
<div className="svc-actions">
<button type="submit" className="btn on" disabled={busy}>{busy ? 'Starting…' : 'Install'}</button>
<span className="spacer" />
<button type="button" className="btn" onClick={onClose}>Cancel</button>
</div>
</form>
</div>
)
}
// ---- the right-hand pane when nothing is selected: what this machine's Incus is ----
function Summary({ ov }: { ov: VirtOverview }) {
const st = ov.status
return (
<div className="svc-detail">
<div className="svc-head"><span className="dname">Incus {st.version}</span><span className="badge">{st.arch}</span>{!st.kvm && <span className="badge warn">no KVM</span>}</div>
<div className="props">
<div><span className="k">Data</span><span className="mono small">{st.dir}</span></div>
<div><span className="k">Memory</span><span>{fmtBytes(st.mem_avail)} available of {fmtBytes(st.mem_total)}</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>}
</div>
<h3 className="stack-head">Storage pools</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>
<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>
<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>
<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>
<div className="muted small">Pick an instance on the left, or make one. Consoles open as tabs on the Terminal page.</div>
</div>
)
}
// ---- one instance ----
function Detail({ i, st, onBack, onChanged, onTask }: { i: VirtInstance; st: VirtStatus; onBack: () => void; onChanged: () => void; onTask: (t: TaskInfo) => void }) {
const [tab, setTab] = useState<'overview' | 'snapshots' | 'config'>('overview')
const running = i.status === 'Running'
const stopped = i.status === 'Stopped'
const act = (a: 'start' | 'stop' | 'restart' | 'kill') => async () => { try { await api.virtAction(i.name, a); toast(`${i.name}: ${a} ok`); onChanged() } catch (e) { toast(`${i.name}: ${errMsg(e)}`, 'err') } }
const term = (k: 'console' | 'exec') => async () => {
try { const t = await api.virtTerminal(i.name, k); toast(`${k === 'exec' ? 'shell' : 'console'} open as “${t.name}” on the Terminal tab`); window.dispatchEvent(new CustomEvent('hamlab:terminal', { detail: t.id })) }
catch (e) { toast(errMsg(e), 'err') }
}
const rename = async () => { const to = await askText(`Rename ${i.name} to`, i.name, 'Rename'); if (!to || to === i.name) return; try { await api.virtRename(i.name, to); toast('renamed'); onBack(); onChanged() } catch (e) { toast(errMsg(e), 'err') } }
const del = async () => {
const typed = await askText(`Delete ${i.name}${running ? ', which is running' : ''}? Its disk and ${i.snapshots.length} snapshot${i.snapshots.length === 1 ? '' : 's'} go with it. Type its name to confirm.`, '', 'Delete')
if (typed === null) return
if (typed !== i.name) { toast('name did not match', 'err'); return }
try { await api.virtDelete(i.name, running); toast(`${i.name} deleted`); onBack(); onChanged() } catch (e) { toast(errMsg(e), 'err') }
}
const publish = async () => {
const alias = await askText(`Publish ${i.name} as an image — an alias for it`, `${i.name}-image`, 'Publish')
if (!alias) return
try { onTask(await api.virtImagePublish(i.name, alias)) } catch (e) { toast(errMsg(e), 'err') }
}
const snap = async () => {
const name = await askText(`Snapshot ${i.name} — a name, or blank for the date`, '', 'Snapshot')
if (name === null) return
try { const r = await api.virtSnapshot(i.name, name); toast(`snapshot ${r.name} taken`); onChanged() } catch (e) { toast(errMsg(e), 'err') }
}
return (
<div className="svc-detail">
<CloseBtn onClick={onBack} />
<div className="svc-head"><button className="btn back" onClick={onBack}>‹ Back</button><span className={'sdot ' + dot(i)} /><span className="dname">{i.name}</span><span className="badge">{kind(i)}</span><span className={'badge' + (i.status === 'Error' ? ' bad' : '')}>{i.status.toLowerCase()}</span></div>
<div className="props">
<div><span className="k">Image</span><span>{i.image || '—'}{i.arch ? <span className="muted"> · {i.arch}</span> : ''}</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 ? ` · ${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>
{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">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>
<div className="svc-actions">
{!running && <Btn className="btn on" onClick={act('start')}>Start</Btn>}
{running && <Btn className="btn" onClick={term('exec')}>Shell</Btn>}
<Btn className="btn" onClick={term('console')} title="the serial console: boot output, and a login prompt where the guest has one on it">Console</Btn>
{running && <Btn className="btn" confirm={`Restart ${i.name}?`} onClick={act('restart')}>Restart</Btn>}
{running && <Btn className="btn danger" confirm={`Stop ${i.name}? It is asked to shut down cleanly.`} onClick={act('stop')}>Stop</Btn>}
{running && <Btn className="btn danger" confirm={`Force stop ${i.name}? Like pulling the plug — anything unsaved inside is lost.`} onClick={act('kill')}>Force stop</Btn>}
<Btn className="btn" onClick={snap}>Snapshot</Btn>
{stopped && <Btn className="btn" onClick={rename}>Rename</Btn>}
{stopped && <Btn className="btn" onClick={publish} title="make an image of it, to launch more like it">Publish</Btn>}
<Btn className="btn danger" onClick={del}>Delete…</Btn>
</div>
<div className="tabs">
<button className={tab === 'overview' ? 'on' : ''} onClick={() => setTab('overview')}>Limits</button>
<button className={tab === 'snapshots' ? 'on' : ''} onClick={() => setTab('snapshots')}>Snapshots · {i.snapshots.length}</button>
<button className={tab === 'config' ? 'on' : ''} onClick={() => setTab('config')}>Config</button>
</div>
{tab === 'overview' && <Limits i={i} st={st} onChanged={onChanged} />}
{tab === 'snapshots' && <Snapshots i={i} onChanged={onChanged} />}
{tab === 'config' && (
<>
<div className="virt-kv">{Object.entries(i.config).sort().map(([k, v]) => <Fragment key={k}><span className="muted mono">{k}</span><span className="mono">{v}</span></Fragment>)}</div>
<h3 className="stack-head">Devices</h3>
<div className="virt-kv">{Object.entries(i.devices).sort().map(([d, cfg]) => <Fragment key={d}><span className="muted mono">{d}</span><span className="mono">{Object.entries(cfg).map(([k, v]) => `${k}=${v}`).join(' ')}</span></Fragment>)}</div>
<div className="muted small">Profiles: {i.profiles.join(', ') || '—'}</div>
</>
)}
</div>
)
}
function Limits({ i, st, onChanged }: { i: VirtInstance; st: VirtStatus; onChanged: () => void }) {
const [cpu, setCpu] = useState(i.cpu ?? '')
const [mem, setMem] = useState(i.memory ?? '')
const [auto, setAuto] = useState(i.config['boot.autostart'] ?? '')
const [desc, setDesc] = useState(i.description ?? '')
useEffect(() => { setCpu(i.cpu ?? ''); setMem(i.memory ?? ''); setAuto(i.config['boot.autostart'] ?? ''); setDesc(i.description ?? '') }, [i.name, i.cpu, i.memory, i.description, i.config])
const changed = cpu !== (i.cpu ?? '') || mem !== (i.memory ?? '') || auto !== (i.config['boot.autostart'] ?? '') || desc !== (i.description ?? '')
const save = async () => { try { await api.virtConfig(i.name, { 'limits.cpu': cpu, 'limits.memory': mem, 'boot.autostart': auto, description: desc }); toast('saved'); onChanged() } catch (e) { toast(errMsg(e), 'err') } }
return (
<section className="card">
<div className="virt-form-row">
<label className="muted">CPUs<input className="mono" value={cpu} onChange={(e) => setCpu(e.target.value)} placeholder="all" /></label>
<label className="muted">Memory<input className="mono" value={mem} onChange={(e) => setMem(e.target.value)} placeholder="unlimited · e.g. 2GiB" /></label>
</div>
<div className="virt-form-row">
<label className="muted">Start with the machine
<select value={auto} onChange={(e) => setAuto(e.target.value)}><option value="">default (as it was when the host went down)</option><option value="true">yes</option><option value="false">no</option></select>
</label>
<label className="muted">Description<input value={desc} onChange={(e) => setDesc(e.target.value)} maxLength={200} /></label>
</div>
<div className="muted small">{i.type === 'container' ? 'Containers take new limits live.' : 'A VM takes CPU and memory changes live where its agent is running, otherwise on its next start.'} {fmtBytes(st.mem_avail)} is free on this machine right now.</div>
<div className="svc-actions"><Btn className="btn on" disabled={!changed} onClick={save}>Save</Btn></div>
</section>
)
}
function Snapshots({ i, onChanged }: { i: VirtInstance; onChanged: () => void }) {
const [sched, setSched] = useState(i.schedule ?? '')
const [exp, setExp] = useState(i.expiry ?? '')
useEffect(() => { setSched(i.schedule ?? ''); setExp(i.expiry ?? '') }, [i.name, i.schedule, i.expiry])
const saveSched = async () => { try { await api.virtConfig(i.name, { 'snapshots.schedule': sched.trim(), 'snapshots.expiry': exp.trim() }); toast('schedule saved'); onChanged() } catch (e) { toast(errMsg(e), 'err') } }
const on = async (s: string, a: 'restore' | 'delete') => {
const ok = await askYes(a === 'restore' ? `Restore ${i.name} to snapshot ${s}? Everything since it is lost${i.status === 'Running' ? '; it is stopped first' : ''}.` : `Delete snapshot ${s} of ${i.name}?`, a === 'restore' ? 'Restore' : 'Delete')
if (!ok) return
try { await api.virtSnapshotAction(i.name, s, a); toast(`${a} ${s}: ok`); onChanged() } catch (e) { toast(errMsg(e), 'err') }
}
return (
<>
{i.snapshots.length === 0 && <div className="muted small">No snapshots. “Snapshot” above takes one now; a schedule below keeps taking them.</div>}
{i.snapshots.length > 0 && (
<table className="virt-table"><thead><tr><th>Snapshot</th><th>Taken</th><th>Expires</th><th className="num">Size</th><th /></tr></thead>
<tbody>{[...i.snapshots].sort((a, b) => b.created.localeCompare(a.created)).map((s) => (
<tr key={s.name}><td className="mono">{s.name}</td><td>{when(s.created)}</td><td>{s.expires ? when(s.expires) : 'never'}</td><td className="num">{s.size ? fmtBytes(s.size) : '—'}</td>
<td className="num"><Btn className="btn" onClick={() => on(s.name, 'restore')}>Restore</Btn> <Btn className="btn danger" onClick={() => on(s.name, 'delete')}>Delete</Btn></td></tr>
))}</tbody></table>
)}
<section className="card">
<div className="virt-form-row">
<label className="muted">Schedule — @hourly, @daily, @weekly, or a cron line<input className="mono" value={sched} onChange={(e) => setSched(e.target.value)} placeholder="none" spellCheck={false} /></label>
<label className="muted">Keep for — 1d, 2w, 3M…<input className="mono" value={exp} onChange={(e) => setExp(e.target.value)} placeholder="forever" spellCheck={false} /></label>
</div>
<div className="muted small">Incus takes and expires these itself, so they carry on whether or not this console is up.</div>
<div className="svc-actions"><Btn className="btn on" disabled={sched === (i.schedule ?? '') && exp === (i.expiry ?? '')} onClick={saveSched}>Save schedule</Btn></div>
</section>
</>
)
}
// ---- making one ----
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 [image, setImage] = useState(preset.image ?? '')
const [name, setName] = useState('')
const [cpu, setCpu] = useState('')
const [mem, setMem] = useState('')
const [disk, setDisk] = useState('')
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 [cloud, setCloud] = useState('')
const [showCloud, setShowCloud] = useState(false)
const [desc, setDesc] = useState('')
const [start, setStart] = useState(true)
const [q, setQ] = useState('')
const [remote, setRemote] = useState<VirtImage[] | null>(null)
const [remoteErr, setRemoteErr] = useState('')
const [err, setErr] = useState('')
const [busy, setBusy] = useState(false)
const type = vm ? 'virtual-machine' : 'container'
useEscape(onClose)
useEffect(() => { setRemote(null); setRemoteErr(''); api.virtRemote(type).then((r) => setRemote(r.images)).catch((e) => setRemoteErr(errMsg(e))) }, [type])
const local = (ov.images ?? []).filter((im) => im.type === type)
const picks = useMemo(() => {
const f = q.trim().toLowerCase()
const match = (im: VirtImage) => !f || `${im.aliases.join(' ')} ${im.description} ${im.os} ${im.release}`.toLowerCase().includes(f)
const seen = new Set<string>()
const out: { ref: string; label: string; sub: string; here: boolean }[] = []
for (const im of local) { if (!match(im)) continue; const ref = imgLabel(im); if (seen.has(ref)) continue; seen.add(ref); out.push({ ref, label: ref, sub: `${im.description} · on this machine · ${fmtBytes(im.size)}`, here: true }) }
for (const im of remote ?? []) {
const a = im.aliases.find((x) => !x.includes('/default')) ?? im.aliases[0]
if (!a || !match(im)) continue
const ref = `images:${a}`
if (seen.has(ref)) continue
seen.add(ref); out.push({ ref, label: a, sub: `${im.description}${im.variant && im.variant !== 'default' ? ` · ${im.variant}` : ''} · ${fmtBytes(im.size)}`, here: false })
}
return out.slice(0, 60)
}, [q, local, remote])
const submit = async (e: FormEvent) => {
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 })) }
catch (e) { setErr(errMsg(e)); setBusy(false) }
}
return (
<div className="modal-back" onClick={onClose}>
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={submit}>
<h3>New instance</h3>
<div className="chips">
<button type="button" className={'chip' + (!vm ? ' on' : '')} onClick={() => setVm(false)}>container</button>
<button type="button" className={'chip' + (vm ? ' on' : '')} disabled={!st.kvm} title={st.kvm ? '' : 'no KVM on this machine'} onClick={() => setVm(true)}>virtual machine</button>
</div>
<label className="muted">Image — from this machine, or the public catalogue for {st.arch}
<input className="mono" value={image} onChange={(e) => { setImage(e.target.value); setQ(e.target.value.replace(/^images:/, '')) }} placeholder="images:debian/13" spellCheck={false} autoCapitalize="off" autoCorrect="off" required />
</label>
<div className="virt-pick">
{remote === null && !remoteErr && <div className="muted small" style={{ padding: 8 }}>Reading the catalogue…</div>}
{remoteErr && <div className="error" style={{ margin: 8 }}>catalogue: {remoteErr}</div>}
{picks.map((p) => (
<div key={p.ref} className={'svc-row' + (image === p.ref ? ' active' : '')} onClick={() => setImage(p.ref)}>
<div className="svc-name"><div className="n">{p.label}{p.here && <span className="badge">here</span>}</div><div className="d">{p.sub}</div></div>
</div>
))}
{remote && picks.length === 0 && <div className="muted small" style={{ padding: 8 }}>Nothing in the catalogue matches.</div>}
</div>
<div className="virt-form-row">
<label className="muted">Name<input value={name} onChange={(e) => setName(e.target.value)} pattern="[a-zA-Z][a-zA-Z0-9\-]{0,62}" placeholder="web-1" spellCheck={false} autoCapitalize="off" autoCorrect="off" required /></label>
<label className="muted">Network<select value={net} onChange={(e) => setNet(e.target.value)}>{nets.map((n) => <option key={n.name} value={n.name}>{n.name}{n.address ? ` · ${n.address}` : ''}</option>)}</select></label>
</div>
<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">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>
</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" style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<input type="checkbox" checked={showCloud} onChange={(e) => setShowCloud(e.target.checked)} style={{ width: 'auto' }} />cloud-init user-data (an SSH key, packages, a first command — needs a /cloud image)
</label>
{showCloud && <textarea className="mono" rows={6} value={cloud} onChange={(e) => setCloud(e.target.value)} placeholder={'#cloud-config\nusers:\n - name: core\n ssh_authorized_keys:\n - ssh-ed25519 AAAA…\n sudo: ALL=(ALL) NOPASSWD:ALL\npackages: [curl]'} spellCheck={false} />}
<label className="muted" style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<input type="checkbox" checked={start} onChange={(e) => setStart(e.target.checked)} style={{ width: 'auto' }} />Start it once made
</label>
{err && <div className="error">{err}</div>}
<div className="svc-actions">
<button type="submit" className="btn on" disabled={busy || !image.trim() || !name.trim()}>{busy ? 'Starting…' : start ? 'Create and start' : 'Create'}</button>
<span className="spacer" />
<button type="button" className="btn" onClick={onClose}>Cancel</button>
</div>
</form>
</div>
)
}
// ---- images: the local store, the catalogue, and files brought in ----
function Images({ st, images, instances, onTask, onNew, onChanged }: { st: VirtStatus; images: VirtImage[]; instances: VirtInstance[]; onTask: (t: TaskInfo) => void; onNew: (image: string, vm: boolean) => void; onChanged: () => void }) {
const [q, setQ] = useState('')
const [type, setType] = useState<'container' | 'virtual-machine'>('container')
const [remote, setRemote] = useState<VirtImage[] | null>(null)
const [remoteErr, setRemoteErr] = useState('')
const [path, setPath] = useState('')
const [alias, setAlias] = useState('')
const [itype, setItype] = useState<'container' | 'virtual-machine'>('container')
const loadRemote = useCallback((refresh = false) => { setRemote(null); setRemoteErr(''); api.virtRemote(type, refresh).then((r) => setRemote(r.images)).catch((e) => setRemoteErr(errMsg(e))) }, [type])
useEffect(() => { loadRemote() }, [loadRemote])
const usedBy = (im: VirtImage) => instances.filter((i) => i.config['volatile.base_image'] === im.fingerprint || im.aliases.includes(i.image)).map((i) => i.name)
const f = q.trim().toLowerCase()
const found = (remote ?? []).filter((im) => f && `${im.aliases.join(' ')} ${im.description} ${im.os} ${im.release} ${im.variant}`.toLowerCase().includes(f)).slice(0, 40)
const del = async (im: VirtImage) => {
if (!(await askYes(`Delete the image ${imgLabel(im)} from this machine's store? Instances made from it are not affected; the file in the library, if any, stays.`, 'Delete'))) return
try { await api.virtImageDelete(im.fingerprint); toast('image deleted'); onChanged() } catch (e) { toast(errMsg(e), 'err') }
}
const imp = async (e: FormEvent) => { e.preventDefault(); try { onTask(await api.virtImageImport(path.trim(), alias.trim(), itype)); setPath(''); setAlias('') } catch (er) { toast(errMsg(er), 'err') } }
return (
<div className="svc-rows" style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 14 }}>
<section className="card">
<div className="sub-head"><span className="dname">On this machine</span><span className="spacer" /><span className="muted small">{images.length} image{images.length === 1 ? '' : 's'} · {fmtBytes(images.reduce((a, im) => a + im.size, 0))}</span></div>
{images.length === 0 && <div className="muted small">No images yet. Make an instance and its image is kept here; or download one below so the next one is instant.</div>}
{images.length > 0 && (
<table className="virt-table"><thead><tr><th>Image</th><th>Type</th><th>What</th><th className="num">Size</th><th>Used by</th><th /></tr></thead>
<tbody>{images.map((im) => (
<tr key={im.fingerprint}><td className="mono">{im.aliases.join(', ') || im.fingerprint.slice(0, 12)}</td><td>{im.type === 'virtual-machine' ? 'VM' : 'LXC'}</td><td>{im.description}{im.cached ? <span className="muted small"> · cached from {im.source}</span> : ''}</td><td className="num">{fmtBytes(im.size)}</td><td className="small">{usedBy(im).join(' ') || '—'}</td>
<td className="num"><Btn className="btn" onClick={() => onNew(imgLabel(im), im.type === 'virtual-machine')}>New instance</Btn> <Btn className="btn danger" onClick={() => del(im)}>Delete</Btn></td></tr>
))}</tbody></table>
)}
</section>
<section className="card">
<div className="sub-head"><span className="dname">Download from the catalogue</span><span className="spacer" />
<div className="chips"><button className={'chip' + (type === 'container' ? ' on' : '')} onClick={() => setType('container')}>containers</button><button className={'chip' + (type === 'virtual-machine' ? ' on' : '')} disabled={!st.kvm} onClick={() => setType('virtual-machine')}>VMs</button></div>
<Btn className="btn" onClick={() => loadRemote(true)} title="the catalogue is kept for an hour">Refresh</Btn></div>
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder={`search images.linuxcontainers.org for ${st.arch} — debian, ubuntu 24.04, alpine…`} />
{remote === null && !remoteErr && <div className="muted small">Reading the catalogue…</div>}
{remoteErr && <div className="error">{remoteErr}</div>}
{remote && !f && <div className="muted small">{remote.length} {type === 'virtual-machine' ? 'VM' : 'container'} images for {st.arch}. Type to search. A download keeps the image in the store and a copy of it in the library.</div>}
{found.length > 0 && (
<div className="virt-pick">{found.map((im) => { const a = im.aliases.find((x) => !x.includes('/default')) ?? im.aliases[0]; return (
<div key={im.fingerprint} className="svc-row" style={{ cursor: 'default' }}>
<div className="svc-name"><div className="n">{a}</div><div className="d">{im.description}{im.variant && im.variant !== 'default' ? ` · ${im.variant}` : ''} · {fmtBytes(im.size)}</div></div>
<Btn className="btn" onClick={async () => { try { onTask(await api.virtImageDownload(a)) } catch (e) { toast(errMsg(e), 'err') } }}>Download</Btn>
</div>) })}</div>
)}
</section>
<section className="card">
<div className="sub-head"><span className="dname">Import a file from this machine</span></div>
<form onSubmit={imp} className="virt-form-row" style={{ flexWrap: 'wrap' }}>
<label className="muted" style={{ flex: '2 1 240px' }}>Path — a template tarball, or a qcow2 / raw disk for a VM<input className="mono" value={path} onChange={(e) => setPath(e.target.value)} placeholder="/path/to/image.tar.xz" spellCheck={false} required /></label>
<label className="muted">Alias<input className="mono" value={alias} onChange={(e) => setAlias(e.target.value)} placeholder="my-template" spellCheck={false} required /></label>
<label className="muted">Type<select value={itype} onChange={(e) => setItype(e.target.value as 'container' | 'virtual-machine')}><option value="container">container template</option><option value="virtual-machine" disabled={!st.kvm}>VM disk image</option></select></label>
<div className="svc-actions" style={{ alignSelf: 'flex-end' }}><button className="btn on" disabled={!path.trim() || !alias.trim()}>Import</button></div>
</form>
<div className="muted small">The file is copied into <span className="mono">{st.dir}/images/</span> first — the library is Ham Lab's — then loaded into Incus.</div>
</section>
</div>
)
}
function Library({ lib, dir }: { lib: { images: LibraryFile[]; isos: LibraryFile[] }; dir: string }) {
const table = (files: LibraryFile[]) => files.length === 0 ? <div className="muted small">empty</div> : (
<table className="virt-table"><thead><tr><th>File</th><th className="num">Size</th><th>Changed</th></tr></thead>
<tbody>{files.map((f) => <tr key={f.name}><td className="mono">{f.name}</td><td className="num">{fmtBytes(f.size)}</td><td>{when(f.modified)}</td></tr>)}</tbody></table>
)
return (
<div className="svc-rows" style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 14 }}>
<section className="card"><div className="sub-head"><span className="dname">Images</span><span className="spacer" /><span className="muted small mono">{dir}/images</span></div>{table(lib.images)}
<div className="muted small">Every image downloaded, imported or published lands here as a file, so the library is whole without Incus and can be reloaded into a fresh install.</div></section>
<section className="card"><div className="sub-head"><span className="dname">ISOs</span><span className="spacer" /><span className="muted small mono">{dir}/isos</span></div>{table(lib.isos)}
<div className="muted small">ISO installs for VMs — upload, fetch by URL, attach, and the graphical console they need — are the next phase. Files put here now will be listed when it lands.</div></section>
</div>
)
}
+28
View File
@@ -254,6 +254,19 @@ export interface FileInfo extends FileEntry { path: string; text: boolean }
const q = (path: string, sudo: boolean, extra = '') => `path=${encodeURIComponent(path)}${sudo ? '&sudo=1' : ''}${extra}` const q = (path: string, sudo: boolean, extra = '') => `path=${encodeURIComponent(path)}${sudo ? '&sudo=1' : ''}${extra}`
// ---- Virtualization: Incus, per machine ----
export interface VirtLayout { driver: string; pool_source: string; pool_size_gib?: number; bridge: string; subnet: string; docker_rules: boolean; arc_max_gib?: number; attempted: string; hamlab_version?: string }
export interface VirtStatus { state: 'ready' | 'repair' | 'installable' | 'unavailable'; reason?: string; kvm: boolean; arch: string; version?: string; pm?: string; drivers: string[]; docker: boolean; zfs_host: boolean; arc_max_gib?: number; mem_total: number; mem_avail: number; disk_free: number; dir: string; subnet: string; pool_size_gib?: number; layout?: VirtLayout; user: string; root: boolean; unit?: string; btrfs_tools: boolean }
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 VirtImage { fingerprint: string; aliases: string[]; arch: string; type: string; size: number; created: string; last_used: string; description: string; os?: string; release?: string; variant?: string; cached: boolean; source?: string; public: boolean }
export interface VirtPool { name: string; driver: string; source: string; used: number; total: number; used_by: number }
export interface VirtNetwork { name: string; type: string; managed: boolean; address?: string; nat: boolean; used_by: number }
export interface LibraryFile { name: string; size: number; modified: string }
export interface VirtOverview { status: VirtStatus; error?: string; instances?: VirtInstance[]; images?: VirtImage[]; pools?: VirtPool[]; networks?: VirtNetwork[]; library: { images: LibraryFile[]; isos: LibraryFile[] } }
export interface VirtInstallOpts { driver: string; pool_size_gib: number; subnet: string; docker_rules: boolean; arc_max_gib: number }
export interface VirtCreate { name: string; image: string; vm: boolean; cpu: string; memory: string; disk: string; network: string; cloud_init: string; profiles: string[]; description: string; start: boolean }
export const api = { export const api = {
procs: () => req<ProcTable>('GET', '/api/procs'), procs: () => req<ProcTable>('GET', '/api/procs'),
space: () => req<{ targets: SpaceTarget[] }>('GET', '/api/space'), space: () => req<{ targets: SpaceTarget[] }>('GET', '/api/space'),
@@ -409,6 +422,21 @@ export const api = {
createTerminal: (name?: string) => req<{ id: string; name: string }>('POST', '/api/terminals', { name }), createTerminal: (name?: string) => req<{ id: string; name: string }>('POST', '/api/terminals', { name }),
renameTerminal: (id: string, name: string) => req<void>('PATCH', `/api/terminals/${id}`, { name }), renameTerminal: (id: string, name: string) => req<void>('PATCH', `/api/terminals/${id}`, { name }),
deleteTerminal: (id: string) => req<void>('DELETE', `/api/terminals/${id}`), deleteTerminal: (id: string) => req<void>('DELETE', `/api/terminals/${id}`),
virt: () => req<VirtOverview>('GET', '/api/virt'),
virtInstall: (o: VirtInstallOpts) => req<TaskInfo>('POST', '/api/virt/install', o),
virtRemote: (type: string, refresh = false) => req<{ images: VirtImage[] }>('GET', `/api/virt/images/remote?type=${type}${refresh ? '&refresh=1' : ''}`),
virtImageDownload: (alias: string) => req<TaskInfo>('POST', '/api/virt/images/download', { alias }),
virtImageImport: (path: string, alias: string, type: string) => req<TaskInfo>('POST', '/api/virt/images/import', { path, alias, type }),
virtImagePublish: (instance: string, alias: string) => req<TaskInfo>('POST', '/api/virt/images/publish', { instance, alias }),
virtImageDelete: (ref: string) => req<void>('POST', '/api/virt/images/delete', { ref }),
virtCreate: (c: VirtCreate) => req<TaskInfo>('POST', '/api/virt/instances', c),
virtAction: (name: string, action: 'start' | 'stop' | 'restart' | 'kill') => req<void>('POST', `/api/virt/instances/${encodeURIComponent(name)}/${action}`),
virtDelete: (name: string, force: boolean) => req<void>('POST', `/api/virt/instances/${encodeURIComponent(name)}/delete`, { confirm: name, force }),
virtTerminal: (name: string, kind: 'console' | 'exec') => req<{ id: string; name: string }>('POST', `/api/virt/instances/${encodeURIComponent(name)}/${kind}`),
virtRename: (name: string, to: string) => req<void>('POST', `/api/virt/instances/${encodeURIComponent(name)}/rename`, { name: to }),
virtConfig: (name: string, cfg: Record<string, string>) => req<void>('PUT', `/api/virt/instances/${encodeURIComponent(name)}/config`, cfg),
virtSnapshot: (name: string, snap: string) => req<{ name: string }>('POST', `/api/virt/instances/${encodeURIComponent(name)}/snapshots`, { name: snap }),
virtSnapshotAction: (name: string, snap: string, action: 'restore' | 'delete') => req<void>('POST', `/api/virt/instances/${encodeURIComponent(name)}/snapshots/${encodeURIComponent(snap)}/${action}`),
} }
export function wsURLPath(path: string): string { export function wsURLPath(path: string): string {
+13
View File
@@ -889,3 +889,16 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%,
.kvm-shrink:hover, .kvm-shrink:active { opacity: 1; } .kvm-shrink:hover, .kvm-shrink:active { opacity: 1; }
.updot { margin-left: 6px; font-size: 11px; font-weight: 700; } .updot { margin-left: 6px; font-size: 11px; font-weight: 700; }
.updot.behind { color: var(--s2); } .updot.unknown { color: var(--muted); } .updot.behind { color: var(--s2); } .updot.unknown { color: var(--muted); }
/* ---- Virtualization ---- */
.virt .svc-tools .task { max-height: 45vh; }
.virt-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.virt-table th, .virt-table td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); vertical-align: top; }
.virt-table th { font-weight: 500; opacity: .7; }
.virt-table td.num, .virt-table th.num { text-align: right; white-space: nowrap; }
.virt-pick { max-height: 240px; overflow-y: auto; border: 1px solid var(--border); border-radius: 6px; }
.virt-pick .svc-row { padding: 6px 10px; }
.virt-kv { display: grid; grid-template-columns: max-content 1fr; gap: 2px 12px; font-size: 12px; word-break: break-all; }
.virt-form-row { display: flex; gap: 8px; }
.virt-form-row > label { flex: 1; min-width: 0; }
.virt .modal { max-width: 640px; }