Files
cv4pve-cli-tui/docs/plans/guest-tui-mvp.md
T
DuckbootandClaude Opus 5.5 364f8e2137
CI / pytest and ruff (pull_request) Successful in 4m47s
CI / pytest and ruff (push) Successful in 4m22s
Show pending node package updates (read-only)
U opens "Package updates": the pending apt updates of every online
node, with node, package, current and new version, origin and
priority. A node that fails is shown as "<node>: unavailable", and the
other nodes are still listed. A hint says the list is as of the
node's last apt update; this call does not refresh it.

The main frame's bottom border shows "· N package updates" after the
backup count, with a singular for 1. The count is fetched on mount,
after a context switch and whenever the updates screen loads, not on
every guest reload, since it costs one CLI call per node. It uses the
same generation guard as the guest list, so a count from the old
cluster is dropped after a switch.

Running apt update or upgrade stays out of scope; it is a write.

Read-only live check: "mainframe · 23 package updates", and 23 rows
in the screen.

Closes #13

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

33 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.