diff --git a/__pycache__/disk_operations.cpython-314.pyc b/__pycache__/disk_operations.cpython-314.pyc index 493291e..635c76b 100644 Binary files a/__pycache__/disk_operations.cpython-314.pyc and b/__pycache__/disk_operations.cpython-314.pyc differ diff --git a/__pycache__/lvm_operations.cpython-314.pyc b/__pycache__/lvm_operations.cpython-314.pyc index f8eb152..1e7b8c3 100644 Binary files a/__pycache__/lvm_operations.cpython-314.pyc and b/__pycache__/lvm_operations.cpython-314.pyc differ diff --git a/__pycache__/occupation_resolver.cpython-314.pyc b/__pycache__/occupation_resolver.cpython-314.pyc new file mode 100644 index 0000000..68fa2da Binary files /dev/null and b/__pycache__/occupation_resolver.cpython-314.pyc differ diff --git a/__pycache__/raid_operations.cpython-314.pyc b/__pycache__/raid_operations.cpython-314.pyc index cf52a22..696211a 100644 Binary files a/__pycache__/raid_operations.cpython-314.pyc and b/__pycache__/raid_operations.cpython-314.pyc differ diff --git a/disk_operations.py b/disk_operations.py index 59c2ebe..de18dd3 100644 --- a/disk_operations.py +++ b/disk_operations.py @@ -9,7 +9,7 @@ from system_info import SystemInfoManager logger = logging.getLogger(__name__) -# --- 新增: 后台格式化工作线程 --- +# --- 后台格式化工作线程 --- class FormatWorker(QObject): # 定义信号,用于向主线程发送格式化结果 finished = Signal(bool, str, str, str) # 成功状态, 设备路径, 标准输出, 标准错误 @@ -19,7 +19,6 @@ class FormatWorker(QObject): super().__init__(parent) self.device_path = device_path self.fs_type = fs_type - # 接收一个可调用的函数,用于执行shell命令 (这里是 DiskOperations._execute_shell_command) self._execute_shell_command_func = execute_shell_command_func def run(self): @@ -48,18 +47,18 @@ class FormatWorker(QObject): command_list, f"格式化设备 {self.device_path} 为 {self.fs_type} 失败", root_privilege=True, - show_dialog=False # <--- 关键:阻止工作线程弹出 QMessageBox + show_dialog=False # 阻止工作线程弹出 QMessageBox ) self.finished.emit(success, self.device_path, stdout, stderr) logger.info(f"后台格式化完成: 设备 {self.device_path}, 成功: {success}") -class DiskOperations(QObject): # <--- 继承 QObject 以便发出信号 +class DiskOperations(QObject): # 继承 QObject 以便发出信号 # 定义信号,用于通知主线程格式化操作的开始和结束 formatting_finished = Signal(bool, str, str, str) # 成功状态, 设备路径, 标准输出, 标准错误 formatting_started = Signal(str) # 设备路径 - def __init__(self, system_manager: SystemInfoManager, lvm_ops, parent=None): # <--- 构造函数现在接收 lvm_ops 实例 + def __init__(self, system_manager: SystemInfoManager, lvm_ops, parent=None): super().__init__(parent) self.system_manager = system_manager self._lvm_ops = lvm_ops # 保存 LvmOperations 实例,以便调用其 _execute_shell_command @@ -89,8 +88,6 @@ class DiskOperations(QObject): # <--- 继承 QObject 以便发出信号 try: # 检查 fstab 中是否已存在相同 UUID 的条目 - # 使用 _execute_shell_command 来读取 fstab,尽管通常不需要 sudo - # 这里为了简化,直接读取文件,但写入时使用 _execute_shell_command with open(fstab_path, 'r') as f: fstab_content = f.readlines() @@ -101,7 +98,6 @@ class DiskOperations(QObject): # <--- 继承 QObject 以便发出信号 return True # 认为成功,因为目标已达成 # 如果不存在,则追加到 fstab - # 使用 _execute_shell_command for sudo write success, _, stderr = self._execute_shell_command( ["sh", "-c", f"echo '{fstab_entry}' >> {fstab_path}"], f"将 {device_path} 添加到 {fstab_path} 失败", @@ -123,7 +119,6 @@ class DiskOperations(QObject): # <--- 继承 QObject 以便发出信号 从 /etc/fstab 中移除指定设备的条目。 此方法需要 SystemInfoManager 来获取设备的 UUID。 """ - # NEW: 使用 system_manager 获取 UUID,它会处理路径解析 device_details = self.system_manager.get_device_details_by_path(device_path) if not device_details or not device_details.get('uuid'): logger.warning(f"无法获取设备 {device_path} 的 UUID,无法从 fstab 中移除。") @@ -131,9 +126,6 @@ class DiskOperations(QObject): # <--- 继承 QObject 以便发出信号 uuid = device_details.get('uuid') fstab_path = "/etc/fstab" - # Use sed for robust removal with sudo - # suppress_critical_dialog_on_stderr_match is added to handle cases where fstab might not exist - # or sed reports no changes, which is not a critical error for removal. command = ["sed", "-i", f"/{re.escape(uuid)}/d", fstab_path] # Use re.escape for UUID success, _, stderr = self._execute_shell_command( command, @@ -149,10 +141,6 @@ class DiskOperations(QObject): # <--- 继承 QObject 以便发出信号 logger.info(f"已从 {fstab_path} 中删除 UUID={uuid} 的条目。") return True else: - # If sed failed, it might be because the entry wasn't found, which is fine. - # _execute_shell_command would have suppressed the dialog if it matched the suppress_critical_dialog_on_stderr_match. - # So, if we reach here, it's either a real error (dialog shown by _execute_shell_command) - # or a suppressed "no changes" type of error. In both cases, if no real error, we return True. if any(s in stderr for s in ( f"sed: {fstab_path}: No such file or directory", f"sed: {fstab_path}: 没有那个文件或目录", @@ -160,109 +148,9 @@ class DiskOperations(QObject): # <--- 继承 QObject 以便发出信号 "No such file or directory" # more general check )): logger.info(f"fstab 中未找到 UUID={uuid} 的条目,无需删除。") - return True # Consider it a success if the entry is not there - return False # Other errors are already handled by _execute_shell_command - - def _resolve_device_occupation(self, device_path, action_description="操作"): - """ - 尝试解决设备占用问题。 - 检查: - 1. 是否是交换分区,如果是则提示用户关闭。 - 2. 是否有进程占用,如果有则提示用户终止。 - :param device_path: 要检查的设备路径。 - :param action_description: 正在尝试的操作描述,用于用户提示。 - :return: True 如果占用已解决或没有占用,False 如果用户取消或解决失败。 - """ - logger.info(f"尝试解决设备 {device_path} 的占用问题,以便进行 {action_description}。") - - # 1. 检查是否是交换分区 - block_devices = self.system_manager.get_block_devices() - device_info = self.system_manager._find_device_by_path_recursive(block_devices, device_path) - - # Check if it's a swap partition and currently active - if device_info and device_info.get('fstype') == 'swap' and device_info.get('mountpoint') == '[SWAP]': - reply = QMessageBox.question( - None, - "设备占用 - 交换分区", - f"设备 {device_path} 是一个活跃的交换分区。为了进行 {action_description},需要关闭它。您要继续吗?", - QMessageBox.Yes | QMessageBox.No, - QMessageBox.No - ) - if reply == QMessageBox.No: - logger.info(f"用户取消了关闭交换分区 {device_path}。") + return True return False - logger.info(f"尝试关闭交换分区 {device_path}。") - success, _, stderr = self._execute_shell_command( - ["swapoff", device_path], - f"关闭交换分区 {device_path} 失败", - show_dialog=True # Show dialog for swapoff failure - ) - if not success: - logger.error(f"关闭交换分区 {device_path} 失败: {stderr}") - QMessageBox.critical(None, "错误", f"关闭交换分区 {device_path} 失败。无法进行 {action_description}。") - return False - QMessageBox.information(None, "信息", f"交换分区 {device_path} 已成功关闭。") - # After swapoff, it might still be reported by fuser, so continue to fuser check. - - # 2. 检查是否有进程占用 (使用 fuser) - success_fuser, stdout_fuser, stderr_fuser = self._execute_shell_command( - ["fuser", "-vm", device_path], - f"检查设备 {device_path} 占用失败", - show_dialog=False, # Don't show dialog if fuser fails (e.g., device not busy) - suppress_critical_dialog_on_stderr_match=( - "No such file or directory", # if device doesn't exist - "not found", # if fuser itself isn't found - "Usage:" # if fuser gets invalid args, though unlikely here - ) - ) - - pids = [] - process_info_lines = [] - # Parse fuser output only if it was successful and has stdout - if success_fuser and stdout_fuser: - for line in stdout_fuser.splitlines(): - # Example: "/dev/sdb1: root 12078 .rce. gpg-agent" - match = re.match(r'^\S+:\s+\S+\s+(\d+)\s+.*', line) - if match: - pid = match.group(1) - pids.append(pid) - process_info_lines.append(line.strip()) - - if pids: - process_list_str = "\n".join(process_info_lines) - reply = QMessageBox.question( - None, - "设备占用 - 进程", - f"设备 {device_path} 正在被以下进程占用:\n{process_list_str}\n\n您要强制终止这些进程吗?这可能会导致数据丢失或系统不稳定!", - QMessageBox.Yes | QMessageBox.No, - QMessageBox.No - ) - if reply == QMessageBox.No: - logger.info(f"用户取消了终止占用设备 {device_path} 的进程。") - return False - - logger.info(f"尝试终止占用设备 {device_path} 的进程: {', '.join(pids)}。") - all_killed = True - for pid in pids: - kill_success, _, kill_stderr = self._execute_shell_command( - ["kill", "-9", pid], - f"终止进程 {pid} 失败", - show_dialog=True # Show dialog for kill failure - ) - if not kill_success: - logger.error(f"终止进程 {pid} 失败: {kill_stderr}") - all_killed = False - if not all_killed: - QMessageBox.critical(None, "错误", f"未能终止所有占用设备 {device_path} 的进程。无法进行 {action_description}。") - return False - QMessageBox.information(None, "信息", f"已尝试终止占用设备 {device_path} 的所有进程。") - else: - logger.info(f"设备 {device_path} 未被任何进程占用。") - - logger.info(f"设备 {device_path} 的占用问题已尝试解决。") - return True - def mount_partition(self, device_path, mount_point, add_to_fstab=False): """ 挂载指定设备到指定挂载点。 @@ -288,7 +176,6 @@ class DiskOperations(QObject): # <--- 继承 QObject 以便发出信号 if success: QMessageBox.information(None, "成功", f"设备 {device_path} 已成功挂载到 {mount_point}。") if add_to_fstab: - # NEW: 使用 self.system_manager 获取 fstype 和 UUID device_details = self.system_manager.get_device_details_by_path(device_path) if device_details: fstype = device_details.get('fstype') @@ -306,66 +193,32 @@ class DiskOperations(QObject): # <--- 继承 QObject 以便发出信号 return False def unmount_partition(self, device_path, show_dialog_on_error=True): - """ - 卸载指定设备。 - :param device_path: 要卸载的设备路径。 - :param show_dialog_on_error: 是否在发生错误时显示对话框。 - :return: True 如果成功,否则 False。 - """ - logger.info(f"尝试卸载设备 {device_path}。") - # 定义表示设备已未挂载的错误信息(中英文) - already_unmounted_errors = ("not mounted", "未挂载", "未指定挂载点") - device_busy_errors = ("device is busy", "设备或资源忙", "Device or resource busy") # Common "device busy" messages + """ + 卸载指定设备。 + :param device_path: 要卸载的设备路径。 + :param show_dialog_on_error: 是否在发生错误时显示对话框。 + :return: True 如果成功,否则 False。 + """ + logger.info(f"尝试卸载设备 {device_path}。") + already_unmounted_errors = ("not mounted", "未挂载", "未指定挂载点") - # First attempt to unmount - success, _, stderr = self._execute_shell_command( - ["umount", device_path], - f"卸载设备 {device_path} 失败", - suppress_critical_dialog_on_stderr_match=already_unmounted_errors + device_busy_errors, # Suppress both types of errors for initial attempt - show_dialog=False # Don't show dialog on first attempt, we'll handle it - ) + success, _, stderr = self._execute_shell_command( + ["umount", device_path], + f"卸载设备 {device_path} 失败", + suppress_critical_dialog_on_stderr_match=already_unmounted_errors, + show_dialog=show_dialog_on_error + ) - if success: - QMessageBox.information(None, "成功", f"设备 {device_path} 已成功卸载。") - return True - else: - # Check if it failed because it was already unmounted - if any(s in stderr for s in already_unmounted_errors): - logger.info(f"设备 {device_path} 已经处于未挂载状态(或挂载点未指定),无需重复卸载。") - return True # Consider it a success - - # Check if it failed because the device was busy - if any(s in stderr for s in device_busy_errors): - logger.warning(f"卸载设备 {device_path} 失败,设备忙。尝试解决占用问题。") - # Call the new helper to resolve occupation - if self._resolve_device_occupation(device_path, action_description=f"卸载 {device_path}"): - logger.info(f"设备 {device_path} 占用问题已解决,重试卸载。") - # Retry unmount after resolving occupation - retry_success, _, retry_stderr = self._execute_shell_command( - ["umount", device_path], - f"重试卸载设备 {device_path} 失败", - suppress_critical_dialog_on_stderr_match=already_unmounted_errors, - show_dialog=show_dialog_on_error # Show dialog if retry fails for other reasons - ) - if retry_success: - QMessageBox.information(None, "成功", f"设备 {device_path} 已成功卸载。") - return True - else: - logger.error(f"重试卸载设备 {device_path} 失败: {retry_stderr}") - if show_dialog_on_error and not any(s in retry_stderr for s in already_unmounted_errors): - QMessageBox.critical(None, "错误", f"重试卸载设备 {device_path} 失败。\n错误详情: {retry_stderr}") - return False - else: - logger.info(f"用户取消或未能解决设备 {device_path} 的占用问题。") - if show_dialog_on_error: - QMessageBox.critical(None, "错误", f"未能解决设备 {device_path} 的占用问题,无法卸载。") - return False + if success: + QMessageBox.information(None, "成功", f"设备 {device_path} 已成功卸载。") + return True else: - # Other types of unmount failures - logger.error(f"卸载设备 {device_path} 失败: {stderr}") - if show_dialog_on_error: - QMessageBox.critical(None, "错误", f"卸载设备 {device_path} 失败。\n错误详情: {stderr}") - return False + is_already_unmounted_error = any(s in stderr for s in already_unmounted_errors) + if is_already_unmounted_error: + logger.info(f"设备 {device_path} 已经处于未挂载状态(或挂载点未指定),无需重复卸载。") + return True + else: + return False def get_disk_free_space_info_mib(self, disk_path, total_disk_mib): """ @@ -376,8 +229,6 @@ class DiskOperations(QObject): # <--- 继承 QObject 以便发出信号 如果磁盘有分区表但没有空闲空间,返回 (None, None)。 """ logger.debug(f"尝试获取磁盘 {disk_path} 的空闲空间信息 (MiB)。") - # suppress_critical_dialog_on_stderr_match is added to handle cases where parted might complain about - # an unrecognized disk label, which is expected for a fresh disk. success, stdout, stderr = self._execute_shell_command( ["parted", "-s", disk_path, "unit", "MiB", "print", "free"], f"获取磁盘 {disk_path} 分区信息失败", @@ -386,9 +237,6 @@ class DiskOperations(QObject): # <--- 继承 QObject 以便发出信号 ) if not success: - # If parted failed and it wasn't due to an unrecognized label (handled by suppress_critical_dialog_on_stderr_match), - # then _execute_shell_command would have shown a dialog. - # We just log and return None. logger.error(f"获取磁盘 {disk_path} 空闲空间信息失败: {stderr}") return None, None @@ -396,18 +244,14 @@ class DiskOperations(QObject): # <--- 继承 QObject 以便发出信号 free_spaces = [] lines = stdout.splitlines() - # Regex to capture StartMiB and SizeMiB from a "Free Space" line - # It's made more flexible to match "Free Space", "空闲空间", and "可用空间" - # Example: " 0.02MiB 8192MiB 8192MiB 可用空间" - # We need to capture the first numeric value (Start) and the third numeric value (Size). free_space_line_pattern = re.compile(r'^\s*(\d+\.?\d*)MiB\s+(\d+\.?\d*)MiB\s+(\d+\.?\d*)MiB\s+(?:Free Space|空闲空间|可用空间)') for line in lines: match = free_space_line_pattern.match(line) if match: try: - start_mib = float(match.group(1)) # Capture StartMiB - size_mib = float(match.group(3)) # Capture SizeMiB (the third numeric value) + start_mib = float(match.group(1)) + size_mib = float(match.group(3)) free_spaces.append({'start_mib': start_mib, 'size_mib': size_mib}) except ValueError as ve: logger.warning(f"解析 parted free space 行 '{line}' 失败: {ve}") @@ -417,7 +261,6 @@ class DiskOperations(QObject): # <--- 继承 QObject 以便发出信号 logger.warning(f"在磁盘 {disk_path} 上未找到空闲空间。") return None, None - # 找到最大的空闲空间块 largest_free_space = max(free_spaces, key=lambda x: x['size_mib']) start_mib = largest_free_space['start_mib'] size_mib = largest_free_space['size_mib'] @@ -440,39 +283,28 @@ class DiskOperations(QObject): # <--- 继承 QObject 以便发出信号 QMessageBox.critical(None, "错误", "无效的磁盘路径或分区表类型。") return False - # NEW: 尝试解决磁盘占用问题 - if not self._resolve_device_occupation(disk_path, action_description=f"在 {disk_path} 上创建分区"): - logger.info(f"用户取消或未能解决磁盘 {disk_path} 的占用问题,取消创建分区。") - return False - # 1. 检查磁盘是否有分区表 has_partition_table = False - # Use suppress_critical_dialog_on_stderr_match for "unrecognized disk label" when checking success_check, stdout_check, stderr_check = self._execute_shell_command( ["parted", "-s", disk_path, "print"], f"检查磁盘 {disk_path} 分区表失败", suppress_critical_dialog_on_stderr_match=("无法辨识的磁盘卷标", "unrecognized disk label"), root_privilege=True ) - # If success_check is False, it means _execute_shell_command encountered an error. - # If that error was "unrecognized disk label", it was suppressed, and we treat it as no partition table. - # If it was another error, a dialog was shown by _execute_shell_command, and we should stop. if not success_check: - # Check if the failure was due to "unrecognized disk label" (which means no partition table) if any(s in stderr_check for s in ("无法辨识的磁盘卷标", "unrecognized disk label")): logger.info(f"磁盘 {disk_path} 没有可识别的分区表。") - has_partition_table = False # Explicitly set to False + has_partition_table = False else: logger.error(f"检查磁盘 {disk_path} 分区表时发生非预期的错误: {stderr_check}") - return False # Other critical error, stop operation. - else: # success_check is True + return False + else: if "Partition Table: unknown" not in stdout_check and "分区表:unknown" not in stdout_check: has_partition_table = True else: logger.info(f"parted print 报告磁盘 {disk_path} 的分区表为 'unknown'。") has_partition_table = False - actual_start_mib_for_parted = 0.0 # 2. 如果没有分区表,则创建分区表 @@ -492,7 +324,6 @@ class DiskOperations(QObject): # <--- 继承 QObject 以便发出信号 ) if not success: return False - # 对于新创建分区表的磁盘,第一个分区通常从 1MiB 开始以确保兼容性和对齐 actual_start_mib_for_parted = 1.0 else: # 如果有分区表,获取下一个可用分区的起始位置 @@ -501,10 +332,7 @@ class DiskOperations(QObject): # <--- 继承 QObject 以便发出信号 QMessageBox.critical(None, "错误", f"无法确定磁盘 {disk_path} 的分区起始位置或没有空闲空间。") return False - # 如果 parted 报告的起始位置非常接近 0 MiB (例如 0.0 MiB 或 0.02 MiB), - # 为了安全和兼容性,也将其调整为 1.0 MiB。 - # 否则,使用 parted 报告的精确起始位置。 - if start_mib_from_parted < 1.0: # covers 0.0MiB and values like 0.017MiB (17.4kB) + if start_mib_from_parted < 1.0: actual_start_mib_for_parted = 1.0 else: actual_start_mib_for_parted = start_mib_from_parted @@ -514,12 +342,7 @@ class DiskOperations(QObject): # <--- 继承 QObject 以便发出信号 end_pos = "100%" size_for_log = "最大可用空间" else: - # 计算结束 MiB - # 注意:这里计算的 end_mib 是基于用户请求的大小, - # parted 会根据实际可用空间和对齐进行微调。 end_mib = actual_start_mib_for_parted + size_gb * 1024 - - # 简单检查,如果请求的大小导致结束位置超出磁盘总大小,则使用最大可用 if end_mib > total_disk_mib: QMessageBox.warning(None, "警告", f"请求的分区大小 ({size_gb}GB) 超出了可用空间。将调整为最大可用空间。") end_pos = "100%" @@ -547,11 +370,6 @@ class DiskOperations(QObject): # <--- 继承 QObject 以便发出信号 :param device_path: 要删除的分区路径。 :return: True 如果成功,否则 False。 """ - # NEW: 尝试解决分区占用问题 - if not self._resolve_device_occupation(device_path, action_description=f"删除分区 {device_path}"): - logger.info(f"用户取消或未能解决分区 {device_path} 的占用问题,取消删除分区。") - return False - reply = QMessageBox.question(None, "确认删除分区", f"您确定要删除分区 {device_path} 吗?此操作将擦除分区上的所有数据!", QMessageBox.Yes | QMessageBox.No, QMessageBox.No) @@ -559,15 +377,13 @@ class DiskOperations(QObject): # <--- 继承 QObject 以便发出信号 logger.info(f"用户取消了删除分区 {device_path} 的操作。") return False - # 尝试卸载分区 (此方法内部会尝试解决占用问题) + # 尝试卸载分区 self.unmount_partition(device_path, show_dialog_on_error=False) # 从 fstab 中移除条目 - # _remove_fstab_entry also handles "not found" gracefully. self._remove_fstab_entry(device_path) # 获取父磁盘和分区号 - # 例如 /dev/sdb1 -> /dev/sdb, 1 match = re.match(r'(/dev/[a-z]+)(\d+)', device_path) if not match: QMessageBox.critical(None, "错误", f"无法解析设备路径 {device_path}。") @@ -602,11 +418,6 @@ class DiskOperations(QObject): # <--- 继承 QObject 以便发出信号 logger.info("用户取消了文件系统选择。") return False - # NEW: 尝试解决设备占用问题 - if not self._resolve_device_occupation(device_path, action_description=f"格式化设备 {device_path}"): - logger.info(f"用户取消或未能解决设备 {device_path} 的占用问题,取消格式化。") - return False - reply = QMessageBox.question(None, "确认格式化", f"您确定要格式化设备 {device_path} 为 {fstype} 吗?此操作将擦除所有数据!", QMessageBox.Yes | QMessageBox.No, QMessageBox.No) @@ -626,20 +437,19 @@ class DiskOperations(QObject): # <--- 继承 QObject 以便发出信号 # 创建 QThread 和 FormatWorker 实例 thread = QThread() - # 将 self._execute_shell_command (它内部调用 LvmOperations._execute_shell_command) 传递给工作线程 worker = FormatWorker(device_path, fstype, self._execute_shell_command) worker.moveToThread(thread) # 连接信号和槽 - thread.started.connect(worker.run) # 线程启动时执行 worker 的 run 方法 - worker.finished.connect(lambda s, dp, o, e: self._on_formatting_finished(s, dp, o, e)) # worker 完成时调用处理函数 - worker.finished.connect(thread.quit) # worker 完成时退出线程 - worker.finished.connect(worker.deleteLater) # worker 完成时自动删除 worker 对象 - thread.finished.connect(thread.deleteLater) # 线程退出时自动删除线程对象 + thread.started.connect(worker.run) + worker.finished.connect(lambda s, dp, o, e: self._on_formatting_finished(s, dp, o, e)) + worker.finished.connect(thread.quit) + worker.finished.connect(worker.deleteLater) + thread.finished.connect(thread.deleteLater) - self.active_format_workers[device_path] = thread # 存储线程以便管理 - self.formatting_started.emit(device_path) # 发出信号通知主界面格式化已开始 - thread.start() # 启动线程 + self.active_format_workers[device_path] = thread + self.formatting_started.emit(device_path) + thread.start() QMessageBox.information(None, "开始格式化", f"设备 {device_path} 正在后台格式化为 {fstype}。完成后将通知您。") return True @@ -649,13 +459,11 @@ class DiskOperations(QObject): # <--- 继承 QObject 以便发出信号 处理格式化工作线程完成后的结果。此槽函数在主线程中执行。 """ if device_path in self.active_format_workers: - del self.active_format_workers[device_path] # 从跟踪列表中移除 + del self.active_format_workers[device_path] - # 在主线程中显示最终结果的消息框 if success: QMessageBox.information(None, "格式化成功", f"设备 {device_path} 已成功格式化。") else: QMessageBox.critical(None, "格式化失败", f"格式化设备 {device_path} 失败。\n错误详情: {stderr}") - self.formatting_finished.emit(success, device_path, stdout, stderr) # 发出信号通知 MainWindow 刷新界面 - + self.formatting_finished.emit(success, device_path, stdout, stderr) diff --git a/lvm_operations.py b/lvm_operations.py index b1c6b86..abd27ed 100644 --- a/lvm_operations.py +++ b/lvm_operations.py @@ -6,12 +6,12 @@ from PySide6.QtWidgets import QMessageBox logger = logging.getLogger(__name__) class LvmOperations: - def __init__(self, disk_ops=None): # <--- Add disk_ops parameter - self.disk_ops = disk_ops # Store disk_ops instance + def __init__(self): + pass def _execute_shell_command(self, command_list, error_message, root_privilege=True, - suppress_critical_dialog_on_stderr_match=None, input_data=None, - show_dialog=True): # <--- 新增 show_dialog 参数,默认为 True + suppress_critical_dialog_on_stderr_match=None, input_data=None, + show_dialog=True, expected_non_zero_exit_codes=None): """ 通用地运行一个 shell 命令,并处理错误。 :param command_list: 命令及其参数的列表。 @@ -21,11 +21,12 @@ class LvmOperations: 可以是字符串或字符串元组/列表。 :param input_data: 传递给命令stdin的数据 (str)。 :param show_dialog: 如果为 False,则不显示关键错误对话框。 + :param expected_non_zero_exit_codes: 预期为非零但仍视为成功的退出码列表。 :return: (True/False, stdout_str, stderr_str) """ if not all(isinstance(arg, str) for arg in command_list): logger.error(f"命令列表包含非字符串元素: {command_list}") - if show_dialog: # <--- 根据 show_dialog 决定是否弹出对话框 + if show_dialog: QMessageBox.critical(None, "错误", f"内部错误:尝试执行的命令包含无效参数。\n命令详情: {command_list}") return False, "", "内部错误:命令参数类型不正确。" @@ -53,6 +54,12 @@ class LvmOperations: logger.error(f"标准输出: {e.stdout.strip()}") logger.error(f"标准错误: {stderr_output}") + # NEW LOGIC: Check if the exit code is expected as non-critical + if expected_non_zero_exit_codes and e.returncode in expected_non_zero_exit_codes: + logger.info(f"命令 '{full_cmd_str}' 以预期非零退出码 {e.returncode} 结束,仍视为成功。") + return True, e.stdout.strip(), stderr_output # Treat as success + + should_suppress_dialog = False if suppress_critical_dialog_on_stderr_match: if isinstance(suppress_critical_dialog_on_stderr_match, str): @@ -64,23 +71,21 @@ class LvmOperations: should_suppress_dialog = True break - if show_dialog and not should_suppress_dialog: # <--- 根据 show_dialog 决定是否弹出对话框 + if show_dialog and not should_suppress_dialog: QMessageBox.critical(None, "错误", f"{error_message}\n错误详情: {stderr_output}") return False, e.stdout.strip(), stderr_output except FileNotFoundError: - if show_dialog: # <--- 根据 show_dialog 决定是否弹出对话框 + if show_dialog: QMessageBox.critical(None, "错误", f"命令 '{command_list[0]}' 未找到。请确保已安装相关工具。") logger.error(f"命令 '{command_list[0]}' 未找到。") return False, "", f"命令 '{command_list[0]}' 未找到。" except Exception as e: - if show_dialog: # <--- 根据 show_dialog 决定是否弹出对话框 + if show_dialog: QMessageBox.critical(None, "错误", f"执行命令时发生未知错误: {e}") logger.error(f"执行命令 {full_cmd_str} 时发生未知错误: {e}") return False, "", str(e) - # 以下是 LvmOperations 中其他方法的代码,保持不变。 - # ... (create_pv, delete_pv, create_vg, delete_vg, create_lv, delete_lv, activate_lv, deactivate_lv) ... def create_pv(self, device_path): """ 创建物理卷 (PV)。 @@ -92,15 +97,6 @@ class LvmOperations: QMessageBox.critical(None, "错误", "无效的设备路径。") return False - # NEW: 尝试解决设备占用问题 - if self.disk_ops: # Ensure disk_ops is available - if not self.disk_ops._resolve_device_occupation(device_path, action_description=f"在 {device_path} 上创建物理卷"): - logger.info(f"用户取消或未能解决设备 {device_path} 的占用问题,取消创建物理卷。") - return False - else: - logger.warning("DiskOperations 实例未传递给 LvmOperations,无法解决设备占用问题。") - - reply = QMessageBox.question(None, "确认创建物理卷", f"您确定要在设备 {device_path} 上创建物理卷吗?此操作将覆盖设备上的数据。", QMessageBox.Yes | QMessageBox.No, QMessageBox.No) @@ -109,20 +105,17 @@ class LvmOperations: return False logger.info(f"尝试在 {device_path} 上创建物理卷。") - # 第一次尝试创建物理卷,抑制 "device is partitioned" 错误,因为我们将在代码中处理它 success, _, stderr = self._execute_shell_command( - ["pvcreate", "-y", device_path], # -y 自动确认 + ["pvcreate", "-y", device_path], f"在 {device_path} 上创建物理卷失败", - suppress_critical_dialog_on_stderr_match="device is partitioned" # 抑制此特定错误 + suppress_critical_dialog_on_stderr_match="device is partitioned" ) if success: QMessageBox.information(None, "成功", f"物理卷已在 {device_path} 上成功创建。") return True else: - # 如果 pvcreate 失败,检查是否是 "device is partitioned" 错误 if "device is partitioned" in stderr: - # 提示用户是否要擦除分区表 wipe_reply = QMessageBox.question( None, "设备已分区", f"设备 {device_path} 已分区。您是否要擦除设备上的所有分区表," @@ -131,35 +124,28 @@ class LvmOperations: ) if wipe_reply == QMessageBox.Yes: logger.warning(f"用户选择擦除 {device_path} 上的分区表并创建物理卷。") - # 尝试擦除分区表 (使用 GPT 作为默认,通常更现代且支持大容量磁盘) - # 注意:这里需要 parted 命令,确保系统已安装 parted mklabel_success, _, mklabel_stderr = self._execute_shell_command( - ["parted", "-s", device_path, "mklabel", "gpt"], # 使用 gpt 分区表 + ["parted", "-s", device_path, "mklabel", "gpt"], f"擦除 {device_path} 上的分区表失败" ) if mklabel_success: logger.info(f"已成功擦除 {device_path} 上的分区表。重试创建物理卷。") - # 再次尝试创建物理卷 retry_success, _, retry_stderr = self._execute_shell_command( ["pvcreate", "-y", device_path], f"在 {device_path} 上创建物理卷失败 (重试)" - # 重试时不再抑制,如果再次失败,_execute_shell_command 会显示错误 ) if retry_success: QMessageBox.information(None, "成功", f"物理卷已在 {device_path} 上成功创建。") return True else: - # 如果重试失败,_execute_shell_command 已经显示错误 return False else: - # mklabel 失败,_execute_shell_command 已经显示错误 return False else: logger.info(f"用户取消了擦除 {device_path} 分区表的操作。") QMessageBox.information(None, "信息", f"未在已分区设备 {device_path} 上创建物理卷。") return False else: - # 对于其他类型的 pvcreate 失败,_execute_shell_command 已经显示了错误 return False def delete_pv(self, device_path): @@ -374,4 +360,3 @@ class LvmOperations: return True else: return False - diff --git a/mainwindow.py b/mainwindow.py index 8224d65..62f8343 100644 --- a/mainwindow.py +++ b/mainwindow.py @@ -6,7 +6,7 @@ import os from PySide6.QtWidgets import (QApplication, QMainWindow, QTreeWidgetItem, QMessageBox, QHeaderView, QMenu, QInputDialog, QDialog) -from PySide6.QtCore import Qt, QPoint, QThread # <--- 确保导入 QThread +from PySide6.QtCore import Qt, QPoint, QThread # 导入自动生成的 UI 文件 from ui_form import Ui_MainWindow @@ -20,6 +20,8 @@ from disk_operations import DiskOperations from raid_operations import RaidOperations # 导入 LVM 操作模块 from lvm_operations import LvmOperations +# 导入新的 resolver 类 +from occupation_resolver import OccupationResolver # 导入自定义对话框 from dialogs import (CreatePartitionDialog, MountDialog, CreateRaidDialog, CreatePvDialog, CreateVgDialog, CreateLvDialog) @@ -34,13 +36,13 @@ class MainWindow(QMainWindow): setup_logging(self.ui.logOutputTextEdit) logger.info("应用程序启动。") - # 初始化管理器和操作类 + # 初始化管理器和操作类 (恢复原始的初始化顺序和依赖关系) self.system_manager = SystemInfoManager() - # Correct order for dependency injection: - self.lvm_ops = LvmOperations() # Initialize LVM first - self.disk_ops = DiskOperations(self.system_manager, self.lvm_ops) # DiskOperations needs system_manager and lvm_ops - self.lvm_ops.disk_ops = self.disk_ops # Inject disk_ops into lvm_ops - self.raid_ops = RaidOperations(self.system_manager, self.disk_ops) # RaidOperations needs system_manager and disk_ops + self.lvm_ops = LvmOperations() + # DiskOperations 仍然需要 lvm_ops 的 _execute_shell_command 方法 + self.disk_ops = DiskOperations(self.system_manager, self.lvm_ops) + # RaidOperations 仍然需要 system_manager + self.raid_ops = RaidOperations(self.system_manager) # 连接刷新按钮的信号到槽函数 @@ -59,10 +61,16 @@ class MainWindow(QMainWindow): self.ui.treeWidget_lvm.setContextMenuPolicy(Qt.CustomContextMenu) self.ui.treeWidget_lvm.customContextMenuRequested.connect(self.show_lvm_context_menu) - # <--- 新增: 连接 DiskOperations 的格式化完成信号 + # 连接 DiskOperations 的格式化完成信号 self.disk_ops.formatting_finished.connect(self.on_disk_formatting_finished) - # 可选:连接格式化开始信号,用于显示进度或禁用相关操作 - # self.disk_ops.formatting_started.connect(self.on_disk_formatting_started) + + # 实例化 OccupationResolver + # 将 lvm_ops._execute_shell_command 和 system_manager.get_mountpoint_for_device 传递给它 + # 确保 lvm_ops 和 system_manager 已经被正确初始化 + self.occupation_resolver = OccupationResolver( + shell_executor_func=self.lvm_ops._execute_shell_command, + mount_info_getter_func=self.system_manager.get_mountpoint_for_device + ) # 初始化时刷新所有数据 self.refresh_all_info() @@ -158,10 +166,8 @@ class MainWindow(QMainWindow): if device_type == 'disk': create_partition_action = menu.addAction(f"创建分区 {device_path}...") create_partition_action.triggered.connect(lambda: self._handle_create_partition(device_path, dev_data)) - # --- 新增功能:擦除分区表 --- wipe_action = menu.addAction(f"擦除分区表 {device_path}...") wipe_action.triggered.connect(lambda: self._handle_wipe_partition_table(device_path)) - # --- 新增功能结束 --- menu.addSeparator() if device_type == 'part': @@ -178,22 +184,70 @@ class MainWindow(QMainWindow): format_action = menu.addAction(f"格式化分区 {device_path}...") format_action.triggered.connect(lambda: self._handle_format_partition(device_path)) + menu.addSeparator() # Add separator before new occupation options + + # --- 新增:解除设备占用选项 --- + # This option is available for any disk or partition that might be occupied + if device_type in ['disk', 'part']: + resolve_occupation_action = menu.addAction(f"解除占用 {device_path}") + resolve_occupation_action.triggered.connect(lambda: self._handle_resolve_device_occupation(device_path)) + + # --- 新增:关闭交换分区选项 --- + # This option is only available for active swap partitions + if device_type == 'part' and mount_point == '[SWAP]': + deactivate_swap_action = menu.addAction(f"关闭交换分区 {device_path}") + deactivate_swap_action.triggered.connect(lambda: self._handle_deactivate_swap(device_path)) if menu.actions(): menu.exec(self.ui.treeWidget_block_devices.mapToGlobal(pos)) else: logger.info("右键点击了空白区域或设备没有可用的操作。") - # --- 新增方法:处理擦除分区表 --- + # --- 新增:处理解除设备占用 --- + def _handle_resolve_device_occupation(self, device_path: str) -> bool: + """ + 处理设备占用问题,通过调用 OccupationResolver 进行全面检查和修复。 + """ + success = self.occupation_resolver.resolve_occupation(device_path) + if success: + # 如果成功解除占用,刷新 UI 显示最新的设备状态 + self.refresh_all_info() + return success + + # --- 新增:处理关闭交换分区 --- + def _handle_deactivate_swap(self, device_path): + """ + 处理关闭交换分区的操作。 + """ + reply = QMessageBox.question( + None, + "确认关闭交换分区", + f"您确定要关闭交换分区 {device_path} 吗?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No + ) + if reply == QMessageBox.No: + logger.info(f"用户取消了关闭交换分区 {device_path}。") + return False + + logger.info(f"尝试关闭交换分区 {device_path}。") + success, _, stderr = self.lvm_ops._execute_shell_command( + ["swapoff", device_path], + f"关闭交换分区 {device_path} 失败", + show_dialog=True + ) + if success: + QMessageBox.information(None, "成功", f"交换分区 {device_path} 已成功关闭。") + self.refresh_all_info() + return True + else: + logger.error(f"关闭交换分区 {device_path} 失败: {stderr}") + return False + def _handle_wipe_partition_table(self, device_path): """ 处理擦除物理盘分区表的操作。 """ - # NEW: 尝试解决磁盘占用问题 - if not self.disk_ops._resolve_device_occupation(device_path, action_description=f"擦除 {device_path} 上的分区表"): - logger.info(f"用户取消或未能解决磁盘 {device_path} 的占用问题,取消擦除分区表。") - return - reply = QMessageBox.question( self, "确认擦除分区表", @@ -208,10 +262,8 @@ class MainWindow(QMainWindow): return logger.warning(f"尝试擦除 {device_path} 上的分区表。") - # 使用 LvmOperations 中通用的 _execute_shell_command 来执行 parted 命令 - # 这里不需要 show_dialog=False,因为这个操作本身就是同步且需要用户确认的 success, _, stderr = self.lvm_ops._execute_shell_command( - ["parted", "-s", device_path, "mklabel", "gpt"], # 默认使用 gpt 分区表类型 + ["parted", "-s", device_path, "mklabel", "gpt"], f"擦除 {device_path} 上的分区表失败" ) @@ -219,9 +271,7 @@ class MainWindow(QMainWindow): QMessageBox.information(self, "成功", f"设备 {device_path} 上的分区表已成功擦除。") self.refresh_all_info() else: - # 错误信息已由 _execute_shell_command 处理并显示 pass - # --- 新增方法结束 --- def _handle_create_partition(self, disk_path, dev_data): total_disk_mib = 0.0 @@ -260,7 +310,6 @@ class MainWindow(QMainWindow): if dev_data.get('children'): logger.debug(f"磁盘 {disk_path} 存在现有分区,尝试计算下一个分区起始位置。") - # 假设 disk_ops.get_disk_free_space_info_mib 能够正确处理 calculated_start_mib, largest_free_space_mib = self.disk_ops.get_disk_free_space_info_mib(disk_path, total_disk_mib) if calculated_start_mib is None or largest_free_space_mib is None: QMessageBox.critical(self, "错误", f"无法确定磁盘 {disk_path} 的分区起始位置。") @@ -271,8 +320,8 @@ class MainWindow(QMainWindow): max_available_mib = 0.0 else: logger.debug(f"磁盘 {disk_path} 没有现有分区,假定从 0 MiB 开始,最大可用空间为整个磁盘。") - max_available_mib = max(0.0, total_disk_mib - 1.0) # 留一点空间,避免边界问题 - start_position_mib = 1.0 # 现代分区表通常从1MB或更大偏移开始 + max_available_mib = max(0.0, total_disk_mib - 1.0) + start_position_mib = 1.0 logger.debug(f"磁盘 {disk_path} 没有现有分区,假定从 {start_position_mib} MiB 开始,最大可用空间为 {max_available_mib} MiB。") dialog = CreatePartitionDialog(self, disk_path, total_disk_mib, max_available_mib) @@ -308,18 +357,14 @@ class MainWindow(QMainWindow): """ 调用 DiskOperations 的异步格式化方法。 """ - # DiskOperations.format_partition 会处理用户确认和启动后台线程 self.disk_ops.format_partition(device_path) - # 界面刷新将在格式化完成后由 on_disk_formatting_finished 槽函数触发,所以这里不需要 refresh_all_info() - # <--- 新增: 格式化完成后的槽函数 def on_disk_formatting_finished(self, success, device_path, stdout, stderr): """ 接收 DiskOperations 发出的格式化完成信号,并刷新界面。 """ logger.info(f"格式化完成信号接收: 设备 {device_path}, 成功: {success}") - # QMessageBox 已经在 DiskOperations 的 _on_formatting_finished 中处理 - self.refresh_all_info() # 刷新所有信息以显示更新后的文件系统类型等 + self.refresh_all_info() # --- RAID 管理 Tab --- def refresh_raid_info(self): @@ -336,7 +381,7 @@ class MainWindow(QMainWindow): self.ui.treeWidget_raid.header().setSectionResizeMode(i, QHeaderView.ResizeToContents) try: - raid_arrays = self.system_manager.get_mdadm_arrays() # 现在会返回所有阵列,包括停止的 + raid_arrays = self.system_manager.get_mdadm_arrays() if not raid_arrays: item = QTreeWidgetItem(self.ui.treeWidget_raid) item.setText(0, "未找到RAID阵列。") @@ -350,14 +395,13 @@ class MainWindow(QMainWindow): logger.warning(f"RAID阵列 '{array.get('name', '未知')}' 的设备路径无效,跳过。") continue - # 对于停止状态的阵列,挂载点可能不存在或不相关 current_mount_point = "" if array.get('state') != 'Stopped (Configured)': current_mount_point = self.system_manager.get_mountpoint_for_device(array_path) array_item.setText(0, array_path) array_item.setText(1, array.get('level', 'N/A')) - array_item.setText(2, array.get('state', 'N/A')) # 显示实际状态 + array_item.setText(2, array.get('state', 'N/A')) array_item.setText(3, array.get('array_size', 'N/A')) array_item.setText(4, array.get('active_devices', 'N/A')) array_item.setText(5, array.get('failed_devices', 'N/A')) @@ -369,17 +413,14 @@ class MainWindow(QMainWindow): array_item.setText(11, current_mount_point if current_mount_point else "") array_item.setExpanded(True) - # 存储完整的阵列数据,包括状态,供上下文菜单使用 array_item.setData(0, Qt.UserRole, array) - # 对于停止状态的阵列,成员设备可能为空,跳过显示子项 if array.get('state') != 'Stopped (Configured)': for member in array.get('member_devices', []): member_item = QTreeWidgetItem(array_item) member_item.setText(0, f" {member.get('device_path', 'N/A')}") - member_item.setText(1, f"成员: {member.get('raid_device', 'N/A')}") # 假设 raid_device 字段存在 + member_item.setText(1, f"成员: {member.get('raid_device', 'N/A')}") member_item.setText(2, member.get('state', 'N/A')) - # member_item.setText(3, f"Major: {member.get('major', 'N/A')}, Minor: {member.get('minor', 'N/A')}") # 假设 major/minor 字段存在 for i in range(len(raid_headers)): self.ui.treeWidget_raid.resizeColumnToContents(i) @@ -397,7 +438,7 @@ class MainWindow(QMainWindow): create_raid_action.triggered.connect(self._handle_create_raid_array) menu.addSeparator() - if item and item.parent() is None: # 只针对顶层阵列项 + if item and item.parent() is None: array_data = item.data(0, Qt.UserRole) if not array_data: logger.warning(f"无法获取 RAID 阵列 {item.text(0)} 的详细数据。") @@ -406,7 +447,7 @@ class MainWindow(QMainWindow): array_path = array_data.get('device') array_state = array_data.get('state', 'N/A') member_devices = [m.get('device_path') for m in array_data.get('member_devices', [])] - array_uuid = array_data.get('uuid') # <--- 获取 UUID + array_uuid = array_data.get('uuid') if not array_path or array_path == 'N/A': logger.warning(f"RAID阵列 '{array_data.get('name', '未知')}' 的设备路径无效,无法显示操作。") @@ -415,11 +456,9 @@ class MainWindow(QMainWindow): if array_state == 'Stopped (Configured)': activate_action = menu.addAction(f"激活阵列 {array_path}") activate_action.triggered.connect(lambda: self._handle_activate_raid_array(array_path)) - # <--- 新增:删除停止状态阵列的配置条目 delete_config_action = menu.addAction(f"删除配置文件条目 (UUID: {array_uuid})") delete_config_action.triggered.connect(lambda: self._handle_delete_configured_raid_array(array_uuid)) - # 停止状态的阵列不显示卸载、停止、删除、格式化等操作 - else: # 活动或降级状态的阵列 + else: current_mount_point = self.system_manager.get_mountpoint_for_device(array_path) if current_mount_point and current_mount_point != '[SWAP]' and current_mount_point != '': @@ -430,10 +469,15 @@ class MainWindow(QMainWindow): mount_action.triggered.connect(lambda: self._handle_mount(array_path)) menu.addSeparator() + # --- 新增:RAID 阵列解除占用选项 --- + resolve_occupation_action = menu.addAction(f"解除占用 {array_path}") + resolve_occupation_action.triggered.connect(lambda: self._handle_resolve_device_occupation(array_path)) + menu.addSeparator() # 在解除占用后添加分隔符 + + stop_action = menu.addAction(f"停止阵列 {array_path}") stop_action.triggered.connect(lambda: self._handle_stop_raid_array(array_path)) - # <--- 修改:删除活动阵列的动作,传递 UUID delete_action = menu.addAction(f"删除阵列 {array_path}") delete_action.triggered.connect(lambda: self._handle_delete_active_raid_array(array_path, member_devices, array_uuid)) @@ -449,8 +493,8 @@ class MainWindow(QMainWindow): """ 处理激活已停止的 RAID 阵列。 """ - item = self.ui.treeWidget_raid.currentItem() # 获取当前选中的项 - if not item or item.parent() is not None: # 确保是顶层阵列项 + item = self.ui.treeWidget_raid.currentItem() + if not item or item.parent() is not None: logger.warning("未选择有效的 RAID 阵列进行激活。") return @@ -477,7 +521,7 @@ class MainWindow(QMainWindow): logger.info(f"用户取消了激活 RAID 阵列 {array_path} 的操作。") return - if self.raid_ops.activate_raid_array(array_path, array_uuid): # 传递 UUID + if self.raid_ops.activate_raid_array(array_path, array_uuid): self.refresh_all_info() def _handle_create_raid_array(self): @@ -498,16 +542,16 @@ class MainWindow(QMainWindow): if self.raid_ops.stop_raid_array(array_path): self.refresh_all_info() - def _handle_delete_active_raid_array(self, array_path, member_devices, uuid): # <--- 修改方法名并添加 uuid 参数 + def _handle_delete_active_raid_array(self, array_path, member_devices, uuid): """ 处理删除一个活动的 RAID 阵列的操作。 """ - if self.raid_ops.delete_active_raid_array(array_path, member_devices, uuid): # <--- 调用修改后的方法 + if self.raid_ops.delete_active_raid_array(array_path, member_devices, uuid): self.refresh_all_info() - def _handle_delete_configured_raid_array(self, uuid): # <--- 新增方法 + def _handle_delete_configured_raid_array(self, uuid): """ - 处理删除停止状态 RAID 阵列的配置文件条目。 + 处理停止状态 RAID 阵列的配置文件条目。 """ if self.raid_ops.delete_configured_raid_array(uuid): self.refresh_all_info() @@ -516,9 +560,7 @@ class MainWindow(QMainWindow): """ 处理 RAID 阵列的格式化。 """ - # 这将调用 DiskOperations 的异步格式化方法 self.disk_ops.format_partition(array_path) - # 刷新操作会在 on_disk_formatting_finished 中触发,所以这里不需要 refresh_all_info() # --- LVM 管理 Tab --- def refresh_lvm_info(self): @@ -697,6 +739,11 @@ class MainWindow(QMainWindow): mount_lv_action.triggered.connect(lambda: self._handle_mount(lv_path)) menu.addSeparator() + # --- 新增:逻辑卷解除占用选项 --- + resolve_occupation_action = menu.addAction(f"解除占用 {lv_path}") + resolve_occupation_action.triggered.connect(lambda: self._handle_resolve_device_occupation(lv_path)) + menu.addSeparator() # 在解除占用后添加分隔符 + delete_lv_action = menu.addAction(f"删除逻辑卷 {lv_name}") delete_lv_action.triggered.connect(lambda: self._handle_delete_lv(lv_name, vg_name)) @@ -739,7 +786,7 @@ class MainWindow(QMainWindow): available_pvs.append(f"/dev/{pv_name}") if not available_pvs: - QMessageBox.warning(self, "警告", "没有可用于创建卷组的物理卷。请确保有未分配给任何卷组的物理卷。") + QMessageBox.warning(self, "警告", "没有可用于创建卷组的物理卷。") return dialog = CreateVgDialog(self, available_pvs) @@ -758,27 +805,23 @@ class MainWindow(QMainWindow): if vg_name and vg_name != 'N/A': available_vgs.append(vg_name) - # 将字符串转换为小写并去除可能存在的 '<' 符号,以便更准确地匹配 free_size_str = vg.get('vg_free', '0B').strip().lower() current_vg_size_gb = 0.0 - # 修正正则表达式: - # 1. 允许可选的 '<' 符号在数字前 - # 2. 确保单位匹配正确,如果单位缺失,默认按 GB 处理 (LVM 的 vg_free 常常默认 GB) match = re.match(r' Tuple[bool, str, str] + mount_info_getter_func: 一个可调用对象,用于获取设备的挂载点。 + 期望签名: (device_path: str) -> Optional[str] + """ + self._execute_shell_command = shell_executor_func + self._get_mountpoint_for_device = mount_info_getter_func + + def _run_command(self, cmd: List[str], error_msg: str, **kwargs) -> Tuple[bool, str, str]: + """ + 执行 shell 命令的包装器。 + """ + return self._execute_shell_command(cmd, error_msg, **kwargs) + + def get_all_mounts_under_path(self, base_path: str) -> List[str]: + """ + 获取指定路径下所有嵌套的挂载点。 + 返回一个挂载点列表,从最深层到最浅层排序。 + """ + mounts = [] + # 尝试使用 findmnt 命令更可靠地获取嵌套挂载信息 + # -l: 列出所有挂载 + # -o TARGET: 只输出目标路径 + # -n: 不显示标题行 + # -r: 原始输出,方便解析 + # --target : 限制只查找 base_path 下的挂载 + # -t noautofs,nosnapfs,nofuse,nocifs,nonfs,notmpfs,nobind: 排除一些不相关的或虚拟的挂载 + findmnt_cmd = ["findmnt", "-l", "-o", "TARGET", "-n", "-r", "--target", base_path, + "-t", "noautofs,nosnapfs,nofuse,nocifs,nonfs,notmpfs,nobind"] + success, stdout, stderr = self._run_command(findmnt_cmd, f"获取 {base_path} 下的挂载信息失败", show_dialog=False) + + if not success: + logger.warning(f"findmnt 命令失败,尝试解析 'mount' 命令输出: {stderr}") + # 如果 findmnt 失败或不可用,则回退到解析 'mount' 命令输出 + success, stdout, stderr = self._run_command(["mount"], "获取挂载信息失败", show_dialog=False) + if not success: + logger.error(f"获取挂载信息失败: {stderr}") + return [] + + for line in stdout.splitlines(): + match = re.search(r' on (\S+) type ', line) + if match: + mount_point = match.group(1) + # 确保是嵌套挂载点而不是基础挂载点本身 + if mount_point.startswith(base_path) and mount_point != base_path: + mounts.append(mount_point) + else: + for line in stdout.splitlines(): + mount_point = line.strip() + # findmnt --target 已经过滤了,这里只需确保不是空行 + if mount_point and mount_point.startswith(base_path) and mount_point != base_path: + mounts.append(mount_point) + + # 排序:从最深层到最浅层,以便正确卸载。路径越长,通常越深层。 + mounts.sort(key=lambda x: len(x.split('/')), reverse=True) + logger.debug(f"在 {base_path} 下找到的嵌套挂载点(从深到浅): {mounts}") + return mounts + + def _unmount_nested_mounts(self, base_mount_point: str) -> bool: + """ + 尝试卸载指定基础挂载点下的所有嵌套挂载。 + """ + nested_mounts = self.get_all_mounts_under_path(base_mount_point) + if not nested_mounts: + logger.debug(f"在 {base_mount_point} 下没有找到嵌套挂载点。") + return True # 没有需要卸载的嵌套挂载 + + logger.info(f"开始卸载 {base_mount_point} 下的嵌套挂载点: {nested_mounts}") + all_nested_unmounted = True + for mount_point in nested_mounts: + if not self._unmount_target(mount_point): + logger.error(f"未能卸载嵌套挂载点 {mount_point}。") + all_nested_unmounted = False + # 即使一个失败,也尝试卸载其他,但标记整体失败 + return all_nested_unmounted + + def _unmount_target(self, target_path: str) -> bool: + """ + 尝试卸载一个目标(设备或挂载点)。 + 先尝试普通卸载,如果失败则尝试强制卸载,再失败则尝试懒惰卸载。 + """ + logger.info(f"尝试卸载 {target_path}。") + success, stdout, stderr = self._run_command( + ["umount", target_path], + f"卸载 {target_path} 失败", + show_dialog=False, + expected_non_zero_exit_codes=[1] # umount 返回 1 表示未挂载或忙碌 + ) + if success: + logger.info(f"成功卸载 {target_path}。") + return True + else: + # 检查是否因为未挂载而失败 + if "not mounted" in stderr.lower() or "未挂载" in stderr: + logger.info(f"目标 {target_path} 未挂载,无需卸载。") + return True + + logger.warning(f"卸载 {target_path} 失败: {stderr}") + # 尝试强制卸载 -f + logger.info(f"尝试强制卸载 {target_path} (umount -f)。") + success_force, stdout_force, stderr_force = self._run_command( + ["umount", "-f", target_path], + f"强制卸载 {target_path} 失败", + show_dialog=False, + expected_non_zero_exit_codes=[1] + ) + if success_force: + logger.info(f"成功强制卸载 {target_path}。") + return True + else: + logger.warning(f"强制卸载 {target_path} 失败: {stderr_force}") + # 尝试懒惰卸载 -l + logger.info(f"尝试懒惰卸载 {target_path} (umount -l)。") + success_lazy, stdout_lazy, stderr_lazy = self._run_command( + ["umount", "-l", target_path], + f"懒惰卸载 {target_path} 失败", + show_dialog=False, + expected_non_zero_exit_codes=[1] + ) + if success_lazy: + logger.info(f"成功懒惰卸载 {target_path}。") + return True + else: + logger.error(f"懒惰卸载 {target_path} 失败: {stderr_lazy}") + return False + + def _kill_pids(self, pids: List[str]) -> bool: + """ + 终止指定PID的进程。 + """ + all_killed = True + for pid in pids: + logger.info(f"尝试终止进程 {pid}。") + kill_success, _, kill_stderr = self._run_command( + ["kill", "-9", pid], + f"终止进程 {pid} 失败", + show_dialog=False # UI 交互在更高层处理 + ) + if not kill_success: + logger.error(f"终止进程 {pid} 失败: {kill_stderr}") + all_killed = False + else: + logger.info(f"成功终止进程 {pid}。") + return all_killed + + def resolve_occupation(self, device_path: str) -> bool: + """ + 尝试解除设备占用,包括处理嵌套挂载和终止进程。 + 返回 True 如果成功解除占用,否则返回 False。 + """ + logger.info(f"开始尝试解除设备 {device_path} 的占用。") + + # 1. 获取设备的当前主挂载点 + main_mount_point = self._get_mountpoint_for_device(device_path) + + # 2. 如果设备有主挂载点,先尝试卸载所有嵌套挂载 + if main_mount_point and main_mount_point != 'N/A' and main_mount_point != '[SWAP]': + logger.debug(f"设备 {device_path} 的主挂载点是 {main_mount_point}。") + + # 尝试卸载嵌套挂载,并循环几次,因为某些挂载可能需要多次尝试 + max_unmount_attempts = 3 + for attempt in range(max_unmount_attempts): + logger.info(f"尝试卸载嵌套挂载点 (第 {attempt + 1} 次尝试)。") + if self._unmount_nested_mounts(main_mount_point): + logger.info(f"所有嵌套挂载点已成功卸载或已不存在。") + break + else: + logger.warning(f"卸载嵌套挂载点失败 (第 {attempt + 1} 次尝试),可能需要重试。") + if attempt == max_unmount_attempts - 1: + QMessageBox.critical(None, "错误", f"未能卸载设备 {device_path} 下的所有嵌套挂载点。") + return False + + # 尝试卸载主挂载点本身 + logger.info(f"尝试卸载主挂载点 {main_mount_point}。") + if self._unmount_target(main_mount_point): + QMessageBox.information(None, "成功", f"设备 {device_path} 及其所有挂载点已成功卸载。") + return True # 主挂载点已卸载,设备应该已空闲 + + # 3. 检查设备本身或其(可能已不存在的)主挂载点是否仍被占用 (fuser) + # 即使主挂载点已卸载,也可能存在进程直接占用设备文件的情况,或者之前卸载失败。 + fuser_targets = [device_path] + if main_mount_point and main_mount_point != 'N/A' and main_mount_point != '[SWAP]' and main_mount_point not in fuser_targets: + fuser_targets.append(main_mount_point) + + pids_to_kill = set() + kernel_mounts_found = False + occupation_info_lines = [] + + for target in fuser_targets: + logger.debug(f"执行 fuser -vm {target} 检查占用。") + success_fuser, stdout_fuser, stderr_fuser = self._run_command( + ["fuser", "-vm", target], + f"检查 {target} 占用失败", + show_dialog=False, + suppress_critical_dialog_on_stderr_match=( + "No such file or directory", "not found", "Usage:", "not mounted" + ), + expected_non_zero_exit_codes=[1] + ) + + if success_fuser and stdout_fuser: + logger.debug(f"fuser -vm {target} 输出:\n{stdout_fuser}") + for line in stdout_fuser.splitlines(): + occupation_info_lines.append(line.strip()) + + if "kernel" in line: + kernel_mounts_found = True + + match = re.match(r'^\S+:\s+\S+\s+(?P\d+)\s+.*', line) + if match: + pids_to_kill.add(match.group('id')) + + pids_to_kill_list = list(pids_to_kill) + + if not kernel_mounts_found and not pids_to_kill_list: + QMessageBox.information(None, "信息", f"设备 {device_path} 未被任何进程占用。") + logger.info(f"设备 {device_path} 未被任何进程占用。") + return True + + # 4. 处理剩余的内核挂载(如果嵌套挂载和主挂载卸载失败,这里会再次捕捉到) + if kernel_mounts_found: + process_list_str = "\n".join(sorted(list(set(occupation_info_lines)))) + reply = QMessageBox.question( + None, + "设备占用 - 内核挂载", + f"设备 {device_path} 仍被内核挂载占用:\n{process_list_str}\n\n您确定要尝试卸载此设备吗?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No + ) + if reply == QMessageBox.No: + logger.info(f"用户取消了卸载设备 {device_path}。") + QMessageBox.information(None, "信息", f"已取消卸载设备 {device_path}。") + return False + + logger.info(f"再次尝试卸载设备 {device_path}。") + if self._unmount_target(device_path): # 尝试直接卸载设备路径 + QMessageBox.information(None, "成功", f"设备 {device_path} 已成功卸载。") + return True + else: + QMessageBox.critical(None, "错误", f"未能卸载设备 {device_path}。") + return False + + # 5. 处理用户进程占用 + if pids_to_kill_list: + process_list_str = "\n".join(sorted(list(set(occupation_info_lines)))) + reply = QMessageBox.question( + None, + "设备占用 - 进程", + f"设备 {device_path} 正在被以下进程占用:\n{process_list_str}\n\n您要强制终止这些进程吗?这可能会导致数据丢失或系统不稳定!", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No + ) + if reply == QMessageBox.No: + logger.info(f"用户取消了终止占用设备 {device_path} 的进程。") + QMessageBox.information(None, "信息", f"已取消终止占用设备 {device_path} 的进程。") + return False + + if self._kill_pids(pids_to_kill_list): + QMessageBox.information(None, "成功", f"已尝试终止占用设备 {device_path} 的所有进程。") + # 进程终止后,设备可能仍然被挂载,因此再次尝试卸载。 + if main_mount_point and main_mount_point != 'N/A' and main_mount_point != '[SWAP]': + logger.info(f"进程终止后,尝试再次卸载主挂载点 {main_mount_point}。") + if self._unmount_target(main_mount_point): + QMessageBox.information(None, "信息", f"设备 {device_path} 已成功卸载。") + return True + else: # 如果没有主挂载点,检查设备本身 + logger.info(f"进程终止后,尝试再次卸载设备 {device_path}。") + if self._unmount_target(device_path): + QMessageBox.information(None, "信息", f"设备 {device_path} 已成功卸载。") + return True + + # 如果进程终止后仍未能卸载 + QMessageBox.warning(None, "警告", f"进程已终止,但未能成功卸载设备 {device_path}。可能仍有其他问题。") + return False # 表示部分成功或需要进一步手动干预 + else: + QMessageBox.critical(None, "错误", f"未能终止所有占用设备 {device_path} 的进程。") + return False + + logger.warning(f"设备 {device_path} 占用解决逻辑未能完全处理。") + return False + diff --git a/pyproject.toml b/pyproject.toml index 27bdb73..7559d02 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,4 +2,4 @@ name = "PySide Widgets Project" [tool.pyside6-project] -files = ["dialogs.py", "disk_operations.py", "form.ui", "logger_config.py", "lvm_operations.py", "mainwindow.py", "raid_operations.py", "system_info.py"] +files = ["dialogs.py", "disk_operations.py", "form.ui", "logger_config.py", "lvm_operations.py", "mainwindow.py", "occupation_resolver.py", "raid_operations.py", "system_info.py"] diff --git a/raid_operations.py b/raid_operations.py index 8d7fa1b..34146e9 100644 --- a/raid_operations.py +++ b/raid_operations.py @@ -1,21 +1,18 @@ # raid_operations.py import logging import subprocess -from PySide6.QtWidgets import QMessageBox, QInputDialog, QLineEdit # 导入 QInputDialog 和 QLineEdit +from PySide6.QtWidgets import QMessageBox, QInputDialog, QLineEdit from PySide6.QtCore import Qt from system_info import SystemInfoManager import re -import pexpect # <--- 新增导入 pexpect +import pexpect import sys -# NEW: 导入 DiskOperations -from disk_operations import DiskOperations logger = logging.getLogger(__name__) class RaidOperations: - def __init__(self, system_manager: SystemInfoManager, disk_ops: DiskOperations): # <--- Add system_manager and disk_ops parameters + def __init__(self, system_manager: SystemInfoManager): self.system_manager = system_manager - self.disk_ops = disk_ops # Store disk_ops instance def _execute_shell_command(self, command_list, error_msg_prefix, suppress_critical_dialog_on_stderr_match=None, input_to_command=None): """ @@ -35,10 +32,9 @@ class RaidOperations: logger.info(f"执行命令: {full_cmd_str}") try: - # SystemInfoManager._run_command 应该负责在 root_privilege=True 时添加 sudo stdout, stderr = self.system_manager._run_command( command_list, - root_privilege=True, # 假设所有 RAID 操作都需要 root 权限 + root_privilege=True, check_output=True, input_data=input_to_command ) @@ -54,7 +50,6 @@ class RaidOperations: logger.error(f"标准输出: {e.stdout.strip()}") logger.error(f"标准错误: {stderr_output}") - # --- 修改开始 --- should_suppress_dialog = False if suppress_critical_dialog_on_stderr_match: if isinstance(suppress_critical_dialog_on_stderr_match, str): @@ -64,13 +59,12 @@ class RaidOperations: for pattern in suppress_critical_dialog_on_stderr_match: if pattern in stderr_output: should_suppress_dialog = True - break # 找到一个匹配就足够了 + break if should_suppress_dialog: logger.info(f"特定错误 '{stderr_output}' 匹配抑制条件,已抑制错误对话框。") else: QMessageBox.critical(None, "错误", f"{error_msg_prefix}\n详细信息: {stderr_output}") - # --- 修改结束 --- return False, e.stdout, stderr_output except FileNotFoundError: @@ -87,25 +81,19 @@ class RaidOperations: 专门处理 mdadm --create 命令的交互式执行。 通过 pexpect 监听提示并弹出 QMessageBox 让用户选择。 """ - # pexpect.spawn 需要一个字符串命令,并且 sudo 应该包含在内 - # 假设 SystemInfoManager._run_command 在 root_privilege=True 时会添加 sudo - # 但 pexpect.spawn 需要完整的命令字符串,所以这里手动添加 sudo full_cmd_str = "sudo " + ' '.join(command_list) logger.info(f"执行交互式命令: {full_cmd_str}") stdout_buffer = [] try: - # 增加 timeout,因为用户交互可能需要时间 - child = pexpect.spawn(full_cmd_str, encoding='utf-8', timeout=300) # 增加超时到 5 分钟 + child = pexpect.spawn(full_cmd_str, encoding='utf-8', timeout=300) child.logfile = sys.stdout - # --- 1. 优先处理 sudo 密码提示 --- try: - # 尝试匹配 sudo 密码提示,设置较短的超时,如果没出现就继续 index = child.expect(['[pP]assword for', pexpect.TIMEOUT], timeout=5) - if index == 0: # 匹配到密码提示 - stdout_buffer.append(child.before) # 捕获提示前的输出 + if index == 0: + stdout_buffer.append(child.before) password, ok = QInputDialog.getText( None, "Sudo 密码", "请输入您的 sudo 密码:", QLineEdit.Password ) @@ -114,45 +102,36 @@ class RaidOperations: child.close() return False, "".join(stdout_buffer), "Sudo 密码未提供或取消。" child.sendline(password) - stdout_buffer.append(child.after) # 捕获发送密码后的输出 + stdout_buffer.append(child.after) logger.info("已发送 sudo 密码。") - elif index == 1: # 超时,未出现密码提示 (可能是 NOPASSWD 或已缓存) + elif index == 1: logger.info("Sudo 未提示输入密码(可能已配置 NOPASSWD 或密码已缓存)。") - stdout_buffer.append(child.before) # 捕获任何初始输出 + stdout_buffer.append(child.before) except pexpect.exceptions.EOF: stdout_buffer.append(child.before) logger.error("命令在 sudo 密码提示前退出。") child.close() return False, "".join(stdout_buffer), "命令在 sudo 密码提示前退出。" except pexpect.exceptions.TIMEOUT: - # 再次捕获超时,确保日志记录 logger.info("Sudo 密码提示超时,可能已配置 NOPASSWD 或密码已缓存。") stdout_buffer.append(child.before) - - # 定义交互式提示及其处理方式 - prompts_data = [ # 重命名为 prompts_data 以避免与循环变量混淆 + prompts_data = [ (r"To optimalize recovery speed, .* write-(?:intent|indent) bitmap, do you want to enable it now\? \[y/N\]\s*", "mdadm 建议启用写入意图位图以优化恢复速度。您希望现在启用它吗?"), (r"Continue creating array \[y/N\]\s*", "mdadm 警告:检测到驱动器大小不一致或分区表。创建阵列将覆盖数据。是否仍要继续创建阵列?") ] - # 提取所有模式,以便 pexpect.expect 可以同时监听它们 patterns_to_expect = [p for p, _ in prompts_data] - # 循环处理所有可能的提示,直到没有更多提示或命令结束 while True: try: - # 尝试匹配任何一个预定义的提示 - # pexpect 会等待直到匹配到其中一个模式,或者超时,或者遇到 EOF - index = child.expect(patterns_to_expect, timeout=10) # 每次等待10秒 - stdout_buffer.append(child.before) # 捕获到提示前的输出 + index = child.expect(patterns_to_expect, timeout=10) + stdout_buffer.append(child.before) - # 获取匹配到的提示的描述 matched_description = prompts_data[index][1] - # 弹出对话框询问用户 reply = QMessageBox.question(None, "mdadm 提示", matched_description, QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes) @@ -164,28 +143,20 @@ class RaidOperations: logger.info(f"用户对提示 '{matched_description}' 选择 '否'") child.sendline(user_choice) - stdout_buffer.append(user_choice + '\n') # 记录用户输入 - - # 等待一小段时间,确保 mdadm 接收并处理了输入,并清空缓冲区 - # 避免在下一次 expect 之前,旧的输出再次被匹配 - # child.expect(pexpect.TIMEOUT, timeout=1) # This line might consume output needed for next prompt + stdout_buffer.append(user_choice + '\n') except pexpect.exceptions.TIMEOUT: - # 如果在等待任何提示时超时,说明没有更多提示了,或者命令已完成。 logger.debug("在等待 mdadm 提示时超时,可能没有更多交互。") - stdout_buffer.append(child.before) # 捕获当前为止的输出 - break # 跳出 while 循环,进入等待 EOF 阶段 + stdout_buffer.append(child.before) + break except pexpect.exceptions.EOF: - # 如果在等待提示时遇到 EOF,说明命令已经执行完毕或失败 logger.warning("在等待 mdadm 提示时遇到 EOF。") stdout_buffer.append(child.before) - break # 跳出 while 循环,进入等待 EOF 阶段 - + break try: - # 增加一个合理的超时时间,以防 mdadm 在后台进行长时间初始化 - child.expect(pexpect.EOF, timeout=120) # 例如,等待 120 秒 - stdout_buffer.append(child.before) # 捕获命令结束前的任何剩余输出 + child.expect(pexpect.EOF, timeout=120) + stdout_buffer.append(child.before) logger.info("mdadm 命令已正常结束 (通过 EOF 捕获)。") except pexpect.exceptions.TIMEOUT: stdout_buffer.append(child.before) @@ -197,12 +168,11 @@ class RaidOperations: stdout_buffer.append(child.before) logger.info("mdadm 命令已正常结束 (通过 EOF 捕获)。") - child.close() final_output = "".join(stdout_buffer) if child.exitstatus == 0: logger.info(f"交互式命令 {full_cmd_str} 成功完成。") - return True, final_output, "" # pexpect 很难区分 stdout/stderr,都视为 stdout + return True, final_output, "" else: logger.error(f"交互式命令 {full_cmd_str} 失败,退出码: {child.exitstatus}") QMessageBox.critical(None, "错误", f"{error_msg_prefix}\n详细信息: 命令执行失败,退出码 {child.exitstatus}\n输出: {final_output}") @@ -247,7 +217,6 @@ class RaidOperations: :param level: RAID 级别,例如 'raid0', 'raid1', 'raid5' (也可以是整数 0, 1, 5) :param chunk_size: Chunk 大小 (KB) """ - # --- 标准化 RAID 级别输入 --- if isinstance(level, int): level_str = f"raid{level}" elif isinstance(level, str): @@ -259,13 +228,11 @@ class RaidOperations: QMessageBox.critical(None, "错误", f"不支持的 RAID 级别类型: {type(level)}") return False level = level_str - # --- 标准化 RAID 级别输入结束 --- - if not devices or len(devices) < 1: # RAID0 理论上可以一个设备,但通常至少两个 + if not devices or len(devices) < 1: QMessageBox.critical(None, "错误", "创建 RAID 阵列至少需要一个设备(RAID0)。") return False - # 检查其他 RAID 级别所需的设备数量 if level == "raid1" and len(devices) < 2: QMessageBox.critical(None, "错误", "RAID1 至少需要两个设备。") return False @@ -275,17 +242,10 @@ class RaidOperations: if level == "raid6" and len(devices) < 4: QMessageBox.critical(None, "错误", "RAID6 至少需要四个设备。") return False - if level == "raid10" and len(devices) < 2: # RAID10 至少需要 2 个设备 (例如 1+0) + if level == "raid10" and len(devices) < 2: QMessageBox.critical(None, "错误", "RAID10 至少需要两个设备。") return False - # NEW: 尝试解决所有成员设备的占用问题 - for dev in devices: - if not self.disk_ops._resolve_device_occupation(dev, action_description=f"创建 RAID 阵列,成员 {dev}"): - logger.info(f"用户取消或未能解决设备 {dev} 的占用问题,取消创建 RAID 阵列。") - return False - - # 确认操作 reply = QMessageBox.question(None, "确认创建 RAID 阵列", f"你确定要使用设备 {', '.join(devices)} 创建 RAID {level} 阵列吗?\n" "此操作将销毁设备上的所有数据!", @@ -322,23 +282,18 @@ class RaidOperations: create_cmd_base = ["mdadm", "--create", array_name, "--level=" + level.replace("raid", ""), f"--raid-devices={len(devices)}"] - # 添加 chunk size (RAID0, RAID5, RAID6, RAID10 通常需要) if level in ["raid0", "raid5", "raid6", "raid10"]: create_cmd_base.append(f"--chunk={chunk_size}K") create_cmd = create_cmd_base + devices - - # 在 --create 命令中添加 --force 选项 create_cmd.insert(2, "--force") - # 调用新的交互式方法 success_create, stdout_create, stderr_create = self._execute_interactive_mdadm_command( create_cmd, f"创建 RAID 阵列失败" ) if not success_create: - # pexpect 捕获的输出可能都在 stdout_create 中 if "Array name" in stdout_create and "is in use already" in stdout_create: QMessageBox.critical(None, "错误", f"创建 RAID 阵列失败:阵列名称 {array_name} 已被占用。请尝试停止或删除现有阵列。") return False @@ -350,7 +305,6 @@ class RaidOperations: examine_scan_cmd = ["mdadm", "--examine", "--scan"] success_scan, scan_stdout, _ = self._execute_shell_command(examine_scan_cmd, "扫描 mdadm 配置失败") if success_scan: - # --- 新增:确保 /etc 目录存在 --- mkdir_cmd = ["mkdir", "-p", "/etc"] success_mkdir, _, stderr_mkdir = self._execute_shell_command( mkdir_cmd, @@ -359,13 +313,9 @@ class RaidOperations: if not success_mkdir: logger.error(f"无法创建 /etc 目录,更新 mdadm.conf 失败。错误: {stderr_mkdir}") QMessageBox.critical(None, "错误", f"无法创建 /etc 目录,更新 mdadm.conf 失败。\n详细信息: {stderr_mkdir}") - return False # 如果连目录都无法创建,则认为创建阵列失败,因为无法保证重启后自动识别 + return False logger.info("已确保 /etc 目录存在。") - # --- 新增结束 --- - # 这里需要确保 /etc/mdadm.conf 存在且可写入 - # _execute_shell_command 会自动添加 sudo - # 使用 tee -a 而不是 >> 来确保 sudo 权限下的写入 success_append, _, _ = self._execute_shell_command( ["bash", "-c", f"echo '{scan_stdout.strip()}' | tee -a /etc/mdadm.conf > /dev/null"], "更新 /etc/mdadm/mdadm.conf 失败" @@ -395,11 +345,12 @@ class RaidOperations: logger.info(f"尝试停止 RAID 阵列: {array_path}") - # NEW: 尝试卸载阵列(如果已挂载),利用 DiskOperations 的增强卸载功能 - # unmount_partition 内部会尝试解决设备占用问题 - if not self.disk_ops.unmount_partition(array_path, show_dialog_on_error=False): - logger.warning(f"未能成功卸载 RAID 阵列 {array_path}。尝试继续停止操作。") - # 即使卸载失败,我们仍然尝试停止 mdadm 阵列,因为有时 mdadm --stop 可以在设备忙时强制停止。 + # Original umount call (not using disk_ops.unmount_partition) + self._execute_shell_command( + ["umount", array_path], + f"尝试卸载 {array_path} 失败", + suppress_critical_dialog_on_stderr_match=("not mounted", "未挂载") + ) if not self._execute_shell_command(["mdadm", "--stop", array_path], f"停止 RAID 阵列 {array_path} 失败")[0]: return False @@ -408,7 +359,7 @@ class RaidOperations: QMessageBox.information(None, "成功", f"成功停止 RAID 阵列 {array_path}。") return True - def delete_active_raid_array(self, array_path, member_devices, uuid): # <--- 修改方法名并添加 uuid 参数 + def delete_active_raid_array(self, array_path, member_devices, uuid): """ 删除一个活动的 RAID 阵列。 此操作将停止阵列、清除成员设备上的超级块,并删除 mdadm.conf 中的配置。 @@ -427,7 +378,6 @@ class RaidOperations: logger.info(f"尝试删除活动 RAID 阵列: {array_path} (UUID: {uuid})") # 1. 停止阵列 - # stop_raid_array 内部会调用 self.disk_ops.unmount_partition 来尝试卸载并解决占用 if not self.stop_raid_array(array_path): QMessageBox.critical(None, "错误", f"删除 RAID 阵列 {array_path} 失败,因为无法停止阵列。") return False @@ -459,7 +409,7 @@ class RaidOperations: except Exception as e: QMessageBox.critical(None, "错误", f"删除 RAID 阵列 {array_path} 的配置失败: {e}") logger.error(f"删除 RAID 阵列 {array_path} 的配置失败: {e}") - return False # 如果配置文件删除失败,也视为整体操作失败 + return False if success_all_cleared: logger.info(f"成功删除 RAID 阵列 {array_path} 并清除了成员设备超级块。") @@ -469,7 +419,7 @@ class RaidOperations: QMessageBox.critical(None, "错误", f"删除 RAID 阵列 {array_path} 完成,但部分成员设备超级块未能完全清除。") return False - def delete_configured_raid_array(self, uuid): # <--- 新增方法 + def delete_configured_raid_array(self, uuid): """ 只删除 mdadm.conf 中停止状态 RAID 阵列的配置条目。 :param uuid: 要删除的 RAID 阵列的 UUID。 @@ -494,7 +444,7 @@ class RaidOperations: return False - def activate_raid_array(self, array_path, array_uuid): # 添加 array_uuid 参数 + def activate_raid_array(self, array_path, array_uuid): """ 激活一个已停止的 RAID 阵列。 使用 mdadm --assemble --uuid= @@ -506,12 +456,9 @@ class RaidOperations: logger.info(f"尝试激活 RAID 阵列: {array_path} (UUID: {array_uuid})") - # 使用 mdadm --assemble --uuid= - # 这样可以确保只激活用户选择的特定阵列 success, stdout, stderr = self._execute_shell_command( ["mdadm", "--assemble", array_path, "--uuid", array_uuid], f"激活 RAID 阵列 {array_path} 失败", - # 抑制一些常见的非致命错误,例如阵列已经激活 suppress_critical_dialog_on_stderr_match=("mdadm: device /dev/md", "already active", "No such file or directory") ) @@ -522,4 +469,3 @@ class RaidOperations: else: logger.error(f"激活 RAID 阵列 {array_path} 失败。") return False -