2845 lines
106 KiB
Python
Executable File
2845 lines
106 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
QEMU TUI Manager — zero external dependencies, uses only curses.
|
|
Features: VM management, disk management, snapshots, port forwarding,
|
|
QEMU monitor console (graceful shutdown, pause/resume).
|
|
Python 3.7+ required.
|
|
"""
|
|
|
|
import curses
|
|
import json
|
|
import os
|
|
import shlex
|
|
import shutil
|
|
import socket as _socket
|
|
import subprocess
|
|
import time
|
|
from dataclasses import dataclass, field, asdict
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
# ── Paths ──────────────────────────────────────────────────────────────────────
|
|
|
|
CONFIG_PATH = Path.home() / ".config" / "qemu-tui" / "vms.json"
|
|
DISK_DIR = Path.home() / ".cache" / "qemu-tui"
|
|
MONITOR_DIR = Path.home() / ".cache" / "qemu-tui" / "monitors"
|
|
RUNTIME_PATH = Path.home() / ".cache" / "qemu-tui" / "runtime.json"
|
|
|
|
OVMF_CANDIDATES = [
|
|
"/usr/share/ovmf/OVMF.fd",
|
|
"/usr/share/ovmf/x64/OVMF.fd",
|
|
"/usr/share/OVMF/OVMF_CODE.fd",
|
|
"/usr/share/edk2/ovmf/OVMF_CODE.fd",
|
|
"/usr/share/edk2-ovmf/OVMF_CODE.fd",
|
|
"/usr/lib/ovmf/OVMF.fd",
|
|
"/usr/share/qemu/ovmf-x86_64.bin",
|
|
"/usr/share/AAVMF/AAVMF_CODE.fd",
|
|
"/usr/share/qemu-efi-aarch64/QEMU_EFI.fd",
|
|
]
|
|
|
|
|
|
def find_ovmf(arch="x86_64"):
|
|
for p in OVMF_CANDIDATES:
|
|
aarch = "aarch64" in p or "aavmf" in p.lower()
|
|
if aarch and arch != "aarch64":
|
|
continue
|
|
if not aarch and arch == "aarch64":
|
|
continue
|
|
if os.path.exists(p):
|
|
return p
|
|
for p in OVMF_CANDIDATES:
|
|
if os.path.exists(p):
|
|
return p
|
|
return None
|
|
|
|
|
|
# ── Data model ─────────────────────────────────────────────────────────────────
|
|
|
|
@dataclass
|
|
class VMConfig:
|
|
name: str
|
|
memory: int = 1024
|
|
cpus: int = 2
|
|
disk: str = ""
|
|
cdrom: str = ""
|
|
arch: str = "x86_64"
|
|
network: str = "user"
|
|
display: str = "none"
|
|
uefi: bool = False
|
|
extra_args: str = ""
|
|
portfwds: list = None
|
|
|
|
def __post_init__(self):
|
|
if self.portfwds is None:
|
|
self.portfwds = []
|
|
|
|
def to_dict(self):
|
|
return asdict(self)
|
|
|
|
@classmethod
|
|
def from_dict(cls, d):
|
|
known = set(cls.__dataclass_fields__)
|
|
obj = cls(**{k: v for k, v in d.items() if k in known})
|
|
if not isinstance(obj.portfwds, list):
|
|
obj.portfwds = []
|
|
return obj
|
|
|
|
def default_disk_path(self):
|
|
return str(DISK_DIR / f"{self.name}.qcow2")
|
|
|
|
|
|
@dataclass
|
|
class VMState:
|
|
config: VMConfig
|
|
pid: Optional[int] = None
|
|
process: Optional[subprocess.Popen] = None
|
|
start_time: Optional[float] = None
|
|
status: str = "stopped"
|
|
error: str = ""
|
|
log_lines: list = field(default_factory=list)
|
|
monitor_sock: str = ""
|
|
|
|
@property
|
|
def uptime(self):
|
|
if not self.start_time:
|
|
return ""
|
|
s = int(time.time() - self.start_time)
|
|
return f"{s//3600:02d}:{(s%3600)//60:02d}:{s%60:02d}"
|
|
|
|
|
|
# ── VM Manager ─────────────────────────────────────────────────────────────────
|
|
|
|
class VMManager:
|
|
def __init__(self):
|
|
self.vms: dict[str, VMState] = {}
|
|
self._load()
|
|
|
|
def _load(self):
|
|
if CONFIG_PATH.exists():
|
|
try:
|
|
data = json.loads(CONFIG_PATH.read_text())
|
|
for d in data:
|
|
cfg = VMConfig.from_dict(d)
|
|
self.vms[cfg.name] = VMState(config=cfg)
|
|
except Exception:
|
|
pass
|
|
self._restore_runtime()
|
|
|
|
def _save(self):
|
|
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
CONFIG_PATH.write_text(
|
|
json.dumps([v.config.to_dict() for v in self.vms.values()], indent=2)
|
|
)
|
|
|
|
def _save_runtime(self):
|
|
"""Persist running/paused VM state so it survives manager restarts."""
|
|
RUNTIME_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
data = {}
|
|
for name, vm in self.vms.items():
|
|
if vm.status in ("running", "paused") and vm.pid:
|
|
data[name] = {
|
|
"pid": vm.pid,
|
|
"start_time": vm.start_time,
|
|
"status": vm.status,
|
|
"monitor_sock": vm.monitor_sock,
|
|
}
|
|
RUNTIME_PATH.write_text(json.dumps(data, indent=2))
|
|
|
|
def _restore_runtime(self):
|
|
"""On startup, re-attach to any VMs still running from a previous session."""
|
|
if not RUNTIME_PATH.exists():
|
|
return
|
|
try:
|
|
data = json.loads(RUNTIME_PATH.read_text())
|
|
except Exception:
|
|
return
|
|
for name, state in data.items():
|
|
vm = self.vms.get(name)
|
|
if not vm:
|
|
continue
|
|
pid = state.get("pid")
|
|
if not pid:
|
|
continue
|
|
# Check if the process is still alive
|
|
alive = False
|
|
try:
|
|
os.kill(pid, 0) # signal 0 = existence check only
|
|
alive = True
|
|
except (OSError, ProcessLookupError):
|
|
alive = False
|
|
if alive:
|
|
vm.pid = pid
|
|
vm.start_time = state.get("start_time")
|
|
vm.status = state.get("status", "running")
|
|
vm.monitor_sock = state.get("monitor_sock", "")
|
|
# process handle is None — poll() will use PID-based check
|
|
vm.process = None
|
|
|
|
def names(self):
|
|
return list(self.vms.keys())
|
|
|
|
def add(self, cfg: VMConfig) -> str:
|
|
if cfg.name in self.vms:
|
|
return f"'{cfg.name}' already exists"
|
|
self.vms[cfg.name] = VMState(config=cfg)
|
|
self._save()
|
|
return ""
|
|
|
|
def remove(self, name: str) -> str:
|
|
vm = self.vms.get(name)
|
|
if not vm:
|
|
return "Not found"
|
|
if vm.status == "running":
|
|
return "Stop VM first"
|
|
del self.vms[name]
|
|
self._save()
|
|
return ""
|
|
|
|
def update(self, name: str, cfg: VMConfig) -> str:
|
|
vm = self.vms.get(name)
|
|
if not vm:
|
|
return "Not found"
|
|
if vm.status == "running":
|
|
return "Stop VM first"
|
|
vm.config = cfg
|
|
self._save()
|
|
return ""
|
|
|
|
# ── build command ──────────────────────────────────────────────────────────
|
|
|
|
def build_cmd(self, cfg: VMConfig):
|
|
"""Returns (cmd_list, monitor_sock_path)."""
|
|
bin_ = f"qemu-system-{cfg.arch}"
|
|
if not shutil.which(bin_):
|
|
bin_ = "qemu-system-x86_64"
|
|
|
|
cmd = [bin_, "-m", str(cfg.memory), "-smp", str(cfg.cpus), "-name", cfg.name]
|
|
|
|
if cfg.uefi:
|
|
ovmf = find_ovmf(cfg.arch)
|
|
if ovmf:
|
|
cmd += ["-drive", f"if=pflash,format=raw,readonly=on,file={ovmf}"]
|
|
|
|
if cfg.disk:
|
|
cmd += ["-drive", f"file={cfg.disk},format=qcow2,if=virtio"]
|
|
|
|
if cfg.cdrom:
|
|
cmd += ["-cdrom", cfg.cdrom]
|
|
|
|
if cfg.network == "user":
|
|
netdev = "user,id=net0"
|
|
for fw in (cfg.portfwds or []):
|
|
proto = fw.get("proto", "tcp")
|
|
haddr = fw.get("host_addr", "")
|
|
hport = fw.get("host_port", "")
|
|
gport = fw.get("guest_port", "")
|
|
hpart = f"{haddr}:{hport}" if haddr else str(hport)
|
|
netdev += f",hostfwd={proto}:{hpart}-:{gport}"
|
|
cmd += ["-netdev", netdev, "-device", "virtio-net,netdev=net0"]
|
|
elif cfg.network == "none":
|
|
cmd += ["-nic", "none"]
|
|
|
|
if cfg.display == "none":
|
|
cmd += ["-display", "none", "-vga", "none"]
|
|
elif cfg.display == "sdl":
|
|
cmd += ["-display", "sdl"]
|
|
elif cfg.display == "vnc":
|
|
cmd += ["-display", "vnc=:0"]
|
|
|
|
if os.path.exists("/dev/kvm"):
|
|
cmd += ["-enable-kvm", "-cpu", "host"]
|
|
|
|
if cfg.extra_args:
|
|
cmd += shlex.split(cfg.extra_args)
|
|
|
|
cmd += ["-serial", "none"]
|
|
|
|
MONITOR_DIR.mkdir(parents=True, exist_ok=True)
|
|
sock = str(MONITOR_DIR / f"{cfg.name}.sock")
|
|
cmd += ["-monitor", f"unix:{sock},server,nowait"]
|
|
|
|
return cmd, sock
|
|
|
|
# ── lifecycle ──────────────────────────────────────────────────────────────
|
|
|
|
def create_disk(self, cfg: VMConfig, size_gb: int = 20) -> str:
|
|
if not cfg.disk:
|
|
return "No disk path set"
|
|
p = Path(cfg.disk)
|
|
if p.exists():
|
|
return ""
|
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
qimg = shutil.which("qemu-img")
|
|
if not qimg:
|
|
return "qemu-img not found"
|
|
try:
|
|
subprocess.run(
|
|
[qimg, "create", "-f", "qcow2", str(p), f"{size_gb}G"],
|
|
check=True, capture_output=True
|
|
)
|
|
except subprocess.CalledProcessError as e:
|
|
return e.stderr.decode().strip()
|
|
return ""
|
|
|
|
def start(self, name: str) -> str:
|
|
vm = self.vms.get(name)
|
|
if not vm:
|
|
return "Not found"
|
|
if vm.status == "running":
|
|
return "Already running"
|
|
cmd, sock = self.build_cmd(vm.config)
|
|
try:
|
|
Path(sock).unlink(missing_ok=True)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
proc = subprocess.Popen(
|
|
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True
|
|
)
|
|
vm.process = proc
|
|
vm.pid = proc.pid
|
|
vm.start_time = time.time()
|
|
vm.status = "running"
|
|
vm.error = ""
|
|
vm.monitor_sock = sock
|
|
self._save_runtime()
|
|
except FileNotFoundError:
|
|
vm.status = "error"
|
|
vm.error = f"Binary not found: {cmd[0]}"
|
|
return vm.error
|
|
except Exception as e:
|
|
vm.status = "error"
|
|
vm.error = str(e)
|
|
return str(e)
|
|
return ""
|
|
|
|
def stop(self, name: str, force=False) -> str:
|
|
import signal as _signal
|
|
vm = self.vms.get(name)
|
|
if not vm:
|
|
return "Not found"
|
|
if vm.status not in ("running", "paused"):
|
|
return "Not running"
|
|
try:
|
|
if vm.process is not None:
|
|
# Normal case — we have a Popen handle
|
|
(vm.process.kill if force else vm.process.terminate)()
|
|
vm.process.wait(timeout=5)
|
|
elif vm.pid is not None:
|
|
# Re-attached VM — send signal directly via PID
|
|
sig = _signal.SIGKILL if force else _signal.SIGTERM
|
|
os.kill(vm.pid, sig)
|
|
# Wait up to 5s for it to die
|
|
deadline = time.time() + 5
|
|
while time.time() < deadline:
|
|
try:
|
|
os.kill(vm.pid, 0) # still alive?
|
|
time.sleep(0.1)
|
|
except (OSError, ProcessLookupError):
|
|
break # gone
|
|
else:
|
|
return "No process handle or PID available"
|
|
except Exception as e:
|
|
return str(e)
|
|
vm.status = "stopped"
|
|
vm.pid = None
|
|
vm.process = None
|
|
vm.start_time = None
|
|
vm.monitor_sock = ""
|
|
self._save_runtime()
|
|
return ""
|
|
|
|
def eject_cdrom(self, name: str) -> str:
|
|
vm = self.vms.get(name)
|
|
if not vm:
|
|
return "Not found"
|
|
if not vm.config.cdrom:
|
|
return "No CD-ROM attached"
|
|
vm.config.cdrom = ""
|
|
self._save()
|
|
return ""
|
|
|
|
def poll(self):
|
|
changed = False
|
|
for vm in self.vms.values():
|
|
if vm.status not in ("running", "paused"):
|
|
continue
|
|
if vm.process is not None:
|
|
# We have a handle — use it
|
|
if vm.process.poll() is not None:
|
|
rc = vm.process.returncode
|
|
vm.status = "stopped" if rc == 0 else "error"
|
|
if rc != 0:
|
|
vm.error = f"Exit code {rc}"
|
|
vm.pid = None
|
|
vm.process = None
|
|
vm.start_time = None
|
|
vm.monitor_sock = ""
|
|
changed = True
|
|
elif vm.pid is not None:
|
|
# Re-attached VM — check PID directly
|
|
alive = False
|
|
try:
|
|
os.kill(vm.pid, 0)
|
|
alive = True
|
|
except (OSError, ProcessLookupError):
|
|
alive = False
|
|
if not alive:
|
|
vm.status = "stopped"
|
|
vm.pid = None
|
|
vm.start_time = None
|
|
vm.monitor_sock = ""
|
|
changed = True
|
|
if changed:
|
|
self._save_runtime()
|
|
|
|
def drain_log(self, name: str):
|
|
vm = self.vms.get(name)
|
|
if not vm or not vm.process or not vm.process.stdout:
|
|
return
|
|
import selectors
|
|
sel = selectors.DefaultSelector()
|
|
sel.register(vm.process.stdout, selectors.EVENT_READ)
|
|
while True:
|
|
if not sel.select(timeout=0):
|
|
break
|
|
line = vm.process.stdout.readline()
|
|
if not line:
|
|
break
|
|
vm.log_lines.append(line.rstrip())
|
|
if len(vm.log_lines) > 500:
|
|
vm.log_lines = vm.log_lines[-500:]
|
|
sel.close()
|
|
|
|
# ── disk management ────────────────────────────────────────────────────────
|
|
|
|
def disk_info(self, path: str) -> dict:
|
|
qimg = shutil.which("qemu-img")
|
|
if not qimg:
|
|
return {"error": "qemu-img not found"}
|
|
if not path or not Path(path).exists():
|
|
return {"error": "Disk file not found"}
|
|
try:
|
|
r = subprocess.run(
|
|
[qimg, "info", "--output=json", path],
|
|
capture_output=True, text=True, timeout=10
|
|
)
|
|
if r.returncode != 0:
|
|
err = r.stderr.strip() or "qemu-img info failed"
|
|
if "write" in err.lower() and "lock" in err.lower():
|
|
return {"error": "Disk locked (VM is running — stop VM to see full info)"}
|
|
return {"error": err}
|
|
data = json.loads(r.stdout)
|
|
return {
|
|
"format": data.get("format", "?"),
|
|
"virtual_size": data.get("virtual-size", 0),
|
|
"actual_size": data.get("actual-size", 0),
|
|
"backing_file": data.get("backing-filename", ""),
|
|
"snapshots": len(data.get("snapshots", [])),
|
|
}
|
|
except Exception as e:
|
|
return {"error": str(e)}
|
|
|
|
def disk_resize(self, path: str, new_size_gb: int) -> str:
|
|
qimg = shutil.which("qemu-img")
|
|
if not qimg:
|
|
return "qemu-img not found"
|
|
if not path or not Path(path).exists():
|
|
return "Disk file not found"
|
|
try:
|
|
r = subprocess.run(
|
|
[qimg, "resize", path, f"{new_size_gb}G"],
|
|
capture_output=True, text=True, timeout=30
|
|
)
|
|
return "" if r.returncode == 0 else (r.stderr.strip() or "resize failed")
|
|
except Exception as e:
|
|
return str(e)
|
|
|
|
def disk_delete(self, path: str) -> str:
|
|
p = Path(path)
|
|
if not p.exists():
|
|
return "File not found"
|
|
try:
|
|
p.unlink()
|
|
return ""
|
|
except Exception as e:
|
|
return str(e)
|
|
|
|
def disk_convert(self, src: str, dst: str, fmt: str = "qcow2") -> str:
|
|
qimg = shutil.which("qemu-img")
|
|
if not qimg:
|
|
return "qemu-img not found"
|
|
if not Path(src).exists():
|
|
return "Source not found"
|
|
try:
|
|
r = subprocess.run(
|
|
[qimg, "convert", "-p", "-O", fmt, src, dst],
|
|
capture_output=True, text=True, timeout=300
|
|
)
|
|
return "" if r.returncode == 0 else (r.stderr.strip() or "convert failed")
|
|
except Exception as e:
|
|
return str(e)
|
|
|
|
# ── snapshots ──────────────────────────────────────────────────────────────
|
|
|
|
def snapshot_list(self, path: str) -> list:
|
|
qimg = shutil.which("qemu-img")
|
|
if not qimg:
|
|
return [{"error": "qemu-img not found"}]
|
|
if not path or not Path(path).exists():
|
|
return [{"error": "Disk file not found"}]
|
|
try:
|
|
r = subprocess.run(
|
|
[qimg, "snapshot", "-l", path],
|
|
capture_output=True, text=True, timeout=10
|
|
)
|
|
if r.returncode != 0:
|
|
err = r.stderr.strip() or "snapshot -l failed"
|
|
if "write" in err.lower() and "lock" in err.lower():
|
|
return [{"error": "Disk locked — stop VM before listing snapshots"}]
|
|
return [{"error": err}]
|
|
snaps = []
|
|
for line in r.stdout.splitlines():
|
|
line = line.strip()
|
|
if not line or line.startswith("Snapshot") or line.startswith("ID"):
|
|
continue
|
|
parts = line.split()
|
|
if len(parts) >= 2:
|
|
snaps.append({
|
|
"id": parts[0],
|
|
"tag": parts[1],
|
|
"vm_size": parts[2] if len(parts) > 2 else "",
|
|
"date": f"{parts[3]} {parts[4]}" if len(parts) > 4 else "",
|
|
"vm_clock": parts[5] if len(parts) > 5 else "",
|
|
})
|
|
return snaps
|
|
except Exception as e:
|
|
return [{"error": str(e)}]
|
|
|
|
def snapshot_create(self, path: str, tag: str) -> str:
|
|
qimg = shutil.which("qemu-img")
|
|
if not qimg:
|
|
return "qemu-img not found"
|
|
if not path or not Path(path).exists():
|
|
return "Disk file not found"
|
|
if not tag.strip():
|
|
return "Snapshot name cannot be empty"
|
|
try:
|
|
r = subprocess.run(
|
|
[qimg, "snapshot", "-c", tag, path],
|
|
capture_output=True, text=True, timeout=30
|
|
)
|
|
return "" if r.returncode == 0 else (r.stderr.strip() or "snapshot create failed")
|
|
except Exception as e:
|
|
return str(e)
|
|
|
|
def snapshot_restore(self, path: str, tag: str) -> str:
|
|
qimg = shutil.which("qemu-img")
|
|
if not qimg:
|
|
return "qemu-img not found"
|
|
if not path or not Path(path).exists():
|
|
return "Disk file not found"
|
|
try:
|
|
r = subprocess.run(
|
|
[qimg, "snapshot", "-a", tag, path],
|
|
capture_output=True, text=True, timeout=30
|
|
)
|
|
return "" if r.returncode == 0 else (r.stderr.strip() or "restore failed")
|
|
except Exception as e:
|
|
return str(e)
|
|
|
|
def snapshot_delete(self, path: str, tag: str) -> str:
|
|
qimg = shutil.which("qemu-img")
|
|
if not qimg:
|
|
return "qemu-img not found"
|
|
if not path or not Path(path).exists():
|
|
return "Disk file not found"
|
|
try:
|
|
r = subprocess.run(
|
|
[qimg, "snapshot", "-d", tag, path],
|
|
capture_output=True, text=True, timeout=30
|
|
)
|
|
return "" if r.returncode == 0 else (r.stderr.strip() or "delete failed")
|
|
except Exception as e:
|
|
return str(e)
|
|
|
|
# ── QEMU monitor ───────────────────────────────────────────────────────────
|
|
|
|
def monitor_cmd(self, name: str, cmd: str, timeout: float = 3.0) -> str:
|
|
"""Send command to QEMU monitor socket, return response or ERROR: string."""
|
|
vm = self.vms.get(name)
|
|
if not vm:
|
|
return "ERROR: VM not found"
|
|
sock_path = vm.monitor_sock
|
|
if not sock_path or not Path(sock_path).exists():
|
|
return "ERROR: Monitor socket not available"
|
|
try:
|
|
s = _socket.socket(_socket.AF_UNIX, _socket.SOCK_STREAM)
|
|
s.settimeout(timeout)
|
|
s.connect(sock_path)
|
|
# drain banner
|
|
banner = b""
|
|
deadline = time.time() + 1.5
|
|
while time.time() < deadline:
|
|
try:
|
|
chunk = s.recv(4096)
|
|
if not chunk:
|
|
break
|
|
banner += chunk
|
|
if b"(qemu)" in banner:
|
|
break
|
|
except _socket.timeout:
|
|
break
|
|
# send command
|
|
s.sendall((cmd.strip() + "\n").encode())
|
|
# read response
|
|
resp = b""
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
try:
|
|
chunk = s.recv(4096)
|
|
if not chunk:
|
|
break
|
|
resp += chunk
|
|
if b"(qemu)" in resp:
|
|
break
|
|
except _socket.timeout:
|
|
break
|
|
s.close()
|
|
text = resp.decode(errors="replace")
|
|
# strip ANSI escape sequences (e.g. cursor movement, colour codes)
|
|
import re as _re
|
|
text = _re.sub(r'\x1b\[[0-9;]*[A-Za-z]', '', text)
|
|
text = _re.sub(r'\x1b\[[0-9;]*m', '', text)
|
|
text = text.replace("(qemu)", "").strip()
|
|
return text or "(ok)"
|
|
except Exception as e:
|
|
return f"ERROR: {e}"
|
|
|
|
def monitor_powerdown(self, name: str) -> str:
|
|
r = self.monitor_cmd(name, "system_powerdown")
|
|
return "" if not r.startswith("ERROR:") else r
|
|
|
|
def monitor_pause(self, name: str) -> str:
|
|
r = self.monitor_cmd(name, "stop")
|
|
if r.startswith("ERROR:"):
|
|
return r
|
|
vm = self.vms.get(name)
|
|
if vm:
|
|
vm.status = "paused"
|
|
return ""
|
|
|
|
def monitor_resume(self, name: str) -> str:
|
|
r = self.monitor_cmd(name, "cont")
|
|
if r.startswith("ERROR:"):
|
|
return r
|
|
vm = self.vms.get(name)
|
|
if vm:
|
|
vm.status = "running"
|
|
return ""
|
|
|
|
def monitor_reset(self, name: str) -> str:
|
|
r = self.monitor_cmd(name, "system_reset")
|
|
return "" if not r.startswith("ERROR:") else r
|
|
|
|
|
|
def clone_vm(self, name: str, new_name: str, disk_mode: str = "linked") -> str:
|
|
"""
|
|
Clone a VM config under new_name.
|
|
disk_mode:
|
|
"none" — same disk path (shared, dangerous but fast)
|
|
"linked" — qcow2 with backing file (small, copy-on-write)
|
|
"full" — full independent copy with qemu-img convert
|
|
Returns error string or "".
|
|
"""
|
|
vm = self.vms.get(name)
|
|
if not vm:
|
|
return "Source VM not found"
|
|
if new_name.strip() == "":
|
|
return "New name cannot be empty"
|
|
if new_name in self.vms:
|
|
return f"'{new_name}' already exists"
|
|
|
|
src_cfg = vm.config
|
|
new_cfg = VMConfig.from_dict(src_cfg.to_dict())
|
|
new_cfg.name = new_name
|
|
new_cfg.portfwds = [] # don't clone port fwds (host port conflicts)
|
|
|
|
if src_cfg.disk and disk_mode != "none":
|
|
DISK_DIR.mkdir(parents=True, exist_ok=True)
|
|
safe = new_name.replace(" ", "_")
|
|
new_disk = str(DISK_DIR / f"{safe}.qcow2")
|
|
new_cfg.disk = new_disk
|
|
|
|
if disk_mode == "linked":
|
|
qimg = shutil.which("qemu-img")
|
|
if not qimg:
|
|
return "qemu-img not found"
|
|
if not Path(src_cfg.disk).exists():
|
|
return f"Source disk not found: {src_cfg.disk}"
|
|
try:
|
|
r = subprocess.run(
|
|
[qimg, "create", "-f", "qcow2",
|
|
"-b", src_cfg.disk, "-F", "qcow2", new_disk],
|
|
capture_output=True, text=True, timeout=30
|
|
)
|
|
if r.returncode != 0:
|
|
return r.stderr.strip() or "linked clone failed"
|
|
except Exception as e:
|
|
return str(e)
|
|
|
|
elif disk_mode == "full":
|
|
err = self.disk_convert(src_cfg.disk, new_disk, fmt="qcow2")
|
|
if err:
|
|
return f"Full copy failed: {err}"
|
|
|
|
self.vms[new_name] = VMState(config=new_cfg)
|
|
self._save()
|
|
return ""
|
|
|
|
def import_vm(self, disk_path: str, vm_name: str) -> str:
|
|
"""
|
|
Import an existing disk image as a new VM.
|
|
Probes the image with qemu-img info to detect format and size.
|
|
Returns error string or "".
|
|
"""
|
|
if not vm_name.strip():
|
|
return "VM name cannot be empty"
|
|
if vm_name in self.vms:
|
|
return f"'{vm_name}' already exists"
|
|
p = Path(disk_path)
|
|
if not p.exists():
|
|
return f"File not found: {disk_path}"
|
|
|
|
# probe with qemu-img info
|
|
info = self.disk_info(disk_path)
|
|
# build config — use detected format in extra_args if not qcow2
|
|
fmt = info.get("format", "qcow2") if "error" not in info else "qcow2"
|
|
|
|
cfg = VMConfig(
|
|
name = vm_name,
|
|
disk = disk_path,
|
|
extra_args = f"-drive file={disk_path},format={fmt},if=virtio" if fmt != "qcow2" else "",
|
|
)
|
|
# if format is not qcow2, use raw drive and clear the standard disk field
|
|
if fmt != "qcow2":
|
|
cfg.disk = ""
|
|
cfg.extra_args = f"-drive file={disk_path},format={fmt},if=virtio"
|
|
|
|
self.vms[vm_name] = VMState(config=cfg)
|
|
self._save()
|
|
return ""
|
|
|
|
|
|
# ── Curses helpers ─────────────────────────────────────────────────────────────
|
|
|
|
def init_colors():
|
|
curses.start_color()
|
|
curses.use_default_colors()
|
|
curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_CYAN)
|
|
curses.init_pair(2, curses.COLOR_BLACK, curses.COLOR_WHITE)
|
|
curses.init_pair(3, curses.COLOR_GREEN, -1)
|
|
curses.init_pair(4, curses.COLOR_WHITE, -1)
|
|
curses.init_pair(5, curses.COLOR_RED, -1)
|
|
curses.init_pair(6, curses.COLOR_BLACK+8, -1)
|
|
curses.init_pair(7, curses.COLOR_CYAN, -1)
|
|
curses.init_pair(8, curses.COLOR_YELLOW, -1)
|
|
|
|
|
|
STATUS_ICON = {"running": ">", "stopped": ".", "error": "!", "paused": "~"}
|
|
|
|
|
|
def status_pair(status):
|
|
return {
|
|
"running": curses.color_pair(3),
|
|
"stopped": curses.color_pair(6),
|
|
"error": curses.color_pair(5),
|
|
"paused": curses.color_pair(8),
|
|
}.get(status, curses.color_pair(4))
|
|
|
|
|
|
def clamp(v, lo, hi):
|
|
return max(lo, min(hi, v))
|
|
|
|
|
|
def _is_esc(ch) -> bool:
|
|
return ch == 27 or ch == "\x1b"
|
|
|
|
|
|
def _flush_esc(win):
|
|
curses.flushinp()
|
|
win.nodelay(True)
|
|
try:
|
|
while win.getch() != curses.ERR:
|
|
pass
|
|
except curses.error:
|
|
pass
|
|
win.nodelay(False)
|
|
|
|
|
|
def _modal_win(stdscr, h, w):
|
|
sh, sw = stdscr.getmaxyx()
|
|
y = clamp((sh - h) // 2, 0, sh - h - 1)
|
|
x = clamp((sw - w) // 2, 0, sw - w - 1)
|
|
win = curses.newwin(h, w, y, x)
|
|
win.keypad(True)
|
|
win.nodelay(False)
|
|
# Ensure no ESC delay on this window
|
|
try:
|
|
curses.set_escdelay(1)
|
|
except AttributeError:
|
|
pass
|
|
return win
|
|
|
|
|
|
def _close_modal(win, stdscr):
|
|
try:
|
|
win.erase()
|
|
win.refresh()
|
|
except curses.error:
|
|
pass
|
|
del win
|
|
stdscr.touchwin()
|
|
stdscr.refresh()
|
|
|
|
|
|
def _fmt_bytes(n: int) -> str:
|
|
if n >= 1_073_741_824:
|
|
return f"{n/1_073_741_824:.2f} GiB"
|
|
if n >= 1_048_576:
|
|
return f"{n/1_048_576:.2f} MiB"
|
|
return f"{n//1024} KiB"
|
|
|
|
|
|
# ── readline modal ─────────────────────────────────────────────────────────────
|
|
|
|
def readline_modal(stdscr, prompt: str, default: str = "") -> Optional[str]:
|
|
w = min(60, stdscr.getmaxyx()[1] - 4)
|
|
win = _modal_win(stdscr, 5, w)
|
|
curses.curs_set(1)
|
|
buf = list(default)
|
|
while True:
|
|
win.erase()
|
|
win.border()
|
|
win.addstr(0, 2, f" {prompt} ", curses.color_pair(7) | curses.A_BOLD)
|
|
win.addstr(2, 2, "> ")
|
|
inner = w - 6
|
|
display = "".join(buf)[-inner:]
|
|
win.addstr(2, 4, display.ljust(inner))
|
|
win.move(2, 4 + min(len(buf), inner))
|
|
win.refresh()
|
|
try:
|
|
ch = win.get_wch()
|
|
except curses.error:
|
|
continue
|
|
if ch in ("\n", "\r", curses.KEY_ENTER):
|
|
break
|
|
elif _is_esc(ch):
|
|
_flush_esc(win)
|
|
curses.curs_set(0)
|
|
_close_modal(win, stdscr)
|
|
return None
|
|
elif ch in (curses.KEY_BACKSPACE, "\x7f", "\b"):
|
|
if buf:
|
|
buf.pop()
|
|
elif isinstance(ch, str) and ch.isprintable():
|
|
buf.append(ch)
|
|
curses.curs_set(0)
|
|
_close_modal(win, stdscr)
|
|
return "".join(buf)
|
|
|
|
|
|
# ── file browser modal ─────────────────────────────────────────────────────────
|
|
|
|
def filebrowser_modal(stdscr, title="Select File", start_dir="",
|
|
extensions=()) -> Optional[str]:
|
|
cwd = Path(start_dir).expanduser() if start_dir else Path.home()
|
|
if not cwd.is_dir():
|
|
cwd = Path.home()
|
|
cursor = 0
|
|
scroll = 0
|
|
|
|
def list_dir(p):
|
|
out = []
|
|
try:
|
|
items = sorted(p.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower()))
|
|
for item in items:
|
|
if item.name.startswith("."):
|
|
continue
|
|
if item.is_dir():
|
|
out.append(item)
|
|
elif not extensions or item.suffix.lower() in extensions:
|
|
out.append(item)
|
|
except PermissionError:
|
|
pass
|
|
return out
|
|
|
|
sh, sw = stdscr.getmaxyx()
|
|
h = max(10, sh - 4)
|
|
w = min(sw - 4, 82)
|
|
win = _modal_win(stdscr, h, w)
|
|
|
|
while True:
|
|
entries = list_dir(cwd)
|
|
has_parent = cwd.parent != cwd
|
|
display = ([None] if has_parent else []) + entries
|
|
|
|
cursor = clamp(cursor, 0, max(0, len(display) - 1))
|
|
list_h = h - 5
|
|
if cursor >= scroll + list_h:
|
|
scroll = cursor - list_h + 1
|
|
if cursor < scroll:
|
|
scroll = cursor
|
|
scroll = clamp(scroll, 0, max(0, len(display) - list_h))
|
|
|
|
win.erase()
|
|
win.border()
|
|
win.addstr(0, 2, f" {title} ", curses.color_pair(7) | curses.A_BOLD)
|
|
cwd_str = str(cwd)
|
|
if len(cwd_str) > w - 4:
|
|
cwd_str = "..." + cwd_str[-(w-7):]
|
|
try:
|
|
win.addstr(1, 2, cwd_str, curses.color_pair(6))
|
|
win.addstr(2, 1, "-" * (w-2), curses.color_pair(6))
|
|
except curses.error:
|
|
pass
|
|
|
|
for row_i in range(list_h):
|
|
idx = scroll + row_i
|
|
if idx >= len(display):
|
|
break
|
|
entry = display[idx]
|
|
is_sel = (idx == cursor)
|
|
if entry is None:
|
|
label = "../ (parent directory)"
|
|
attr = (curses.color_pair(2) | curses.A_BOLD) if is_sel else curses.color_pair(8)
|
|
elif entry.is_dir():
|
|
label = entry.name + "/"
|
|
attr = (curses.color_pair(2) | curses.A_BOLD) if is_sel else curses.color_pair(7)
|
|
else:
|
|
try:
|
|
size = entry.stat().st_size
|
|
except OSError:
|
|
size = 0
|
|
if size >= 1_073_741_824:
|
|
sz = f"{size/1_073_741_824:.1f}G"
|
|
elif size >= 1_048_576:
|
|
sz = f"{size/1_048_576:.1f}M"
|
|
else:
|
|
sz = f"{size//1024}K"
|
|
nw = w - 12
|
|
label = f"{entry.name[:nw]:<{nw}} {sz:>6}"
|
|
attr = (curses.color_pair(2) | curses.A_BOLD) if is_sel else 0
|
|
try:
|
|
win.addstr(3 + row_i, 2, label[:w-4].ljust(w-4), attr)
|
|
except curses.error:
|
|
pass
|
|
|
|
hint = " ↑↓=navigate →/Enter=open ←/Bksp=up Esc=cancel "
|
|
try:
|
|
win.addstr(h-2, max(1, (w-len(hint))//2), hint, curses.color_pair(6))
|
|
except curses.error:
|
|
pass
|
|
win.refresh()
|
|
|
|
try:
|
|
ch = win.get_wch()
|
|
except curses.error:
|
|
continue
|
|
|
|
if _is_esc(ch):
|
|
_flush_esc(win)
|
|
_close_modal(win, stdscr)
|
|
return None
|
|
elif ch == curses.KEY_DOWN:
|
|
cursor = clamp(cursor + 1, 0, len(display) - 1)
|
|
elif ch == curses.KEY_UP:
|
|
cursor = clamp(cursor - 1, 0, len(display) - 1)
|
|
elif ch == curses.KEY_PPAGE:
|
|
cursor = clamp(cursor - list_h, 0, len(display) - 1)
|
|
elif ch == curses.KEY_NPAGE:
|
|
cursor = clamp(cursor + list_h, 0, len(display) - 1)
|
|
elif ch in (curses.KEY_LEFT, curses.KEY_BACKSPACE, "\x7f", "\b"):
|
|
if has_parent:
|
|
cwd = cwd.parent
|
|
cursor = 0
|
|
scroll = 0
|
|
elif ch in (curses.KEY_RIGHT, "\n", "\r", curses.KEY_ENTER):
|
|
if not display:
|
|
continue
|
|
entry = display[cursor]
|
|
if entry is None:
|
|
cwd = cwd.parent
|
|
cursor = 0
|
|
scroll = 0
|
|
elif entry.is_dir():
|
|
cwd = entry
|
|
cursor = 0
|
|
scroll = 0
|
|
else:
|
|
result = str(entry)
|
|
_close_modal(win, stdscr)
|
|
return result
|
|
|
|
|
|
# ── confirm modal ──────────────────────────────────────────────────────────────
|
|
|
|
def confirm_modal(stdscr, msg: str) -> bool:
|
|
w = min(max(len(msg) + 10, 34), stdscr.getmaxyx()[1] - 4)
|
|
win = _modal_win(stdscr, 5, w)
|
|
win.erase()
|
|
win.border()
|
|
win.addstr(0, 2, " Confirm ", curses.color_pair(8) | curses.A_BOLD)
|
|
try:
|
|
win.addstr(1, 2, msg[:w-4])
|
|
win.addstr(3, 2, "[Y]es [N]o [Esc]=cancel", curses.color_pair(8))
|
|
except curses.error:
|
|
pass
|
|
win.refresh()
|
|
while True:
|
|
try:
|
|
ch = win.get_wch()
|
|
except curses.error:
|
|
continue
|
|
if isinstance(ch, str) and ch.lower() == "y":
|
|
_close_modal(win, stdscr)
|
|
return True
|
|
if isinstance(ch, str) and ch.lower() == "n":
|
|
_close_modal(win, stdscr)
|
|
return False
|
|
if _is_esc(ch):
|
|
_flush_esc(win)
|
|
_close_modal(win, stdscr)
|
|
return False
|
|
|
|
|
|
# ── VM form modal ──────────────────────────────────────────────────────────────
|
|
|
|
ARCH_OPTIONS = ["x86_64", "aarch64", "arm", "riscv64", "mips"]
|
|
NETWORK_OPTIONS = ["user", "none"]
|
|
DISPLAY_OPTIONS = ["none", "sdl", "vnc"]
|
|
UEFI_OPTIONS = ["no", "yes"]
|
|
_BTN_FIELDS = 0
|
|
_BTN_SAVE = 1
|
|
_BTN_CANCEL = 2
|
|
|
|
|
|
def vm_form_modal(stdscr, cfg: Optional[VMConfig] = None) -> Optional[VMConfig]:
|
|
editing = cfg is not None
|
|
if cfg is None:
|
|
cfg = VMConfig(name="")
|
|
|
|
fields = [
|
|
("Name", "name", "text"),
|
|
("Memory (MiB)", "memory", "text"),
|
|
("CPUs", "cpus", "text"),
|
|
("Disk image", "disk", "browse_disk"),
|
|
("CD-ROM / ISO", "cdrom", "browse_iso"),
|
|
("Architecture", "arch", ARCH_OPTIONS),
|
|
("Network", "network", NETWORK_OPTIONS),
|
|
("Display", "display", DISPLAY_OPTIONS),
|
|
("UEFI / OVMF", "uefi", UEFI_OPTIONS),
|
|
("Extra args", "extra_args", "text"),
|
|
]
|
|
|
|
def cfg_val(key):
|
|
v = getattr(cfg, key)
|
|
if key == "uefi":
|
|
return "yes" if v else "no"
|
|
return str(v)
|
|
|
|
values = {f[1]: cfg_val(f[1]) for f in fields}
|
|
name_filled = bool(cfg.name)
|
|
h = len(fields) + 6
|
|
w = 66
|
|
win = _modal_win(stdscr, h, w)
|
|
cursor = 0
|
|
btn_focus = _BTN_FIELDS
|
|
|
|
def _do_save():
|
|
name = values["name"].strip()
|
|
if not name:
|
|
return None
|
|
try:
|
|
return VMConfig(
|
|
name = name,
|
|
memory = int(values["memory"] or 1024),
|
|
cpus = int(values["cpus"] or 2),
|
|
disk = values["disk"].strip(),
|
|
cdrom = values["cdrom"].strip(),
|
|
arch = values["arch"],
|
|
network = values["network"],
|
|
display = values["display"],
|
|
uefi = (values["uefi"] == "yes"),
|
|
extra_args = values["extra_args"].strip(),
|
|
portfwds = list(cfg.portfwds),
|
|
)
|
|
except ValueError:
|
|
return None
|
|
|
|
while True:
|
|
if not editing and not name_filled and values["name"]:
|
|
name_filled = True
|
|
if not editing and name_filled and not values["disk"] and values["name"].strip():
|
|
safe = values["name"].strip().replace(" ", "_")
|
|
values["disk"] = str(DISK_DIR / f"{safe}.qcow2")
|
|
|
|
win.erase()
|
|
win.border()
|
|
title = " Edit VM " if editing else " New VM "
|
|
win.addstr(0, 2, title, curses.color_pair(7) | curses.A_BOLD)
|
|
|
|
ovmf_path = find_ovmf(values.get("arch", "x86_64"))
|
|
ovmf_hint = ovmf_path or "OVMF not found!"
|
|
|
|
for i, (label, key, kind) in enumerate(fields):
|
|
row = i + 1
|
|
sel = (btn_focus == _BTN_FIELDS and i == cursor)
|
|
attr = curses.color_pair(2) | curses.A_BOLD if sel else 0
|
|
try:
|
|
win.addstr(row, 1, f" {label:<17}", attr)
|
|
except curses.error:
|
|
pass
|
|
val = values[key]
|
|
if isinstance(kind, list):
|
|
idx = kind.index(val) if val in kind else 0
|
|
display = f"< {kind[idx]} >"
|
|
if key == "uefi" and sel:
|
|
short = ovmf_hint if len(ovmf_hint) < 26 else "..." + ovmf_hint[-23:]
|
|
display = f"< {kind[idx]} > {short}"
|
|
elif kind in ("browse_disk", "browse_iso"):
|
|
inner = val[-(w-26):] if len(val) > w-26 else val
|
|
display = (inner or "(none)") + (" [B]" if sel else "")
|
|
else:
|
|
display = val
|
|
try:
|
|
win.addstr(row, 20, display[:w-23].ljust(w-23), attr)
|
|
except curses.error:
|
|
pass
|
|
|
|
btn_row = h - 2
|
|
save_attr = curses.color_pair(2) | curses.A_BOLD if btn_focus == _BTN_SAVE else curses.color_pair(3)
|
|
cancel_attr = curses.color_pair(2) | curses.A_BOLD if btn_focus == _BTN_CANCEL else curses.color_pair(5)
|
|
try:
|
|
win.addstr(btn_row, 1, " " * (w-2), curses.color_pair(6))
|
|
win.addstr(btn_row, w//2 - 10, " [ Save ] ", save_attr)
|
|
win.addstr(btn_row, w//2 + 2, " [ Cancel ] ", cancel_attr)
|
|
except curses.error:
|
|
pass
|
|
|
|
hint = " Tab/↑↓=navigate ←→=cycle Enter/B=browse "
|
|
try:
|
|
win.addstr(h-1, max(1, (w-len(hint))//2), hint[:w-2], curses.color_pair(6))
|
|
except curses.error:
|
|
pass
|
|
win.refresh()
|
|
|
|
try:
|
|
ch = win.get_wch()
|
|
except curses.error:
|
|
continue
|
|
|
|
label, key, kind = fields[cursor]
|
|
|
|
if _is_esc(ch):
|
|
_flush_esc(win)
|
|
_close_modal(win, stdscr)
|
|
return None
|
|
|
|
elif ch in ("\t", curses.KEY_DOWN):
|
|
if btn_focus == _BTN_FIELDS:
|
|
if cursor < len(fields) - 1:
|
|
cursor += 1
|
|
else:
|
|
btn_focus = _BTN_SAVE
|
|
elif btn_focus == _BTN_SAVE:
|
|
btn_focus = _BTN_CANCEL
|
|
else:
|
|
btn_focus = _BTN_FIELDS
|
|
cursor = 0
|
|
|
|
elif ch == curses.KEY_UP:
|
|
if btn_focus == _BTN_FIELDS:
|
|
if cursor > 0:
|
|
cursor -= 1
|
|
else:
|
|
btn_focus = _BTN_CANCEL
|
|
elif btn_focus == _BTN_CANCEL:
|
|
btn_focus = _BTN_SAVE
|
|
else:
|
|
btn_focus = _BTN_FIELDS
|
|
cursor = len(fields) - 1
|
|
|
|
elif ch == curses.KEY_LEFT:
|
|
if btn_focus == _BTN_FIELDS and isinstance(kind, list):
|
|
idx = kind.index(values[key]) if values[key] in kind else 0
|
|
values[key] = kind[(idx - 1) % len(kind)]
|
|
elif btn_focus == _BTN_CANCEL:
|
|
btn_focus = _BTN_SAVE
|
|
elif btn_focus == _BTN_SAVE:
|
|
btn_focus = _BTN_CANCEL
|
|
|
|
elif ch == curses.KEY_RIGHT:
|
|
if btn_focus == _BTN_FIELDS and isinstance(kind, list):
|
|
idx = kind.index(values[key]) if values[key] in kind else 0
|
|
values[key] = kind[(idx + 1) % len(kind)]
|
|
elif btn_focus == _BTN_SAVE:
|
|
btn_focus = _BTN_CANCEL
|
|
elif btn_focus == _BTN_CANCEL:
|
|
btn_focus = _BTN_SAVE
|
|
|
|
elif ch in ("\n", "\r", curses.KEY_ENTER, ord("b"), ord("B")):
|
|
if btn_focus == _BTN_SAVE:
|
|
result = _do_save()
|
|
if result:
|
|
_close_modal(win, stdscr)
|
|
return result
|
|
continue
|
|
if btn_focus == _BTN_CANCEL:
|
|
_close_modal(win, stdscr)
|
|
return None
|
|
if isinstance(kind, list):
|
|
idx = kind.index(values[key]) if values[key] in kind else 0
|
|
values[key] = kind[(idx + 1) % len(kind)]
|
|
elif kind == "browse_iso":
|
|
start = str(Path(values[key]).parent) if values[key] else ""
|
|
result = filebrowser_modal(stdscr, title="Select ISO / CD-ROM",
|
|
start_dir=start,
|
|
extensions=(".iso", ".img", ".dmg", ".toast"))
|
|
if result is not None:
|
|
values[key] = result
|
|
win.touchwin(); win.refresh()
|
|
elif kind == "browse_disk":
|
|
start = str(Path(values[key]).parent) if values[key] else ""
|
|
result = filebrowser_modal(stdscr, title="Select Disk Image",
|
|
start_dir=start,
|
|
extensions=(".qcow2", ".img", ".raw", ".vmdk", ".vdi"))
|
|
if result is not None:
|
|
values[key] = result
|
|
win.touchwin(); win.refresh()
|
|
else:
|
|
result = readline_modal(stdscr, label, values[key])
|
|
if result is not None:
|
|
values[key] = result
|
|
win.touchwin(); win.refresh()
|
|
|
|
|
|
# ── disk management modal ──────────────────────────────────────────────────────
|
|
|
|
def disk_mgmt_modal(stdscr, mgr: VMManager, vm_state) -> str:
|
|
cfg = vm_state.config
|
|
running = vm_state.status == "running"
|
|
ACTIONS = [
|
|
("info", "Show disk info"),
|
|
("create", "Create new disk"),
|
|
("resize", "Resize disk"),
|
|
("convert", "Convert to another format"),
|
|
("delete", "Delete disk file"),
|
|
]
|
|
cursor = 0
|
|
msg = ""
|
|
|
|
def _load_info():
|
|
return mgr.disk_info(cfg.disk) if cfg.disk else {}
|
|
|
|
info = _load_info()
|
|
sh, sw_ = stdscr.getmaxyx()
|
|
h = min(30, sh - 4)
|
|
w = min(70, sw_ - 4)
|
|
win = _modal_win(stdscr, h, w)
|
|
|
|
while True:
|
|
win.erase()
|
|
win.border()
|
|
win.addstr(0, 2, " Disk Management ", curses.color_pair(7) | curses.A_BOLD)
|
|
|
|
row = 1
|
|
disk_path = cfg.disk or "(no disk configured)"
|
|
path_disp = disk_path[-(w-6):] if len(disk_path) > w-6 else disk_path
|
|
try:
|
|
win.addstr(row, 2, "Path: ", curses.color_pair(6))
|
|
win.addstr(row, 10, path_disp[:w-12])
|
|
except curses.error:
|
|
pass
|
|
row += 1
|
|
|
|
if "error" in info:
|
|
try:
|
|
win.addstr(row, 2, info["error"][:w-4], curses.color_pair(5))
|
|
except curses.error:
|
|
pass
|
|
row += 1
|
|
elif info:
|
|
vsize = info.get("virtual_size", 0)
|
|
asize = info.get("actual_size", 0)
|
|
pct = f" ({asize*100//vsize}%)" if vsize else ""
|
|
for label, val in [
|
|
("Format", info.get("format", "?")),
|
|
("Virt size", _fmt_bytes(vsize)),
|
|
("Used", _fmt_bytes(asize) + pct),
|
|
("Snapshots", str(info.get("snapshots", 0))),
|
|
]:
|
|
try:
|
|
win.addstr(row, 2, f"{label:<12}", curses.color_pair(6))
|
|
win.addstr(row, 14, val[:w-16])
|
|
except curses.error:
|
|
pass
|
|
row += 1
|
|
if info.get("backing_file"):
|
|
try:
|
|
win.addstr(row, 2, f"{'Backing':<12}", curses.color_pair(6))
|
|
win.addstr(row, 14, info["backing_file"][:w-16])
|
|
except curses.error:
|
|
pass
|
|
row += 1
|
|
|
|
row += 1
|
|
try:
|
|
win.addstr(row, 1, "-" * (w-2), curses.color_pair(6))
|
|
except curses.error:
|
|
pass
|
|
row += 1
|
|
|
|
for i, (act_id, act_label) in enumerate(ACTIONS):
|
|
sel = (i == cursor)
|
|
attr = curses.color_pair(2) | curses.A_BOLD if sel else 0
|
|
if running and act_id in ("resize", "delete", "convert"):
|
|
attr = curses.color_pair(6)
|
|
marker = "> " if sel else " "
|
|
try:
|
|
win.addstr(row + i, 2, f"{marker}{act_label:<30}", attr)
|
|
except curses.error:
|
|
pass
|
|
|
|
if msg:
|
|
ok_attr = curses.color_pair(3) if not msg.startswith("Error") else curses.color_pair(5)
|
|
try:
|
|
win.addstr(h-3, 2, msg[:w-4], ok_attr)
|
|
except curses.error:
|
|
pass
|
|
|
|
hint = " ↑↓=select Enter=run R=refresh Esc=close "
|
|
try:
|
|
win.addstr(h-2, max(1, (w-len(hint))//2), hint, curses.color_pair(6))
|
|
except curses.error:
|
|
pass
|
|
win.refresh()
|
|
|
|
try:
|
|
ch = win.get_wch()
|
|
except curses.error:
|
|
continue
|
|
|
|
if _is_esc(ch):
|
|
_flush_esc(win)
|
|
_close_modal(win, stdscr)
|
|
return msg or ""
|
|
elif ch == curses.KEY_UP:
|
|
cursor = (cursor - 1) % len(ACTIONS)
|
|
elif ch == curses.KEY_DOWN:
|
|
cursor = (cursor + 1) % len(ACTIONS)
|
|
elif ch in ("r", "R"):
|
|
info = _load_info()
|
|
msg = "Refreshed."
|
|
elif ch in ("\n", "\r", curses.KEY_ENTER):
|
|
act_id = ACTIONS[cursor][0]
|
|
|
|
if act_id == "info":
|
|
info = _load_info()
|
|
msg = "Info refreshed."
|
|
|
|
elif act_id == "create":
|
|
default_path = cfg.disk or cfg.default_disk_path()
|
|
path_raw = readline_modal(stdscr, "Disk path (.qcow2)", default_path)
|
|
win.touchwin(); win.refresh()
|
|
if path_raw is None:
|
|
msg = "Cancelled."
|
|
continue
|
|
path_raw = path_raw.strip()
|
|
if not path_raw:
|
|
msg = "Error: empty path"
|
|
continue
|
|
if Path(path_raw).exists():
|
|
msg = "Error: file already exists"
|
|
continue
|
|
size_raw = readline_modal(stdscr, "Size in GiB (e.g. 20)", "20")
|
|
win.touchwin(); win.refresh()
|
|
if size_raw is None:
|
|
msg = "Cancelled."
|
|
continue
|
|
try:
|
|
gb = int(size_raw.strip())
|
|
if gb < 1:
|
|
raise ValueError
|
|
except ValueError:
|
|
msg = "Error: invalid size"
|
|
continue
|
|
tmp_cfg = VMConfig(**cfg.to_dict())
|
|
tmp_cfg.disk = path_raw
|
|
err = mgr.create_disk(tmp_cfg, size_gb=gb)
|
|
if err:
|
|
msg = f"Error: {err}"
|
|
else:
|
|
if not cfg.disk:
|
|
cfg.disk = path_raw
|
|
mgr.update(cfg.name, cfg)
|
|
msg = f"Created {path_raw} ({gb} GiB)"
|
|
info = _load_info()
|
|
|
|
elif act_id == "resize":
|
|
if running:
|
|
msg = "Error: stop VM before resizing"
|
|
continue
|
|
if not cfg.disk or not Path(cfg.disk).exists():
|
|
msg = "Error: no disk file"
|
|
continue
|
|
cur_gb = info.get("virtual_size", 0) // 1_073_741_824
|
|
size_raw = readline_modal(stdscr, "New size in GiB (must be larger)", str(cur_gb or 20))
|
|
win.touchwin(); win.refresh()
|
|
if size_raw is None:
|
|
msg = "Cancelled."
|
|
continue
|
|
try:
|
|
gb = int(size_raw.strip())
|
|
if gb < 1:
|
|
raise ValueError
|
|
except ValueError:
|
|
msg = "Error: invalid size"
|
|
continue
|
|
err = mgr.disk_resize(cfg.disk, gb)
|
|
if err:
|
|
msg = f"Error: {err}"
|
|
else:
|
|
msg = f"Resized to {gb} GiB"
|
|
info = _load_info()
|
|
|
|
elif act_id == "convert":
|
|
if running:
|
|
msg = "Error: stop VM before converting"
|
|
continue
|
|
if not cfg.disk or not Path(cfg.disk).exists():
|
|
msg = "Error: no disk file"
|
|
continue
|
|
stem = Path(cfg.disk).stem
|
|
default = str(Path(cfg.disk).parent / f"{stem}_converted.qcow2")
|
|
dst_raw = readline_modal(stdscr, "Output path", default)
|
|
win.touchwin(); win.refresh()
|
|
if dst_raw is None:
|
|
msg = "Cancelled."
|
|
continue
|
|
fmt_raw = readline_modal(stdscr, "Format (qcow2/raw/vmdk/vdi)", "qcow2")
|
|
win.touchwin(); win.refresh()
|
|
if fmt_raw is None:
|
|
msg = "Cancelled."
|
|
continue
|
|
try:
|
|
win.addstr(h-3, 2, "Converting...".ljust(w-4), curses.color_pair(8))
|
|
win.refresh()
|
|
except curses.error:
|
|
pass
|
|
err = mgr.disk_convert(cfg.disk, dst_raw.strip(), fmt=fmt_raw.strip() or "qcow2")
|
|
msg = f"Error: {err}" if err else f"Converted -> {Path(dst_raw).name}"
|
|
|
|
elif act_id == "delete":
|
|
if running:
|
|
msg = "Error: stop VM before deleting"
|
|
continue
|
|
if not cfg.disk or not Path(cfg.disk).exists():
|
|
msg = "Error: no disk file"
|
|
continue
|
|
fname = Path(cfg.disk).name
|
|
if confirm_modal(stdscr, f"DELETE {fname}? This cannot be undone!"):
|
|
err = mgr.disk_delete(cfg.disk)
|
|
win.touchwin(); win.refresh()
|
|
msg = f"Error: {err}" if err else f"Deleted {fname}"
|
|
if not err:
|
|
info = {}
|
|
else:
|
|
win.touchwin(); win.refresh()
|
|
msg = "Cancelled."
|
|
|
|
|
|
# ── snapshot modal ─────────────────────────────────────────────────────────────
|
|
|
|
def snapshot_modal(stdscr, mgr: VMManager, vm_state) -> str:
|
|
cfg = vm_state.config
|
|
running = vm_state.status == "running"
|
|
|
|
if not cfg.disk:
|
|
return "No disk configured"
|
|
if not Path(cfg.disk).exists():
|
|
return "Disk file not found"
|
|
|
|
cursor = 0
|
|
msg = ""
|
|
msg_ok = True
|
|
snaps = mgr.snapshot_list(cfg.disk)
|
|
|
|
sh, sw_ = stdscr.getmaxyx()
|
|
h = min(32, sh - 4)
|
|
w = min(72, sw_ - 4)
|
|
win = _modal_win(stdscr, h, w)
|
|
|
|
COL_ID = 2
|
|
COL_TAG = 8
|
|
COL_DATE = 30
|
|
COL_CLK = 52
|
|
|
|
while True:
|
|
win.erase()
|
|
win.border()
|
|
win.addstr(0, 2, " Snapshots ", curses.color_pair(7) | curses.A_BOLD)
|
|
disk_disp = cfg.disk[-(w-10):] if len(cfg.disk) > w-10 else cfg.disk
|
|
try:
|
|
win.addstr(1, 2, f"Disk: {disk_disp}", curses.color_pair(6))
|
|
except curses.error:
|
|
pass
|
|
|
|
list_y = 3
|
|
try:
|
|
win.addstr(list_y, COL_ID, "ID", curses.color_pair(8) | curses.A_BOLD)
|
|
win.addstr(list_y, COL_TAG, "Tag/Name", curses.color_pair(8) | curses.A_BOLD)
|
|
win.addstr(list_y, COL_DATE,"Date", curses.color_pair(8) | curses.A_BOLD)
|
|
win.addstr(list_y, COL_CLK, "VM Clock", curses.color_pair(8) | curses.A_BOLD)
|
|
win.addstr(list_y+1, 1, "-" * (w-2), curses.color_pair(6))
|
|
except curses.error:
|
|
pass
|
|
list_y += 2
|
|
list_h = h - list_y - 4
|
|
|
|
if snaps and "error" in snaps[0]:
|
|
try:
|
|
win.addstr(list_y, 2, snaps[0]["error"][:w-4], curses.color_pair(5))
|
|
except curses.error:
|
|
pass
|
|
elif not snaps:
|
|
try:
|
|
win.addstr(list_y, 2, "(no snapshots — press 'c' to create one)", curses.color_pair(6))
|
|
except curses.error:
|
|
pass
|
|
else:
|
|
cursor = clamp(cursor, 0, len(snaps) - 1)
|
|
scroll = max(0, cursor - list_h + 1) if cursor >= list_h else 0
|
|
for ri, snap in enumerate(snaps[scroll: scroll+list_h]):
|
|
idx = scroll + ri
|
|
is_sel = (idx == cursor)
|
|
attr = curses.color_pair(2) | curses.A_BOLD if is_sel else 0
|
|
try:
|
|
win.addstr(list_y+ri, 1, " " * (w-2), attr)
|
|
win.addstr(list_y+ri, COL_ID, snap.get("id", "")[:5], attr)
|
|
win.addstr(list_y+ri, COL_TAG, snap.get("tag", "")[:20], attr)
|
|
win.addstr(list_y+ri, COL_DATE, snap.get("date","")[:20], attr)
|
|
win.addstr(list_y+ri, COL_CLK, snap.get("vm_clock","")[:16], attr)
|
|
except curses.error:
|
|
pass
|
|
try:
|
|
win.addstr(h-4, w-14, f"{cursor+1}/{len(snaps)}", curses.color_pair(6))
|
|
except curses.error:
|
|
pass
|
|
|
|
if msg:
|
|
attr = curses.color_pair(3) if msg_ok else curses.color_pair(5)
|
|
try:
|
|
win.addstr(h-3, 2, msg[:w-4], attr)
|
|
except curses.error:
|
|
pass
|
|
|
|
hint = " c=create r=restore x/Del=delete R=refresh Esc=close "
|
|
if running:
|
|
hint = " c=create R=refresh Esc=close (stop VM to restore/delete) "
|
|
try:
|
|
win.addstr(h-2, max(1, (w-len(hint))//2), hint[:w-2], curses.color_pair(6))
|
|
except curses.error:
|
|
pass
|
|
win.refresh()
|
|
|
|
try:
|
|
ch = win.get_wch()
|
|
except curses.error:
|
|
continue
|
|
|
|
if _is_esc(ch):
|
|
_flush_esc(win)
|
|
_close_modal(win, stdscr)
|
|
return msg or ""
|
|
|
|
elif ch == curses.KEY_UP:
|
|
cursor = max(0, cursor - 1)
|
|
elif ch == curses.KEY_DOWN:
|
|
cursor = min(max(0, len(snaps)-1), cursor + 1)
|
|
|
|
elif ch in ("c", "C"):
|
|
tag = readline_modal(stdscr, "Snapshot name (no spaces)", "")
|
|
win.touchwin(); win.refresh()
|
|
if tag is None:
|
|
msg = "Cancelled."; msg_ok = True
|
|
elif not tag.strip():
|
|
msg = "Error: name cannot be empty"; msg_ok = False
|
|
elif " " in tag:
|
|
msg = "Error: no spaces in name"; msg_ok = False
|
|
else:
|
|
err = mgr.snapshot_create(cfg.disk, tag.strip())
|
|
if err:
|
|
msg = f"Error: {err}"; msg_ok = False
|
|
else:
|
|
snaps = mgr.snapshot_list(cfg.disk)
|
|
cursor = max(0, len(snaps) - 1)
|
|
msg = f"Snapshot '{tag.strip()}' created."; msg_ok = True
|
|
|
|
elif ch == "r" and not running:
|
|
if not snaps or "error" in snaps[0]:
|
|
msg = "No snapshots to restore."; msg_ok = False
|
|
else:
|
|
tag = snaps[cursor]["tag"]
|
|
if confirm_modal(stdscr, f"Restore '{tag}'? Unsaved changes will be lost!"):
|
|
win.touchwin(); win.refresh()
|
|
err = mgr.snapshot_restore(cfg.disk, tag)
|
|
msg = f"Error: {err}" if err else f"Restored to '{tag}'."; msg_ok = not err
|
|
else:
|
|
win.touchwin(); win.refresh()
|
|
msg = "Cancelled."; msg_ok = True
|
|
|
|
elif ch == "r" and running:
|
|
msg = "Error: stop VM before restoring."; msg_ok = False
|
|
|
|
elif ch in ("x", "X", curses.KEY_DC):
|
|
if running:
|
|
msg = "Error: stop VM before deleting."; msg_ok = False
|
|
elif not snaps or "error" in snaps[0]:
|
|
msg = "No snapshots to delete."; msg_ok = False
|
|
else:
|
|
tag = snaps[cursor]["tag"]
|
|
if confirm_modal(stdscr, f"Delete snapshot '{tag}'?"):
|
|
win.touchwin(); win.refresh()
|
|
err = mgr.snapshot_delete(cfg.disk, tag)
|
|
if err:
|
|
msg = f"Error: {err}"; msg_ok = False
|
|
else:
|
|
snaps = mgr.snapshot_list(cfg.disk)
|
|
cursor = clamp(cursor, 0, max(0, len(snaps)-1))
|
|
msg = f"Deleted '{tag}'."; msg_ok = True
|
|
else:
|
|
win.touchwin(); win.refresh()
|
|
msg = "Cancelled."; msg_ok = True
|
|
|
|
elif ch == "R":
|
|
snaps = mgr.snapshot_list(cfg.disk)
|
|
msg = "Refreshed."; msg_ok = True
|
|
|
|
|
|
# ── port forward modal ─────────────────────────────────────────────────────────
|
|
|
|
PORTFWD_PRESETS = [
|
|
("SSH", "tcp", "", 22, 2222),
|
|
("HTTP", "tcp", "", 80, 8080),
|
|
("HTTPS", "tcp", "", 443, 8443),
|
|
("RDP", "tcp", "", 3389, 3389),
|
|
("VNC", "tcp", "", 5900, 5900),
|
|
("Custom", None, None, None, None),
|
|
]
|
|
|
|
|
|
def portfwd_modal(stdscr, mgr: VMManager, vm_state) -> str:
|
|
cfg = vm_state.config
|
|
running = vm_state.status == "running"
|
|
|
|
if cfg.network != "user":
|
|
return "Port forwarding only works with network=user"
|
|
|
|
rules = [dict(r) for r in (cfg.portfwds or [])]
|
|
cursor = 0
|
|
msg = ""
|
|
msg_ok = True
|
|
|
|
sh, sw_ = stdscr.getmaxyx()
|
|
h = min(28, sh - 4)
|
|
w = min(68, sw_ - 4)
|
|
win = _modal_win(stdscr, h, w)
|
|
|
|
COL_PROTO = 2
|
|
COL_HADDR = 10
|
|
COL_HPORT = 28
|
|
COL_GPORT = 40
|
|
COL_DESC = 52
|
|
|
|
def _save():
|
|
cfg.portfwds = [dict(r) for r in rules]
|
|
mgr.update(cfg.name, cfg)
|
|
|
|
while True:
|
|
win.erase()
|
|
win.border()
|
|
win.addstr(0, 2, " Port Forwarding ", curses.color_pair(7) | curses.A_BOLD)
|
|
try:
|
|
note = " (changes apply on next VM start) " if running else \
|
|
"Rules passed as -netdev hostfwd= arguments."
|
|
win.addstr(1, 2, note, curses.color_pair(8) if running else curses.color_pair(6))
|
|
except curses.error:
|
|
pass
|
|
|
|
list_y = 3
|
|
try:
|
|
win.addstr(list_y, COL_PROTO, "Proto", curses.color_pair(8) | curses.A_BOLD)
|
|
win.addstr(list_y, COL_HADDR, "Host addr", curses.color_pair(8) | curses.A_BOLD)
|
|
win.addstr(list_y, COL_HPORT, "Host port", curses.color_pair(8) | curses.A_BOLD)
|
|
win.addstr(list_y, COL_GPORT, "Guest port", curses.color_pair(8) | curses.A_BOLD)
|
|
win.addstr(list_y, COL_DESC, "Desc", curses.color_pair(8) | curses.A_BOLD)
|
|
win.addstr(list_y+1, 1, "-" * (w-2), curses.color_pair(6))
|
|
except curses.error:
|
|
pass
|
|
list_y += 2
|
|
list_h = h - list_y - 4
|
|
|
|
if not rules:
|
|
try:
|
|
win.addstr(list_y, 2, "(no rules — press 'a' to add one)", curses.color_pair(6))
|
|
except curses.error:
|
|
pass
|
|
else:
|
|
cursor = clamp(cursor, 0, len(rules) - 1)
|
|
scroll = max(0, cursor - list_h + 1) if cursor >= list_h else 0
|
|
for ri, rule in enumerate(rules[scroll: scroll+list_h]):
|
|
idx = scroll + ri
|
|
is_sel = (idx == cursor)
|
|
attr = curses.color_pair(2) | curses.A_BOLD if is_sel else 0
|
|
haddr = rule.get("host_addr", "") or "*"
|
|
try:
|
|
win.addstr(list_y+ri, 1, " " * (w-2), attr)
|
|
win.addstr(list_y+ri, COL_PROTO, rule.get("proto","tcp")[:5], attr)
|
|
win.addstr(list_y+ri, COL_HADDR, haddr[:16], attr)
|
|
win.addstr(list_y+ri, COL_HPORT, str(rule.get("host_port",""))[:8], attr)
|
|
win.addstr(list_y+ri, COL_GPORT, str(rule.get("guest_port",""))[:8], attr)
|
|
win.addstr(list_y+ri, COL_DESC, rule.get("desc","")[:w-COL_DESC-2], attr)
|
|
except curses.error:
|
|
pass
|
|
|
|
if msg:
|
|
attr = curses.color_pair(3) if msg_ok else curses.color_pair(5)
|
|
try:
|
|
win.addstr(h-3, 2, msg[:w-4], attr)
|
|
except curses.error:
|
|
pass
|
|
|
|
hint = " ↑↓=select a=add d/Del=delete Esc=save & close "
|
|
try:
|
|
win.addstr(h-2, max(1, (w-len(hint))//2), hint[:w-2], curses.color_pair(6))
|
|
except curses.error:
|
|
pass
|
|
win.refresh()
|
|
|
|
try:
|
|
ch = win.get_wch()
|
|
except curses.error:
|
|
continue
|
|
|
|
if _is_esc(ch):
|
|
_flush_esc(win)
|
|
_save()
|
|
_close_modal(win, stdscr)
|
|
return f"Saved {len(rules)} rule(s)." if rules else "No rules."
|
|
|
|
elif ch == curses.KEY_UP:
|
|
cursor = max(0, cursor - 1)
|
|
elif ch == curses.KEY_DOWN:
|
|
cursor = min(max(0, len(rules)-1), cursor + 1)
|
|
|
|
elif ch in ("a", "A"):
|
|
# preset picker
|
|
ph = len(PORTFWD_PRESETS) + 4
|
|
pw = 36
|
|
pwn = _modal_win(stdscr, ph, pw)
|
|
pc = 0
|
|
chosen = None
|
|
while True:
|
|
pwn.erase()
|
|
pwn.border()
|
|
pwn.addstr(0, 2, " Quick Preset ", curses.color_pair(7) | curses.A_BOLD)
|
|
for pi, (pname, *_) in enumerate(PORTFWD_PRESETS):
|
|
attr = curses.color_pair(2) | curses.A_BOLD if pi == pc else 0
|
|
try:
|
|
pwn.addstr(pi+1, 2, f"{'> ' if pi==pc else ' '}{pname}", attr)
|
|
except curses.error:
|
|
pass
|
|
pwn.addstr(ph-2, 2, " ↑↓=pick Enter=select Esc=cancel ", curses.color_pair(6))
|
|
pwn.refresh()
|
|
pch = pwn.get_wch()
|
|
if _is_esc(pch):
|
|
_close_modal(pwn, stdscr)
|
|
win.touchwin(); win.refresh()
|
|
msg = "Cancelled."; msg_ok = True
|
|
break
|
|
elif pch == curses.KEY_UP:
|
|
pc = max(0, pc-1)
|
|
elif pch == curses.KEY_DOWN:
|
|
pc = min(len(PORTFWD_PRESETS)-1, pc+1)
|
|
elif pch in ("\n", "\r", curses.KEY_ENTER):
|
|
chosen = PORTFWD_PRESETS[pc]
|
|
_close_modal(pwn, stdscr)
|
|
win.touchwin(); win.refresh()
|
|
break
|
|
|
|
if chosen is None:
|
|
continue
|
|
|
|
pname, proto, haddr, gport, hport = chosen
|
|
if proto is None:
|
|
raw = readline_modal(stdscr, "Protocol (tcp/udp)", "tcp")
|
|
win.touchwin(); win.refresh()
|
|
if raw is None:
|
|
msg = "Cancelled."; msg_ok = True; continue
|
|
proto = raw.strip().lower() or "tcp"
|
|
|
|
hp_raw = readline_modal(stdscr, "Host port (on your machine)", str(hport) if hport else "")
|
|
win.touchwin(); win.refresh()
|
|
if hp_raw is None:
|
|
msg = "Cancelled."; msg_ok = True; continue
|
|
try:
|
|
hport = int(hp_raw.strip())
|
|
if not (1 <= hport <= 65535):
|
|
raise ValueError
|
|
except ValueError:
|
|
msg = "Error: invalid host port"; msg_ok = False; continue
|
|
|
|
gp_raw = readline_modal(stdscr, "Guest port (inside VM)", str(gport) if gport else str(hport))
|
|
win.touchwin(); win.refresh()
|
|
if gp_raw is None:
|
|
msg = "Cancelled."; msg_ok = True; continue
|
|
try:
|
|
gport = int(gp_raw.strip())
|
|
if not (1 <= gport <= 65535):
|
|
raise ValueError
|
|
except ValueError:
|
|
msg = "Error: invalid guest port"; msg_ok = False; continue
|
|
|
|
ha_raw = readline_modal(stdscr, "Host bind addr (blank = all)", haddr or "")
|
|
win.touchwin(); win.refresh()
|
|
if ha_raw is None:
|
|
msg = "Cancelled."; msg_ok = True; continue
|
|
haddr = ha_raw.strip()
|
|
|
|
desc_raw = readline_modal(stdscr, "Description (optional)", pname if pname != "Custom" else "")
|
|
win.touchwin(); win.refresh()
|
|
desc = desc_raw.strip() if desc_raw is not None else ""
|
|
|
|
conflict = any(r.get("host_port") == hport and r.get("proto") == proto for r in rules)
|
|
if conflict:
|
|
msg = f"Error: host port {hport}/{proto} already used"; msg_ok = False
|
|
else:
|
|
rules.append({"proto": proto, "host_port": hport,
|
|
"guest_port": gport, "host_addr": haddr, "desc": desc})
|
|
cursor = len(rules) - 1
|
|
msg = f"Added {proto}:{hport} -> guest:{gport}"; msg_ok = True
|
|
|
|
elif ch in ("d", "D", curses.KEY_DC):
|
|
if not rules:
|
|
msg = "No rules to delete."; msg_ok = False
|
|
else:
|
|
rule = rules[cursor]
|
|
desc = rule.get("desc") or f"port {rule.get('host_port')}"
|
|
if confirm_modal(stdscr, f"Delete rule '{desc}'?"):
|
|
rules.pop(cursor)
|
|
cursor = clamp(cursor, 0, max(0, len(rules)-1))
|
|
msg = "Rule deleted."; msg_ok = True
|
|
else:
|
|
msg = "Cancelled."; msg_ok = True
|
|
win.touchwin(); win.refresh()
|
|
|
|
|
|
# ── clone modal ───────────────────────────────────────────────────────────────
|
|
|
|
def clone_modal(stdscr, mgr: VMManager, vm_state) -> str:
|
|
"""
|
|
Clone a VM. Asks for new name and disk copy mode.
|
|
Returns status message.
|
|
"""
|
|
cfg = vm_state.config
|
|
|
|
# ── Step 1: new name ──────────────────────────────────────────────────────
|
|
new_name = readline_modal(stdscr, "New VM name", f"{cfg.name}-clone")
|
|
if new_name is None:
|
|
return "Cancelled."
|
|
new_name = new_name.strip()
|
|
if not new_name:
|
|
return "Error: name cannot be empty"
|
|
if new_name in mgr.vms:
|
|
return f"Error: '{new_name}' already exists"
|
|
|
|
# ── Step 2: disk copy mode ────────────────────────────────────────────────
|
|
MODES = [
|
|
("linked", "Linked clone (qcow2 backing file, small & fast)"),
|
|
("full", "Full copy (independent copy, uses full disk space)"),
|
|
("none", "No copy (share same disk path — dangerous!)"),
|
|
]
|
|
sh, sw_ = stdscr.getmaxyx()
|
|
mh = len(MODES) + 5
|
|
mw = 58
|
|
mwin = _modal_win(stdscr, mh, mw)
|
|
mc = 0
|
|
|
|
if not cfg.disk:
|
|
# no disk — skip mode selection
|
|
disk_mode = "none"
|
|
_close_modal(mwin, stdscr)
|
|
else:
|
|
while True:
|
|
mwin.erase()
|
|
mwin.border()
|
|
mwin.addstr(0, 2, " Disk copy mode ", curses.color_pair(7) | curses.A_BOLD)
|
|
try:
|
|
mwin.addstr(1, 2, f"Source: {Path(cfg.disk).name[:mw-12]}", curses.color_pair(6))
|
|
except curses.error:
|
|
pass
|
|
for i, (mode_id, mode_label) in enumerate(MODES):
|
|
sel = (i == mc)
|
|
attr = curses.color_pair(2) | curses.A_BOLD if sel else 0
|
|
try:
|
|
mwin.addstr(i + 2, 2, f"{'> ' if sel else ' '}{mode_label}", attr)
|
|
except curses.error:
|
|
pass
|
|
hint = " ↑↓=select Enter=confirm Esc=cancel "
|
|
try:
|
|
mwin.addstr(mh - 2, max(1, (mw - len(hint)) // 2), hint, curses.color_pair(6))
|
|
except curses.error:
|
|
pass
|
|
mwin.refresh()
|
|
ch = mwin.get_wch()
|
|
if _is_esc(ch):
|
|
_flush_esc(mwin)
|
|
_close_modal(mwin, stdscr)
|
|
return "Cancelled."
|
|
elif ch == curses.KEY_UP:
|
|
mc = max(0, mc - 1)
|
|
elif ch == curses.KEY_DOWN:
|
|
mc = min(len(MODES) - 1, mc + 1)
|
|
elif ch in ("\n", "\r", curses.KEY_ENTER):
|
|
|
|
|
|
disk_mode = MODES[mc][0]
|
|
_close_modal(mwin, stdscr)
|
|
break
|
|
|
|
# ── Step 3: perform clone ─────────────────────────────────────────────────
|
|
if disk_mode == "full":
|
|
# show progress hint — full copy can take a while
|
|
ph = 3
|
|
pw = 44
|
|
pwin = _modal_win(stdscr, ph, pw)
|
|
pwin.erase()
|
|
pwin.border()
|
|
try:
|
|
pwin.addstr(1, 2, "Copying disk... (may take a while)", curses.color_pair(8))
|
|
except curses.error:
|
|
pass
|
|
pwin.refresh()
|
|
err = mgr.clone_vm(cfg.name, new_name, disk_mode)
|
|
_close_modal(pwin, stdscr)
|
|
else:
|
|
err = mgr.clone_vm(cfg.name, new_name, disk_mode)
|
|
|
|
if err:
|
|
return f"Error: {err}"
|
|
return f"Cloned '{cfg.name}' -> '{new_name}' ({disk_mode})"
|
|
|
|
|
|
# ── import modal ───────────────────────────────────────────────────────────────
|
|
|
|
def import_modal(stdscr, mgr: VMManager) -> str:
|
|
"""
|
|
Import an existing disk image as a new VM.
|
|
Returns status message.
|
|
"""
|
|
# ── Step 1: browse for disk image ─────────────────────────────────────────
|
|
disk_path = filebrowser_modal(
|
|
stdscr,
|
|
title="Select disk image to import",
|
|
extensions=(".qcow2", ".img", ".raw", ".vmdk", ".vdi", ".iso"),
|
|
)
|
|
if disk_path is None:
|
|
return "Cancelled."
|
|
|
|
# ── Step 2: probe the image ────────────────────────────────────────────────
|
|
# Quick probe via qemu-img info to show user what they picked
|
|
qimg = shutil.which("qemu-img")
|
|
info_lines = []
|
|
fmt = "qcow2"
|
|
vsize = 0
|
|
if qimg and Path(disk_path).exists():
|
|
try:
|
|
r = subprocess.run(
|
|
[qimg, "info", "--output=json", disk_path],
|
|
capture_output=True, text=True, timeout=10
|
|
)
|
|
if r.returncode == 0:
|
|
d = json.loads(r.stdout)
|
|
fmt = d.get("format", "qcow2")
|
|
vsize = d.get("virtual-size", 0)
|
|
info_lines = [
|
|
f"Format: {fmt}",
|
|
f"Size: {_fmt_bytes(vsize)}",
|
|
]
|
|
if d.get("backing-filename"):
|
|
info_lines.append(f"Backing: {d['backing-filename']}")
|
|
except Exception:
|
|
pass
|
|
|
|
# ── Step 3: show info + ask for VM name ────────────────────────────────────
|
|
sh, sw_ = stdscr.getmaxyx()
|
|
ih = max(10, len(info_lines) + 8)
|
|
iw = min(64, sw_ - 4)
|
|
iwin = _modal_win(stdscr, ih, iw)
|
|
|
|
iwin.erase()
|
|
iwin.border()
|
|
iwin.addstr(0, 2, " Import VM ", curses.color_pair(7) | curses.A_BOLD)
|
|
fname = Path(disk_path).name
|
|
try:
|
|
iwin.addstr(1, 2, f"File: {fname[:iw-8]}", curses.color_pair(6))
|
|
except curses.error:
|
|
pass
|
|
for i, line in enumerate(info_lines):
|
|
try:
|
|
iwin.addstr(2 + i, 2, line[:iw-4], curses.color_pair(6))
|
|
except curses.error:
|
|
pass
|
|
iwin.refresh()
|
|
_close_modal(iwin, stdscr)
|
|
|
|
# default name = stem of filename
|
|
default_name = Path(disk_path).stem.replace(" ", "_").replace("-", "_")
|
|
vm_name = readline_modal(stdscr, "VM name", default_name)
|
|
if vm_name is None:
|
|
return "Cancelled."
|
|
vm_name = vm_name.strip()
|
|
if not vm_name:
|
|
return "Error: name cannot be empty"
|
|
|
|
err = mgr.import_vm(disk_path, vm_name)
|
|
if err:
|
|
return f"Error: {err}"
|
|
|
|
size_str = f" ({_fmt_bytes(vsize)})" if vsize else ""
|
|
return f"Imported '{vm_name}' from {fname}{size_str}"
|
|
|
|
|
|
# ── monitor console modal ──────────────────────────────────────────────────────
|
|
|
|
MONITOR_QUICK = [
|
|
("info status", "VM status"),
|
|
("info version", "QEMU version"),
|
|
("info kvm", "KVM info"),
|
|
("info cpus", "CPU info"),
|
|
("info network", "Network info"),
|
|
("info block", "Block devices"),
|
|
("info snapshots", "Snapshots"),
|
|
("info mem", "Memory map"),
|
|
("info pci", "PCI devices"),
|
|
("system_powerdown", "ACPI power off"),
|
|
("system_reset", "Hard reset"),
|
|
("stop", "Pause VM"),
|
|
("cont", "Resume VM"),
|
|
]
|
|
|
|
|
|
def monitor_console_modal(stdscr, mgr: VMManager, vm_state) -> str:
|
|
name = vm_state.config.name
|
|
running = vm_state.status in ("running", "paused")
|
|
|
|
output_lines = ["── QEMU Monitor ──",
|
|
"Type a command below or select from the quick list.",
|
|
""]
|
|
QUICK_W = 22
|
|
focus = "input"
|
|
qcursor = 0
|
|
out_off = 0
|
|
inp_buf = []
|
|
|
|
sh, sw_ = stdscr.getmaxyx()
|
|
h = max(18, sh - 4)
|
|
w = min(84, sw_ - 2)
|
|
win = _modal_win(stdscr, h, w)
|
|
|
|
OUTPUT_X = QUICK_W + 2
|
|
OUTPUT_W = w - OUTPUT_X - 1
|
|
OUTPUT_H = h - 5
|
|
INPUT_ROW = h - 3
|
|
|
|
def _run(cmd_str):
|
|
nonlocal out_off
|
|
# clear previous output, keep only the new command
|
|
output_lines.clear()
|
|
output_lines.append(f"(qemu) {cmd_str}")
|
|
if not running:
|
|
output_lines.append("ERROR: VM is not running")
|
|
else:
|
|
resp = mgr.monitor_cmd(name, cmd_str)
|
|
for line in resp.splitlines():
|
|
output_lines.append(line)
|
|
output_lines.append("")
|
|
out_off = 0
|
|
|
|
while True:
|
|
win.erase()
|
|
win.border()
|
|
win.addstr(0, 2, " QEMU Monitor ", curses.color_pair(7) | curses.A_BOLD)
|
|
status_txt = vm_state.status
|
|
try:
|
|
win.addstr(0, w - len(status_txt) - 3, status_txt,
|
|
status_pair(vm_state.status) | curses.A_BOLD)
|
|
except curses.error:
|
|
pass
|
|
|
|
# quick-command pane
|
|
try:
|
|
win.addstr(1, 1, "Quick Commands".center(QUICK_W), curses.color_pair(8) | curses.A_BOLD)
|
|
win.addstr(2, 1, "-" * QUICK_W, curses.color_pair(6))
|
|
except curses.error:
|
|
pass
|
|
for qi, (qcmd, qlabel) in enumerate(MONITOR_QUICK):
|
|
row = 3 + qi
|
|
if row >= h - 3:
|
|
break
|
|
sel = (focus == "quick" and qi == qcursor)
|
|
attr = curses.color_pair(2) | curses.A_BOLD if sel else 0
|
|
try:
|
|
win.addstr(row, 1, f" {qlabel:<{QUICK_W-2}}", attr)
|
|
except curses.error:
|
|
pass
|
|
|
|
# divider
|
|
for r in range(1, h-1):
|
|
try:
|
|
win.addch(r, QUICK_W+1, curses.ACS_VLINE, curses.color_pair(6))
|
|
except curses.error:
|
|
pass
|
|
|
|
# output pane
|
|
try:
|
|
win.addstr(1, OUTPUT_X, "Output".ljust(OUTPUT_W), curses.color_pair(8) | curses.A_BOLD)
|
|
win.addstr(2, OUTPUT_X, "-" * OUTPUT_W, curses.color_pair(6))
|
|
except curses.error:
|
|
pass
|
|
out_off = clamp(out_off, 0, max(0, len(output_lines) - OUTPUT_H))
|
|
for li, line in enumerate(output_lines[out_off: out_off + OUTPUT_H]):
|
|
color = curses.color_pair(5) if line.startswith("ERROR") else 0
|
|
try:
|
|
win.addstr(3 + li, OUTPUT_X, line[:OUTPUT_W-1].ljust(OUTPUT_W-1), color)
|
|
except curses.error:
|
|
pass
|
|
if len(output_lines) > OUTPUT_H:
|
|
pct = f"{out_off+1}-{min(out_off+OUTPUT_H,len(output_lines))}/{len(output_lines)}"
|
|
try:
|
|
win.addstr(h-4, w - len(pct) - 2, pct, curses.color_pair(6))
|
|
except curses.error:
|
|
pass
|
|
|
|
# input line
|
|
try:
|
|
win.addstr(INPUT_ROW-1, 1, "-" * (w-2), curses.color_pair(6))
|
|
prompt_attr = curses.color_pair(7) | (curses.A_BOLD if focus == "input" else 0)
|
|
win.addstr(INPUT_ROW, 1, "> ", prompt_attr)
|
|
inp_inner = w - 5
|
|
inp_disp = "".join(inp_buf)[-(inp_inner):]
|
|
win.addstr(INPUT_ROW, 3, inp_disp.ljust(inp_inner))
|
|
except curses.error:
|
|
pass
|
|
|
|
hint = " Tab=toggle ↑↓=quick-list Enter=run PgUp/Dn=scroll Esc=close "
|
|
try:
|
|
win.addstr(h-2, max(1, (w-len(hint))//2), hint[:w-2], curses.color_pair(6))
|
|
except curses.error:
|
|
pass
|
|
|
|
if focus == "input":
|
|
curses.curs_set(1)
|
|
try:
|
|
win.move(INPUT_ROW, 3 + min(len(inp_buf), inp_inner))
|
|
except curses.error:
|
|
pass
|
|
else:
|
|
curses.curs_set(0)
|
|
|
|
win.refresh()
|
|
|
|
try:
|
|
ch = win.get_wch()
|
|
except curses.error:
|
|
continue
|
|
|
|
if _is_esc(ch):
|
|
_flush_esc(win)
|
|
curses.curs_set(0)
|
|
_close_modal(win, stdscr)
|
|
return ""
|
|
|
|
elif ch == "\t":
|
|
focus = "input" if focus == "quick" else "quick"
|
|
|
|
elif ch == curses.KEY_PPAGE:
|
|
out_off = max(0, out_off - (OUTPUT_H // 2))
|
|
elif ch == curses.KEY_NPAGE:
|
|
out_off = min(max(0, len(output_lines) - OUTPUT_H),
|
|
out_off + (OUTPUT_H // 2))
|
|
|
|
elif focus == "quick":
|
|
if ch == curses.KEY_UP:
|
|
qcursor = (qcursor - 1) % len(MONITOR_QUICK)
|
|
elif ch == curses.KEY_DOWN:
|
|
qcursor = (qcursor + 1) % len(MONITOR_QUICK)
|
|
elif ch in ("\n", "\r", curses.KEY_ENTER):
|
|
_run(MONITOR_QUICK[qcursor][0])
|
|
|
|
elif focus == "input":
|
|
if ch in ("\n", "\r", curses.KEY_ENTER):
|
|
cmd_str = "".join(inp_buf).strip()
|
|
if cmd_str:
|
|
_run(cmd_str)
|
|
inp_buf.clear()
|
|
elif ch in (curses.KEY_BACKSPACE, "\x7f", "\b"):
|
|
if inp_buf:
|
|
inp_buf.pop()
|
|
elif isinstance(ch, str) and ch.isprintable():
|
|
inp_buf.append(ch)
|
|
elif ch == curses.KEY_UP:
|
|
focus = "quick"
|
|
|
|
|
|
# ── Main TUI ───────────────────────────────────────────────────────────────────
|
|
|
|
class TUI:
|
|
SIDEBAR_W = 24
|
|
|
|
def __init__(self, mgr: VMManager):
|
|
self.mgr = mgr
|
|
self.sel = 0
|
|
# tabs: 0=Info 1=Command 2=Console 3=Disk 4=Snapshots 5=Monitor
|
|
self.tab = 0
|
|
self.log_off = 0
|
|
self.msg = "Ready | n=new Tab=switch tab q=quit"
|
|
self.msg_ok = True
|
|
self.last_poll = 0.0
|
|
|
|
# ── drawing ────────────────────────────────────────────────────────────────
|
|
|
|
def draw(self, scr):
|
|
scr.erase()
|
|
sh, sw = scr.getmaxyx()
|
|
names = self.mgr.names()
|
|
vm = self.mgr.vms.get(names[self.sel]) if names else None
|
|
self._draw_sidebar(scr, sh, names)
|
|
self._draw_main(scr, sh, sw, vm)
|
|
self._draw_statusbar(scr, sh, sw)
|
|
scr.noutrefresh()
|
|
curses.doupdate()
|
|
|
|
def _draw_sidebar(self, scr, sh, names):
|
|
sw = self.SIDEBAR_W
|
|
scr.addstr(0, 0, " QEMU Manager".ljust(sw), curses.color_pair(1) | curses.A_BOLD)
|
|
|
|
for i, name in enumerate(names):
|
|
vm = self.mgr.vms[name]
|
|
icon = STATUS_ICON.get(vm.status, "?")
|
|
row = 1 + i * 2
|
|
if row + 1 >= sh - 2:
|
|
break
|
|
if i == self.sel:
|
|
scr.addstr(row, 0, f" {name[:sw-3]:<{sw-2}}", curses.color_pair(2) | curses.A_BOLD)
|
|
scr.addstr(row+1, 0, f" {icon} {vm.status:<{sw-6}}", curses.color_pair(2))
|
|
else:
|
|
scr.addstr(row, 0, f" {name[:sw-3]:<{sw-2}}")
|
|
scr.addstr(row+1, 0, f" {icon} {vm.status:<{sw-6}}", status_pair(vm.status))
|
|
|
|
hints = [
|
|
("n","new"), ("e","edit"), ("Del","delete"),
|
|
("c","clone"), ("i","import"),
|
|
("s","start"), ("k","stop"), ("F","force kill"),
|
|
("g","ACPI"), ("z","pause"), ("~","monitor ~"),
|
|
("d","disk"), ("p","snaps"), ("f","portfwd"),
|
|
("x","eject"), ("q","quit"),
|
|
]
|
|
hy = sh - len(hints) - 2
|
|
for key, act in hints:
|
|
if 0 < hy < sh - 1:
|
|
try:
|
|
scr.addstr(hy, 1, key, curses.color_pair(8) | curses.A_BOLD)
|
|
scr.addstr(hy, 1 + len(key), f" {act}", curses.color_pair(6))
|
|
except curses.error:
|
|
pass
|
|
hy += 1
|
|
|
|
for r in range(sh - 1):
|
|
try:
|
|
scr.addch(r, sw, curses.ACS_VLINE, curses.color_pair(6))
|
|
except curses.error:
|
|
pass
|
|
|
|
def _draw_main(self, scr, sh, sw, vm):
|
|
x0 = self.SIDEBAR_W + 1
|
|
mw = sw - x0 - 1
|
|
|
|
if not vm:
|
|
try:
|
|
msg = "No VMs -- press 'n' to create one"
|
|
scr.addstr(sh//2, x0 + max(0,(mw-len(msg))//2), msg, curses.color_pair(6))
|
|
except curses.error:
|
|
pass
|
|
return
|
|
|
|
cfg = vm.config
|
|
icon = STATUS_ICON.get(vm.status, "?")
|
|
uefi_tag = " [UEFI]" if cfg.uefi else ""
|
|
header = f" {cfg.name}{uefi_tag} {icon} {vm.status}"
|
|
if vm.pid:
|
|
header += f" pid:{vm.pid}"
|
|
if vm.uptime:
|
|
header += f" up:{vm.uptime}"
|
|
try:
|
|
scr.addstr(0, x0, header[:mw], curses.color_pair(7) | curses.A_BOLD)
|
|
scr.addstr(1, x0, "-" * mw, curses.color_pair(6))
|
|
except curses.error:
|
|
pass
|
|
|
|
tabs = ["Info", "Command", "Log", "Disk", "Snapshots", "Monitor"]
|
|
tx = x0
|
|
for i, t in enumerate(tabs):
|
|
attr = (curses.color_pair(2) | curses.A_BOLD) if i == self.tab else curses.color_pair(6)
|
|
try:
|
|
scr.addstr(1, tx, f" {t} ", attr)
|
|
except curses.error:
|
|
pass
|
|
tx += len(t) + 3
|
|
|
|
try:
|
|
scr.addstr(2, x0, "-" * mw, curses.color_pair(6))
|
|
except curses.error:
|
|
pass
|
|
|
|
cy = 3
|
|
ch = sh - cy - 1
|
|
if self.tab == 0: self._draw_info(scr, cy, x0, ch, mw, vm)
|
|
elif self.tab == 1: self._draw_command(scr, cy, x0, ch, mw, vm)
|
|
elif self.tab == 2: self._draw_console(scr, cy, x0, ch, mw, vm)
|
|
elif self.tab == 3: self._draw_disk(scr, cy, x0, ch, mw, vm)
|
|
elif self.tab == 4: self._draw_snapshots(scr, cy, x0, ch, mw, vm)
|
|
elif self.tab == 5: self._draw_monitor(scr, cy, x0, ch, mw, vm)
|
|
|
|
def _draw_info(self, scr, y0, x0, h, w, vm):
|
|
cfg = vm.config
|
|
ovmf = find_ovmf(cfg.arch) if cfg.uefi else None
|
|
rows = [
|
|
("Architecture", cfg.arch),
|
|
("Memory", f"{cfg.memory} MiB"),
|
|
("CPUs", str(cfg.cpus)),
|
|
("Disk", cfg.disk or "(none)"),
|
|
("CD-ROM", cfg.cdrom or "(none)"),
|
|
("UEFI", ("yes — " + (ovmf or "OVMF NOT FOUND")) if cfg.uefi else "no"),
|
|
("Network", cfg.network),
|
|
("Display", cfg.display),
|
|
("Extra args", cfg.extra_args or "(none)"),
|
|
("PID", str(vm.pid) if vm.pid else "-"),
|
|
("Uptime", vm.uptime or "-"),
|
|
]
|
|
if vm.error:
|
|
rows.append(("Error", vm.error))
|
|
|
|
for i, (k, v) in enumerate(rows):
|
|
if i >= h:
|
|
break
|
|
color = curses.color_pair(5) if k == "UEFI" and "NOT FOUND" in v else 0
|
|
try:
|
|
scr.addstr(y0 + i, x0, f"{k:<16}", curses.color_pair(6))
|
|
scr.addstr(y0 + i, x0 + 16, v[:w-17], color)
|
|
except curses.error:
|
|
pass
|
|
|
|
# port forward rules
|
|
fwds = cfg.portfwds or []
|
|
base = y0 + len(rows)
|
|
if base < y0 + h and fwds:
|
|
try:
|
|
scr.addstr(base, x0, f"{'Port Fwds':<16}", curses.color_pair(6))
|
|
except curses.error:
|
|
pass
|
|
for j, fw in enumerate(fwds):
|
|
row = base + j
|
|
if row >= y0 + h:
|
|
break
|
|
haddr = fw.get("host_addr", "")
|
|
hport = fw.get("host_port", "")
|
|
gport = fw.get("guest_port", "")
|
|
proto = fw.get("proto", "tcp")
|
|
desc = fw.get("desc", "")
|
|
hpart = f"{haddr}:{hport}" if haddr else str(hport)
|
|
line = f"{proto} {hpart} -> guest:{gport}"
|
|
if desc:
|
|
line += f" ({desc})"
|
|
try:
|
|
scr.addstr(row, x0 + 16, line[:w-17], curses.color_pair(3))
|
|
except curses.error:
|
|
pass
|
|
elif base < y0 + h and cfg.network == "user":
|
|
try:
|
|
scr.addstr(base, x0, f"{'Port Fwds':<16}", curses.color_pair(6))
|
|
scr.addstr(base, x0 + 16, "(none — press F to add)", curses.color_pair(6))
|
|
except curses.error:
|
|
pass
|
|
|
|
def _draw_command(self, scr, y0, x0, h, w, vm):
|
|
cmd, _ = self.mgr.build_cmd(vm.config)
|
|
lines = []
|
|
cur = ""
|
|
for part in cmd:
|
|
candidate = (cur + " " + part).lstrip()
|
|
if len(candidate) > w - 3:
|
|
lines.append(cur + " \\")
|
|
cur = " " + part
|
|
else:
|
|
cur = candidate
|
|
if cur:
|
|
lines.append(cur)
|
|
for i, line in enumerate(lines):
|
|
if i >= h:
|
|
break
|
|
try:
|
|
scr.addstr(y0 + i, x0, line[:w-1], curses.color_pair(7))
|
|
except curses.error:
|
|
pass
|
|
|
|
def _draw_console(self, scr, y0, x0, h, w, vm):
|
|
lines = vm.log_lines
|
|
total = len(lines)
|
|
max_off = max(0, total - (h-1))
|
|
self.log_off = clamp(self.log_off, 0, max_off)
|
|
for i, line in enumerate(lines[self.log_off: self.log_off + h - 1]):
|
|
try:
|
|
scr.addstr(y0 + i, x0, line[:w-1], curses.color_pair(6))
|
|
except curses.error:
|
|
pass
|
|
if not lines:
|
|
try:
|
|
scr.addstr(y0, x0, "(no output yet)", curses.color_pair(6))
|
|
except curses.error:
|
|
pass
|
|
if total > h:
|
|
ind = f"-- {self.log_off+1}-{min(self.log_off+h,total)}/{total} PgUp/PgDn --"
|
|
try:
|
|
scr.addstr(y0 + h - 1, x0, ind[:w-1], curses.color_pair(6))
|
|
except curses.error:
|
|
pass
|
|
|
|
def _draw_disk(self, scr, y0, x0, h, w, vm):
|
|
cfg = vm.config
|
|
row = y0
|
|
|
|
def addrow(label, val, color=0):
|
|
nonlocal row
|
|
if row >= y0 + h:
|
|
return
|
|
try:
|
|
scr.addstr(row, x0, f"{label:<16}", curses.color_pair(6))
|
|
scr.addstr(row, x0+16, val[:w-17], color)
|
|
except curses.error:
|
|
pass
|
|
row += 1
|
|
|
|
if not cfg.disk:
|
|
try:
|
|
scr.addstr(row, x0, "No disk configured.", curses.color_pair(6))
|
|
row += 1
|
|
scr.addstr(row, x0, "Press 'm' to open Disk Management and create one.",
|
|
curses.color_pair(8))
|
|
except curses.error:
|
|
pass
|
|
return
|
|
|
|
addrow("Path", cfg.disk)
|
|
p = Path(cfg.disk)
|
|
if not p.exists():
|
|
addrow("Status", "FILE NOT FOUND", curses.color_pair(5))
|
|
try:
|
|
scr.addstr(row+1, x0, "Press 'm' > Create to make the disk image.",
|
|
curses.color_pair(8))
|
|
except curses.error:
|
|
pass
|
|
return
|
|
|
|
info = self.mgr.disk_info(cfg.disk)
|
|
if "error" in info:
|
|
addrow("Error", info["error"], curses.color_pair(5))
|
|
else:
|
|
vsize = info.get("virtual_size", 0)
|
|
asize = info.get("actual_size", 0)
|
|
pct = f" ({asize*100//vsize}% used)" if vsize else ""
|
|
addrow("Format", info.get("format", "?"))
|
|
addrow("Virt size", _fmt_bytes(vsize))
|
|
addrow("Used", _fmt_bytes(asize) + pct,
|
|
curses.color_pair(3) if asize < vsize else curses.color_pair(5))
|
|
addrow("Snapshots", str(info.get("snapshots", 0)))
|
|
if info.get("backing_file"):
|
|
addrow("Backing", info["backing_file"])
|
|
|
|
row += 1
|
|
try:
|
|
scr.addstr(row, x0, "-" * min(w-1, 50), curses.color_pair(6))
|
|
row += 1
|
|
scr.addstr(row, x0, "Press 'm' to manage: create / resize / convert / delete",
|
|
curses.color_pair(8))
|
|
except curses.error:
|
|
pass
|
|
|
|
def _draw_snapshots(self, scr, y0, x0, h, w, vm):
|
|
cfg = vm.config
|
|
row = y0
|
|
|
|
def put(text, color=0, bold=False):
|
|
nonlocal row
|
|
if row >= y0 + h:
|
|
return
|
|
try:
|
|
scr.addstr(row, x0, text[:w-1],
|
|
color | (curses.A_BOLD if bold else 0))
|
|
except curses.error:
|
|
pass
|
|
row += 1
|
|
|
|
if not cfg.disk:
|
|
put("No disk configured.", curses.color_pair(6))
|
|
return
|
|
if not Path(cfg.disk).exists():
|
|
put("Disk file not found.", curses.color_pair(5))
|
|
return
|
|
|
|
snaps = self.mgr.snapshot_list(cfg.disk)
|
|
if snaps and "error" in snaps[0]:
|
|
put(snaps[0]["error"], curses.color_pair(5))
|
|
return
|
|
if not snaps:
|
|
put("No snapshots yet.", curses.color_pair(6))
|
|
put("")
|
|
put("Press 'p' to open Snapshot Manager and create one.", curses.color_pair(8))
|
|
return
|
|
|
|
hdr = f"{'ID':<5} {'Name':<20} {'Date':<19} {'VM Clock'}"
|
|
try:
|
|
scr.addstr(row, x0, hdr[:w-1], curses.color_pair(8) | curses.A_BOLD)
|
|
except curses.error:
|
|
pass
|
|
row += 1
|
|
try:
|
|
scr.addstr(row, x0, "-" * min(w-1, 60), curses.color_pair(6))
|
|
except curses.error:
|
|
pass
|
|
row += 1
|
|
|
|
for snap in snaps:
|
|
if row >= y0 + h - 2:
|
|
break
|
|
line = (f"{snap.get('id',''):<5} "
|
|
f"{snap.get('tag',''):<20} "
|
|
f"{snap.get('date',''):<19} "
|
|
f"{snap.get('vm_clock','')}")
|
|
try:
|
|
scr.addstr(row, x0, line[:w-1])
|
|
except curses.error:
|
|
pass
|
|
row += 1
|
|
|
|
row += 1
|
|
try:
|
|
scr.addstr(row, x0, "-" * min(w-1, 60), curses.color_pair(6))
|
|
row += 1
|
|
scr.addstr(row, x0,
|
|
f"{len(snaps)} snapshot(s) Press 'p' to create / restore / delete",
|
|
curses.color_pair(8))
|
|
except curses.error:
|
|
pass
|
|
|
|
def _draw_monitor(self, scr, y0, x0, h, w, vm):
|
|
row = y0
|
|
|
|
def put(text, color=0):
|
|
nonlocal row
|
|
if row >= y0 + h:
|
|
return
|
|
try:
|
|
scr.addstr(row, x0, text[:w-1], color)
|
|
except curses.error:
|
|
pass
|
|
row += 1
|
|
|
|
if vm.status not in ("running", "paused"):
|
|
put("VM is not running.", curses.color_pair(6))
|
|
put("")
|
|
put("Start the VM, then press '~' to open the monitor console.",
|
|
curses.color_pair(8))
|
|
return
|
|
|
|
sock = vm.monitor_sock
|
|
if not sock or not Path(sock).exists():
|
|
put("Monitor socket not ready yet.", curses.color_pair(8))
|
|
if sock:
|
|
put(f"Expected: {sock}", curses.color_pair(6))
|
|
return
|
|
|
|
put(f"Monitor socket: {sock}", curses.color_pair(3))
|
|
put("")
|
|
put("Quick keys:", curses.color_pair(8))
|
|
put(" g — graceful ACPI power-off", curses.color_pair(6))
|
|
put(" z — pause / resume toggle", curses.color_pair(6))
|
|
put(" ~ — open interactive monitor console", curses.color_pair(6))
|
|
put("")
|
|
put("Useful monitor commands:", curses.color_pair(8))
|
|
cmds = ["info status", "info network", "info block",
|
|
"info cpus", "info mem", "info pci",
|
|
"system_powerdown", "system_reset", "stop / cont"]
|
|
for i in range(0, len(cmds), 3):
|
|
chunk = cmds[i:i+3]
|
|
put(" " + " ".join(f"{c:<22}" for c in chunk).rstrip(), curses.color_pair(6))
|
|
|
|
def _draw_statusbar(self, scr, sh, sw):
|
|
attr = curses.color_pair(3) if self.msg_ok else curses.color_pair(5)
|
|
try:
|
|
scr.addstr(sh-1, 0, f" {self.msg}"[:sw-1].ljust(sw-1), attr)
|
|
except curses.error:
|
|
pass
|
|
|
|
def set_msg(self, msg, ok=True):
|
|
self.msg = msg
|
|
self.msg_ok = ok
|
|
|
|
# ── key handling ───────────────────────────────────────────────────────────
|
|
|
|
def handle_key(self, scr, ch) -> bool:
|
|
names = self.mgr.names()
|
|
name = names[self.sel] if names else None
|
|
vm = self.mgr.vms.get(name) if name else None
|
|
|
|
if ch in (ord("q"), ord("Q")):
|
|
return False
|
|
if _is_esc(ch):
|
|
return False
|
|
|
|
elif ch == curses.KEY_DOWN:
|
|
self.sel = clamp(self.sel + 1, 0, max(0, len(names)-1))
|
|
self.log_off = 0
|
|
elif ch == curses.KEY_UP:
|
|
self.sel = clamp(self.sel - 1, 0, max(0, len(names)-1))
|
|
self.log_off = 0
|
|
|
|
elif ch == ord("\t"):
|
|
self.tab = (self.tab + 1) % 6
|
|
self.log_off = 0
|
|
|
|
elif ch == curses.KEY_PPAGE:
|
|
self.log_off = max(0, self.log_off - 10)
|
|
elif ch == curses.KEY_NPAGE:
|
|
self.log_off += 10
|
|
|
|
# ── n : new VM ────────────────────────────────────────────────────────
|
|
elif ch == ord("n"):
|
|
scr.nodelay(False)
|
|
new_cfg = vm_form_modal(scr)
|
|
scr.nodelay(True)
|
|
if new_cfg:
|
|
if new_cfg.disk and not Path(new_cfg.disk).exists():
|
|
scr.nodelay(False)
|
|
if confirm_modal(scr, f"Create disk '{Path(new_cfg.disk).name}'?"):
|
|
err = self.mgr.create_disk(new_cfg)
|
|
if err:
|
|
self.set_msg(f"Disk error: {err}", ok=False)
|
|
scr.nodelay(True)
|
|
err = self.mgr.add(new_cfg)
|
|
if err:
|
|
self.set_msg(f"Error: {err}", ok=False)
|
|
else:
|
|
self.sel = self.mgr.names().index(new_cfg.name)
|
|
self.set_msg(f"Created '{new_cfg.name}'")
|
|
else:
|
|
self.set_msg("Cancelled")
|
|
|
|
# ── e : edit VM ───────────────────────────────────────────────────────
|
|
elif ch == ord("e") and vm:
|
|
if vm.status == "running":
|
|
self.set_msg("Stop VM before editing", ok=False)
|
|
else:
|
|
scr.nodelay(False)
|
|
new_cfg = vm_form_modal(scr, VMConfig(**vm.config.to_dict()))
|
|
scr.nodelay(True)
|
|
if new_cfg:
|
|
new_cfg.name = name
|
|
if new_cfg.disk and not Path(new_cfg.disk).exists():
|
|
scr.nodelay(False)
|
|
if confirm_modal(scr, f"Create disk '{Path(new_cfg.disk).name}'?"):
|
|
self.mgr.create_disk(new_cfg)
|
|
scr.nodelay(True)
|
|
err = self.mgr.update(name, new_cfg)
|
|
self.set_msg("Updated." if not err else f"Error: {err}", ok=not err)
|
|
else:
|
|
self.set_msg("Cancelled")
|
|
|
|
# ── Del : delete VM ───────────────────────────────────────────────────
|
|
elif ch == curses.KEY_DC and vm:
|
|
scr.nodelay(False)
|
|
confirmed = confirm_modal(scr, f"Delete '{name}'?")
|
|
scr.nodelay(True)
|
|
if confirmed:
|
|
err = self.mgr.remove(name)
|
|
if err:
|
|
self.set_msg(f"Error: {err}", ok=False)
|
|
else:
|
|
self.sel = clamp(self.sel, 0, max(0, len(self.mgr.names())-1))
|
|
self.set_msg(f"Deleted '{name}'")
|
|
|
|
# ── s : start VM ──────────────────────────────────────────────────────
|
|
elif ch == ord("s") and vm:
|
|
err = self.mgr.start(name)
|
|
self.set_msg(f"Started '{name}'" if not err else f"Error: {err}", ok=not err)
|
|
|
|
# ── k : graceful stop (SIGTERM) ───────────────────────────────────────
|
|
elif ch == ord("k") and vm:
|
|
err = self.mgr.stop(name)
|
|
self.set_msg(f"Stopped '{name}'" if not err else f"Error: {err}", ok=not err)
|
|
|
|
# ── F : force kill ────────────────────────────────────────────────────
|
|
elif ch == ord("F") and vm:
|
|
scr.nodelay(False)
|
|
confirmed = confirm_modal(scr, f"Force-kill '{name}'?")
|
|
scr.nodelay(True)
|
|
if confirmed:
|
|
err = self.mgr.stop(name, force=True)
|
|
self.set_msg(f"Killed '{name}'" if not err else f"Error: {err}", ok=not err)
|
|
|
|
# ── g : ACPI graceful power-off ───────────────────────────────────────
|
|
elif ch == ord("g") and vm:
|
|
err = self.mgr.monitor_powerdown(name)
|
|
self.set_msg("ACPI power-down sent." if not err else f"Error: {err}", ok=not err)
|
|
|
|
# ── z : pause / resume toggle ─────────────────────────────────────────
|
|
elif ch == ord("z") and vm:
|
|
if vm.status == "paused":
|
|
err = self.mgr.monitor_resume(name)
|
|
self.set_msg("Resumed." if not err else f"Error: {err}", ok=not err)
|
|
elif vm.status == "running":
|
|
err = self.mgr.monitor_pause(name)
|
|
self.set_msg("Paused." if not err else f"Error: {err}", ok=not err)
|
|
else:
|
|
self.set_msg("VM not running or paused", ok=False)
|
|
|
|
# ── ~ : open monitor console ──────────────────────────────────────────
|
|
elif ch == ord("~") and vm:
|
|
scr.nodelay(False)
|
|
monitor_console_modal(scr, self.mgr, vm)
|
|
scr.nodelay(True)
|
|
self.set_msg("Monitor closed.")
|
|
|
|
# ── d : disk management ───────────────────────────────────────────────
|
|
elif ch == ord("d") and vm:
|
|
scr.nodelay(False)
|
|
status_msg = disk_mgmt_modal(scr, self.mgr, vm)
|
|
scr.nodelay(True)
|
|
if status_msg:
|
|
self.set_msg(status_msg)
|
|
|
|
# ── p : snapshots ─────────────────────────────────────────────────────
|
|
elif ch == ord("p") and vm:
|
|
scr.nodelay(False)
|
|
status_msg = snapshot_modal(scr, self.mgr, vm)
|
|
scr.nodelay(True)
|
|
if status_msg:
|
|
self.set_msg(status_msg)
|
|
|
|
# ── f : port forwarding ───────────────────────────────────────────────
|
|
elif ch == ord("f") and vm:
|
|
scr.nodelay(False)
|
|
status_msg = portfwd_modal(scr, self.mgr, vm)
|
|
scr.nodelay(True)
|
|
if status_msg:
|
|
self.set_msg(status_msg)
|
|
|
|
# ── x : eject ISO ────────────────────────────────────────────────────
|
|
elif ch == ord("x") and vm:
|
|
if not vm.config.cdrom:
|
|
self.set_msg("No CD-ROM attached", ok=False)
|
|
else:
|
|
scr.nodelay(False)
|
|
confirmed = confirm_modal(scr, f"Eject ISO from '{name}'?")
|
|
scr.nodelay(True)
|
|
if confirmed:
|
|
err = self.mgr.eject_cdrom(name)
|
|
self.set_msg("ISO ejected." if not err else f"Error: {err}", ok=not err)
|
|
|
|
# ── c : clone VM ──────────────────────────────────────────────────────
|
|
elif ch == ord("c") and vm:
|
|
if vm.status == "running":
|
|
self.set_msg("Stop VM before cloning", ok=False)
|
|
else:
|
|
scr.nodelay(False)
|
|
status_msg = clone_modal(scr, self.mgr, vm)
|
|
scr.nodelay(True)
|
|
ok = not status_msg.startswith("Error")
|
|
self.set_msg(status_msg, ok=ok)
|
|
if ok and not status_msg.startswith("Cancelled"):
|
|
new_name = status_msg.split("'")[3] if status_msg.count("'") >= 4 else None
|
|
if new_name and new_name in self.mgr.names():
|
|
self.sel = self.mgr.names().index(new_name)
|
|
|
|
# ── i : import VM ─────────────────────────────────────────────────────
|
|
elif ch == ord("i"):
|
|
scr.nodelay(False)
|
|
status_msg = import_modal(scr, self.mgr)
|
|
scr.nodelay(True)
|
|
ok = not status_msg.startswith("Error")
|
|
self.set_msg(status_msg, ok=ok)
|
|
if ok and not status_msg.startswith("Cancelled"):
|
|
parts = status_msg.split("'")
|
|
if len(parts) >= 2:
|
|
imported_name = parts[1]
|
|
if imported_name in self.mgr.names():
|
|
self.sel = self.mgr.names().index(imported_name)
|
|
|
|
return True
|
|
|
|
# ── main loop ──────────────────────────────────────────────────────────────
|
|
|
|
def run(self, scr):
|
|
init_colors()
|
|
curses.curs_set(0)
|
|
scr.keypad(True)
|
|
scr.nodelay(True)
|
|
# Zero ESC delay so ESC closes modals immediately (Python 3.9+)
|
|
try:
|
|
curses.set_escdelay(1)
|
|
except AttributeError:
|
|
pass
|
|
|
|
while True:
|
|
now = time.time()
|
|
if now - self.last_poll > 1.0:
|
|
self.mgr.poll()
|
|
for n in self.mgr.names():
|
|
self.mgr.drain_log(n)
|
|
self.last_poll = now
|
|
|
|
self.draw(scr)
|
|
|
|
ch = scr.getch()
|
|
if ch == curses.ERR:
|
|
time.sleep(0.05)
|
|
continue
|
|
if not self.handle_key(scr, ch):
|
|
break
|
|
|
|
|
|
# ── Entry point ────────────────────────────────────────────────────────────────
|
|
|
|
def main():
|
|
# Tell curses not to wait after ESC — makes ESC close popups immediately.
|
|
# Must be set before curses.initscr() / curses.wrapper().
|
|
os.environ.setdefault("ESCDELAY", "0")
|
|
mgr = VMManager()
|
|
tui = TUI(mgr)
|
|
curses.wrapper(tui.run)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|