Guest info screen: backups, backup now, network (read-only) #3

Merged
Duckboot merged 3 commits from feat/guest-info into main 2026-11-02 21:17:27 +01:00
4 changed files with 91 additions and 12 deletions
Showing only changes of commit 3efd028708 - Show all commits

No files matched your search

+1 -1
View File
@@ -20,7 +20,7 @@ uv run cv4pve-tui
| `x` | stop - hard power-off (confirm) | | `x` | stop - hard power-off (confirm) |
| `r` | reboot (confirm) | | `r` | reboot (confirm) |
| `e` | edit cores / memory / grow a disk (disk growth asks again; it cannot be undone) | | `e` | edit cores / memory / grow a disk (disk growth asks again; it cannot be undone) |
| `i` | info: backups, backup job, network (config and runtime IPs); `b` there = backup now (snapshot, zstd, storage choice) | | `i` | info: backups, backup job, network (config and runtime IPs); `b` there = backup now (snapshot, zstd, storage choice); `a` there = also show backups of earlier guests that had the same VMID |
| `t` | shell in a running LXC container (`ssh -t root@<node> pct enter <vmid>`) | | `t` | shell in a running LXC container (`ssh -t root@<node> pct enter <vmid>`) |
| `g` | refresh | | `g` | refresh |
| `c` | switch cv4pve-cli context (cluster) | | `c` | switch cv4pve-cli context (cluster) |
+5
View File
@@ -325,6 +325,11 @@ Observed CLI behaviour (cv4pve-cli 2.4.0, all read-only probes):
Firewall (`yes`/`no`). Firewall (`yes`/`no`).
- `DataTable#addresses`: Interface, MAC, IPv4, IPv6 (runtime), or the unavailable - `DataTable#addresses`: Interface, MAC, IPv4, IPv6 (runtime), or the unavailable
line. line.
- VMIDs are reused: a deleted guest's backups keep the VMID and are listed by the API
for the new guest. Backups whose notes (the job's `{{guestname}}`) are non-empty and
are neither the current name nor start with `<name> ` (e.g. `debian (manual)`) are
hidden by default; `Static#hidden` says how many; key `a` toggles showing them,
marked `(earlier guest)`. Backups without notes are always shown.
- Key `b` in InfoScreen: `BackupScreen` modal with `Select#storage` (options = - Key `b` in InfoScreen: `BackupScreen` modal with `Select#storage` (options =
backup storages, preselected `job_storage` or the first), text saying mode snapshot, backup storages, preselected `job_storage` or the first), text saying mode snapshot,
zstd, and the guest; buttons `#yes` / `#no`; escape = no. On yes: guest marked busy zstd, and the guest; buttons `#yes` / `#no`; escape = no. On yes: guest marked busy
+40 -11
View File
@@ -167,6 +167,7 @@ class InfoScreen(ModalScreen):
("escape", "dismiss", "Close"), ("escape", "dismiss", "Close"),
("g", "reload", "Refresh"), ("g", "reload", "Refresh"),
("b", "backup", "Backup now"), ("b", "backup", "Backup now"),
("a", "toggle_all", "All backups"),
] ]
DEFAULT_CSS = "InfoScreen DataTable { height: auto; max-height: 12; }" DEFAULT_CSS = "InfoScreen DataTable { height: auto; max-height: 12; }"
@@ -175,12 +176,15 @@ class InfoScreen(ModalScreen):
self.guest = guest self.guest = guest
self.storages: list[str] = [] self.storages: list[str] = []
self.jobs: list[dict] = [] self.jobs: list[dict] = []
self.backups: list[dict] = []
self.show_all = False
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
with Vertical(): with Vertical():
yield Static(f"{self.guest['name']} ({self.guest['vmid']})", markup=False) yield Static(f"{self.guest['name']} ({self.guest['vmid']})", markup=False)
yield Static("", id="jobs", markup=False) yield Static("", id="jobs", markup=False)
yield DataTable(id="backups") yield DataTable(id="backups")
yield Static("", id="hidden", markup=False)
yield DataTable(id="nets") yield DataTable(id="nets")
yield Static("", id="unavailable", markup=False) yield Static("", id="unavailable", markup=False)
yield DataTable(id="addresses") yield DataTable(id="addresses")
@@ -252,17 +256,8 @@ class InfoScreen(ModalScreen):
"; ".join(_job_text(j) for j in covering) "; ".join(_job_text(j) for j in covering)
or "No backup job covers this guest" or "No backup job covers this guest"
) )
table = self.query_one("#backups", DataTable).clear() self.backups = backups
for b in backups: self._show_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() table = self.query_one("#nets", DataTable).clear()
for name, net in nets.items(): for name, net in nets.items():
if cli.kind(self.guest) == "vm": # IP settings are cloud-init's ipconfigN if cli.kind(self.guest) == "vm": # IP settings are cloud-init's ipconfigN
@@ -286,6 +281,34 @@ class InfoScreen(ModalScreen):
i["name"], i["mac"] or "-", ", ".join(i["ipv4"]), ", ".join(i["ipv6"]) i["name"], i["mac"] or "-", ", ".join(i["ipv4"]), ", ".join(i["ipv6"])
) )
def _show_backups(self) -> None:
name = self.guest["name"]
shown = [
b for b in self.backups if self.show_all or not _earlier_guest(b, name)
]
table = self.query_one("#backups", DataTable).clear()
for b in shown:
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 "")
+ (" (earlier guest)" if _earlier_guest(b, name) else ""),
)
hidden = len(self.backups) - len(shown)
self.query_one("#hidden", Static).update(
f"{hidden} backup(s) of an earlier guest with this VMID hidden - press a to show"
if hidden
else ""
)
def action_toggle_all(self) -> None:
self.show_all = not self.show_all
self._show_backups()
def action_backup(self) -> None: def action_backup(self) -> None:
if not self.storages: if not self.storages:
self.app.notify("No backup storage available", severity="warning") self.app.notify("No backup storage available", severity="warning")
@@ -301,6 +324,12 @@ class InfoScreen(ModalScreen):
) )
def _earlier_guest(backup: dict, name: str) -> bool:
"""VMIDs are reused: notes (the job's {{guestname}}) naming another guest mean a deleted one."""
notes = backup.get("notes") or ""
return bool(notes) and notes != name and not notes.startswith(f"{name} ")
def _job_text(job: dict) -> str: def _job_text(job: dict) -> str:
keep = (job.get("prune-backups") or {}).get("keep-last") keep = (job.get("prune-backups") or {}).get("keep-last")
return f"{job['schedule']} to {job['storage']}" + ( return f"{job['schedule']} to {job['storage']}" + (
+45
View File
@@ -1271,3 +1271,48 @@ async def test_info_uses_live_status_not_table_status(fake):
async with app.run_test() as pilot: async with app.run_test() as pilot:
await open_info(app, pilot, down=1) await open_info(app, pilot, down=1)
assert fake.iface_calls == [100] assert fake.iface_calls == [100]
# --- backups of earlier guests with a reused VMID ------------------------------
EARLIER = {"volid": "truenas-backup:backup/vzdump-qemu-108-planka.vma.zst", "ctime": T_OLD - 60,
"size": 2**30, "format": "vma.zst", "notes": "planka", "storage": "truenas-backup"} # fmt: skip
def backup_notes(app):
t = app.screen.query_one("#backups", DataTable)
return [cells(t, i)[4] for i in range(t.row_count)]
async def test_backups_of_earlier_guest_hidden_by_default_with_hint(fake):
# VMIDs are reused: a deleted guest's backups (notes = its name) are not this guest's
fake.backup_list = [*BACKUPS, EARLIER]
app = make_app()
async with app.run_test() as pilot:
await open_info(app, pilot)
assert "planka" not in backup_notes(app)
assert backup_notes(app) == [
"docker (manual)",
"",
] # own name and no notes stay
assert "1" in str(app.screen.query_one("#hidden", Static).render())
async def test_a_toggles_showing_earlier_guest_backups(fake):
fake.backup_list = [*BACKUPS, EARLIER]
app = make_app()
async with app.run_test() as pilot:
await open_info(app, pilot)
await pilot.press("a")
await pilot.pause()
assert any("planka" in n for n in backup_notes(app))
await pilot.press("a")
await pilot.pause()
assert not any("planka" in n for n in backup_notes(app))
async def test_no_hint_when_nothing_hidden(fake):
app = make_app()
async with app.run_test() as pilot:
await open_info(app, pilot)
assert str(app.screen.query_one("#hidden", Static).render()).strip() == ""