feat: Add multi-LV selection and fix Borg settings visibility

Fixes:
1. Files → Borg mode now shows Borg settings panel
2. LV → Borg and Files → Borg modes support multi-selection

Multi-LV Selection:
- Replace single combobox with multi-select listbox for LV modes
- Hold Ctrl/Cmd to select multiple logical volumes
- Backup all selected LVs sequentially in one operation
- Shows progress for each LV individually
- Continues with remaining LVs if one fails
- Summary report at the end

Benefits:
- No need to start separate backup processes manually
- Repository initialized once, used for all LVs
- Better deduplication across multiple LVs
- Clear progress tracking and error reporting
- Time-efficient for backing up multiple volumes

GUI Changes:
- Dynamic source selection widget (combobox vs listbox)
- Helper text explaining multi-selection
- Improved confirmation dialogs showing all selected LVs
- Enhanced progress reporting for multi-LV operations

Perfect for backing up boot, root, home, etc. in one go!
This commit is contained in:
root
2025-10-09 08:29:58 +02:00
parent 7708b98674
commit 543b95cb61

View File

@@ -57,9 +57,34 @@ class SimpleBackupGUI:
# Source selection
ttk.Label(main_frame, text="Source:").grid(row=1, column=0, sticky=tk.W, pady=5)
# Create a frame for source selection that can switch between combobox and listbox
self.source_frame = ttk.Frame(main_frame)
self.source_frame.grid(row=1, column=1, columnspan=2, sticky=(tk.W, tk.E), pady=5)
# Single selection (combobox) - for most modes
self.source_var = tk.StringVar()
self.source_combo = ttk.Combobox(main_frame, textvariable=self.source_var, width=50)
self.source_combo.grid(row=1, column=1, columnspan=2, sticky=(tk.W, tk.E), pady=5)
self.source_combo = ttk.Combobox(self.source_frame, textvariable=self.source_var, width=50)
self.source_combo.grid(row=0, column=0, sticky=(tk.W, tk.E))
# Multi-selection (listbox) - for LV modes
self.source_listbox_frame = ttk.Frame(self.source_frame)
# Help text for multi-selection
help_text = ttk.Label(self.source_listbox_frame, text="Hold Ctrl/Cmd to select multiple LVs",
font=("TkDefaultFont", 8), foreground="gray")
help_text.grid(row=0, column=0, columnspan=2, sticky=tk.W, pady=(0, 2))
self.source_listbox = tk.Listbox(self.source_listbox_frame, height=6, selectmode=tk.EXTENDED)
source_scrollbar = ttk.Scrollbar(self.source_listbox_frame, orient="vertical", command=self.source_listbox.yview)
self.source_listbox.configure(yscrollcommand=source_scrollbar.set)
self.source_listbox.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
source_scrollbar.grid(row=1, column=1, sticky=(tk.N, tk.S))
self.source_listbox_frame.columnconfigure(0, weight=1)
self.source_listbox_frame.rowconfigure(1, weight=1)
# Configure source frame
self.source_frame.columnconfigure(0, weight=1)
# Target selection
ttk.Label(main_frame, text="Target:").grid(row=2, column=0, sticky=tk.W, pady=5)
@@ -170,7 +195,7 @@ class SimpleBackupGUI:
mode = self.mode_var.get()
# Show/hide Borg settings
if mode in ["lv_to_borg", "vg_to_borg"]:
if mode in ["lv_to_borg", "vg_to_borg", "files_to_borg"]:
self.borg_frame.grid()
# Clear target combo for Borg modes (repo path is used instead)
self.target_combo['values'] = []
@@ -233,6 +258,16 @@ class SimpleBackupGUI:
mode = self.mode_var.get()
self.log(f"Refreshing drives for mode: {mode}")
# Show appropriate source selection widget
if mode in ["lv_to_borg", "files_to_borg"]:
# Multi-selection for LV modes
self.source_combo.grid_remove()
self.source_listbox_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
else:
# Single selection for other modes
self.source_listbox_frame.grid_remove()
self.source_combo.grid(row=0, column=0, sticky=(tk.W, tk.E))
# Source options
if mode == "vg_to_raw":
# Show volume groups
@@ -254,7 +289,7 @@ class SimpleBackupGUI:
self.log("No volume groups found")
self.source_combo['values'] = []
else:
# Show logical volumes (lv_to_lv and lv_to_raw)
# Show logical volumes (all LV modes)
success, output = self.run_command("lvs --noheadings -o lv_path,lv_size,vg_name", show_output=False)
if success:
lv_list = []
@@ -267,11 +302,21 @@ class SimpleBackupGUI:
vg_name = parts[2]
lv_list.append(f"{lv_path} ({lv_size}) [VG: {vg_name}]")
self.source_combo['values'] = lv_list
# Update both single and multi-selection widgets
if mode in ["lv_to_borg", "files_to_borg"]:
self.source_listbox.delete(0, tk.END)
for lv in lv_list:
self.source_listbox.insert(tk.END, lv)
else:
self.source_combo['values'] = lv_list
self.log(f"Found {len(lv_list)} logical volumes")
else:
self.log("No LVM volumes found")
self.source_combo['values'] = []
if mode in ["lv_to_borg", "files_to_borg"]:
self.source_listbox.delete(0, tk.END)
else:
self.source_combo['values'] = []
# Target options
if mode == "lv_to_lv":
@@ -323,8 +368,63 @@ class SimpleBackupGUI:
"""Start the backup process"""
mode = self.mode_var.get()
# Get selected sources based on mode
if mode in ["lv_to_borg", "files_to_borg"]:
# Multi-selection mode
selected_indices = self.source_listbox.curselection()
if not selected_indices:
messagebox.showerror("Error", "Please select at least one logical volume")
return
selected_sources = []
for index in selected_indices:
selected_sources.append(self.source_listbox.get(index).split()[0])
if not self.repo_path_var.get():
messagebox.showerror("Error", "Please select repository path")
return
# Check if borg is installed
success, _ = self.run_command("which borg", show_output=False)
if not success:
messagebox.showerror("Error", "Borg Backup is not installed. Please install it first:\nsudo apt install borgbackup")
return
repo_path = self.repo_path_var.get()
# Build confirmation message for multiple LVs
if mode == "lv_to_borg":
msg = f"Borg backup of {len(selected_sources)} LVs (block-level):\n\n"
else: # files_to_borg
msg = f"Borg backup of {len(selected_sources)} LVs (file-level):\n\n"
for source in selected_sources:
msg += f"{source}\n"
msg += f"\nRepository: {repo_path}\n"
if self.create_new_repo.get():
msg += "This will create a new Borg repository.\n"
else:
msg += "This will add to existing Borg repository.\n"
msg += f"Encryption: {self.encryption_var.get()}\n\nContinue?"
if not messagebox.askyesno("Confirm Backup", msg):
return
# Start multi-LV backup
self.backup_running = True
self.backup_btn.config(state="disabled")
self.stop_btn.config(state="normal")
self.progress.start()
thread = threading.Thread(target=self.multi_lv_backup_worker, args=(mode, selected_sources, repo_path))
thread.daemon = True
thread.start()
return
# Original single-selection logic for other modes
# Validate inputs based on mode
if mode in ["lv_to_borg", "vg_to_borg", "files_to_borg"]:
if mode in ["vg_to_borg"]:
if not self.source_var.get() or not self.repo_path_var.get():
messagebox.showerror("Error", "Please select source and repository path")
return
@@ -338,30 +438,14 @@ class SimpleBackupGUI:
source = self.source_var.get().split()[0]
repo_path = self.repo_path_var.get()
# Build confirmation message for Borg
if mode == "lv_to_borg":
msg = f"Borg backup of LV (block-level):\n\nSource LV: {source}\nRepository: {repo_path}\n\n"
if self.create_new_repo.get():
msg += "This will create a new Borg repository.\n"
else:
msg += "This will add to existing Borg repository.\n"
msg += f"Encryption: {self.encryption_var.get()}\n\nContinue?"
elif mode == "files_to_borg":
msg = f"Borg backup of LV (file-level):\n\nSource LV: {source}\nRepository: {repo_path}\n\n"
msg += "This will backup files from the LV (space-efficient).\n"
if self.create_new_repo.get():
msg += "This will create a new Borg repository.\n"
else:
msg += "This will add to existing Borg repository.\n"
msg += f"Encryption: {self.encryption_var.get()}\n\nContinue?"
else: # vg_to_borg
msg = f"Borg backup of entire VG (block-level):\n\nSource VG: {source}\nRepository: {repo_path}\n\n"
if self.create_new_repo.get():
msg += "This will create a new Borg repository.\n"
else:
msg += "This will add to existing Borg repository.\n"
msg += f"Encryption: {self.encryption_var.get()}\n"
msg += "This will backup ALL logical volumes in the VG.\n\nContinue?"
# Build confirmation message for VG Borg
msg = f"Borg backup of entire VG (block-level):\n\nSource VG: {source}\nRepository: {repo_path}\n\n"
if self.create_new_repo.get():
msg += "This will create a new Borg repository.\n"
else:
msg += "This will add to existing Borg repository.\n"
msg += f"Encryption: {self.encryption_var.get()}\n"
msg += "This will backup ALL logical volumes in the VG.\n\nContinue?"
target = repo_path
else:
@@ -428,6 +512,73 @@ class SimpleBackupGUI:
# Reset UI state
self.root.after(0, self.reset_ui_state)
def multi_lv_backup_worker(self, mode, selected_sources, repo_path):
"""Backup multiple LVs sequentially"""
try:
self.log(f"=== Starting multi-LV {mode} backup ===")
self.log(f"Selected {len(selected_sources)} logical volumes")
# Initialize repository once if needed
if self.create_new_repo.get():
borg_env = os.environ.copy()
if self.passphrase_var.get():
borg_env['BORG_PASSPHRASE'] = self.passphrase_var.get()
self.log(f"Creating new Borg repository: {repo_path}")
encryption = self.encryption_var.get()
if encryption == "none":
init_cmd = f"borg init --encryption=none {repo_path}"
else:
init_cmd = f"borg init --encryption={encryption} {repo_path}"
result = subprocess.run(init_cmd, shell=True, capture_output=True, text=True, env=borg_env)
if result.returncode != 0:
raise Exception(f"Failed to initialize Borg repository: {result.stderr}")
self.log("Repository initialized successfully")
# Backup each LV
successful_backups = 0
failed_backups = []
for i, source_lv in enumerate(selected_sources, 1):
self.log(f"--- Processing LV {i}/{len(selected_sources)}: {source_lv} ---")
try:
if mode == "lv_to_borg":
self.backup_lv_to_borg(source_lv, repo_path)
elif mode == "files_to_borg":
self.backup_files_to_borg(source_lv, repo_path)
successful_backups += 1
self.log(f"Successfully backed up {source_lv}")
except Exception as e:
self.log(f"Failed to backup {source_lv}: {str(e)}")
failed_backups.append(source_lv)
# Continue with next LV instead of stopping
# Summary
self.log(f"=== Multi-LV backup completed ===")
self.log(f"Successful: {successful_backups}/{len(selected_sources)}")
if failed_backups:
self.log(f"Failed: {', '.join(failed_backups)}")
# Show completion dialog
if failed_backups:
msg = f"Backup completed with some failures:\n\nSuccessful: {successful_backups}\nFailed: {len(failed_backups)}\n\nFailed LVs:\n" + '\n'.join(failed_backups)
self.root.after(0, lambda: messagebox.showwarning("Backup Completed with Warnings", msg))
else:
self.root.after(0, lambda: messagebox.showinfo("Success", f"All {successful_backups} LVs backed up successfully!"))
except Exception as e:
self.log(f"ERROR: {str(e)}")
self.cleanup_on_error()
self.root.after(0, lambda: messagebox.showerror("Backup Failed", str(e)))
finally:
# Reset UI state
self.root.after(0, self.reset_ui_state)
def backup_lv_to_lv(self, source_lv, target_lv):
"""Backup LV to existing LV"""
self.log("Mode: LV to LV (updating existing backup)")