Add guest info screen with backups, backup now and network

Press i on a guest to see its backups and network. Backups from every
backup-capable storage are listed newest first, with the backup jobs
that cover the guest. The network part shows the netN settings
(bridge, VLAN, MAC, IPv4/IPv6 setting, firewall) and the addresses in
use.

Press b in the info screen to back the guest up now: snapshot mode and
zstd, like the scheduled job, to a storage you pick (the job's storage
is preselected). It asks for confirmation, marks the guest busy and
uses --wait.

Fixed after review and a read-only live check:
- prune-backups arrives as a JSON string; parsing it as a dict
  crashed the app.
- CT addresses come from ip-addresses. inet6 holds only the
  link-local address, so the global SLAAC address was missing.
- The info screen uses the guest's live status, and odd API data
  (null size, unknown address types) no longer kills the app.
- The test fake now copies guest dicts; a shared constant was being
  mutated across tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DuckbootandClaude Opus 5.5 committed 2026-11-02 21:06:22 +01:00
1 parent 70bcb9e7b7
commit 32809b6743
6 files changed
+1057 -2

No files matched your search

+217
View File
@@ -2,6 +2,7 @@
import subprocess
import sys
from datetime import datetime
from textual import work
from textual.app import App, ComposeResult, SuspendNotSupported
@@ -128,6 +129,185 @@ class ContextScreen(ModalScreen[str | None]):
self.dismiss(event.option.id)
class BackupScreen(ModalScreen[str | None]):
"""Dismisses with the chosen storage, or None when cancelled."""
BINDINGS = [("escape", "dismiss(None)", "Cancel")] # noqa: RUF012 - Textual idiom
def __init__(self, guest: dict, storages: list[str], chosen: str) -> None:
super().__init__()
self.guest, self.storages, self.chosen = guest, storages, chosen
def compose(self) -> ComposeResult:
with Vertical():
yield Static(
f"Back up {self.guest['name']} ({self.guest['vmid']}) now: mode snapshot, zstd"
)
yield Select(
[(s, s) for s in self.storages],
value=self.chosen,
allow_blank=False,
id="storage",
)
with Horizontal():
yield Button("Yes", id="yes", variant="primary")
yield Button("No", id="no")
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "yes":
self.dismiss(self.query_one("#storage", Select).value)
else:
self.dismiss(None)
class InfoScreen(ModalScreen):
"""Backups, backup job and network of one guest (read-only), plus backup now."""
BINDINGS = [ # noqa: RUF012 - Textual idiom
("escape", "dismiss", "Close"),
("g", "reload", "Refresh"),
("b", "backup", "Backup now"),
]
DEFAULT_CSS = "InfoScreen DataTable { height: auto; max-height: 12; }"
def __init__(self, guest: dict) -> None:
super().__init__()
self.guest = guest
self.storages: list[str] = []
self.jobs: list[dict] = []
def compose(self) -> ComposeResult:
with Vertical():
yield Static(f"{self.guest['name']} ({self.guest['vmid']})", markup=False)
yield Static("", id="jobs", markup=False)
yield DataTable(id="backups")
yield DataTable(id="nets")
yield Static("", id="unavailable", markup=False)
yield DataTable(id="addresses")
yield Footer()
def on_mount(self) -> None:
self.query_one("#backups", DataTable).add_columns(
"Date", "Storage", "Size (GiB)", "Format", "Notes"
)
self.query_one("#nets", DataTable).add_columns(
"Iface", "Bridge", "VLAN", "MAC", "IPv4", "IPv6", "Firewall"
)
self.query_one("#addresses", DataTable).add_columns(
"Interface", "MAC", "IPv4", "IPv6"
)
self.action_reload()
def action_reload(self) -> None:
self._load_worker()
@work(thread=True)
def _load_worker(self) -> None:
self.load()
def load(self) -> None:
"""Runs in a worker thread."""
guest = self.guest
try:
config = cli.guest_config(guest)
nets = cli.nets(config)
storages = cli.backup_storages(guest)
jobs = cli.backup_jobs()
backups = cli.backups(guest, storages)
except Exception as exc: # noqa: BLE001 - surface every failure, never kill the app
self.app.call_from_thread(self.app.notify, _message(exc), severity="error")
return
addresses, unavailable = [], ""
try: # live status: the guest table's status may be stale
running = cli.status(guest) == "running"
except Exception as exc: # noqa: BLE001 - shown inline, the rest of the screen still works
running, unavailable = False, f"unavailable: {_message(exc)}"
if running:
try:
addresses = cli.interfaces(guest)
except Exception as exc: # noqa: BLE001 - shown inline, the rest of the screen still works
unavailable = f"unavailable: {_message(exc)}"
self.app.call_from_thread(
self._show, config, nets, storages, jobs, backups, addresses, unavailable
)
def _show(
self, config, nets, storages, jobs, backups, addresses, unavailable
) -> None:
if not self.is_attached: # closed while loading
return
try:
self._fill_screen(
config, nets, storages, jobs, backups, addresses, unavailable
)
except Exception as exc: # noqa: BLE001 - odd API data must not kill the app
self.app.notify(f"info: {_message(exc)}", severity="error")
def _fill_screen(
self, config, nets, storages, jobs, backups, addresses, unavailable
) -> None:
self.storages, self.jobs = storages, jobs
covering = cli.covering_jobs(jobs, self.guest)
self.query_one("#jobs", Static).update(
"; ".join(_job_text(j) for j in covering)
or "No backup job covers this guest"
)
table = self.query_one("#backups", DataTable).clear()
for b in backups:
table.add_row(
datetime.fromtimestamp(b["ctime"]).strftime("%Y-%m-%d %H:%M")
if b.get("ctime")
else "-",
b["storage"],
f"{b['size'] / 2**30:.1f}" if b.get("size") is not None else "-",
b.get("format") or "-",
b.get("notes") or "",
)
table = self.query_one("#nets", DataTable).clear()
for name, net in nets.items():
if cli.kind(self.guest) == "vm": # IP settings are cloud-init's ipconfigN
mac = next(iter(net.values()), "-")
ip = cli.parse_net(str(config.get(f"ipconfig{name[3:]}", "")))
else:
mac, ip = net.get("hwaddr", "-"), net
table.add_row(
name,
net.get("bridge", "-"),
net.get("tag", "-"),
mac,
ip.get("ip", "-"),
ip.get("ip6", "-"),
"yes" if net.get("firewall") == "1" else "no",
)
self.query_one("#unavailable", Static).update(unavailable)
table = self.query_one("#addresses", DataTable).clear()
for i in addresses:
table.add_row(
i["name"], i["mac"] or "-", ", ".join(i["ipv4"]), ", ".join(i["ipv6"])
)
def action_backup(self) -> None:
if not self.storages:
self.app.notify("No backup storage available", severity="warning")
return
chosen = cli.job_storage(self.jobs, self.guest)
if chosen not in self.storages:
chosen = self.storages[0]
self.app.push_screen(
BackupScreen(self.guest, self.storages, chosen),
lambda storage: (
self.app.start_backup(self.guest, storage) if storage else None
),
)
def _job_text(job: dict) -> str:
keep = (job.get("prune-backups") or {}).get("keep-last")
return f"{job['schedule']} to {job['storage']}" + (
f", keep-last {keep}" if keep else ""
)
class GuestApp(App):
BINDINGS = [ # noqa: RUF012 - Textual idiom
("s", "act('start')", "Start"),
@@ -135,6 +315,7 @@ class GuestApp(App):
("x", "act('stop')", "Stop"),
("r", "act('reboot')", "Reboot"),
("e", "edit", "Edit"),
("i", "info", "Info"),
("t", "shell", "Shell"),
("g", "reload", "Refresh"),
("c", "context", "Context"),
@@ -340,6 +521,42 @@ class GuestApp(App):
return
self._open_edit(guest)
def action_info(self) -> None:
guest = self._selected()
if guest is not None:
self.push_screen(InfoScreen(guest))
def start_backup(self, guest: dict, storage: str) -> None:
who = f"{guest['name']} ({guest['vmid']})"
if guest["vmid"] in self.busy:
self.notify(f"{who} is busy", severity="warning")
return
self.busy.add(guest["vmid"])
self.notify(f"Backup started: {who} to {storage}")
self._run_backup(guest, storage)
@work(thread=True)
def _run_backup(self, guest: dict, storage: str) -> None:
who = f"{guest['name']} ({guest['vmid']})"
try:
cli.backup_now(guest, storage)
except Exception as exc: # noqa: BLE001 - surface every failure, never kill the app
self.call_from_thread(
self.notify, f"Backup failed: {_message(exc)}", severity="error"
)
else:
self.call_from_thread(self.notify, f"Backup done: {who}")
finally:
for screen in list(
self.screen_stack
): # reload while still busy, like _run_job
if (
isinstance(screen, InfoScreen)
and screen.guest["vmid"] == guest["vmid"]
):
screen.load()
self.busy.discard(guest["vmid"])
def action_shell(self) -> None:
guest = self._selected()
if guest is None:
+118
View File
@@ -156,3 +156,121 @@ def contexts() -> list[dict]:
def use_context(name: str) -> None:
"""Changes cv4pve-cli's GLOBAL current context."""
run("config", "use", name)
def backup_storages(guest: dict) -> list[str]:
"""Enabled, active storages on the guest's node that accept backups, in API order."""
items = json.loads(run("get", "node", "storages", guest["node"], "-o", "json"))
return [
s["storage"]
for s in items
if "backup" in (s.get("content") or "").split(",")
and s.get("enabled")
and s.get("active")
]
def backup_jobs() -> list[dict]:
jobs = json.loads(run("get", "cluster", "backup-jobs", "-o", "json"))
for job in jobs: # the CLI returns prune-backups as a JSON string
if isinstance(job.get("prune-backups"), str):
job["prune-backups"] = json.loads(job["prune-backups"])
return jobs
def _ids(value) -> list[str]:
return str(value).split(",") if value else []
def covering_jobs(jobs: list[dict], guest: dict) -> list[dict]:
"""Enabled jobs that back up the guest (job order)."""
vmid = str(guest["vmid"])
return [
j
for j in jobs
if j.get("enabled")
and (
j.get("all")
and vmid not in _ids(j.get("exclude"))
or vmid in _ids(j.get("vmid"))
)
and j.get("node") in (None, guest["node"])
]
def job_storage(jobs: list[dict], guest: dict) -> str | None:
covering = covering_jobs(jobs, guest)
return covering[0]["storage"] if covering else None
def backups(guest: dict, storages: list[str]) -> list[dict]:
"""The guest's backups on the given storages, newest first, each with its `storage`."""
found = []
for storage in storages:
out = run(
"get", "guest", "backups", guest["node"], storage, str(guest["vmid"]),
"-o", "json",
) # fmt: skip
for item in json.loads(out):
item["storage"] = item["volid"].split(":", 1)[0]
found.append(item)
return sorted(found, key=lambda b: b["ctime"], reverse=True)
def backup_now(guest: dict, storage: str) -> None:
"""Same settings as the scheduled job; no --yes option exists for this command."""
run(
"do", "backup", "guest", guest["node"], str(guest["vmid"]), storage,
"--mode", "snapshot", "--compress", "zstd",
"--notes-template", "{{guestname}} (manual)", "--wait",
) # fmt: skip
def parse_net(value: str) -> dict:
"""`k=v,k=v` to an ordered dict; a VM's leading `<model>=<MAC>` is just the first pair."""
return dict(item.partition("=")[::2] for item in value.split(",") if item)
def nets(config: dict) -> dict[str, dict]:
keys = sorted(
(k for k in config if re.fullmatch(r"net\d+", k)), key=lambda k: int(k[3:])
)
return {k: parse_net(str(config[k])) for k in keys}
def _addresses(listed: list[dict], types: dict[str, str]) -> dict[str, list[str]]:
"""ip-addresses entries -> {"ipv4": [cidr], "ipv6": [cidr]}; unknown types ignored."""
addrs = {"ipv4": [], "ipv6": []}
for a in listed:
family = types.get(a.get("ip-address-type"))
if family:
addrs[family].append(f"{a['ip-address']}/{a['prefix']}")
return addrs
def interfaces(guest: dict) -> list[dict]:
"""Runtime interfaces of a running guest, without loopback."""
args = (guest["node"], str(guest["vmid"]), "-o", "json")
if kind(guest) == "ct":
found = []
for i in json.loads(run("get", "ct", "interfaces", *args)):
listed = i.get("ip-addresses") # all addresses; inet6 alone holds only one
if isinstance(listed, str):
listed = json.loads(listed)
if listed:
addrs = _addresses(listed, {"inet": "ipv4", "inet6": "ipv6"})
else:
addrs = {"ipv4": [i["inet"]] if i.get("inet") else [],
"ipv6": [i["inet6"]] if i.get("inet6") else []} # fmt: skip
found.append({"name": i["name"], "mac": i.get("hwaddr"), **addrs})
else:
items = json.loads(run("get", "vm", "agent", "network", *args))
found = []
for i in json.loads(
{x["key"]: x["value"] for x in items}.get("result") or "[]"
):
addrs = _addresses(
i.get("ip-addresses") or [], {"ipv4": "ipv4", "ipv6": "ipv6"}
)
found.append({"name": i["name"], "mac": i.get("hardware-address"), **addrs})
return [i for i in found if i["name"] != "lo"]