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:
DuckbootandClaude Opus 5.5 committed 2026-11-02 23:26:49 +01:00
1 parent 520de16cc6
commit ec35a4c996
6 files changed
+1035 -14

No files matched your search

+170 -14
View File
@@ -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: