From 3efd0287088f18fdc9c80b3bdf1252becf23fb1a Mon Sep 17 00:00:00 2001 From: Per-Arne Hellarvik Date: Mon, 2 Nov 2026 21:15:49 +0100 Subject: [PATCH] Hide backups of earlier guests that reused the VMID Proxmox ties backups to the VMID only, so a deleted guest's backups were listed under the new guest with that VMID. CT 104 showed planka backups next to debian's, and VM 108 showed vaultwarden backups. Backups whose notes name another guest are now hidden by default. The notes come from the job's {{guestname}}, so "debian (manual)" still counts as debian. A line says how many are hidden, and the a key shows them, marked "(earlier guest)". Backups without notes are always shown. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- docs/plans/guest-tui-mvp.md | 5 ++++ src/cv4pve_tui/app.py | 51 +++++++++++++++++++++++++++++-------- tests/test_app.py | 45 ++++++++++++++++++++++++++++++++ 4 files changed, 91 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index e3a2325..2d2d612 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ uv run cv4pve-tui | `x` | stop - hard power-off (confirm) | | `r` | reboot (confirm) | | `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@ pct enter `) | | `g` | refresh | | `c` | switch cv4pve-cli context (cluster) | diff --git a/docs/plans/guest-tui-mvp.md b/docs/plans/guest-tui-mvp.md index 7e43e40..23f5855 100644 --- a/docs/plans/guest-tui-mvp.md +++ b/docs/plans/guest-tui-mvp.md @@ -325,6 +325,11 @@ Observed CLI behaviour (cv4pve-cli 2.4.0, all read-only probes): Firewall (`yes`/`no`). - `DataTable#addresses`: Interface, MAC, IPv4, IPv6 (runtime), or the unavailable 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 ` ` (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 = 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 diff --git a/src/cv4pve_tui/app.py b/src/cv4pve_tui/app.py index a755502..36faee1 100644 --- a/src/cv4pve_tui/app.py +++ b/src/cv4pve_tui/app.py @@ -167,6 +167,7 @@ class InfoScreen(ModalScreen): ("escape", "dismiss", "Close"), ("g", "reload", "Refresh"), ("b", "backup", "Backup now"), + ("a", "toggle_all", "All backups"), ] DEFAULT_CSS = "InfoScreen DataTable { height: auto; max-height: 12; }" @@ -175,12 +176,15 @@ class InfoScreen(ModalScreen): self.guest = guest self.storages: list[str] = [] self.jobs: list[dict] = [] + self.backups: list[dict] = [] + self.show_all = False 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 Static("", id="hidden", markup=False) yield DataTable(id="nets") yield Static("", id="unavailable", markup=False) yield DataTable(id="addresses") @@ -252,17 +256,8 @@ class InfoScreen(ModalScreen): "; ".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 "", - ) + self.backups = backups + self._show_backups() 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 @@ -286,6 +281,34 @@ class InfoScreen(ModalScreen): 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: if not self.storages: 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: keep = (job.get("prune-backups") or {}).get("keep-last") return f"{job['schedule']} to {job['storage']}" + ( diff --git a/tests/test_app.py b/tests/test_app.py index b81d423..d3c9ae8 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1271,3 +1271,48 @@ async def test_info_uses_live_status_not_table_status(fake): async with app.run_test() as pilot: await open_info(app, pilot, down=1) 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() == ""