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) |
|
| `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