Snapshots anzulegen half bisher nur halb - an die Daten darin kam man nur
ueber einen Rollback. Dafuer jetzt zwei Werkzeuge auf gemeinsamer Basis.
snapfs.py bindet einen Snapshot schreibgeschuetzt ein und liefert einen
gewoehnlichen Pfad; die Oberflaechen wissen dadurch nichts ueber Storages:
rbd (Ceph) rbd map pool/image@snap, bei nicht unterstuetzten
Image-Features faellt es auf rbd-nbd zurueck
zfspool Container ueber .zfs/snapshot, VMs ueber einen Klon
lvmthin/lvm die Snapshot-LV snap_<volume>_<snapname> aktivieren
dir/nfs/cifs qemu-nbd --load-snapshot (nur qcow2)
Gemountet wird mit ro,noload bzw. ro,norecovery,nouuid - Snapshots
laufender Gaeste haben fast immer ein unsauberes Journal. Alles Angelegte
steht in /run/pvesnap/explorer.json und laesst sich nach einem Absturz mit
"--cleanup" wieder abraeumen.
pvesnap-explorer: zwei Fenster wie im Midnight Commander, links der
Snapshot, rechts der lokale Rechner. Markieren, F5, Fortschrittsbalken,
ESC bricht ab, vorhandene Dateien werden abgefragt. In den Snapshot hinein
kann nicht kopiert werden. Geraetedateien und Sockets werden
uebersprungen, symbolische Verweise bleiben Verweise.
pvesnap-web: derselbe Inhalt im Browser, auch vom anderen Rechner.
Einzelne Dateien direkt, Verzeichnisse und Mehrfachauswahl als ZIP, das im
Strom erzeugt wird - ohne Zwischendatei auf der Platte. Zugang nur mit dem
beim Start ausgegebenen Schluessel; Pfade ausserhalb des Snapshots werden
abgewiesen; es wird ausschliesslich gelesen.
Nebenbei: die curses-Bausteine sind aus tui.py nach curses_util.py
gewandert, damit Editor und Explorer sie teilen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
906 lines
37 KiB
Python
906 lines
37 KiB
Python
"""ncurses-Editor fuer die pvesnap-Konfiguration.
|
|
|
|
Aufruf: pvesnap config
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import curses
|
|
import os
|
|
import subprocess
|
|
|
|
from .curses_util import (C_DIM, C_FOOTER, C_HEADER, C_OK, C_SEL, C_WARN,
|
|
choose, confirm, fill, fill_row, init_colors, is_enter,
|
|
is_escape, keybar, message, prompt, put, read_key)
|
|
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)"),
|
|
]
|
|
|
|
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)
|
|
|
|
|
|
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):
|
|
return message(win, text, error)
|
|
|
|
def _confirm(self, win, question):
|
|
return confirm(win, question)
|
|
|
|
def _prompt(self, win, label, initial=""):
|
|
return prompt(win, label, initial)
|
|
|
|
def _choose(self, win, title, entries, current=0):
|
|
return choose(win, title, entries, current)
|
|
|
|
# -- Hauptschleife ----------------------------------------------------
|
|
|
|
def run(self, stdscr):
|
|
curses.curs_set(0)
|
|
if hasattr(curses, "set_escdelay"):
|
|
curses.set_escdelay(25)
|
|
init_colors()
|
|
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"),
|
|
("Wiederholungen bei belegter Sperre", str(globals_.retries),
|
|
"int", "retries"),
|
|
("Wartezeit vor Wiederholung", format_duration(globals_.retry_delay),
|
|
"duration", "retry_delay"),
|
|
("Pause zwischen zwei VMs", format_duration(globals_.pause_between, zero="keine"),
|
|
"duration", "pause_between"),
|
|
("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 == "int":
|
|
raw = self._prompt(win, label, str(getattr(globals_, attribute)))
|
|
if raw is None:
|
|
return
|
|
try:
|
|
setattr(globals_, attribute, max(0, int(raw.strip() or 0)))
|
|
self.dirty = True
|
|
except ValueError:
|
|
self._message(win, "Bitte eine ganze Zahl eingeben.", error=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 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
|