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) <noreply@anthropic.com>
This commit is contained in:
duffyduck
2026-07-31 01:47:04 +02:00
co-authored by Claude Opus 5
parent 4e7ff59911
commit 8f1bf037cd
2 changed files with 175 additions and 9 deletions
+168 -8
View File
@@ -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 -----------------------------------------