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 -----------------------------------------