Edit autostart, startup order and tags from the info screen
CI / pytest and ruff (pull_request) Successful in 5m31s
CI / pytest and ruff (push) Has been cancelled

The info screen's Resources table shows Autostart (yes/no), Startup
(order, up and down delays) and Tags. o opens a dialog to change them.
Only keys that differ are sent, in one `set <kind> config` call
through the existing set_net. A confirm lists the old and new value
of each key.

Tags are removed with `--delete tags` on set config. The CLI also has
a `delete ct|vm ... tags` command, but it shares the form that
destroys the whole guest, so the app never uses it; nothing in the
code builds a delete ct|vm argv. Inputs are validated before sending:
startup values must be ints >= 0, and tags must match
[A-Za-z0-9_][A-Za-z0-9_+.-]*. They are split on ; , or whitespace and
deduplicated.

Live test on CT 104: onboot 1 -> 0, startup order=5,up=10, tag
tui-test added, then all set back through the dialog. The config
ended identical to the baseline.

Closes #18

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit was merged in pull request #30.
This commit is contained in:
DuckbootandClaude Opus 5.5 committed 2026-11-03 00:55:02 +01:00
1 parent d97ad22c78
commit f5f3effce5
6 files changed
+634

No files matched your search

+118
View File
@@ -1,5 +1,6 @@
"""Textual app for managing Proxmox guests through cv4pve-cli."""
import re
import subprocess
import sys
from datetime import datetime
@@ -419,6 +420,95 @@ class NetScreen(DialogScreen):
)
class BootScreen(DialogScreen):
"""Edit autostart, startup order and tags; confirms, then sets them in one call."""
BINDINGS = [("escape", "dismiss", "Cancel")] # noqa: RUF012 - Textual idiom
def __init__(self, guest: dict, config: dict) -> None:
super().__init__()
self.guest, self.config = guest, config
def compose(self) -> ComposeResult:
with self.box(f"Boot and tags: {self.guest['name']} ({self.guest['vmid']})"):
with Horizontal(classes="row"):
yield Label("Autostart")
yield Checkbox("", id="onboot")
for wid, label, hint in (
("order", "Order", "start order (empty = not set)"),
("up", "Up delay", "seconds to wait after start (empty = not set)"),
("down", "Down delay", "seconds to wait on shutdown (empty = not set)"),
("tags", "Tags", "a; b; c (empty = none)"),
):
with Horizontal(classes="row"):
yield Label(label)
yield Input(placeholder=hint, id=wid)
with Horizontal(classes="buttons"):
yield Button("Apply", id="apply", variant="primary")
yield Button("Cancel", id="cancel")
def on_mount(self) -> None:
self.query_one("#onboot", Checkbox).value = _autostart(self.config)
order, up, down = cli.parse_startup(self.config.get("startup"))
for wid, value in (("order", order), ("up", up), ("down", down)):
self.query_one(f"#{wid}", Input).value = "" if value is None else str(value)
self.query_one("#tags", Input).value = "; ".join(_tags(self.config))
def _number(self, wid: str) -> int | None:
text = self.query_one(f"#{wid}", Input).value.strip()
if text and not text.isdecimal():
raise ValueError(f"{wid}: a whole number >= 0, or empty")
return int(text) if text else None
def _changes(self) -> dict[str, str | None]:
"""Keys whose value differs from the config; ValueError on bad input."""
startup = cli.startup_value(*(self._number(w) for w in ("order", "up", "down")))
tags = cli.tags_value(self.query_one("#tags", Input).value)
onboot = self.query_one("#onboot", Checkbox).value
changes: dict[str, str | None] = {}
if onboot != _autostart(self.config):
changes["onboot"] = "1" if onboot else "0"
if startup != cli.startup_value(*cli.parse_startup(self.config.get("startup"))):
changes["startup"] = startup
if tags != cli.tags_value(";".join(_tags(self.config))):
changes["tags"] = tags
return changes
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "cancel":
self.dismiss()
return
try:
changes = self._changes()
except ValueError as exc:
self.app.notify(str(exc), severity="error")
return
if not changes:
self.app.notify("No change")
return
lines = [
f"{k}\n from {self.config.get(k, '(not set)')}\n to {v or '(removed)'}"
for k, v in changes.items()
]
who = f"{self.guest['name']} ({self.guest['vmid']})"
self.app.push_screen(
ConfirmScreen(
"\n".join([f"Change boot options of {who}?", *lines]), "Boot and tags"
),
self._confirmed(changes),
)
def _confirmed(self, changes: dict[str, str | None]):
def done(ok: bool) -> None:
if ok:
self.dismiss()
self.app.start_job(
self.guest, "Boot options", lambda: cli.set_net(self.guest, changes)
)
return done
class TaskLogScreen(DialogScreen):
"""The log of one task, as plain text (task output is never parsed as markup)."""
@@ -621,6 +711,7 @@ class InfoScreen(ModalScreen):
("b", "backup", "Backup now"),
("a", "toggle_all", "All backups"),
("n", "net", "Edit network"),
("o", "boot", "Autostart, startup, tags"),
("u", "undo", "Undo network"),
("e", "edit", "Edit resources"),
("p", "snapshot", "Take snapshot"),
@@ -851,6 +942,10 @@ class InfoScreen(ModalScreen):
)
return busy
def action_boot(self) -> None:
if self.config and not self._refuse_if_busy():
self.app.push_screen(BootScreen(self.guest, self.config))
def action_net(self) -> None:
if cli.nets(self.config) and not self._refuse_if_busy():
self._open_net()
@@ -1587,9 +1682,32 @@ def _resources(config: dict, kind: str) -> list[tuple[str, str]]:
if kind == "ct" and config.get("swap") is not None:
rows.append(("Swap", f"{config['swap']} MiB"))
rows += [(f"Disk {name}", size) for name, size in cli.disks(config, kind).items()]
order, up, down = cli.parse_startup(config.get("startup"))
startup = [
f"{label} {v}{unit}"
for label, v, unit in (
("order", order, ""),
("up", up, " s"),
("down", down, " s"),
)
if v is not None
]
rows += [
("Autostart", "yes" if _autostart(config) else "no"),
("Startup", ", ".join(startup) or "-"),
("Tags", ", ".join(_tags(config)) or "-"),
]
return rows
def _autostart(config: dict) -> bool:
return str(config.get("onboot", 0)) == "1" # int or str, depending on the source
def _tags(config: dict) -> list[str]:
return [t for t in re.split(r"[;,\s]+", str(config.get("tags") or "")) if t]
def _pct(part, whole) -> float | None:
return part / whole * 100 if part is not None and whole else None
+39
View File
@@ -390,6 +390,45 @@ def set_net(guest: dict, changes: dict[str, str | None]) -> None:
)
def startup_value(order: int | None, up: int | None, down: int | None) -> str | None:
"""`order=N[,up=S][,down=S]` from the given parts; None when none is given."""
parts = []
for name, value in (("order", order), ("up", up), ("down", down)):
if value is None:
continue
if type(value) is not int or value < 0:
raise ValueError(f"startup {name} must be a whole number >= 0")
parts.append(f"{name}={value}")
return ",".join(parts) or None
def parse_startup(text: str | None) -> tuple[int | None, int | None, int | None]:
"""(order, up, down) of a startup string; a bare leading number is the order."""
got: dict[str, int] = {}
for i, part in enumerate((text or "").split(",")):
key, sep, value = part.partition("=")
if not sep and i == 0 and key.strip().isdecimal():
key, value = "order", key
if key.strip() in ("order", "up", "down") and value.strip().isdecimal():
got[key.strip()] = int(value)
return got.get("order"), got.get("up"), got.get("down")
_TAG = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_+.-]*")
def tags_value(text: str) -> str | None:
"""Tags split on `;`, `,` or whitespace, deduplicated, `;`-joined; None when empty."""
tags = list(dict.fromkeys(re.split(r"[;,\s]+", text.strip())))
tags = [t for t in tags if t]
for tag in tags:
if not _TAG.fullmatch(tag):
raise ValueError(
f"invalid tag {tag!r}: letters, digits, _ + . - (not starting with + . -)"
)
return ";".join(tags) or None
def not_backed_up() -> set[int]:
"""VMIDs no backup job covers (Proxmox's own answer, incl. pool-based jobs)."""
items = json.loads(run("get", "cluster", "not-backed-up", "-o", "json"))