Guest info screen: backups, backup now, network (read-only) #3
No files matched your search
@@ -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) |
|
||||
|
||||
@@ -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
@@ -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']}" + (
|
||||
|
||||
@@ -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() == ""
|
||||
Reference in new issue
Block a user