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
|
# Source selection
|
||||||
ttk.Label(main_frame, text="Source:").grid(row=1, column=0, sticky=tk.W, pady=5)
|
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_var = tk.StringVar()
|
||||||
self.source_combo = ttk.Combobox(main_frame, textvariable=self.source_var, width=50)
|
self.source_combo = ttk.Combobox(self.source_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.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
|
# Target selection
|
||||||
ttk.Label(main_frame, text="Target:").grid(row=2, column=0, sticky=tk.W, pady=5)
|
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()
|
mode = self.mode_var.get()
|
||||||
|
|
||||||
# Show/hide Borg settings
|
# 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()
|
self.borg_frame.grid()
|
||||||
# Clear target combo for Borg modes (repo path is used instead)
|
# Clear target combo for Borg modes (repo path is used instead)
|
||||||
self.target_combo['values'] = []
|
self.target_combo['values'] = []
|
||||||
@@ -233,6 +258,16 @@ class SimpleBackupGUI:
|
|||||||
mode = self.mode_var.get()
|
mode = self.mode_var.get()
|
||||||
self.log(f"Refreshing drives for mode: {mode}")
|
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
|
# Source options
|
||||||
if mode == "vg_to_raw":
|
if mode == "vg_to_raw":
|
||||||
# Show volume groups
|
# Show volume groups
|
||||||
@@ -254,7 +289,7 @@ class SimpleBackupGUI:
|
|||||||
self.log("No volume groups found")
|
self.log("No volume groups found")
|
||||||
self.source_combo['values'] = []
|
self.source_combo['values'] = []
|
||||||
else:
|
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)
|
success, output = self.run_command("lvs --noheadings -o lv_path,lv_size,vg_name", show_output=False)
|
||||||
if success:
|
if success:
|
||||||
lv_list = []
|
lv_list = []
|
||||||
@@ -267,11 +302,21 @@ class SimpleBackupGUI:
|
|||||||
vg_name = parts[2]
|
vg_name = parts[2]
|
||||||
lv_list.append(f"{lv_path} ({lv_size}) [VG: {vg_name}]")
|
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")
|
self.log(f"Found {len(lv_list)} logical volumes")
|
||||||
else:
|
else:
|
||||||
self.log("No LVM volumes found")
|
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
|
# Target options
|
||||||
if mode == "lv_to_lv":
|
if mode == "lv_to_lv":
|
||||||
@@ -323,8 +368,63 @@ class SimpleBackupGUI:
|
|||||||
"""Start the backup process"""
|
"""Start the backup process"""
|
||||||
mode = self.mode_var.get()
|
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
|
# 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():
|
if not self.source_var.get() or not self.repo_path_var.get():
|
||||||
messagebox.showerror("Error", "Please select source and repository path")
|
messagebox.showerror("Error", "Please select source and repository path")
|
||||||
return
|
return
|
||||||
@@ -338,30 +438,14 @@ class SimpleBackupGUI:
|
|||||||
source = self.source_var.get().split()[0]
|
source = self.source_var.get().split()[0]
|
||||||
repo_path = self.repo_path_var.get()
|
repo_path = self.repo_path_var.get()
|
||||||
|
|
||||||
# Build confirmation message for Borg
|
# Build confirmation message for VG Borg
|
||||||
if mode == "lv_to_borg":
|
msg = f"Borg backup of entire VG (block-level):\n\nSource VG: {source}\nRepository: {repo_path}\n\n"
|
||||||
msg = f"Borg backup of LV (block-level):\n\nSource LV: {source}\nRepository: {repo_path}\n\n"
|
if self.create_new_repo.get():
|
||||||
if self.create_new_repo.get():
|
msg += "This will create a new Borg repository.\n"
|
||||||
msg += "This will create a new Borg repository.\n"
|
else:
|
||||||
else:
|
msg += "This will add to existing Borg repository.\n"
|
||||||
msg += "This will add to existing Borg repository.\n"
|
msg += f"Encryption: {self.encryption_var.get()}\n"
|
||||||
msg += f"Encryption: {self.encryption_var.get()}\n\nContinue?"
|
msg += "This will backup ALL logical volumes in the VG.\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?"
|
|
||||||
|
|
||||||
target = repo_path
|
target = repo_path
|
||||||
else:
|
else:
|
||||||
@@ -428,6 +512,73 @@ class SimpleBackupGUI:
|
|||||||
# Reset UI state
|
# Reset UI state
|
||||||
self.root.after(0, self.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):
|
def backup_lv_to_lv(self, source_lv, target_lv):
|
||||||
"""Backup LV to existing LV"""
|
"""Backup LV to existing LV"""
|
||||||
self.log("Mode: LV to LV (updating existing backup)")
|
self.log("Mode: LV to LV (updating existing backup)")
|
||||||
|
|||||||
Reference in New Issue
Block a user