/ 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>
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 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 (
debian (manual), 2026-11-02 21:09). - Push
feat/guest-info, merge tomain(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 whenipconfigNends up empty).get node network <node> -o json-> list of dicts withiface,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]-ifaceof entries with typebridgeorOVSBridgeon the guest's node, sorted.format_net(d: dict) -> str- inverse ofparse_net:k=vjoined by,, order kept.format_net(parse_net(s)) == sfor the real samples.has_cloudinit(config) -> bool.apply_link(net, bridge, tag, firewall) -> dict- a copy ofnet:bridgeset;tagint 1-4094 or None (None removestag);firewallTrue ->"1", False ->firewallremoved. 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 usesip/gw, 6 usesip6/gw6. Modes:dhcp,static,none, and for 6 alsoauto;manualreads asnone.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;autoonly for family 6.set_net(guest, changes: dict[str, str | None])-> one call:set <kind> config <node> <vmid> [--<key> <value> ...] [--delete <k1,k2>] --wait;Nonevalues go to--delete. Emptychanges-> ValueError.
app.py (UI contract)
- Key
nin InfoScreen opensNetScreenfor the guest (needs the loaded config; nothing if the guest has nonetN). 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(autolabelled 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#ifaceprefills again. VM without cloud-init: IP widgets disabled andStatic#ip_noteexplains 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. OtherwiseConfirmScreenlisting 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 (Nonefor keys that did not exist) are stored as the guest's undo entry. - Live finding (CT 104): Proxmox applies a changed
ipon 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 storesnetN. - Review fixes: undo entries are cleared on a context switch (VMIDs repeat across
clusters);
has_cloudinitonly 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=manualstays). - Key
uin InfoScreen: no undo entry -> info notify. OtherwiseConfirmScreenshowing what will be restored, thencli.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 tomain(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 alsovmstate. Always contains a pseudo entryname == "current"("You are here!") - not a snapshot.create ct|vm snapshot <node> <vmid> <snapname> <descr>(+--vmstate 1for VM RAM), no--yes; with--wait.do rollback ct|vm <node> <vmid> <snapname>needs-y; API paramstart(bool) starts the guest after a successful rollback.delete ct|vm snapshot <node> <vmid> <snapname>needs-y. DANGER:delete ct|vm <node> <vmid> -y(withoutsnapshot) 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 notcurrent(case-insensitive).snapshots(guest) -> list[dict]- withoutcurrent, newest first (snaptimedesc, 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 containsnapshotright 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") withDataTable#snapshots(Name, DateYYYY-MM-DD HH:MMor-, Description, and for VMs RAMyes/no) andStatic#snap_note. Loaded with the rest; a failure ofcli.snapshotsis shown in#snap_note("unavailable:"), the rest of the screen still works. Empty list -> #snap_note"No snapshots". p:SnapshotScreendialog (DialogScreen style, title "Take snapshot: ()"):Input#snapnameprefilledtui_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, workercli.take_snapshot, done/error notify, info reloads.R: roll back the snapshot under the cursor of#snapshots.ConfirmScreentitled "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 andstart=Trueis passed. Yes -> busy guard, worker, notify, info reloads.D: delete the snapshot under the cursor.ConfirmScreentitled "Delete snapshot" naming it. Yes -> busy guard, workercli.delete_snapshot, notify, info reloads.R/Dwith 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 jsonper guest:cpu(fraction of the guest's CPUs, 0..1; the web UI showscpu*100%),mem,maxmem(bytes),disk,maxdisk(bytes). Stopped guests have 0s. VMs reportdisk: 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 alsoswap,maxswap; VMs alsofreemem,balloon.
cli.py
current(guest) -> dict- the status/current key/value list as a dict.status(guest)keeps its behaviour (returnscurrent(guest)["status"]), argv unchanged.
app.py
- Main table: two new columns at the end, "CPU %" and "Mem %":
cpu*100andmem/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 ofcli.currentkeeps today's behaviour (inline "unavailable", status unknown -> not running).
- CPU:
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 jsonlacks start/end times.api get /cluster/tasks -o jsonhas them: list of dictsupid,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 OKat the end).
cli.py
tasks() -> list[dict]-/cluster/tasks, newest first bystarttime.task_log(upid) -> str-task log <upid>; ValueError unlessupidstarts withUPID:(positional argument).
app.py (UI contract)
TasksScreen(ModalScreen), opened bylon the main screen (all tasks) and on the info screen (vmidfilter: only tasks whoseid == str(vmid)). Frame#tasks_sectiontitled "Tasks" or "Tasks: ()".DataTable#tasks(row cursor): Start (YYYY-MM-DD HH:MMlocal), Duration (1h 2m,3m 12s,45s;runningwithout endtime), Node, Type, VMID (-for node tasks), User, Status (OK, the error text, orrunning). A failed task (status present and notOK) gets a!in a leading column.Static#tasks_notesays "No tasks" when empty. Loaded in a thread worker; failure -> error notify, screen stays. Keys:enteropen the log,greload,escapeclose.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.escapecloses.- 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 withnode,status(online/offline), ...get node packages <node> -o json-> list of dicts withPackage,OldVersion,Version,Title,Priority,Origin, ... (23 on mainframe). The list is as fresh as the node's lastapt 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 byPackage.
app.py (UI contract)
UpdatesScreen(ModalScreen)opened byU: frame#updates_sectiontitled "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; greloads;escapecloses. 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 showsInput#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
(
tagsfromget guests,;-separated); is:running/is:stoppedmatch the status;type:lxc/type:ctandtype:vm/type:qemumatch the guest type.
- a plain term matches a substring of the name, the VMID or one of the tags
(
- 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) -> boolinapp.py(orcli.py-free module-level helper) so it can be tested without the UI.