Files
proxmox-snapshot-service/pvesnap/tui.py
T
duffyduckandClaude Opus 5 8f1bf037cd 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>
2026-07-31 01:47:04 +02:00

1050 lines
42 KiB
Python

"""ncurses-Editor fuer die pvesnap-Konfiguration.
Aufruf: pvesnap config
"""
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)
from .naming import effective_slug
from .proxmox import Proxmox, ProxmoxError, pvesh_available
from .schedule import describe
from .util import (format_duration, format_vmid_list, parse_duration,
parse_list, parse_vmid_list, truncate)
SCHEDULE_CHOICES = [
("interval", "Intervall (z.B. alle 30 Minuten)"),
("hourly", "stuendlich"),
("daily", "taeglich"),
("weekly", "woechentlich"),
("monthly", "monatlich"),
("yearly", "jaehrlich"),
]
TYPE_CHOICES = [
([], "alle (VMs und Container)"),
(["qemu"], "nur VMs (QEMU/KVM)"),
(["lxc"], "nur Container (LXC)"),
]
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
# ---------------------------------------------------------------------------
def _put(win, y, x, text, attr=0):
"""addstr, das bei zu kleinem Terminal nicht abstuerzt."""
height, width = win.getmaxyx()
if y < 0 or y >= height or x >= width:
return
try:
win.addstr(y, x, str(text)[: max(0, width - x - 1)], attr)
except curses.error:
pass
def _fill(win, y, text, attr):
height, width = win.getmaxyx()
if y < 0 or y >= height:
return
try:
win.addstr(y, 0, str(text).ljust(width - 1)[: width - 1], attr)
except curses.error:
pass
def _read_key(win):
try:
return win.get_wch()
except curses.error:
return None
except KeyboardInterrupt:
return "\x1b"
def _is_enter(key):
return key in ("\n", "\r", curses.KEY_ENTER)
def _is_escape(key):
return key == "\x1b"
def _is_backspace(key):
return key in ("\x7f", "\b", "\x08", curses.KEY_BACKSPACE)
class Editor:
def __init__(self, path):
self.path = path
self.config = None
self.dirty = False
self._guests = None
self._inventory_error = ""
self._service_cache = None
# -- Daten ------------------------------------------------------------
def load(self):
if os.path.exists(self.path):
self.config = load_config(self.path)
else:
self.config = Config(path=self.path)
self.dirty = True
def guests(self, refresh=False):
"""Inventar der VMs; wird nur bei Bedarf einmal geholt."""
if self._guests is not None and not refresh:
return self._guests
self._guests = []
self._inventory_error = ""
if not pvesh_available():
self._inventory_error = ("'pvesh' nicht gefunden - VM-Liste nur auf einem "
"Proxmox-Host verfuegbar.")
return self._guests
try:
self._guests = Proxmox().inventory(refresh=True)
except ProxmoxError as exc:
self._inventory_error = str(exc)
return self._guests
# -- Rahmen -----------------------------------------------------------
def _frame(self, win, title, subtitle, keys):
win.erase()
height, width = win.getmaxyx()
_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))
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):
height, _ = win.getmaxyx()
attr = curses.color_pair(C_WARN if error else C_OK) | curses.A_BOLD
_fill(win, height - 2, " " + str(text) + " [Taste druecken]", attr)
win.refresh()
_read_key(win)
def _confirm(self, win, question):
height, _ = win.getmaxyx()
_fill(win, height - 2, " %s [j/n]" % question,
curses.color_pair(C_WARN) | curses.A_BOLD)
win.refresh()
while True:
key = _read_key(win)
if key in ("j", "J", "y", "Y"):
return True
if key in ("n", "N") or _is_escape(key):
return False
def _prompt(self, win, label, initial=""):
"""Einzeilige Eingabe am unteren Rand. None = abgebrochen."""
height, width = win.getmaxyx()
buffer = list(str(initial))
curses.curs_set(1)
try:
while True:
text = "".join(buffer)
prompt = " %s: " % label
visible = width - len(prompt) - 2
shown = text[-visible:] if visible > 0 and len(text) > visible else text
_fill(win, height - 2, prompt + shown, curses.color_pair(C_SEL))
try:
win.move(height - 2, min(width - 2, len(prompt) + len(shown)))
except curses.error:
pass
win.refresh()
key = _read_key(win)
if key is None:
continue
if _is_enter(key):
return "".join(buffer).strip()
if _is_escape(key):
return None
if _is_backspace(key):
if buffer:
buffer.pop()
continue
if key == "\x15": # Strg-U
buffer = []
continue
if isinstance(key, str) and key.isprintable():
buffer.append(key)
finally:
curses.curs_set(0)
def _choose(self, win, title, entries, current=0):
"""Kleines Auswahlfenster. entries: Liste von (wert, beschriftung)."""
height, width = win.getmaxyx()
box_h = min(len(entries) + 4, height - 2)
box_w = min(max([len(title)] + [len(e[1]) for e in entries]) + 8, width - 2)
top = max(0, (height - box_h) // 2)
left = max(0, (width - box_w) // 2)
box = curses.newwin(box_h, box_w, top, left)
box.keypad(True)
index = max(0, min(current, len(entries) - 1))
while True:
box.erase()
box.box()
_put(box, 0, 2, " %s " % title, curses.A_BOLD)
for row, (_value, label) in enumerate(entries[: box_h - 4]):
attr = curses.color_pair(C_SEL) if row == index else 0
_put(box, row + 2, 2, " " + label.ljust(box_w - 5), attr)
box.refresh()
key = _read_key(box)
if key in (curses.KEY_UP, "k"):
index = (index - 1) % len(entries)
elif key in (curses.KEY_DOWN, "j"):
index = (index + 1) % len(entries)
elif _is_enter(key):
return entries[index][0]
elif _is_escape(key) or key == "q":
return None
# -- Hauptschleife ----------------------------------------------------
def run(self, stdscr):
curses.curs_set(0)
if hasattr(curses, "set_escdelay"):
curses.set_escdelay(25)
try:
curses.start_color()
curses.use_default_colors()
curses.init_pair(C_HEADER, curses.COLOR_WHITE, curses.COLOR_BLUE)
curses.init_pair(C_FOOTER, curses.COLOR_BLACK, curses.COLOR_WHITE)
curses.init_pair(C_SEL, curses.COLOR_BLACK, curses.COLOR_CYAN)
curses.init_pair(C_WARN, curses.COLOR_WHITE, curses.COLOR_RED)
curses.init_pair(C_OK, curses.COLOR_BLACK, curses.COLOR_GREEN)
curses.init_pair(C_DIM, -1, -1)
except curses.error:
pass
stdscr.keypad(True)
self.screen_groups(stdscr)
# -- Bildschirm: Gruppenliste ----------------------------------------
def screen_groups(self, win):
index = 0
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 [%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)
_put(win, 4, 2, "-" * max(0, width - 4), curses.color_pair(C_DIM))
if not groups:
_put(win, 6, 4, "Noch keine Gruppe angelegt - mit 'n' eine erste anlegen.")
_put(win, 8, 4, "Beispiel: 'stuendlich' alle 1h, 24 Snapshots behalten;")
_put(win, 9, 4, " 'taeglich' um 02:30, 14 Tage Vorhaltezeit.")
visible = max(1, height - 8)
start = max(0, min(index - visible // 2, max(0, len(groups) - visible)))
for row, group in enumerate(groups[start:start + visible]):
position = start + row
attr = curses.color_pair(C_SEL) if position == index else 0
keep = "%s / %s" % (group.keep_count or "unbegr.",
format_duration(group.keep_time))
line = ("%s%s%s%s%s"
% (truncate(group.name, 17).ljust(18),
("ja" if group.enabled else "nein").ljust(7),
truncate(describe(group), 29).ljust(30),
truncate(keep, 19).ljust(20),
truncate(self._selection_text(group), max(4, width - 80))))
_fill_row(win, 5 + row, 2, line, width, attr)
problems = self.config.validate()
if problems:
_fill(win, height - 2, " %d Hinweis(e) - 'p' zeigt Details"
% len(problems), curses.color_pair(C_WARN))
win.refresh()
key = _read_key(win)
if key in (curses.KEY_UP, "k"):
index -= 1
elif key in (curses.KEY_DOWN, "j"):
index += 1
elif key == curses.KEY_HOME:
index = 0
elif key == curses.KEY_END:
index = len(groups) - 1
elif _is_enter(key) and groups:
self.screen_group(win, groups[index])
elif key == "n":
self._new_group(win)
index = len(self.config.groups) - 1
elif key == "c" and groups:
name = self._prompt(win, "Name der Kopie", groups[index].name + "-kopie")
if name:
self.config.groups.append(clone_group(groups[index], name))
self.dirty = True
elif key == "d" and groups:
if self._confirm(win, "Gruppe '%s' wirklich loeschen?" % groups[index].name):
del self.config.groups[index]
self.dirty = True
elif key == " " and groups:
groups[index].enabled = not groups[index].enabled
self.dirty = True
elif key == "g":
self.screen_globals(win)
elif key == "v":
self.screen_overview(win)
elif key == "p":
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
return
def _selection_text(self, group):
parts = []
if group.all:
parts.append("alle VMs")
if group.vmids:
parts.append("IDs: " + format_vmid_list(group.vmids))
if group.names:
parts.append("Namen: " + ", ".join(group.names))
if group.tags:
parts.append("Tags: " + ", ".join(group.tags))
if group.pools:
parts.append("Pools: " + ", ".join(group.pools))
return "; ".join(parts) or "(nichts ausgewaehlt)"
def _new_group(self, win):
name = self._prompt(win, "Name der neuen Gruppe", "")
if not name:
return
if self.config.group(name):
self._message(win, "Eine Gruppe mit diesem Namen gibt es bereits.", error=True)
return
group = Group(name=name, interval=3600, keep_count=24, keep_time=2 * 86400)
self.config.groups.append(group)
self.dirty = True
self.screen_group(win, group)
def _show_problems(self, win):
problems = self.config.validate()
height, width = self._frame(win, "Hinweise zur Konfiguration", self.path,
"beliebige Taste = zurueck")
if not problems:
_put(win, 4, 4, "Alles in Ordnung.", curses.color_pair(C_OK))
for row, problem in enumerate(problems[: height - 6]):
_put(win, 4 + row, 4, truncate("- " + problem, width - 6))
win.refresh()
_read_key(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)):
return
try:
target = save_config(self.config, self.path)
self.dirty = False
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 -----------------------------------------
def screen_group(self, win, group):
index = 0
top = 0
while True:
fields = self._group_fields(group)
index = max(0, min(index, len(fields) - 1))
while fields[index][0] == "-" and index < len(fields) - 1:
index += 1
height, width = self._frame(
win, "Gruppe: %s" % group.name,
"Kurzname im Snapshot: %s-%s-JJJJMMTT-HHMMSS"
% (self.config.globals.prefix,
effective_slug(self.config.globals.prefix, group.slug)),
"Enter Aendern | Leer Umschalten | v VM-Auswahl | q zurueck")
visible = height - 5
if index < top:
top = index
if index >= top + visible:
top = index - visible + 1
for row in range(visible):
position = top + row
if position >= len(fields):
break
kind, label, value, _handler = fields[position]
if kind == "-":
_put(win, 3 + row, 2, "-- %s " % label + "-" * max(0, width - len(label) - 8),
curses.color_pair(C_DIM) | curses.A_BOLD)
continue
attr = curses.color_pair(C_SEL) if position == index else 0
_fill_row(win, 3 + row, 2,
truncate(label, 30).ljust(32) + truncate(value, max(4, width - 40)),
width, attr)
win.refresh()
key = _read_key(win)
if key in (curses.KEY_UP, "k"):
index = self._step(fields, index, -1)
elif key in (curses.KEY_DOWN, "j"):
index = self._step(fields, index, +1)
elif key == curses.KEY_NPAGE:
index = self._step(fields, min(index + 10, len(fields) - 1), +1, land=True)
elif key == curses.KEY_PPAGE:
index = self._step(fields, max(index - 10, 0), -1, land=True)
elif _is_enter(key) or key == " ":
handler = fields[index][3]
if handler:
handler(win)
self.dirty = True
elif key == "v":
self._pick_vms(win, group)
elif key in ("q", "Q") or _is_escape(key):
return
@staticmethod
def _step(fields, index, direction, land=False):
position = index if land else index + direction
while 0 <= position < len(fields) and fields[position][0] == "-":
position += direction
if position < 0 or position >= len(fields):
return index
return position
def _group_fields(self, group):
"""Liste von (art, beschriftung, angezeigter_wert, handler)."""
fields = []
def text_field(label, getter, setter, hint=""):
def handler(win):
value = self._prompt(win, hint or label, getter())
if value is not None:
setter(value)
fields.append(("f", label, getter(), handler))
def bool_field(label, getter, setter):
def handler(_win):
setter(not getter())
fields.append(("f", label, "ja" if getter() else "nein", handler))
def int_field(label, getter, setter, zero_text=""):
def handler(win):
raw = self._prompt(win, label, str(getter()))
if raw is None:
return
try:
setter(max(0, int(raw.strip() or 0)))
except ValueError:
self._message(win, "Bitte eine ganze Zahl eingeben.", error=True)
value = getter()
fields.append(("f", label, zero_text if (value == 0 and zero_text) else str(value),
handler))
def duration_field(label, getter, setter):
def handler(win):
raw = self._prompt(win, label + " (z.B. 30m, 6h, 7d, 0=unbegrenzt)",
format_duration(getter(), zero="0"))
if raw is None:
return
try:
setter(parse_duration(raw, default_unit="m"))
except ValueError as exc:
self._message(win, str(exc), error=True)
fields.append(("f", label, format_duration(getter()), handler))
def list_field(label, getter, setter, hint=""):
def handler(win):
raw = self._prompt(win, hint or label, ", ".join(getter()))
if raw is not None:
setter(parse_list(raw))
fields.append(("f", label, ", ".join(getter()) or "-", handler))
# --- Allgemein ---
fields.append(("-", "Allgemein", "", None))
def set_name(value):
if value:
group.name = value
text_field("Name", lambda: group.name, set_name)
bool_field("Aktiv", lambda: group.enabled,
lambda v: setattr(group, "enabled", v))
# --- Zeitplan ---
fields.append(("-", "Zeitplan", "", None))
current_kind = group.schedule or "interval"
def change_kind(win):
values = [c[0] for c in SCHEDULE_CHOICES]
chosen = self._choose(win, "Zeitplan", SCHEDULE_CHOICES,
values.index(current_kind))
if chosen is None:
return
if chosen == "interval":
group.schedule = ""
group.interval = group.interval or 3600
else:
group.schedule = chosen
group.interval = 0
fields.append(("f", "Art des Zeitplans",
dict(SCHEDULE_CHOICES)[current_kind], change_kind))
if current_kind == "interval":
duration_field("Intervall", lambda: group.interval,
lambda v: setattr(group, "interval", max(60, v)))
bool_field("An der Uhr ausrichten", lambda: group.align,
lambda v: setattr(group, "align", v))
elif current_kind == "hourly":
int_field("Minute (0-59)", lambda: group.minute,
lambda v: setattr(group, "minute", min(59, v)))
else:
def set_time(value):
try:
group.at = "%02d:%02d" % parse_time_of_day(value)
except ValueError:
pass
text_field("Uhrzeit (HH:MM)", lambda: group.at, set_time)
if current_kind == "weekly":
def change_day(win):
entries = [(i, name) for i, name in enumerate(
["Montag", "Dienstag", "Mittwoch", "Donnerstag",
"Freitag", "Samstag", "Sonntag"])]
chosen = self._choose(win, "Wochentag", entries, group.day_of_week)
if chosen is not None:
group.day_of_week = chosen
fields.append(("f", "Wochentag",
WEEKDAY_NAMES[group.day_of_week], change_day))
if current_kind in ("monthly", "yearly"):
int_field("Tag im Monat (1-31)", lambda: group.day_of_month,
lambda v: setattr(group, "day_of_month", max(1, min(31, v))))
if current_kind == "yearly":
int_field("Monat (1-12)", lambda: group.month,
lambda v: setattr(group, "month", max(1, min(12, v))))
fields.append(("f", "Naechster Termin (Vorschau)", _preview_next(group), None))
# --- Vorhaltezeit ---
fields.append(("-", "Vorhaltezeit", "", None))
int_field("Anzahl behalten (je VM)", lambda: group.keep_count,
lambda v: setattr(group, "keep_count", v), zero_text="unbegrenzt")
duration_field("Maximales Alter", lambda: group.keep_time,
lambda v: setattr(group, "keep_time", v))
int_field("Mindestens behalten", lambda: group.keep_min,
lambda v: setattr(group, "keep_min", v), zero_text="0 (keine Untergrenze)")
# --- Auswahl ---
fields.append(("-", "Welche VMs?", "", None))
bool_field("Alle VMs/Container", lambda: group.all,
lambda v: setattr(group, "all", v))
fields.append(("f", "VMs aus Liste waehlen ...",
format_vmid_list(group.vmids) or "-",
lambda win: self._pick_vms(win, group)))
list_field("Namensmuster (z.B. web-*)", lambda: group.names,
lambda v: setattr(group, "names", v))
list_field("Tags", lambda: group.tags,
lambda v: setattr(group, "tags", [t.lower() for t in v]))
list_field("Pools", lambda: group.pools,
lambda v: setattr(group, "pools", v))
def change_types(win):
labels = [(index, text) for index, (_v, text) in enumerate(TYPE_CHOICES)]
current = 0
for index, (value, _text) in enumerate(TYPE_CHOICES):
if sorted(value) == sorted(group.types):
current = index
chosen = self._choose(win, "Welche Gasttypen?", labels, current)
if chosen is not None:
group.types = list(TYPE_CHOICES[chosen][0])
current_type_text = "alle (VMs und Container)"
for value, text in TYPE_CHOICES:
if sorted(value) == sorted(group.types):
current_type_text = text
fields.append(("f", "Gasttypen", current_type_text, change_types))
def set_exclude_vmids(win):
raw = self._prompt(win, "Ausgeschlossene VMIDs (z.B. 900,905-910)",
format_vmid_list(group.exclude_vmids))
if raw is None:
return
try:
group.exclude_vmids = parse_vmid_list(raw)
except ValueError as exc:
self._message(win, str(exc), error=True)
fields.append(("f", "Ausgeschlossene VMIDs",
format_vmid_list(group.exclude_vmids) or "-", set_exclude_vmids))
list_field("Ausgeschlossene Namensmuster", lambda: group.exclude_names,
lambda v: setattr(group, "exclude_names", v))
list_field("Ausgeschlossene Tags", lambda: group.exclude_tags,
lambda v: setattr(group, "exclude_tags", [t.lower() for t in v]))
matched = self._match_count(group)
fields.append(("f", "Trifft aktuell zu auf", matched, None))
# --- Optionen ---
fields.append(("-", "Snapshot-Optionen", "", None))
bool_field("RAM mitsichern (nur laufende VMs)", lambda: group.vmstate,
lambda v: setattr(group, "vmstate", v))
bool_field("Gestoppte ueberspringen", lambda: group.skip_stopped,
lambda v: setattr(group, "skip_stopped", v))
text_field("Beschreibung (Vorlage)",
lambda: group.description or self.config.globals.description,
lambda v: setattr(group, "description", v),
hint="Beschreibung, Platzhalter: {group} {vmid} {name} {datetime} "
"{keep_time} {keep_count}")
return fields
def _match_count(self, group):
guests = self.guests()
if not guests:
return self._inventory_error or "unbekannt"
from .engine import select_guests
selected = select_guests(group, guests)
if not selected:
return "0 Gaeste"
preview = ", ".join("%d" % g.vmid for g in selected[:8])
return "%d Gast/Gaeste (%s%s)" % (len(selected), preview,
" ..." if len(selected) > 8 else "")
# -- Bildschirm: VM-Auswahl ------------------------------------------
def _pick_vms(self, win, group):
guests = self.guests()
if not guests:
self._message(win, self._inventory_error or "Keine VMs gefunden.", error=True)
return
chosen = set(group.vmids)
index, top, filter_text = 0, 0, ""
while True:
shown = [g for g in guests if self._matches_filter(g, filter_text)]
index = max(0, min(index, max(0, len(shown) - 1)))
height, width = self._frame(
win, "VMs fuer Gruppe '%s'" % group.name,
"%d ausgewaehlt%s" % (len(chosen),
(" Filter: " + filter_text) if filter_text else ""),
"Leer Auswaehlen | a Alle | n Keine | i Umkehren | / Filter | "
"Enter Uebernehmen | q Abbruch")
_put(win, 3, 2, " VMID Typ Name".ljust(46) + "Node".ljust(14)
+ "Status".ljust(10) + "Tags", curses.A_BOLD)
visible = max(1, height - 6)
if index < top:
top = index
if index >= top + visible:
top = index - visible + 1
for row in range(visible):
position = top + row
if position >= len(shown):
break
guest = shown[position]
attr = curses.color_pair(C_SEL) if position == index else 0
mark = "[x]" if guest.vmid in chosen else "[ ]"
line = ("%s %s %s %s%s%s%s"
% (mark,
str(guest.vmid).rjust(6),
("LXC" if guest.type == "lxc" else "VM ").ljust(4),
truncate(guest.name or "-", 26).ljust(28),
truncate(guest.node, 12).ljust(14),
truncate(guest.status, 9).ljust(10),
truncate(",".join(guest.tags) or "-", 20)))
_fill_row(win, 4 + row, 2, line, width, attr)
win.refresh()
key = _read_key(win)
if key in (curses.KEY_UP, "k"):
index -= 1
elif key in (curses.KEY_DOWN, "j"):
index += 1
elif key == curses.KEY_NPAGE:
index += visible
elif key == curses.KEY_PPAGE:
index -= visible
elif key == curses.KEY_HOME:
index = 0
elif key == curses.KEY_END:
index = len(shown) - 1
elif key == " " and shown:
vmid = shown[index].vmid
if vmid in chosen:
chosen.discard(vmid)
else:
chosen.add(vmid)
index += 1
elif key == "a":
chosen.update(g.vmid for g in shown)
elif key == "n":
chosen.difference_update(g.vmid for g in shown)
elif key == "i":
for guest in shown:
if guest.vmid in chosen:
chosen.discard(guest.vmid)
else:
chosen.add(guest.vmid)
elif key == "/":
entered = self._prompt(win, "Filter (Name, VMID oder Tag)", filter_text)
filter_text = entered or ""
index = top = 0
elif key == "r":
self.guests(refresh=True)
guests = self._guests
elif _is_enter(key):
group.vmids = sorted(chosen)
self.dirty = True
return
elif key in ("q", "Q") or _is_escape(key):
return
index = max(0, min(index, max(0, len(shown) - 1)))
@staticmethod
def _matches_filter(guest, text):
if not text:
return True
needle = text.lower()
return (needle in (guest.name or "").lower()
or needle in str(guest.vmid)
or needle in " ".join(guest.tags)
or needle in (guest.pool or "").lower()
or needle in (guest.node or "").lower())
# -- Bildschirm: globale Einstellungen -------------------------------
def screen_globals(self, win):
globals_ = self.config.globals
index = 0
while True:
fields = [
("Praefix im Snapshot-Namen", globals_.prefix, "text", "prefix"),
("Pruefintervall des Dienstes", format_duration(globals_.check_interval),
"duration", "check_interval"),
("Zustandsdatei", globals_.state_file, "text", "state_file"),
("Protokollstufe", globals_.log_level, "level", "log_level"),
("Zusaetzliche Logdatei", globals_.log_file or "-", "text", "log_file"),
("Zeitlimit je Snapshot-Task", format_duration(globals_.task_timeout),
"duration", "task_timeout"),
("Beim Start sofort ausfuehren", "ja" if globals_.run_on_start else "nein",
"bool", "run_on_start"),
("Testlauf (nichts wirklich tun)", "ja" if globals_.dry_run else "nein",
"bool", "dry_run"),
("Standard-Beschreibung", globals_.description, "text", "description"),
]
index = max(0, min(index, len(fields) - 1))
height, width = self._frame(win, "Globale Einstellungen", self.path,
"Enter Aendern | q zurueck")
for row, (label, value, _kind, _key) in enumerate(fields):
attr = curses.color_pair(C_SEL) if row == index else 0
_fill_row(win, 4 + row, 2,
truncate(label, 32).ljust(34) + truncate(str(value), max(4, width - 42)),
width, attr)
_put(win, 6 + len(fields), 2,
"Platzhalter der Beschreibung: {group} {vmid} {name} {node} {type} "
"{datetime} {date} {time} {keep_time} {keep_count} {schedule}",
curses.color_pair(C_DIM))
win.refresh()
key = _read_key(win)
if key in (curses.KEY_UP, "k"):
index -= 1
elif key in (curses.KEY_DOWN, "j"):
index += 1
elif _is_enter(key) or key == " ":
label, _value, kind, attribute = fields[index]
self._edit_global(win, label, kind, attribute)
elif key in ("q", "Q") or _is_escape(key):
return
def _edit_global(self, win, label, kind, attribute):
globals_ = self.config.globals
if kind == "bool":
setattr(globals_, attribute, not getattr(globals_, attribute))
self.dirty = True
return
if kind == "level":
entries = [(lvl, lvl) for lvl in ("DEBUG", "INFO", "WARNING", "ERROR")]
chosen = self._choose(win, "Protokollstufe", entries)
if chosen:
globals_.log_level = chosen
self.dirty = True
return
if kind == "duration":
raw = self._prompt(win, label, format_duration(getattr(globals_, attribute),
zero="0"))
if raw is None:
return
try:
setattr(globals_, attribute, parse_duration(raw, default_unit="s"))
self.dirty = True
except ValueError as exc:
self._message(win, str(exc), error=True)
return
raw = self._prompt(win, label, getattr(globals_, attribute))
if raw is None:
return
setattr(globals_, attribute, raw)
self.dirty = True
# -- Bildschirm: Uebersicht ------------------------------------------
def screen_overview(self, win):
from .engine import select_guests
guests = self.guests()
top = 0
while True:
height, width = self._frame(win, "Uebersicht: welche VM in welcher Gruppe?",
self._inventory_error or "%d Gaeste" % len(guests),
"Pfeiltasten Blaettern | r Neu laden | q zurueck")
_put(win, 3, 2, "VMID".ljust(8) + "Name".ljust(26) + "Status".ljust(10) + "Gruppen",
curses.A_BOLD)
rows = []
for guest in guests:
names = [g.name for g in self.config.groups if guest in select_guests(g, [guest])]
rows.append((guest, names))
visible = max(1, height - 6)
top = max(0, min(top, max(0, len(rows) - visible)))
for row in range(visible):
position = top + row
if position >= len(rows):
break
guest, names = rows[position]
attr = 0 if names else curses.color_pair(C_DIM) | curses.A_DIM
_put(win, 4 + row, 2,
truncate(str(guest.vmid), 7).ljust(8)
+ truncate(guest.name or "-", 24).ljust(26)
+ truncate(guest.status, 9).ljust(10)
+ truncate(", ".join(names) or "(keine Gruppe)", max(4, width - 48)),
attr)
win.refresh()
key = _read_key(win)
if key == curses.KEY_DOWN:
top += 1
elif key == curses.KEY_UP:
top -= 1
elif key == curses.KEY_NPAGE:
top += visible
elif key == curses.KEY_PPAGE:
top -= visible
elif key == "r":
guests = self.guests(refresh=True)
elif key in ("q", "Q") or _is_escape(key):
return
top = max(0, top)
def _preview_next(group):
"""Zeigt den naechsten Termin, damit man die Einstellung sofort pruefen kann."""
from datetime import datetime
from .schedule import next_due
try:
moment = next_due(group, None, datetime.now())
except ValueError:
return "-"
tage = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]
return "%s %s (%s)" % (tage[moment.weekday()],
moment.strftime("%d.%m.%Y %H:%M"), describe(group))
def _fill_row(win, y, x, text, width, attr):
_put(win, y, x, str(text).ljust(max(0, width - x - 1))[: max(0, width - x - 1)], attr)
def run_editor(path):
editor = Editor(path)
try:
editor.load()
except ConfigError as exc:
print(str(exc))
return 2
curses.wrapper(editor.run)
if editor.dirty:
print("Achtung: Aenderungen wurden NICHT gespeichert.")
return 1
return 0