添加解除占用功能

This commit is contained in:
zj
2026-02-05 01:41:11 +08:00
parent cf80cb000c
commit dc9f1a3656
10 changed files with 512 additions and 423 deletions

Binary file not shown.

View File

@@ -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,108 +148,8 @@ 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 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
return False
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')
@@ -313,58 +200,24 @@ class DiskOperations(QObject): # <--- 继承 QObject 以便发出信号
: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
# 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
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):
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 # 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
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
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)

View File

@@ -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
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

View File

@@ -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'<?(\d+\.?\d*)\s*([gmktb])?', free_size_str)
if match:
value = float(match.group(1))
unit = match.group(2) # unit will be 'g', 'm', 't', 'k', 'b' or None
unit = match.group(2)
if unit == 'k':
current_vg_size_gb = value / (1024 * 1024) # KB to GB
current_vg_size_gb = value / (1024 * 1024)
elif unit == 'm':
current_vg_size_gb = value / 1024 # MB to GB
elif unit == 'g' or unit is None: # 如果单位是 'g' 或没有指定单位,则认为是 GB
current_vg_size_gb = value / 1024
elif unit == 'g' or unit is None:
current_vg_size_gb = value
elif unit == 't':
current_vg_size_gb = value * 1024 # TB to GB
elif unit == 'b': # Bytes to GB
current_vg_size_gb = value * 1024
elif unit == 'b':
current_vg_size_gb = value / (1024 * 1024 * 1024)
else:
logger.warning(f"未知LVM单位: '{unit}' for '{free_size_str}'")

307
occupation_resolver.py Normal file
View File

@@ -0,0 +1,307 @@
# occupation_resolver.py
import logging
import re
from typing import List, Tuple, Callable, Optional
# 假设 QApplication 和 QMessageBox 在主 UI 线程中可用,
# 并且我们在这里直接使用它们进行用户交互。
# 在更严格的架构中UI交互应该通过回调或事件机制与核心逻辑分离。
# from PyQt5.QtWidgets import QMessageBox
from PySide6.QtWidgets import (QApplication, QMainWindow, QTreeWidgetItem,
QMessageBox, QHeaderView, QMenu, QInputDialog, QDialog)
logger = logging.getLogger(__name__)
class OccupationResolver:
"""
负责检查和解除设备占用的类。
处理包括内核挂载、嵌套挂载和用户进程占用等情况。
"""
def __init__(self, shell_executor_func: Callable, mount_info_getter_func: Callable):
"""
初始化设备占用解决器。
Args:
shell_executor_func: 一个可调用对象,用于执行 shell 命令。
期望签名: (cmd: List[str], error_msg: str, show_dialog: bool,
suppress_critical_dialog_on_stderr_match: Tuple,
expected_non_zero_exit_codes: List[int]) -> 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>: 限制只查找 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<id>\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

View File

@@ -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"]

View File

@@ -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 <device> --uuid=<uuid>
@@ -506,12 +456,9 @@ class RaidOperations:
logger.info(f"尝试激活 RAID 阵列: {array_path} (UUID: {array_uuid})")
# 使用 mdadm --assemble <device> --uuid=<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