Files
cv4pve-cli-tui/docs/plans/guest-tui-mvp.md
T
DuckbootandClaude Opus 5.5 d97ad22c78
CI / pytest and ruff (pull_request) Successful in 4m48s
CI / pytest and ruff (push) Has been cancelled
Add search and filter to the guest table
/ opens a filter box under the guest table, and the table filters as
you type. Terms are separated by spaces, all must match, and case is
ignored:

- a plain term matches part of the name, the VMID or a tag
- is:running and is:stopped match the status
- type:lxc/ct and type:vm/qemu match the guest type

Enter keeps the filter and goes back to the table. Escape clears it,
from the box or from the table. The frame shows
"· filter: <text> · N of M". Actions only ever see the visible rows,
and the filter survives reloads and context switches.

Review fixes:
- Typing "[/b]" or "[/]" crashed the app, because the filter text went
  into the border subtitle, which Textual parses as markup. The
  subtitle is now plain text, so "[red]" is also shown literally.
- Escape on the table now clears a filter that was applied with Enter.
- guest_matches tolerates a None status or type.

Read-only live check on mainframe: "debian" gives 104, "type:vm" gives
108, "community-script is:running" gives 14 of 19, and "is:stopped"
gives 0 of 19.

Closes #16

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 00:42:13 +01:00

34 KiB

Guest TUI MVP

Problem

Manage Proxmox guests (VMs and LXC containers) from the terminal without remembering cv4pve-cli syntax: list them, start/shutdown/stop/reboot, and change CPU cores, memory and disk size.

Approach

Thin subprocess wrapper around cv4pve-cli (tested against 2.4.0) plus a Textual app. The app never handles credentials: it uses whatever context cv4pve-cli config use selected.

Observed CLI behaviour the design relies on:

  • get guests -o json -> list of dicts with vmid, type (qemu|lxc), node, name, status, maxcpu, maxmem (bytes), maxdisk (bytes), ...
  • show vm|ct <node> <vmid> -o json -> list of {"key": ..., "value": ...}.
  • api get /nodes/<node>/<qemu|lxc>/<vmid>/pending -o json -> list of {"key", "value", "pending", "delete"}; pending non-null means not yet applied.
  • Async actions print a UPID and exit 0 even when the task fails. With --wait the CLI waits for the task and exits non-zero (5) with the error text on failure. Every mutating call therefore uses --wait.
  • do stop refuses to run without -y (verified); -y is accepted by all four lifecycle actions, so it is always passed.
  • Errors: non-zero exit, message on stdout/stderr. Sync failures are multi-line (Error: ... / Call: ... / Alias: ...). Success is judged by exit code only, never by parsing UPID text.
  • Each call costs ~0.5 s (.NET startup), so calls run in worker threads.
  • get guests (/cluster/resources) lags a few seconds behind actions; after an action the app reads the acted-on guest's live status from api get /nodes/<node>/<qemu|lxc>/<vmid>/status/current (key/value list).

Module cv4pve_tui/cli.py

  • CliError(Exception) - str() is the CLI's combined, stripped output; .summary is one line for the UI: the first non-empty line that is not a UPID, Error:-prefix stripped, plus the following indented detail line if the first line ends with :.
  • run(*args) -> str - subprocess.run(["cv4pve-cli", *args], capture_output=True, text=True, stdin=DEVNULL, timeout=330); non-zero exit -> CliError.
  • kind(guest) -> "vm" | "ct" - from type (qemu -> vm, lxc -> ct).
  • list_guests() -> list[dict] - sorted by vmid.
  • guest_config(guest) -> dict - key/value list flattened to a dict.
  • disks(config, kind) -> dict[str, str] - resizable disks -> size string. VM: scsiN|virtioN|sataN|ideN with a size=, excluding media=cdrom and cloud-init. CT: rootfs, mpN.
  • ACTIONS = ("start", "shutdown", "stop", "reboot"); action(name, guest) -> do <name> <kind> <node> <vmid> -y --wait; unknown name -> ValueError.
  • set_resources(guest, cores=None, memory_mb=None) -> set <kind> config <node> <vmid> [--cores N] [--memory M] --wait; values must be positive ints; nothing to set -> ValueError.
  • resize_disk(guest, disk, grow_gb) -> do resize <kind> disk <node> <vmid> <disk> +<grow_gb>G --wait; grow_gb must be a positive int (growth only - Proxmox cannot shrink, and an increment cannot shrink by construction).
  • pending_keys(guest) -> list[str] - keys with a non-null pending.

Module cv4pve_tui/app.py

  • DataTable of guests: VMID, name, type, node, status, cores, memory (MiB), disk (GiB). Cursor row = selected guest.
  • Keys: s start, S shutdown, x stop (hard), r reboot, e edit, g refresh, q quit.
  • shutdown, stop, reboot open a confirm modal naming the guest; stop says it is a hard power-off. start needs no confirmation.
  • e opens an edit modal: cores, memory MiB, disk select + "grow by GiB". Empty field = unchanged. Disk growth gets its own confirm (irreversible).
  • After set_resources, if pending_keys contains cores or memory, notify that the change applies at next reboot.
  • All CLI calls in thread workers; results/errors via notify (errors show CliError.summary); table refreshes after each action.
  • A "working" notice is shown when an action starts (--wait can block minutes), and a second action on a guest that is already busy is refused.
  • Action keys on an empty table do nothing (no crash).

UI contract (shared by tests and implementation)

  • app.py does from cv4pve_tui import cli and always calls cli.<function>(...), so tests monkeypatch cv4pve_tui.cli.<function>.
  • class GuestApp(App); def main() runs it.
  • Guest table: DataTable with id guests, one row per guest, row key = str(vmid), in list_guests() order. Loaded on mount.
  • ConfirmScreen(ModalScreen[bool]): buttons #yes / #no; its text names the guest (name and vmid); for stop it contains the words "hard power-off".
  • EditScreen(ModalScreen): Input#cores, Input#memory, Select#disk, Input#grow, buttons #apply / #cancel. Inputs are pre-filled with nothing (empty = unchanged); placeholders show current values. Disk options come from cli.disks(cli.guest_config(guest), cli.kind(guest)). On apply: if cores/memory given -> cli.set_resources(guest, cores=..., memory_mb=...) (only the given ones, as ints); if grow given -> ConfirmScreen first, then cli.resize_disk(guest, disk, grow_gb). Non-integer or <= 0 input -> notify with severity "error" and no CLI call.
  • Thread workers (@work(thread=True) or run_worker(..., thread=True)); tests wait with await app.workers.wait_for_complete() then await pilot.pause().

Out of scope

Create/delete, snapshots, migration, console, backup, in-app context switching, sockets/vCPU/hotplug settings, live auto-refresh.

Steps

  • Project skeleton (uv, pyproject.toml, entry point cv4pve-tui) - uv run python -c "import cv4pve_tui" succeeds.
  • Tests for cli.py with subprocess.run mocked - fail before implementation.
  • cli.py - tests pass.
  • Tests for the app with Textual run_test and cli functions mocked.
  • app.py - tests pass.
  • Read-only smoke test against the real cluster (listing, edit dialog disk options, pending check).
  • Live edit (cores/memory), shutdown, start, reboot on CT 104 (mainframe), restored to baseline afterwards. Disk resize not run live (irreversible).

Open questions

None blocking.


Context switching

Problem

Several cv4pve-cli contexts (clusters) exist; switch between them inside the TUI instead of quitting and running cv4pve-cli config use.

Decision

Option A: switching runs cv4pve-cli config use <name>, which changes the global current context (all shells and scripts follow) and stays after the app exits. cv4pve-cli 2.4.0 has no per-call context flag or env var (verified), so there is no app-local option.

Observed CLI behaviour:

  • config list has no -o json. Text output, one line per context; current one prefixed with * , others with two spaces: * mainframe mainframe.iot.home.arpa:8006 (api-token) mainframe-edge mainframe-edge.home.arpa:8006 (api-token)
  • config use <name> -> exit 0 on success; unknown name -> exit 3, Error: Context '<name>' not found., current context unchanged.

cli.py

  • contexts() -> list[dict] - run("config", "list"), each line parsed to {"name", "host", "auth", "current": bool} (regex ^(\*)?\s*(\S+)\s+(\S+)\s+\((.*)\)\s*$); non-matching lines are ignored.
  • use_context(name) - run("config", "use", name).

app.py (UI contract)

  • The active context is always visible: app.sub_title == f"context: {name}" (shown in the Header). Set on mount from cli.contexts() and after every switch. If contexts() fails: error notify, sub_title context: ?.
  • Key c opens ContextScreen(ModalScreen[str | None]) with OptionList#contexts, one option per context, option id = context name, current one marked with *. Selecting an option dismisses with its name; escape dismisses with None.
  • c while any guest is busy -> warning notify, no screen (VMIDs collide across clusters, so the busy guard would point at the wrong cluster).
  • Selecting the current context or dismissing -> no CLI call.
  • A reload started before a switch must not refill the table (generation counter); g and c are refused while a switch runs; the post-action reload finishes before the guest stops being busy.
  • Selecting another context: table cleared immediately (old cluster's guests must not stay actionable), then in a worker cli.use_context(name), sub_title updated, guests reloaded. On CliError: error notify with summary, sub_title re-read from cli.contexts(), guests reloaded.

Out of scope

Adding/editing/deleting contexts, config verify, restoring the original context on exit.

Steps

  • Tests for contexts/use_context and the UI contract above - red first.
  • Implementation - full suite green, ruff clean.
  • Live: open the list, switch to mainframe-edge and back to mainframe (only config use and read-only listing; no guest actions); final context is mainframe.
  • Push feat/guest-tui-mvp, merge to main (user-approved).

LXC shell

Problem

Open an interactive shell in a running LXC container from the TUI. cv4pve-cli 2.4.0 has no console/shell/exec for LXC (verified: no command; the Proxmox API has no LXC exec), so the app goes around it: SSH to the PVE node as root and run pct enter <vmid>.

Decision

Option 1 from the discussion: ssh -t root@<node> pct enter <vmid>. The node name is the SSH host (resolves via DNS here: mainframe, mainframe-edge); the user's key is in root's authorized_keys (verified: ssh -o BatchMode=yes root@mainframe pct status 104 -> status: running). This bypasses cv4pve-cli and its API token.

app.py (UI contract)

  • Key t ("Shell") on the selected guest.
  • Only for type == "lxc" and status == "running"; otherwise a warning notify that says why (not a container / not running) and nothing runs. Empty table: nothing.
  • Busy guest (action running) -> warning notify, nothing runs.
  • Runs on the main thread inside with self.suspend(): - subprocess.run(["ssh", "-t", f"root@{node}", "pct", "enter", str(vmid)]) (no capture, no timeout: the user's terminal is handed over; import subprocess in app.py so tests patch cv4pve_tui.app.subprocess.run).
  • Exit 255 (ssh itself failed) -> error notify naming the guest and the code. Other exit codes are the container shell's last status (exit after a failed command gives 1) and are not errors.
  • Inside suspend(), right before ssh, the terminal gets a full reset (ESC c, what reset sends) so the container shell starts on a clean screen without leftover TUI terminal modes (user request). Done locally, not in the container: no dependency on reset existing there, and pct enter stays unchanged.
  • OSError from subprocess.run is caught inside the suspend() block: Textual 8.2.8's suspend() has no try/finally, so an exception escaping it leaves the app suspended.
  • The guest table reloads after the shell returns (the user may have stopped or rebooted the container from inside).
  • textual.app.SuspendNotSupported (headless / unsupported driver) and OSError (no ssh binary) -> error notify, app keeps running.
  • Tests: App.suspend raises SuspendNotSupported under run_test, so tests replace app.suspend with a no-op context manager (e.g. contextlib.nullcontext) unless testing that error path.

Out of scope

VM consoles (serial/VNC), configurable SSH user/host/options, termproxy/websocket.

Steps

  • Tests for the UI contract above - red first.
  • Implementation - full suite green, ruff clean. README key table gets t.
  • Interactive test by the user: t on CT 104, exit, back in the table; screen clean after the reset.
  • Push feat/lxc-shell, merge to main (user-approved).

Guest info screen: backups, backup now, network (read-only) - delivery A

Problem

Per guest, see its latest backups and network setup, and start a backup now. Network editing is delivery B (separate, live-tested), not part of this.

Decisions (user-approved)

  • Backup now uses the scheduled job's settings: --mode snapshot --compress zstd. Storage is chosen from the node's backup-capable storages, preselected to the job's storage (truenas-backup here), local as alternative.
  • Manual backups get --notes-template "{{guestname}} (manual)" so they are recognisable. Note for the user: truenas-backup has storage prune keep-all=1, but the job has keep-last 3 per guest; a manual backup there likely counts towards it at the job's next run (not verified).

Observed CLI behaviour (cv4pve-cli 2.4.0, all read-only probes):

  • get node storages <node> -o json -> list of dicts: storage, content (comma list, e.g. iso,snippets,import,backup,vztmpl), enabled, active, type, avail (bytes).
  • get cluster backup-jobs -o json -> list of dicts: id, enabled, storage, schedule (e.g. mon,wed,fri 04:00), mode, compress, all (1 = all guests), vmid (comma list or null), exclude (comma list or null), node (null = all), next-run (epoch), prune-backups (e.g. {"keep-last":"3"}).
  • get guest backups <node> <storage> <vmid> -o json -> list of dicts: volid (truenas-backup:backup/vzdump-lxc-104-2026_11_02-04_11_47.tar.zst), ctime (epoch), size (bytes), format, notes, protected, subtype (lxc|qemu), vmid.
  • do backup guest <node> <vmid> <storage> [--key value] - no --yes option. vzdump params: mode snapshot|suspend|stop, compress 0|1|gzip|lzo|zstd, notes-template.
  • CT net config: netN = name=eth0,bridge=vmbr0,hwaddr=BC:24:11:36:DE:78,ip=dhcp,ip6=auto,type=veth (optional gw, gw6, tag, firewall, rate, mtu, ...).
  • VM net config: netN = virtio=02:24:A6:49:A8:25,bridge=vmbr0[,tag=..][,firewall=1] (first item is <model>=<MAC>); IP settings live in ipconfigN (cloud-init), e.g. ip=dhcp.
  • Runtime IPs, CT (running): get ct interfaces <node> <vmid> -o json -> list of dicts: name, hwaddr, inet (e.g. 172.16.41.200/23), inet6 (may be absent/ null), ip-addresses (JSON string).
  • Runtime IPs, VM (running, guest agent): get vm agent network <node> <vmid> -o json -> [{"key": "result", "value": "<JSON string>"}]; the JSON is a list of {"name", "hardware-address", "ip-addresses": [{"ip-address", "prefix", "ip-address-type": "ipv4"|"ipv6"}]}. Fails when the agent is not running.

cli.py

  • backup_storages(guest) -> list[str] - storages on the guest's node whose content contains backup and that are enabled and active, in API order.
  • backup_jobs() -> list[dict].
  • job_storage(jobs, guest) -> str | None - storage of the first enabled job covering the guest: (all truthy and vmid not in exclude) or vmid in vmid; and node null or equal to the guest's node.
  • backup_jobs() parses prune-backups when the CLI returns it as a JSON string (real output: '{"keep-last":"3"}').
  • backups(guest, storages) -> list[dict] - all backups of the guest on those storages, newest first (ctime desc). Each dict gets storage (from volid prefix).
  • backup_now(guest, storage) -> do backup guest <node> <vmid> <storage> --mode snapshot --compress zstd --notes-template "{{guestname}} (manual)" --wait.
  • parse_net(value: str) -> dict - netN string to an ordered dict of key -> value (insertion order kept, so B can re-serialise unchanged keys). VM: the first <model>=<MAC> item is kept as is (key = model).
  • nets(config) -> dict[str, dict] - netN keys (sorted by N) -> parse_net(value).
  • interfaces(guest) -> list[dict] - runtime interfaces as {"name", "mac", "ipv4": [cidr...], "ipv6": [cidr...]}, loopback (lo) excluded. CT via get ct interfaces (addresses from its ip-addresses JSON string - inet6 alone holds only one, e.g. link-local, and misses the global SLAAC address), VM via get vm agent network. Only called when the guest's live status is running.

app.py (UI contract)

  • Key i on a guest opens InfoScreen(guest) (a Screen or ModalScreen). Empty table: nothing.
  • InfoScreen loads in a thread worker: config -> cli.nets, cli.interfaces (only if status is running; a failure there is shown inline as "unavailable: ", not as a fatal error), cli.backup_storages, cli.backup_jobs, cli.backups.
  • Shows:
    • DataTable#backups: Date (local time, YYYY-MM-DD HH:MM), Storage, Size (GiB, one decimal), Format, Notes. Newest first. A Static#jobs line naming the job(s) that cover the guest (schedule, storage, keep-last if present) or "No backup job covers this guest".
    • DataTable#nets: Iface (net0...), Bridge, VLAN (tag or -), MAC, IPv4 (CT ip, VM ipconfigN ip, or -), IPv6 (CT ip6, VM ipconfigN ip6, or -), 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 in app.busy (refuse with a warning if already busy), "backup started" notify, worker runs cli.backup_now, then "backup done"/error notify, busy released, backup list reloaded if the InfoScreen is still open.
  • escape closes InfoScreen; g in InfoScreen reloads it.
  • Every CLI failure -> error notify with _message(exc), app keeps running.

Steps

  • Tests (cli + UI contract) - red first.
  • Implementation - suite green, ruff clean, README keys i / b.
  • Read-only live check: open InfoScreen for CT 104 and VM 108, data shown.
  • User tests "Backup now" on CT 104 (debian (manual), 2026-11-02 21:09).
  • Push feat/guest-info, merge to main (user-approved after the test).

Network editing - delivery B

Problem

Change a guest's network settings from the info screen: bridge, VLAN, firewall, IPv4 and IPv6 (incl. SLAAC), without breaking what is not edited.

Decisions (user-approved)

  • VM IP settings go through cloud-init ipconfigN, only when the VM has a cloud-init drive; they apply at the next boot. VMs without cloud-init: IP fields disabled with a note (IP is configured inside the VM).
  • IPv4 modes: DHCP / Static (CIDR + optional gateway) / None. IPv6 modes: SLAAC / DHCP / Static (CIDR + optional gateway) / None.
  • Undo key u: restores the values from before the last network change made in this app session for that guest.
  • Out of scope: adding/removing interfaces, MAC, mtu, rate, trunks, DNS.

API facts (Proxmox VE 9.2, api usage ... config set -v):

  • LXC net[n]: name=<string>[,bridge][,firewall=<1|0>][,gw=<IPv4>][,gw6=<IPv6>] [,host-managed][,hwaddr][,ip=<IPv4/CIDR|dhcp|manual>][,ip6=<IPv6/CIDR|auto|dhcp|manual>] [,link_down][,mtu][,rate][,tag=<int>][,trunks][,type=veth]. ip6=auto = SLAAC. Applied immediately on a running CT.
  • VM net[n]: [model=]<enum>[,bridge][,firewall=<1|0>][,link_down][,macaddr][,mtu] [,queues][,rate][,tag=<int>][,trunks][,<model>=<macaddr>]. Applied immediately.
  • VM ipconfig[n]: [gw=<IPv4>][,gw6=<IPv6>][,ip=<IPv4/CIDR|dhcp>][,ip6=<IPv6/CIDR|dhcp|auto>] (cloud-init; "no explicit gateway with dhcp"). Applies at the next boot.
  • delete=<list> removes settings (used when ipconfigN ends up empty).
  • get node network <node> -o json -> list of dicts with iface, type (bridge, OVSBridge, eth, ...), active.
  • Cloud-init drive: a disk value containing cloudinit (ide2 = local-lvm:vm-108-cloudinit,media=cdrom).

cli.py

  • bridges(guest) -> list[str] - iface of entries with type bridge or OVSBridge on the guest's node, sorted.
  • format_net(d: dict) -> str - inverse of parse_net: k=v joined by ,, order kept. format_net(parse_net(s)) == s for the real samples.
  • has_cloudinit(config) -> bool.
  • apply_link(net, bridge, tag, firewall) -> dict - a copy of net: bridge set; tag int 1-4094 or None (None removes tag); firewall True -> "1", False -> firewall removed. Existing keys keep their position; new keys are appended.
  • ip_mode(cfg, family) -> tuple[str, str, str] - (mode, cidr, gw) read from a parsed LXC net or VM ipconfig dict; family 4 uses ip/gw, 6 uses ip6/gw6. Modes: dhcp, static, none, and for 6 also auto; manual reads as none.
  • apply_ip(cfg, family, mode, cidr="", gw="") -> dict - copy with the family's keys set: dhcp/auto -> ip[6]=dhcp|auto, gateway removed; static -> ip[6]=<cidr>, gw[6] set if given else removed; none -> both removed. Validation (ValueError): static needs a CIDR of the right family with a /prefix (ipaddress.ip_interface); a gateway must be an address of the same family; auto only for family 6.
  • set_net(guest, changes: dict[str, str | None]) -> one call: set <kind> config <node> <vmid> [--<key> <value> ...] [--delete <k1,k2>] --wait; None values go to --delete. Empty changes -> ValueError.

app.py (UI contract)

  • Key n in InfoScreen opens NetScreen for the guest (needs the loaded config; nothing if the guest has no netN).
  • NetScreen(ModalScreen): Select#iface (netN keys), Select#bridge (cli.bridges), Input#tag (empty = none), Checkbox#firewall, Select#ipv4 (dhcp/static/none), Input#ipv4_cidr, Input#ipv4_gw, Select#ipv6 (auto labelled SLAAC / dhcp / static / none), Input#ipv6_cidr, Input#ipv6_gw, buttons #apply / #cancel; escape = cancel. Prefilled from the current values of the selected interface; changing #iface prefills again. VM without cloud-init: IP widgets disabled and Static#ip_note explains why.
  • Apply: build the new netN (and for VMs the new ipconfigN) with apply_link / apply_ip; ValueError -> error notify, screen stays open, no CLI call. No change -> info notify, nothing else. Otherwise ConfirmScreen listing each changed key with its old and new value (raw strings); for a running guest it says link changes apply immediately, and for VM ipconfig that it applies at next boot.
  • On yes: guest busy (refuse if busy), worker runs cli.set_net(guest, changes), done/error notify, busy released, InfoScreen reloads. On success the old values (None for keys that did not exist) are stored as the guest's undo entry.
  • Live finding (CT 104): Proxmox applies a changed ip on a running CT, but static -> dhcp left the CT without IPv4 (no DHCP client restart) until the CT was rebooted. The confirm therefore says, for IP changes on a running container, that a reboot of the container may be needed. Proxmox re-orders keys when it stores netN.
  • Review fixes: undo entries are cleared on a context switch (VMIDs repeat across clusters); has_cloudinit only looks at disk keys; CIDR needs a decimal prefix 1-32 / 1-128 (no netmask form, no /0); fields the user did not touch are not rewritten (e.g. ip6=manual stays).
  • Key u in InfoScreen: no undo entry -> info notify. Otherwise ConfirmScreen showing what will be restored, then cli.set_net(guest, old_values) the same way; the undo entry is cleared after success.

Steps

  • Tests - red first.
  • Implementation - suite green, ruff clean, README keys n / u.
  • Live on CT 104 (approved), each step reverted: firewall off->on->off; VLAN none->99->none (undo key); IPv6 SLAAC->DHCP->SLAAC; IPv4 DHCP->static 172.16.41.200/23 gw 172.16.40.1->DHCP. Bridge change cannot be tested live: the node has a single bridge (vmbr0). Final net0 string identical to the baseline.
  • Push feat/network-edit, merge to main (ask first).

Snapshots in the info screen (issue #8)

Decision (user-approved)

Info screen section "Snapshots"; keys p take, R roll back, D delete. LXC and VM.

CLI facts (cv4pve-cli 2.4.0, read-only probes):

  • get guest snapshots <node> <qemu|lxc> <vmid> -o json -> list of dicts: name, description, snaptime (epoch or null), parent, VM also vmstate. Always contains a pseudo entry name == "current" ("You are here!") - not a snapshot.
  • create ct|vm snapshot <node> <vmid> <snapname> <descr> (+ --vmstate 1 for VM RAM), no --yes; with --wait.
  • do rollback ct|vm <node> <vmid> <snapname> needs -y; API param start (bool) starts the guest after a successful rollback.
  • delete ct|vm snapshot <node> <vmid> <snapname> needs -y. DANGER: delete ct|vm <node> <vmid> -y (without snapshot) DESTROYS THE WHOLE GUEST.

cli.py

  • valid_snapname(name) -> str - ValueError unless it matches [A-Za-z][A-Za-z0-9_-]{0,39} and is not current (case-insensitive).
  • snapshots(guest) -> list[dict] - without current, newest first (snaptime desc, null last).
  • take_snapshot(guest, name, description="", vmstate=False) -> create <kind> snapshot <node> <vmid> <name> <description or "cv4pve-tui"> [--vmstate 1 (VM only)] --wait; name validated first.
  • rollback_snapshot(guest, name, start=False) -> do rollback <kind> <node> <vmid> <name> [--start 1] -y --wait; name validated.
  • delete_snapshot(guest, name) -> delete <kind> snapshot <node> <vmid> <name> -y --wait; name validated first, and the argv is asserted to contain snapshot right after the kind and the name right after the vmid (defence against ever sending the guest-destroying form).

app.py (UI contract)

  • InfoScreen gets Vertical#snapshots_section (border_title "Snapshots") with DataTable#snapshots (Name, Date YYYY-MM-DD HH:MM or -, Description, and for VMs RAM yes/no) and Static#snap_note. Loaded with the rest; a failure of cli.snapshots is shown in #snap_note ("unavailable: "), the rest of the screen still works. Empty list -> #snap_note "No snapshots".
  • p: SnapshotScreen dialog (DialogScreen style, title "Take snapshot: ()"): Input#snapname prefilled tui_YYYYMMDD_HHMM (local time), Input#snapdesc, Checkbox#vmstate (VMs only; not shown for CTs), buttons #yes/#no, escape = no. Invalid name -> error notify, dialog stays, no call. Yes -> busy guard, worker cli.take_snapshot, done/error notify, info reloads.
  • R: roll back the snapshot under the cursor of #snapshots. ConfirmScreen titled "Roll back" naming snapshot and date and saying everything changed since then is lost; if the guest is running (live status) it says it will be started again and start=True is passed. Yes -> busy guard, worker, notify, info reloads.
  • D: delete the snapshot under the cursor. ConfirmScreen titled "Delete snapshot" naming it. Yes -> busy guard, worker cli.delete_snapshot, notify, info reloads.
  • R/D with no snapshot -> info notify, no dialog.

Steps

  • Tests - red first. - [x] Implementation - suite green, ruff clean, README keys.
  • Review fixes: rollback reads the live status right before rolling back (if it cannot be read, no rollback); the info screen scrolls; a description starting with - is refused (positional argument).
  • Live on CT 104: take tui_live_test, listed; roll back (CT running -> started again); delete; list back to the baseline (no snapshots); CT running.
  • PR with Closes #8, merge after user OK.

Live usage: CPU %, memory used, disk used (issue #12)

Decision (user-approved)

Usage in the main table and in the info screen's Resources section. Values refresh with the listing (g, after actions); no auto-refresh and no rrddata trend (out of scope).

CLI facts (read-only probes):

  • get guests -o json per guest: cpu (fraction of the guest's CPUs, 0..1; the web UI shows cpu*100 %), mem, maxmem (bytes), disk, maxdisk (bytes). Stopped guests have 0s. VMs report disk: 0 (no guest-agent disk info) - shown as -, not 0.
  • api get /nodes/<node>/<qemu|lxc>/<vmid>/status/current -o json -> key/value list: status, cpu, cpus, mem, maxmem, disk, maxdisk, uptime; CTs also swap, maxswap; VMs also freemem, balloon.

cli.py

  • current(guest) -> dict - the status/current key/value list as a dict.
  • status(guest) keeps its behaviour (returns current(guest)["status"]), argv unchanged.

app.py

  • Main table: two new columns at the end, "CPU %" and "Mem %": cpu*100 and mem/maxmem*100, one decimal; - for a guest that is not running or lacks the field.
  • Info screen Resources table gets a header and a third column: Resource | Configured | In use. The load uses cli.current(guest) (one call) for both the live status and the usage. In use, only when running:
    • CPU: <cpu*100:.1f> %
    • Memory: <mem MiB> MiB (<pct:.0f> %)
    • Swap (CT): <swap MiB> MiB
    • Disk rootfs (CT): <disk GiB:.1f> GiB (<pct:.0f> %); other disks and all VM disks -
    • otherwise -. A failure of cli.current keeps today's behaviour (inline "unavailable", status unknown -> not running).

Task log (issue #10)

Decision (user-approved)

l on the main screen: all recent cluster tasks. l on the info screen: the tasks of that guest. Enter on a task shows its log.

CLI facts (read-only probes):

  • task list -o json lacks start/end times. api get /cluster/tasks -o json has them: list of dicts upid, type (e.g. vzdump, qmstart, vzsnapshot), id (VMID as a string, "" for node tasks), node, user, starttime, endtime (epoch; missing or null while running), status (OK, an error text, or missing while running), saved, tokenid.
  • task log <upid> prints the log as plain text lines (e.g. TASK OK at the end).

cli.py

  • tasks() -> list[dict] - /cluster/tasks, newest first by starttime.
  • task_log(upid) -> str - task log <upid>; ValueError unless upid starts with UPID: (positional argument).

app.py (UI contract)

  • TasksScreen(ModalScreen), opened by l on the main screen (all tasks) and on the info screen (vmid filter: only tasks whose id == str(vmid)). Frame #tasks_section titled "Tasks" or "Tasks: ()". DataTable#tasks (row cursor): Start (YYYY-MM-DD HH:MM local), Duration (1h 2m, 3m 12s, 45s; running without endtime), Node, Type, VMID (- for node tasks), User, Status (OK, the error text, or running). A failed task (status present and not OK) gets a ! in a leading column. Static#tasks_note says "No tasks" when empty. Loaded in a thread worker; failure -> error notify, screen stays. Keys: enter open the log, g reload, escape close.
  • TaskLogScreen (DialogScreen style, wide box), title "Log: ", a read-only plain-text log widget #tasklog (no markup parsing), loaded in a worker; failure -> error notify. escape closes.
  • Main-screen footer shows l Tasks.

Node package updates (issue #13)

Decision (user-approved)

U on the main screen shows pending package updates per node; the frame's bottom border shows the count. Read-only: running apt update/upgrade stays out of scope.

CLI facts (read-only probes):

  • get nodes -o json -> list of dicts with node, status (online/offline), ...
  • get node packages <node> -o json -> list of dicts with Package, OldVersion, Version, Title, Priority, Origin, ... (23 on mainframe). The list is as fresh as the node's last apt update (Proxmox runs it daily); the call does not refresh it.

cli.py

  • nodes() -> list[str] - names of online nodes, sorted.
  • package_updates(node) -> list[dict] - get node packages <node> -o json, sorted by Package.

app.py (UI contract)

  • UpdatesScreen(ModalScreen) opened by U: frame #updates_section titled "Package updates", DataTable#updates: Node, Package, Current (OldVersion), New (Version), Origin, Priority; Static#updates_note: "No pending updates" when empty, plus one line per node that failed (": unavailable: ") and a fixed hint that the list is as of the node's last apt update. Loaded in a thread worker; g reloads; escape closes. A failing node does not hide the others.
  • The main frame's bottom border adds "· N package updates" when N > 0, after the backup count. The count is fetched in a worker on mount, after a context switch, and every time the UpdatesScreen loads - not on every guest reload (one CLI call per node). It follows the same generation guard as the guest list (a count from the old cluster is dropped after a switch) and is cleared when a switch starts. Failures leave the count unknown (nothing shown), no error notify on mount.
  • Main footer shows U Updates.

Search and filter in the guest table (issue #16)

Decision

  • / on the main screen shows Input#filter (hidden until then) below the guest table and focuses it. The table filters as you type.
  • Filter text = space-separated terms, all must match (AND), case-insensitive:
    • a plain term matches a substring of the name, the VMID or one of the tags (tags from get guests, ;-separated);
    • is:running / is:stopped match the status;
    • type:lxc / type:ct and type:vm / type:qemu match the guest type.
  • Enter in the filter keeps the filter and moves focus to the table. Escape in the filter clears it, hides the input and moves focus to the table.
  • The frame's bottom border gets "· filter: · N of M" while a filter is active, after the context name and before the backup/package counts.
  • Actions (s/S/x/r/t/Enter) act on the selected visible row only; the table only ever contains the matching guests. A reload (g, after actions, context switch) keeps the filter applied. A context switch keeps the filter text.
  • Placeholder of the input shows the syntax: name, vmid, tag · is:running · type:lxc.
  • No match: empty table, and the frame says "0 of M"; action keys do nothing.

Code

  • Pure function guest_matches(guest: dict, query: str) -> bool in app.py (or cli.py-free module-level helper) so it can be tested without the UI.