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>
228 lines
7.1 KiB
Python
228 lines
7.1 KiB
Python
"""Gemeinsame curses-Bausteine fuer Konfigurationseditor und Explorer."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import curses
|
|
|
|
C_HEADER, C_FOOTER, C_SEL, C_WARN, C_OK, C_DIM, C_DIR, C_MARK = 1, 2, 3, 4, 5, 6, 7, 8
|
|
|
|
|
|
def init_colors():
|
|
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)
|
|
curses.init_pair(C_DIR, curses.COLOR_CYAN, -1)
|
|
curses.init_pair(C_MARK, curses.COLOR_YELLOW, -1)
|
|
except curses.error:
|
|
pass
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Zeichnen
|
|
# ---------------------------------------------------------------------------
|
|
|
|
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 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)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tasten
|
|
# ---------------------------------------------------------------------------
|
|
|
|
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)
|
|
|
|
|
|
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)]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Dialoge
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def message(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(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 ask(win, question, answers, default=None):
|
|
"""Mehrfachauswahl in einer Zeile. `answers` ist z.B. 'jnak'."""
|
|
height, _ = win.getmaxyx()
|
|
fill(win, height - 2, " %s" % question, curses.color_pair(C_WARN) | curses.A_BOLD)
|
|
win.refresh()
|
|
while True:
|
|
key = read_key(win)
|
|
if isinstance(key, str) and key.lower() in answers:
|
|
return key.lower()
|
|
if is_escape(key):
|
|
return default
|
|
|
|
|
|
def prompt(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)
|
|
label_text = " %s: " % label
|
|
visible = width - len(label_text) - 2
|
|
shown = text[-visible:] if visible > 0 and len(text) > visible else text
|
|
fill(win, height - 2, label_text + shown, curses.color_pair(C_SEL))
|
|
try:
|
|
win.move(height - 2, min(width - 2, len(label_text) + 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(win, title, entries, current=0):
|
|
"""Kleines Auswahlfenster. entries: Liste von (wert, beschriftung)."""
|
|
if not entries:
|
|
return None
|
|
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))
|
|
scroll = 0
|
|
rows = max(1, box_h - 4)
|
|
|
|
while True:
|
|
if index < scroll:
|
|
scroll = index
|
|
if index >= scroll + rows:
|
|
scroll = index - rows + 1
|
|
box.erase()
|
|
box.box()
|
|
put(box, 0, 2, " %s " % title, curses.A_BOLD)
|
|
for row in range(rows):
|
|
position = scroll + row
|
|
if position >= len(entries):
|
|
break
|
|
attr = curses.color_pair(C_SEL) if position == index else 0
|
|
put(box, row + 2, 2, " " + entries[position][1].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 key == curses.KEY_NPAGE:
|
|
index = min(len(entries) - 1, index + rows)
|
|
elif key == curses.KEY_PPAGE:
|
|
index = max(0, index - rows)
|
|
elif is_enter(key):
|
|
return entries[index][0]
|
|
elif is_escape(key) or key == "q":
|
|
return None
|