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:
1 parent
32809b6743
commit
3efd028708
4 files changed
+91
-12
No files matched your search
@@ -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) |
|
||||||
|
|||||||
@@ -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
@@ -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']}" + (
|
||||||
|
|||||||
@@ -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() == ""
|
||||||
Reference in new issue
Block a user