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:
co-authored by
Claude Opus 5
parent
4e7ff59911
commit
8f1bf037cd
@@ -55,7 +55,13 @@ pvesnap config
|
|||||||
|
|
||||||
* **Gruppenliste** — `Enter` bearbeiten, `n` neu, `c` kopieren, `d` löschen,
|
* **Gruppenliste** — `Enter` bearbeiten, `n` neu, `c` kopieren, `d` löschen,
|
||||||
`Leertaste` an/aus, `g` globale Einstellungen, `v` Übersicht welche VM in
|
`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.
|
* **Gruppe bearbeiten** — Feld auswählen, `Enter` ändern, `Leertaste` umschalten.
|
||||||
Ganz unten steht live, auf wie viele Gäste die Auswahl gerade zutrifft.
|
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;
|
* **VM-Auswahl** (`v`) — Liste aller VMs und Container mit Node, Status und Tags;
|
||||||
|
|||||||
+168
-8
@@ -7,6 +7,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import curses
|
import curses
|
||||||
import os
|
import os
|
||||||
|
import subprocess
|
||||||
|
|
||||||
from .config import (WEEKDAY_NAMES, Config, ConfigError, Group, clone_group,
|
from .config import (WEEKDAY_NAMES, Config, ConfigError, Group, clone_group,
|
||||||
load_config, parse_time_of_day, save_config)
|
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
|
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
|
# Zeichen-Helfer
|
||||||
@@ -87,6 +174,7 @@ class Editor:
|
|||||||
self.dirty = False
|
self.dirty = False
|
||||||
self._guests = None
|
self._guests = None
|
||||||
self._inventory_error = ""
|
self._inventory_error = ""
|
||||||
|
self._service_cache = None
|
||||||
|
|
||||||
# -- Daten ------------------------------------------------------------
|
# -- Daten ------------------------------------------------------------
|
||||||
|
|
||||||
@@ -121,7 +209,9 @@ class Editor:
|
|||||||
_fill(win, 0, " pvesnap - %s" % title, curses.color_pair(C_HEADER) | curses.A_BOLD)
|
_fill(win, 0, " pvesnap - %s" % title, curses.color_pair(C_HEADER) | curses.A_BOLD)
|
||||||
marker = "*" if self.dirty else " "
|
marker = "*" if self.dirty else " "
|
||||||
_fill(win, 1, " %s%s" % (marker, subtitle), curses.color_pair(C_DIM))
|
_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
|
return height, width
|
||||||
|
|
||||||
def _message(self, win, text, error=False):
|
def _message(self, win, text, error=False):
|
||||||
@@ -237,11 +327,16 @@ class Editor:
|
|||||||
while True:
|
while True:
|
||||||
groups = self.config.groups
|
groups = self.config.groups
|
||||||
index = max(0, min(index, max(0, len(groups) - 1)))
|
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(
|
height, width = self._frame(
|
||||||
win, "Gruppen",
|
win, "Gruppen", "Datei: %s [%s]" % (path_text, status),
|
||||||
"Datei: %s" % self.path,
|
_keybar(width_now, GROUP_KEYS))
|
||||||
"Enter Bearbeiten | n Neu | c Kopieren | d Loeschen | Leer An/Aus | "
|
|
||||||
"g Global | v VMs | s Speichern | q Ende")
|
|
||||||
|
|
||||||
_put(win, 3, 2, "Gruppe".ljust(18) + "Aktiv " + "Zeitplan".ljust(30)
|
_put(win, 3, 2, "Gruppe".ljust(18) + "Aktiv " + "Zeitplan".ljust(30)
|
||||||
+ "Behalte".ljust(20) + "Auswahl", curses.A_BOLD)
|
+ "Behalte".ljust(20) + "Auswahl", curses.A_BOLD)
|
||||||
@@ -308,6 +403,8 @@ class Editor:
|
|||||||
self._show_problems(win)
|
self._show_problems(win)
|
||||||
elif key == "s":
|
elif key == "s":
|
||||||
self._save(win)
|
self._save(win)
|
||||||
|
elif key in ("r", "R"):
|
||||||
|
self._reload_service(win)
|
||||||
elif key in ("q", "Q") or _is_escape(key):
|
elif key in ("q", "Q") or _is_escape(key):
|
||||||
if self.dirty and not self._confirm(win, "Ungespeicherte Aenderungen verwerfen?"):
|
if self.dirty and not self._confirm(win, "Ungespeicherte Aenderungen verwerfen?"):
|
||||||
continue
|
continue
|
||||||
@@ -350,7 +447,7 @@ class Editor:
|
|||||||
win.refresh()
|
win.refresh()
|
||||||
_read_key(win)
|
_read_key(win)
|
||||||
|
|
||||||
def _save(self, win):
|
def _save(self, win, offer_reload=True):
|
||||||
problems = self.config.validate()
|
problems = self.config.validate()
|
||||||
if problems:
|
if problems:
|
||||||
if not self._confirm(win, "%d Hinweis(e) - trotzdem speichern?" % len(problems)):
|
if not self._confirm(win, "%d Hinweis(e) - trotzdem speichern?" % len(problems)):
|
||||||
@@ -358,10 +455,73 @@ class Editor:
|
|||||||
try:
|
try:
|
||||||
target = save_config(self.config, self.path)
|
target = save_config(self.config, self.path)
|
||||||
self.dirty = False
|
self.dirty = False
|
||||||
self._message(win, "Gespeichert: %s (systemctl reload pvesnap nicht vergessen)"
|
|
||||||
% target)
|
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
self._message(win, "Speichern fehlgeschlagen: %s" % exc, error=True)
|
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 -----------------------------------------
|
# -- Bildschirm: eine Gruppe -----------------------------------------
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user