# 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 -o json` -> list of `{"key": ..., "value": ...}`. - `api get /nodes////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////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 -y --wait`; unknown name -> `ValueError`. - `set_resources(guest, cores=None, memory_mb=None)` -> `set config [--cores N] [--memory M] --wait`; values must be positive ints; nothing to set -> `ValueError`. - `resize_disk(guest, disk, grow_gb)` -> `do resize disk +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.(...)`, so tests monkeypatch `cv4pve_tui.cli.`. - `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 - [x] Project skeleton (`uv`, `pyproject.toml`, entry point `cv4pve-tui`) - `uv run python -c "import cv4pve_tui"` succeeds. - [x] Tests for `cli.py` with `subprocess.run` mocked - fail before implementation. - [x] `cli.py` - tests pass. - [x] Tests for the app with Textual `run_test` and `cli` functions mocked. - [x] `app.py` - tests pass. - [x] Read-only smoke test against the real cluster (listing, edit dialog disk options, pending check). - [x] 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 `, 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 ` -> exit 0 on success; unknown name -> exit 3, `Error: Context '' 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 - [x] Tests for `contexts`/`use_context` and the UI contract above - red first. - [x] Implementation - full suite green, ruff clean. - [x] 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 `. ## Decision Option 1 from the discussion: `ssh -t root@ pct enter `. 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 - [x] Tests for the UI contract above - red first. - [x] Implementation - full suite green, ruff clean. README key table gets `t`. - [x] 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 -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 -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 [--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 `=`); IP settings live in `ipconfigN` (cloud-init), e.g. `ip=dhcp`. - Runtime IPs, CT (running): `get ct interfaces -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 -o json` -> `[{"key": "result", "value": ""}]`; 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 --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 `=` 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 ` ` (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 - [x] Tests (cli + UI contract) - red first. - [x] Implementation - suite green, ruff clean, README keys `i` / `b`. - [x] Read-only live check: open InfoScreen for CT 104 and VM 108, data shown. - [x] 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=[,bridge][,firewall=<1|0>][,gw=][,gw6=] [,host-managed][,hwaddr][,ip=][,ip6=] [,link_down][,mtu][,rate][,tag=][,trunks][,type=veth]`. `ip6=auto` = SLAAC. Applied immediately on a running CT. - VM `net[n]`: `[model=][,bridge][,firewall=<1|0>][,link_down][,macaddr][,mtu] [,queues][,rate][,tag=][,trunks][,=]`. Applied immediately. - VM `ipconfig[n]`: `[gw=][,gw6=][,ip=][,ip6=]` (cloud-init; "no explicit gateway with dhcp"). Applies at the next boot. - `delete=` removes settings (used when `ipconfigN` ends up empty). - `get node network -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]=`, `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 config [-- ...] [--delete ] --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 - [x] Tests - red first. - [x] Implementation - suite green, ruff clean, README keys `n` / `u`. - [x] 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 -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 ` (+ `--vmstate 1` for VM RAM), no `--yes`; with `--wait`. - `do rollback ct|vm ` needs `-y`; API param `start` (bool) starts the guest after a successful rollback. - `delete ct|vm snapshot ` needs `-y`. DANGER: `delete ct|vm -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 snapshot [--vmstate 1 (VM only)] --wait`; name validated first. - `rollback_snapshot(guest, name, start=False)` -> `do rollback [--start 1] -y --wait`; name validated. - `delete_snapshot(guest, name)` -> `delete snapshot -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 - [x] 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). - [x] 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////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: ` %` - Memory: ` MiB ( %)` - Swap (CT): ` MiB` - Disk rootfs (CT): ` GiB ( %)`; 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 ` 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 `; 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 -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 -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`.