""" Backup Widget for GUI - Manages backup profiles and operations """ from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QTableWidget, QTableWidgetItem, QLabel, QMessageBox, QGroupBox, QTextEdit, QHeaderView, QAbstractItemView, QFileDialog ) from PyQt6.QtCore import Qt, pyqtSignal, QThread, QTimer from PyQt6.QtGui import QFont, QColor from loguru import logger from datetime import datetime import socket from core.database import Database from core.config import SchedulerConfig class BackupWorker(QThread): """Worker thread for running backup without blocking GUI""" finished = pyqtSignal(dict) # result dict def __init__(self, profile_id: int, db): super().__init__() self.profile_id = profile_id self.db = db def run(self): from modules.backup.backup_module import BackupModule module = BackupModule(self.db) result = module.execute_backup(self.profile_id) self.finished.emit(result) class BackupWidget(QWidget): """Widget for managing backup profiles and operations""" profile_changed = pyqtSignal() def __init__(self, db: Database, config: SchedulerConfig): super().__init__() self.db = db self.config = config self._setup_ui() self.refresh() def _setup_ui(self): """Setup backup UI components""" layout = QVBoxLayout(self) layout.setContentsMargins(15, 15, 15, 15) layout.setSpacing(15) # Header header_layout = QHBoxLayout() title_label = QLabel("💾 Backup Profiles") title_font = QFont() title_font.setPointSize(14) title_font.setBold(True) title_label.setFont(title_font) header_layout.addWidget(title_label) header_layout.addStretch() # Action buttons self.new_btn = QPushButton("➕ New Profile") self.new_btn.clicked.connect(self._create_profile) header_layout.addWidget(self.new_btn) self.run_btn = QPushButton("▶ Run Backup") self.run_btn.clicked.connect(self._run_backup) self.run_btn.setEnabled(False) header_layout.addWidget(self.run_btn) self.schedule_btn = QPushButton("📅 Schedule") self.schedule_btn.clicked.connect(self._schedule_backup) self.schedule_btn.setEnabled(False) header_layout.addWidget(self.schedule_btn) self.delete_btn = QPushButton("🗑 Delete") self.delete_btn.clicked.connect(self._delete_profile) self.delete_btn.setEnabled(False) header_layout.addWidget(self.delete_btn) self.restore_btn = QPushButton("🔄 Restore") self.restore_btn.clicked.connect(self._restore_backup) self.restore_btn.setEnabled(False) header_layout.addWidget(self.restore_btn) self.view_all_btn = QPushButton("📋 View All Backups") self.view_all_btn.clicked.connect(self._view_all_backups) header_layout.addWidget(self.view_all_btn) layout.addLayout(header_layout) # Profiles table profiles_group = QGroupBox("Backup Profiles") profiles_layout = QVBoxLayout(profiles_group) self.profiles_table = QTableWidget() self.profiles_table.setColumnCount(6) self.profiles_table.setHorizontalHeaderLabels([ "ID", "Name", "Backup Root", "Sources", "Retention (days)", "Enabled" ]) self.profiles_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Interactive) self.profiles_table.horizontalHeader().setStretchLastSection(True) self.profiles_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.profiles_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) self.profiles_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) self.profiles_table.itemSelectionChanged.connect(self._on_profile_selected) self.profiles_table.doubleClicked.connect(self._edit_profile) # Set column widths self.profiles_table.setColumnWidth(0, 50) self.profiles_table.setColumnWidth(1, 200) self.profiles_table.setColumnWidth(2, 250) self.profiles_table.setColumnWidth(3, 150) self.profiles_table.setColumnWidth(4, 120) self.profiles_table.setColumnWidth(5, 80) profiles_layout.addWidget(self.profiles_table) layout.addWidget(profiles_group, stretch=2) # Recent operations operations_group = QGroupBox("Recent Backup Operations") operations_layout = QVBoxLayout(operations_group) self.operations_table = QTableWidget() self.operations_table.setColumnCount(7) self.operations_table.setHorizontalHeaderLabels([ "ID", "Profile", "Type", "Status", "Start Time", "Duration", "Files" ]) self.operations_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Interactive) self.operations_table.horizontalHeader().setStretchLastSection(True) self.operations_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.operations_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) self.operations_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) self.operations_table.itemSelectionChanged.connect(self._on_operation_selected) # Set column widths self.operations_table.setColumnWidth(0, 50) self.operations_table.setColumnWidth(1, 150) self.operations_table.setColumnWidth(2, 100) self.operations_table.setColumnWidth(3, 100) self.operations_table.setColumnWidth(4, 150) self.operations_table.setColumnWidth(5, 100) self.operations_table.setColumnWidth(6, 150) operations_layout.addWidget(self.operations_table) layout.addWidget(operations_group, stretch=1) def refresh(self): """Refresh all data""" self._load_profiles() self._load_operations() def _load_profiles(self): """Load backup profiles from database""" try: profiles = self.db.get_all_backup_profiles(hostname=socket.gethostname()) self.profiles_table.setRowCount(len(profiles)) for row, profile in enumerate(profiles): # ID id_item = QTableWidgetItem(str(profile['id'])) id_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) self.profiles_table.setItem(row, 0, id_item) # Name name_item = QTableWidgetItem(profile['name']) self.profiles_table.setItem(row, 1, name_item) # Backup Root root_item = QTableWidgetItem(profile['backup_root']) self.profiles_table.setItem(row, 2, root_item) # Sources count import json try: sources = json.loads(profile['sources']) if isinstance(profile['sources'], str) else profile['sources'] sources_count = len(sources) except: sources_count = 0 sources_item = QTableWidgetItem(f"{sources_count} source(s)") sources_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) self.profiles_table.setItem(row, 3, sources_item) # Retention retention_item = QTableWidgetItem(str(profile['retention_days'])) retention_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) self.profiles_table.setItem(row, 4, retention_item) # Enabled enabled_text = "✓ Yes" if profile['enabled'] else "✗ No" enabled_item = QTableWidgetItem(enabled_text) enabled_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) if profile['enabled']: enabled_item.setForeground(QColor('green')) else: enabled_item.setForeground(QColor('red')) self.profiles_table.setItem(row, 5, enabled_item) except Exception as e: logger.error(f"Error loading backup profiles: {e}") QMessageBox.critical(self, "Error", f"Failed to load backup profiles: {e}") def _load_operations(self): """Load recent backup operations""" try: operations = self.db.get_recent_backup_operations(limit=20, hostname=socket.gethostname()) self.operations_table.setRowCount(len(operations)) for row, op in enumerate(operations): # ID id_item = QTableWidgetItem(str(op['id'])) id_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) self.operations_table.setItem(row, 0, id_item) # Profile name profile = self.db.get_backup_profile(op['profile_id']) profile_name = profile['name'] if profile else f"Profile {op['profile_id']}" profile_item = QTableWidgetItem(profile_name) self.operations_table.setItem(row, 1, profile_item) # Type type_item = QTableWidgetItem(op['operation_type']) self.operations_table.setItem(row, 2, type_item) # Status status_text = op['status'] status_item = QTableWidgetItem(status_text) status_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) # Color code status if op['status'] == 'completed': status_item.setForeground(QColor('green')) elif op['status'] == 'failed': status_item.setForeground(QColor('red')) elif op['status'] == 'running': status_item.setForeground(QColor('orange')) self.operations_table.setItem(row, 3, status_item) # Start time start_time = op['start_time'] if start_time: try: dt = datetime.fromisoformat(start_time) time_str = dt.strftime('%Y-%m-%d %H:%M:%S') except: time_str = start_time[:19] if len(start_time) > 19 else start_time else: time_str = "N/A" time_item = QTableWidgetItem(time_str) self.operations_table.setItem(row, 4, time_item) # Duration if op.get('end_time') and op.get('start_time'): try: start = datetime.fromisoformat(op['start_time']) end = datetime.fromisoformat(op['end_time']) duration = (end - start).total_seconds() duration_str = f"{duration:.1f}s" except: duration_str = "N/A" else: duration_str = "Running..." if op['status'] == 'running' else "N/A" duration_item = QTableWidgetItem(duration_str) duration_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) self.operations_table.setItem(row, 5, duration_item) # Files files_total = op.get('files_total', 0) files_linked = op.get('files_linked', 0) files_str = f"{files_total} ({files_linked} links)" if files_total > 0 else "N/A" files_item = QTableWidgetItem(files_str) files_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) self.operations_table.setItem(row, 6, files_item) except Exception as e: logger.error(f"Error loading backup operations: {e}") def _on_profile_selected(self): """Handle profile selection""" has_selection = len(self.profiles_table.selectedItems()) > 0 self.run_btn.setEnabled(has_selection) self.schedule_btn.setEnabled(has_selection) self.delete_btn.setEnabled(has_selection) def _get_selected_profile_id(self): """Get selected profile ID""" selected = self.profiles_table.selectedItems() if not selected: return None row = selected[0].row() return int(self.profiles_table.item(row, 0).text()) def _create_profile(self): """Create new backup profile""" from gui.backup_profile_dialog import BackupProfileDialog from PyQt6.QtWidgets import QDialog dialog = BackupProfileDialog(self, self.db) if dialog.exec() == QDialog.DialogCode.Accepted: logger.info("New backup profile created") self.refresh() self.profile_changed.emit() def _edit_profile(self): """Edit selected backup profile""" from gui.backup_profile_dialog import BackupProfileDialog from PyQt6.QtWidgets import QDialog profile_id = self._get_selected_profile_id() if not profile_id: QMessageBox.warning(self, "No Selection", "Please select a profile to edit") return dialog = BackupProfileDialog(self, self.db, profile_id) if dialog.exec() == QDialog.DialogCode.Accepted: logger.info(f"Backup profile {profile_id} updated") self.refresh() self.profile_changed.emit() def _run_backup(self): """Run backup for selected profile""" profile_id = self._get_selected_profile_id() if not profile_id: return reply = QMessageBox.question( self, "Run Backup", f"Start backup operation for profile {profile_id}?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No ) if reply == QMessageBox.StandardButton.Yes: self.run_btn.setEnabled(False) self.run_btn.setText("⏳ Running...") self._backup_worker = BackupWorker(profile_id, self.db) self._backup_worker.finished.connect(self._on_backup_finished) self._backup_worker.start() def _on_backup_finished(self, result: dict): """Called when backup worker thread completes""" self.run_btn.setEnabled(True) self.run_btn.setText("▶ Run Now") # Delay refresh to allow SQLite WAL commit to become visible to main thread QTimer.singleShot(300, self.refresh) self.profile_changed.emit() if result.get('success'): stats = result.get('stats', {}) msg = ( f"Backup completed successfully.\n\n" f"Files copied: {stats.get('files_copied', '?')}\n" f"Files linked: {stats.get('files_linked', '?')}\n" f"Total files: {stats.get('files_total', '?')}" ) QMessageBox.information(self, "Backup Complete", msg) else: error = result.get('error', 'Unknown error') QMessageBox.critical(self, "Backup Failed", f"Backup failed:\n{error}") def _schedule_backup(self): """Open schedule dialog for selected backup profile""" profile_id = self._get_selected_profile_id() if not profile_id: return try: from gui.schedule_editor_dialog import ScheduleEditorDialog from PyQt6.QtWidgets import QDialog # Get profile info profile = self.db.get_backup_profile(profile_id) if not profile: QMessageBox.warning(self, "Error", f"Profile {profile_id} not found") return # Open schedule dialog with backup profile pre-selected dialog = ScheduleEditorDialog(self, self.db, self.config) # Pre-select the backup radio and the profile dialog.backup_radio.setChecked(True) # Find and select this profile in the combo for i in range(dialog.backup_combo.count()): if dialog.backup_combo.itemData(i) == profile_id: dialog.backup_combo.setCurrentIndex(i) break # Pre-fill schedule name dialog.name_input.setText(f"Scheduled Backup: {profile['name']}") if dialog.exec() == QDialog.DialogCode.Accepted: logger.info(f"Created schedule for backup profile {profile_id}") QMessageBox.information( self, "Schedule Created", f"Schedule created successfully for '{profile['name']}'.\n\n" "Check the Schedules tab to view and manage it." ) except Exception as e: logger.error(f"Error scheduling backup: {e}") QMessageBox.critical(self, "Error", f"Failed to open schedule dialog: {e}") def _delete_profile(self): """Delete selected backup profile""" profile_id = self._get_selected_profile_id() if not profile_id: return reply = QMessageBox.question( self, "Delete Profile", f"Are you sure you want to delete profile {profile_id}?\n\n" "This will also delete all associated backup operations.", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No ) if reply == QMessageBox.StandardButton.Yes: try: self.db.delete_backup_profile(profile_id) logger.info(f"Deleted backup profile {profile_id}") self.refresh() self.profile_changed.emit() except Exception as e: logger.error(f"Error deleting profile: {e}") QMessageBox.critical(self, "Error", f"Failed to delete profile: {e}") def _on_operation_selected(self): """Handle operation selection - enable restore button only for completed backups""" selected = self.operations_table.selectedItems() if not selected: self.restore_btn.setEnabled(False) return row = selected[0].row() status = self.operations_table.item(row, 3).text() # Status column operation_type = self.operations_table.item(row, 2).text() # Type column # Enable restore only for completed backup operations self.restore_btn.setEnabled(status == 'completed' and operation_type == 'backup') def _get_selected_operation(self): """Get selected backup operation details""" selected = self.operations_table.selectedItems() if not selected: return None row = selected[0].row() op_id = int(self.operations_table.item(row, 0).text()) # Get full operation details from database try: operations = self.db.get_recent_backup_operations(limit=100, hostname=socket.gethostname()) for op in operations: if op['id'] == op_id: return op except Exception as e: logger.error(f"Error getting operation details: {e}") return None def _restore_backup(self): """Restore selected backup with selective restore dialog""" from gui.backup_restore_dialog import BackupRestoreDialog operation = self._get_selected_operation() if not operation: QMessageBox.warning(self, "No Selection", "Please select a completed backup to restore") return backup_name = operation.get('backup_name') if not backup_name: QMessageBox.warning(self, "Error", "Selected operation has no backup name") return # Get profile to find backup_root profile = self.db.get_backup_profile(operation['profile_id']) if not profile: QMessageBox.critical(self, "Error", f"Profile {operation['profile_id']} not found") return try: # Open selective restore dialog dialog = BackupRestoreDialog(self, profile['backup_root'], backup_name, profile=profile) dialog.exec() except Exception as e: logger.error(f"Error opening restore dialog: {e}") QMessageBox.critical( self, "Restore Failed", f"Failed to open restore dialog:\n{str(e)}" ) def _view_all_backups(self): """Open dialog showing all backup operations""" try: from gui.all_backups_dialog import AllBackupsDialog dialog = AllBackupsDialog(self, self.db) dialog.exec() except Exception as e: logger.error(f"Error opening all backups dialog: {e}") QMessageBox.critical( self, "Error", f"Failed to open all backups dialog:\n{str(e)}" )