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:
@@ -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,10 +302,20 @@ class SimpleBackupGUI:
|
||||
vg_name = parts[2]
|
||||
lv_list.append(f"{lv_path} ({lv_size}) [VG: {vg_name}]")
|
||||
|
||||
# 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")
|
||||
if mode in ["lv_to_borg", "files_to_borg"]:
|
||||
self.source_listbox.delete(0, tk.END)
|
||||
else:
|
||||
self.source_combo['values'] = []
|
||||
|
||||
# Target options
|
||||
@@ -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,23 +438,7 @@ 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
|
||||
# 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"
|
||||
@@ -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)")
|
||||
|
||||
Reference in New Issue
Block a user