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:
1 parent
70bcb9e7b7
commit
32809b6743
6 files changed
+1057
-2
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
Reference in new issue
Block a user