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: