Files
cv4pve-cli-tui/docs/plans/guest-tui-mvp.md
T
DuckbootandClaude Opus 5.5 5038148686 Add network editing with VLAN, firewall, IPv4/IPv6 and undo
Press n in the info screen to edit one interface: bridge, VLAN tag,
firewall, IPv4 (DHCP, static or none) and IPv6 (SLAAC, DHCP, static or
none). The netN string is parsed, the chosen fields are changed and
the string is written back, so hwaddr, model=MAC, mtu, rate, trunks
and type are kept. For VMs, IP settings go to cloud-init ipconfigN and
only when the VM has a cloud-init drive.

A confirm shows the old and new raw values before anything is sent.
Press u to restore the values from before the last change to that
guest in this session.

Review fixes:
- Undo entries are cleared on a context switch, because VMIDs repeat
  across clusters.
- has_cloudinit only looks at disks.
- CIDR needs a decimal prefix: no netmask form and no /0.
- Fields you did not touch are not rewritten.

Live test on CT 104: firewall, VLAN 99, IPv6 DHCP and static IPv4 were
each applied and undone, and net0 ended identical to the baseline.
Going from static back to DHCP left the CT without IPv4 until a
reboot, so the confirm now warns that IP changes on a running
container may need one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 21:45:06 +01:00

449 lines
24 KiB
Markdown

# 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
- [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 <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
- [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 <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
- [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 <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: <summary>", 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
- [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=<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
- [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).