diff --git a/simple_backup_gui.py b/simple_backup_gui.py index fe5d863..5d0d2f7 100755 --- a/simple_backup_gui.py +++ b/simple_backup_gui.py @@ -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)")