Proxmox ties backups to the VMID only, so a deleted guest's backups
were listed under the new guest with that VMID. CT 104 showed planka
backups next to debian's, and VM 108 showed vaultwarden backups.
Backups whose notes name another guest are now hidden by default. The
notes come from the job's {{guestname}}, so "debian (manual)" still
counts as debian. A line says how many are hidden, and the a key shows
them, marked "(earlier guest)". Backups without notes are always
shown.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
18 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 withvmid,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"};pendingnon-null means not yet applied.- Async actions print a UPID and exit 0 even when the task fails. With
--waitthe CLI waits for the task and exits non-zero (5) with the error text on failure. Every mutating call therefore uses--wait. do stoprefuses to run without-y(verified);-yis 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 fromapi 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;.summaryis 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"- fromtype(qemu->vm,lxc->ct).list_guests() -> list[dict]- sorted byvmid.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|ideNwith asize=, excludingmedia=cdromand 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_gbmust 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-nullpending.
Module cv4pve_tui/app.py
DataTableof guests: VMID, name, type, node, status, cores, memory (MiB), disk (GiB). Cursor row = selected guest.- Keys:
sstart,Sshutdown,xstop (hard),rreboot,eedit,grefresh,qquit. shutdown,stop,rebootopen a confirm modal naming the guest;stopsays it is a hard power-off.startneeds no confirmation.eopens an edit modal: cores, memory MiB, disk select + "grow by GiB". Empty field = unchanged. Disk growth gets its own confirm (irreversible).- After
set_resources, ifpending_keyscontainscoresormemory, notify that the change applies at next reboot. - All CLI calls in thread workers; results/errors via
notify(errors showCliError.summary); table refreshes after each action. - A "working" notice is shown when an action starts (
--waitcan 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.pydoesfrom cv4pve_tui import cliand always callscli.<function>(...), so tests monkeypatchcv4pve_tui.cli.<function>.class GuestApp(App);def main()runs it.- Guest table:
DataTablewith idguests, one row per guest, row key = str(vmid), inlist_guests()order. Loaded on mount. ConfirmScreen(ModalScreen[bool]): buttons#yes/#no; its text names the guest (name and vmid); forstopit 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 fromcli.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 ->ConfirmScreenfirst, thencli.resize_disk(guest, disk, grow_gb). Non-integer or <= 0 input ->notifywith severity "error" and no CLI call.- Thread workers (
@work(thread=True)orrun_worker(..., thread=True)); tests wait withawait app.workers.wait_for_complete()thenawait 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 pointcv4pve-tui) -uv run python -c "import cv4pve_tui"succeeds. - Tests for
cli.pywithsubprocess.runmocked - fail before implementation. cli.py- tests pass.- Tests for the app with Textual
run_testandclifunctions 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 listhas 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 fromcli.contexts()and after every switch. Ifcontexts()fails: error notify, sub_titlecontext: ?. - Key
copensContextScreen(ModalScreen[str | None])withOptionList#contexts, one option per context, option id = context name, current one marked with*. Selecting an option dismisses with its name;escapedismisses withNone. cwhile 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);
gandcare 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. OnCliError: error notify with summary, sub_title re-read fromcli.contexts(), guests reloaded.
Out of scope
Adding/editing/deleting contexts, config verify, restoring the original context on exit.
Steps
- Tests for
contexts/use_contextand the UI contract above - red first. - Implementation - full suite green, ruff clean.
- Live: open the list, switch to
mainframe-edgeand back tomainframe(onlyconfig useand read-only listing; no guest actions); final context ismainframe. - Push
feat/guest-tui-mvp, merge tomain(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"andstatus == "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 subprocessin app.py so tests patchcv4pve_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 (
exitafter a failed command gives 1) and are not errors. - Inside
suspend(), right before ssh, the terminal gets a full reset (ESC c, whatresetsends) so the container shell starts on a clean screen without leftover TUI terminal modes (user request). Done locally, not in the container: no dependency onresetexisting there, andpct enterstays unchanged. OSErrorfromsubprocess.runis caught inside thesuspend()block: Textual 8.2.8'ssuspend()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) andOSError(nosshbinary) -> error notify, app keeps running.- Tests:
App.suspendraisesSuspendNotSupportedunderrun_test, so tests replaceapp.suspendwith 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:
ton CT 104,exit, back in the table; screen clean after the reset. - Push
feat/lxc-shell, merge tomain(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-backuphere),localas alternative. - Manual backups get
--notes-template "{{guestname}} (manual)"so they are recognisable. Note for the user:truenas-backuphas storage prunekeep-all=1, but the job haskeep-last 3per 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--yesoption. vzdump params:modesnapshot|suspend|stop,compress0|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(optionalgw,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 inipconfigN(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 whosecontentcontainsbackupand 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: (alltruthy and vmid not inexclude) or vmid invmid; andnodenull or equal to the guest's node.backup_jobs()parsesprune-backupswhen 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 (ctimedesc). Each dict getsstorage(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-netNstring 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]-netNkeys (sorted by N) ->parse_net(value).interfaces(guest) -> list[dict]- runtime interfaces as{"name", "mac", "ipv4": [cidr...], "ipv6": [cidr...]}, loopback (lo) excluded. CT viaget ct interfaces(addresses from itsip-addressesJSON string -inet6alone holds only one, e.g. link-local, and misses the global SLAAC address), VM viaget vm agent network. Only called when the guest's live status is running.
app.py (UI contract)
- Key
ion a guest opensInfoScreen(guest)(aScreenorModalScreen). Empty table: nothing. - InfoScreen loads in a thread worker: config ->
cli.nets,cli.interfaces(only if status isrunning; 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. AStatic#jobsline naming the job(s) that cover the guest (schedule, storage,keep-lastif present) or "No backup job covers this guest".DataTable#nets: Iface (net0...), Bridge, VLAN (tagor-), MAC, IPv4 (CTip, VMipconfigNip, or-), IPv6 (CTip6, VMipconfigNip6, 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#hiddensays how many; keyatoggles showing them, marked(earlier guest). Backups without notes are always shown. - Key
bin InfoScreen:BackupScreenmodal withSelect#storage(options = backup storages, preselectedjob_storageor the first), text saying mode snapshot, zstd, and the guest; buttons#yes/#no; escape = no. On yes: guest marked busy inapp.busy(refuse with a warning if already busy), "backup started" notify, worker runscli.backup_now, then "backup done"/error notify, busy released, backup list reloaded if the InfoScreen is still open. escapecloses InfoScreen;gin 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.
- Push
feat/guest-info, merge tomain(user-approved after the test).