Edit autostart, startup order and tags from the info screen
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:
1 parent
d97ad22c78
commit
f5f3effce5
6 files changed
+634
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
Reference in new issue
Block a user