From 8f1bf037cdda898805b6fd75c8205f2fd79212b3 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Fri, 31 Jul 2026 01:47:04 +0200 Subject: [PATCH] TUI: Dienst per Taste 'r' neu laden Nach dem Speichern musste man bisher in die Konsole wechseln, um die Konfiguration mit "systemctl reload pvesnap" scharf zu schalten. Die Gruppenliste kann das jetzt selbst: * 'r' laedt den Dienst neu; ungespeicherte Aenderungen werden vorher zum Speichern angeboten, ein gestoppter Dienst zum Starten, und wenn das Neuladen scheitert, wird ein Neustart angeboten. * Nach 's' fragt der Editor direkt, ob neu geladen werden soll. * Die Kopfzeile zeigt laufend den Zustand des Dienstes. * Die Tastenleiste bricht auf schmalen Terminals auf zwei Zeilen um, statt hinten abgeschnitten zu werden. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 8 ++- pvesnap/tui.py | 176 ++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 175 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 780d653..da5d2cb 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,13 @@ pvesnap config * **Gruppenliste** — `Enter` bearbeiten, `n` neu, `c` kopieren, `d` löschen, `Leertaste` an/aus, `g` globale Einstellungen, `v` Übersicht welche VM in - welcher Gruppe landet, `s` speichern, `q` Ende + welcher Gruppe landet, `s` speichern, `r` Dienst neu laden, `q` Ende +* **`r` — Dienst neu laden**, ohne den Editor zu verlassen: entspricht + `systemctl reload pvesnap`. Ungespeicherte Änderungen werden vorher zum + Speichern angeboten, ein gestoppter Dienst zum Starten, und wenn das Neuladen + scheitert, wird ein Neustart angeboten. Nach `s` fragt der Editor ohnehin + gleich, ob neu geladen werden soll. Oben in der Kopfzeile steht jederzeit, ob + der Dienst läuft. (Braucht root — also `sudo pvesnap config`.) * **Gruppe bearbeiten** — Feld auswählen, `Enter` ändern, `Leertaste` umschalten. Ganz unten steht live, auf wie viele Gäste die Auswahl gerade zutrifft. * **VM-Auswahl** (`v`) — Liste aller VMs und Container mit Node, Status und Tags; diff --git a/pvesnap/tui.py b/pvesnap/tui.py index acc71f5..3353e7f 100644 --- a/pvesnap/tui.py +++ b/pvesnap/tui.py @@ -7,6 +7,7 @@ from __future__ import annotations import curses import os +import subprocess from .config import (WEEKDAY_NAMES, Config, ConfigError, Group, clone_group, load_config, parse_time_of_day, save_config) @@ -33,6 +34,92 @@ TYPE_CHOICES = [ C_HEADER, C_FOOTER, C_SEL, C_WARN, C_OK, C_DIM = 1, 2, 3, 4, 5, 6 +SERVICE = "pvesnap.service" + +# Tastenleiste der Gruppenliste: (Taste, lange Beschriftung, kurze Beschriftung) +GROUP_KEYS = [ + ("Enter", "Bearbeiten", "Bearb."), + ("n", "Neu", "Neu"), + ("c", "Kopieren", "Kopie"), + ("d", "Loeschen", "Loesch"), + ("Leer", "An/Aus", "An/Aus"), + ("g", "Global", "Global"), + ("v", "VMs", "VMs"), + ("s", "Speichern", "Speich."), + ("r", "Dienst neu laden", "Reload"), + ("q", "Ende", "Ende"), +] + + +# --------------------------------------------------------------------------- +# systemd +# --------------------------------------------------------------------------- + +def _systemctl(*args, **kwargs): + """Ruft systemctl auf und gibt (Rueckgabewert, Ausgabe) zurueck.""" + try: + proc = subprocess.run(["systemctl"] + list(args), stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, timeout=kwargs.get("timeout", 30)) + return proc.returncode, proc.stdout.decode("utf-8", "replace").strip() + except FileNotFoundError: + return 127, "systemctl nicht gefunden" + except subprocess.TimeoutExpired: + return 124, "Zeitueberschreitung beim Aufruf von systemctl" + + +def service_state(): + """('geladen?', 'Zustand') des pvesnap-Dienstes, z.B. (True, 'active').""" + code, output = _systemctl("show", "-p", "LoadState", "-p", "ActiveState", SERVICE) + if code not in (0, 3): + return None, output.splitlines()[0] if output else "unbekannt" + values = {} + for line in output.splitlines(): + if "=" in line: + key, _, value = line.partition("=") + values[key.strip()] = value.strip() + loaded = values.get("LoadState") == "loaded" + return loaded, values.get("ActiveState", "unbekannt") + + +def service_text(): + loaded, state = service_state() + if loaded is None: + return "Dienst: %s" % state + if not loaded: + return "Dienst: nicht installiert" + return {"active": "Dienst: laeuft", + "inactive": "Dienst: gestoppt", + "failed": "Dienst: FEHLER", + "activating": "Dienst: startet"}.get(state, "Dienst: %s" % state) + + +def _keybar(width, items): + """Tastenleiste als Liste von Zeilen, passend zur Breite des Terminals. + + Reicht der Platz nicht fuer eine Zeile, wird auf zwei Zeilen umgebrochen; + bei ganz schmalen Terminals bleiben nur noch die Tasten uebrig. + """ + def join(entries, index): + return " | ".join("%s %s" % (entry[0], entry[index]) for entry in entries) + + long_text = join(items, 1) + if len(long_text) + 2 <= width: + return [long_text] + + half = (len(items) + 1) // 2 + first, second = join(items[:half], 1), join(items[half:], 1) + if max(len(first), len(second)) + 2 <= width: + return [first, second] + + short_text = join(items, 2) + if len(short_text) + 2 <= width: + return [short_text] + + first, second = join(items[:half], 2), join(items[half:], 2) + if max(len(first), len(second)) + 2 <= width: + return [first, second] + return [" ".join(entry[0] for entry in items)] + # --------------------------------------------------------------------------- # Zeichen-Helfer @@ -87,6 +174,7 @@ class Editor: self.dirty = False self._guests = None self._inventory_error = "" + self._service_cache = None # -- Daten ------------------------------------------------------------ @@ -121,7 +209,9 @@ class Editor: _fill(win, 0, " pvesnap - %s" % title, curses.color_pair(C_HEADER) | curses.A_BOLD) marker = "*" if self.dirty else " " _fill(win, 1, " %s%s" % (marker, subtitle), curses.color_pair(C_DIM)) - _fill(win, height - 1, " " + keys, curses.color_pair(C_FOOTER)) + lines = list(keys) if isinstance(keys, (list, tuple)) else [keys] + for offset, line in enumerate(reversed(lines)): + _fill(win, height - 1 - offset, " " + line, curses.color_pair(C_FOOTER)) return height, width def _message(self, win, text, error=False): @@ -237,11 +327,16 @@ class Editor: while True: groups = self.config.groups index = max(0, min(index, max(0, len(groups) - 1))) + _, width_now = win.getmaxyx() + status = self._service_text() + # Der Dienststatus soll immer sichtbar bleiben - notfalls wird der + # Pfad vorne gekuerzt. + room = max(12, width_now - len(status) - 15) + path_text = (self.path if len(self.path) <= room + else "…" + self.path[-(room - 1):]) height, width = self._frame( - win, "Gruppen", - "Datei: %s" % self.path, - "Enter Bearbeiten | n Neu | c Kopieren | d Loeschen | Leer An/Aus | " - "g Global | v VMs | s Speichern | q Ende") + win, "Gruppen", "Datei: %s [%s]" % (path_text, status), + _keybar(width_now, GROUP_KEYS)) _put(win, 3, 2, "Gruppe".ljust(18) + "Aktiv " + "Zeitplan".ljust(30) + "Behalte".ljust(20) + "Auswahl", curses.A_BOLD) @@ -308,6 +403,8 @@ class Editor: self._show_problems(win) elif key == "s": self._save(win) + elif key in ("r", "R"): + self._reload_service(win) elif key in ("q", "Q") or _is_escape(key): if self.dirty and not self._confirm(win, "Ungespeicherte Aenderungen verwerfen?"): continue @@ -350,7 +447,7 @@ class Editor: win.refresh() _read_key(win) - def _save(self, win): + def _save(self, win, offer_reload=True): problems = self.config.validate() if problems: if not self._confirm(win, "%d Hinweis(e) - trotzdem speichern?" % len(problems)): @@ -358,10 +455,73 @@ class Editor: try: target = save_config(self.config, self.path) self.dirty = False - self._message(win, "Gespeichert: %s (systemctl reload pvesnap nicht vergessen)" - % target) except OSError as exc: self._message(win, "Speichern fehlgeschlagen: %s" % exc, error=True) + return + + # Direkt anbieten, die Aenderung auch scharf zu schalten. + loaded, state = service_state() + if offer_reload and loaded and state == "active": + if self._confirm(win, "Gespeichert. Dienst jetzt neu laden?"): + self._reload_service(win) + return + self._message(win, "Gespeichert: %s (wirksam nach 'r' oder " + "'systemctl reload pvesnap')" % target) + else: + self._message(win, "Gespeichert: %s" % target) + + # -- Dienst steuern --------------------------------------------------- + + def _service_text(self, refresh=False): + if self._service_cache is None or refresh: + self._service_cache = service_text() + return self._service_cache + + def _reload_service(self, win): + """Taste 'r': Konfiguration speichern und den Dienst neu laden.""" + if self.dirty: + if self._confirm(win, "Es gibt ungespeicherte Aenderungen - erst speichern?"): + self._save(win, offer_reload=False) + if self.dirty: + return # Speichern abgebrochen oder fehlgeschlagen + + loaded, state = service_state() + if loaded is None: + self._message(win, "systemd nicht erreichbar: %s" % state, error=True) + return + if not loaded: + self._message(win, "Der Dienst %s ist nicht installiert - bitte install.sh " + "ausfuehren." % SERVICE, error=True) + return + if os.geteuid() != 0: + self._message(win, "Dazu werden root-Rechte benoetigt - bitte 'sudo pvesnap " + "config' verwenden.", error=True) + return + + if state == "active": + code, output = self._run_service_action(win, "reload", "neu geladen") + if code != 0: + first = (output.splitlines() or ["Fehler %d" % code])[0] + if self._confirm(win, "Fehlgeschlagen: %s - stattdessen neu starten?" + % truncate(first, 50)): + self._run_service_action(win, "restart", "neu gestartet") + else: + if self._confirm(win, "Der Dienst laeuft nicht - jetzt starten?"): + self._run_service_action(win, "start", "gestartet") + + def _run_service_action(self, win, action, past_tense): + height, _ = win.getmaxyx() + _fill(win, height - 2, " systemctl %s %s ..." % (action, SERVICE), + curses.color_pair(C_SEL)) + win.refresh() + code, output = _systemctl(action, SERVICE) + self._service_cache = None + if code == 0: + self._message(win, "Dienst %s - %s" % (past_tense, self._service_text(refresh=True))) + else: + first = (output.splitlines() or ["Rueckgabewert %d" % code])[0] + self._message(win, "systemctl %s fehlgeschlagen: %s" % (action, first), error=True) + return code, output # -- Bildschirm: eine Gruppe -----------------------------------------