Files
proxmox-snapshot-service/pvesnap/tui.py
T
duffyduckandClaude Opus 5 f37a942fea Snapshot-Explorer: Dateien zurueckholen, im Terminal und im Browser
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>
2026-07-31 09:14:16 +02:00

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