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 <noreply@anthropic.com>
This commit is contained in:
DuckbootandClaude Opus 5.5 committed 2026-11-02 21:15:49 +01:00
1 parent 32809b6743
commit 3efd028708
4 files changed
+91 -12

No files matched your search

+1 -1
View File
@@ -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@<node> pct enter <vmid>`) |
| `g` | refresh |
| `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`).
- `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 `<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 =
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
+40 -11
View File
@@ -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']}" + (
+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:
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() == ""