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