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() == ""