Add snapshots to the info screen: take, roll back, delete
A "Snapshots" section on the info screen lists the snapshots, newest first, without Proxmox's "current" pseudo entry. Keys: - p takes a snapshot. The name defaults to tui_YYYYMMDD_HHMM, the description is optional, and VMs can include RAM. - R rolls back to the snapshot under the cursor. The confirm names the snapshot and its date. A running guest is started again; its status is read right before the rollback, and if it cannot be read the rollback does not run. - D deletes the snapshot under the cursor, after a confirm. `delete ct|vm <node> <vmid> -y` without the snapshot subcommand destroys the whole guest. delete_snapshot therefore validates the name first (letter first, then [A-Za-z0-9_-], not "current"), writes "snapshot" into the argv as a literal, and checks the argv shape before running it. A property test covers rejected names. The info screen now scrolls, because four sections do not fit short terminals. A description starting with "-" is refused, since it is a positional argument and could be read as an option. Live test on CT 104: take, roll back (started again) and delete. The snapshot list, status, config and IPv4/IPv6 match the baseline. Closes #8 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit was merged in pull request #21.
This commit is contained in:
1 parent
520de16cc6
commit
ec35a4c996
6 files changed
+1035
-14
No files matched your search
+170
-14
@@ -8,7 +8,7 @@ from rich.text import Text
|
||||
from textual import work
|
||||
from textual.app import App, ComposeResult, SuspendNotSupported
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.containers import Horizontal, Vertical, VerticalScroll
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import (
|
||||
Button,
|
||||
@@ -202,6 +202,51 @@ class BackupScreen(DialogScreen):
|
||||
self.dismiss(None)
|
||||
|
||||
|
||||
class SnapshotScreen(DialogScreen):
|
||||
"""Dismisses with {name, description, vmstate}, or None when cancelled."""
|
||||
|
||||
BINDINGS = [("escape", "dismiss(None)", "Cancel")] # noqa: RUF012 - Textual idiom
|
||||
|
||||
def __init__(self, guest: dict) -> None:
|
||||
super().__init__()
|
||||
self.guest = guest
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with self.box(f"Take snapshot: {self.guest['name']} ({self.guest['vmid']})"):
|
||||
with Horizontal(classes="row"):
|
||||
yield Label("Name")
|
||||
yield Input(datetime.now().strftime("tui_%Y%m%d_%H%M"), id="snapname")
|
||||
with Horizontal(classes="row"):
|
||||
yield Label("Description")
|
||||
yield Input(placeholder="optional", id="snapdesc")
|
||||
if cli.kind(self.guest) == "vm":
|
||||
yield Checkbox("Include RAM (vmstate)", id="vmstate")
|
||||
with Horizontal(classes="buttons"):
|
||||
yield Button("Yes", id="yes", variant="primary")
|
||||
yield Button("No", id="no")
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
if event.button.id != "yes":
|
||||
self.dismiss(None)
|
||||
return
|
||||
try:
|
||||
name = cli.valid_snapname(self.query_one("#snapname", Input).value)
|
||||
description = cli.valid_description(
|
||||
self.query_one("#snapdesc", Input).value.strip()
|
||||
)
|
||||
except ValueError as exc:
|
||||
self.app.notify(str(exc), severity="error")
|
||||
return
|
||||
vmstate = next(iter(self.query("#vmstate").results(Checkbox)), None)
|
||||
self.dismiss(
|
||||
{
|
||||
"name": name,
|
||||
"description": description,
|
||||
"vmstate": bool(vmstate and vmstate.value),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
_IPV4_MODES = [("DHCP", "dhcp"), ("Static", "static"), ("None", "none")]
|
||||
_IPV6_MODES = [("SLAAC", "auto"), *_IPV4_MODES]
|
||||
|
||||
@@ -383,6 +428,9 @@ class InfoScreen(ModalScreen):
|
||||
("n", "net", "Edit network"),
|
||||
("u", "undo", "Undo network"),
|
||||
("e", "edit", "Edit resources"),
|
||||
("p", "snapshot", "Take snapshot"),
|
||||
("R", "rollback", "Roll back"),
|
||||
("D", "delete_snapshot", "Delete snapshot"),
|
||||
]
|
||||
DEFAULT_CSS = """
|
||||
InfoScreen DataTable { height: auto; max-height: 12; }
|
||||
@@ -403,9 +451,10 @@ class InfoScreen(ModalScreen):
|
||||
self.show_all = False
|
||||
self.config: dict = {}
|
||||
self.running = False
|
||||
self.snaps: list[dict] = []
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical():
|
||||
with VerticalScroll(id="info_body"): # four sections do not fit short terminals
|
||||
yield Static(f"{self.guest['name']} ({self.guest['vmid']})", markup=False)
|
||||
with Vertical(id="resources_section", classes="section") as res:
|
||||
res.border_title = "Resources"
|
||||
@@ -415,6 +464,10 @@ class InfoScreen(ModalScreen):
|
||||
yield Static("", id="jobs", markup=False)
|
||||
yield DataTable(id="backups")
|
||||
yield Static("", id="hidden", markup=False)
|
||||
with Vertical(id="snapshots_section", classes="section") as snaps:
|
||||
snaps.border_title = "Snapshots"
|
||||
yield DataTable(id="snapshots", cursor_type="row")
|
||||
yield Static("", id="snap_note", markup=False)
|
||||
with Vertical(id="network_section", classes="section") as network:
|
||||
network.border_title = "Network"
|
||||
yield Static("Configured", classes="label first")
|
||||
@@ -422,13 +475,19 @@ class InfoScreen(ModalScreen):
|
||||
yield Static("In use", classes="label")
|
||||
yield Static("", id="unavailable", markup=False)
|
||||
yield DataTable(id="addresses")
|
||||
yield Footer()
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one("#resources", DataTable).add_columns("Resource", "Value")
|
||||
self.query_one("#backups", DataTable).add_columns(
|
||||
"Date", "Storage", "Size (GiB)", "Format", "Notes"
|
||||
)
|
||||
self.query_one("#snapshots", DataTable).add_columns(
|
||||
"Name",
|
||||
"Date",
|
||||
"Description",
|
||||
*(["RAM"] if cli.kind(self.guest) == "vm" else []),
|
||||
)
|
||||
self.query_one("#nets", DataTable).add_columns(
|
||||
"Iface", "Bridge", "VLAN", "MAC", "IPv4", "IPv6", "Firewall"
|
||||
)
|
||||
@@ -466,20 +525,25 @@ class InfoScreen(ModalScreen):
|
||||
addresses = cli.interfaces(guest)
|
||||
except Exception as exc: # noqa: BLE001 - shown inline, the rest of the screen still works
|
||||
unavailable = f"unavailable: {_message(exc)}"
|
||||
snaps, snap_note = [], ""
|
||||
try:
|
||||
snaps = cli.snapshots(guest)
|
||||
except Exception as exc: # noqa: BLE001 - shown inline, the rest of the screen still works
|
||||
snap_note = f"unavailable: {_message(exc)}"
|
||||
self.running = running
|
||||
self.app.call_from_thread(
|
||||
self._show, config, nets, storages, jobs, backups, addresses, unavailable
|
||||
self._show,
|
||||
(config, nets, storages, jobs, backups, addresses, unavailable),
|
||||
snaps,
|
||||
snap_note,
|
||||
)
|
||||
|
||||
def _show(
|
||||
self, config, nets, storages, jobs, backups, addresses, unavailable
|
||||
) -> None:
|
||||
def _show(self, loaded: tuple, snaps: list[dict], snap_note: str) -> None:
|
||||
if not self.is_attached: # closed while loading
|
||||
return
|
||||
try:
|
||||
self._fill_screen(
|
||||
config, nets, storages, jobs, backups, addresses, unavailable
|
||||
)
|
||||
self._fill_screen(*loaded)
|
||||
self._show_snapshots(snaps, snap_note)
|
||||
except Exception as exc: # noqa: BLE001 - odd API data must not kill the app
|
||||
self.app.notify(f"info: {_message(exc)}", severity="error")
|
||||
|
||||
@@ -527,6 +591,22 @@ class InfoScreen(ModalScreen):
|
||||
", ".join(i["ipv6"]),
|
||||
)
|
||||
|
||||
def _show_snapshots(self, snaps: list[dict], note: str) -> None:
|
||||
self.snaps = snaps
|
||||
table = self.query_one("#snapshots", DataTable).clear()
|
||||
vm = cli.kind(self.guest) == "vm"
|
||||
for s in snaps:
|
||||
_add_plain(
|
||||
table,
|
||||
s["name"],
|
||||
_when(s.get("snaptime")),
|
||||
s.get("description") or "",
|
||||
*(["yes" if s.get("vmstate") else "no"] if vm else []),
|
||||
)
|
||||
self.query_one("#snap_note", Static).update(
|
||||
note or ("" if snaps else "No snapshots")
|
||||
)
|
||||
|
||||
def _show_backups(self) -> None:
|
||||
name = self.guest["name"]
|
||||
shown = [
|
||||
@@ -600,6 +680,78 @@ class InfoScreen(ModalScreen):
|
||||
lambda ok: self.app.start_net_change(self.guest, old, None) if ok else None,
|
||||
)
|
||||
|
||||
def _snapshot_under_cursor(self) -> dict | None:
|
||||
"""The snapshot at the cursor; None (with a notify) when there is none or the guest is busy."""
|
||||
row = self.query_one("#snapshots", DataTable).cursor_row
|
||||
if not 0 <= row < len(self.snaps):
|
||||
self.app.notify("No snapshots")
|
||||
return None
|
||||
return None if self._refuse_if_busy() else self.snaps[row]
|
||||
|
||||
def action_snapshot(self) -> None:
|
||||
if self._refuse_if_busy():
|
||||
return
|
||||
self.app.push_screen(SnapshotScreen(self.guest), self._take_snapshot)
|
||||
|
||||
def _take_snapshot(self, values: dict | None) -> None:
|
||||
if values:
|
||||
self.app.start_job(
|
||||
self.guest,
|
||||
"Snapshot",
|
||||
lambda: cli.take_snapshot(
|
||||
self.guest,
|
||||
values["name"],
|
||||
values["description"],
|
||||
values["vmstate"],
|
||||
),
|
||||
)
|
||||
|
||||
def action_rollback(self) -> None:
|
||||
snap = self._snapshot_under_cursor()
|
||||
if snap is None:
|
||||
return
|
||||
text = (
|
||||
f"Roll back to snapshot {snap['name']} ({_when(snap.get('snaptime'))})? "
|
||||
"Everything changed since then is lost. "
|
||||
"If the guest is running, it is started again afterwards."
|
||||
)
|
||||
|
||||
def job() -> None:
|
||||
# status read right before the rollback (not at screen load); if it cannot be
|
||||
# read the rollback does not run, rather than leave a running guest stopped
|
||||
start = cli.status(self.guest) == "running"
|
||||
cli.rollback_snapshot(self.guest, snap["name"], start)
|
||||
|
||||
self.app.push_screen(
|
||||
ConfirmScreen(text, "Roll back"),
|
||||
lambda ok: (
|
||||
self.app.start_job(
|
||||
self.guest,
|
||||
"Rollback",
|
||||
job,
|
||||
)
|
||||
if ok
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
def action_delete_snapshot(self) -> None:
|
||||
snap = self._snapshot_under_cursor()
|
||||
if snap is None:
|
||||
return
|
||||
self.app.push_screen(
|
||||
ConfirmScreen(f"Delete snapshot {snap['name']}?", "Delete snapshot"),
|
||||
lambda ok: (
|
||||
self.app.start_job(
|
||||
self.guest,
|
||||
"Delete snapshot",
|
||||
lambda: cli.delete_snapshot(self.guest, snap["name"]),
|
||||
)
|
||||
if ok
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
def action_backup(self) -> None:
|
||||
if not self.storages:
|
||||
self.app.notify("No backup storage available", severity="warning")
|
||||
@@ -615,6 +767,10 @@ class InfoScreen(ModalScreen):
|
||||
)
|
||||
|
||||
|
||||
def _when(epoch: int | None) -> str:
|
||||
return datetime.fromtimestamp(epoch).strftime("%Y-%m-%d %H:%M") if epoch else "-"
|
||||
|
||||
|
||||
def _ip_changed(old: str | None, new: str | None) -> bool:
|
||||
before, after = cli.parse_net(old or ""), cli.parse_net(new or "")
|
||||
return any(before.get(k) != after.get(k) for k in ("ip", "gw", "ip6", "gw6"))
|
||||
@@ -833,7 +989,7 @@ class GuestApp(App):
|
||||
finally:
|
||||
self.call_from_thread(setattr, self, "switching", False)
|
||||
|
||||
def _start_job(self, guest: dict, label: str, job) -> None:
|
||||
def start_job(self, guest: dict, label: str, job) -> None:
|
||||
if guest["vmid"] in self.busy:
|
||||
self.notify(
|
||||
f"{guest['name']} ({guest['vmid']}) is busy", severity="warning"
|
||||
@@ -869,7 +1025,7 @@ class GuestApp(App):
|
||||
return
|
||||
run = lambda: cli.action(name, guest)
|
||||
if name == "start":
|
||||
self._start_job(guest, name, run)
|
||||
self.start_job(guest, name, run)
|
||||
return
|
||||
who = f"{guest['name']} ({guest['vmid']})"
|
||||
text = f"{name.capitalize()} {who}?"
|
||||
@@ -877,7 +1033,7 @@ class GuestApp(App):
|
||||
text = f"Hard power-off {who}? The guest is killed without shutting down."
|
||||
self.push_screen(
|
||||
ConfirmScreen(text, name.capitalize()),
|
||||
lambda ok: self._start_job(guest, name, run) if ok else None,
|
||||
lambda ok: self.start_job(guest, name, run) if ok else None,
|
||||
)
|
||||
|
||||
def edit_guest(self, guest: dict) -> None:
|
||||
@@ -1056,7 +1212,7 @@ class GuestApp(App):
|
||||
if {"cores", "memory"} & set(pending):
|
||||
self.call_from_thread(self.notify, "Change applies at next reboot")
|
||||
|
||||
self._start_job(guest, "edit", job)
|
||||
self.start_job(guest, "edit", job)
|
||||
|
||||
|
||||
def _reset_terminal() -> None:
|
||||
|
||||
Reference in new issue
Block a user