From a90725d1780d72ff3d781af8b5c48e87b53f567f Mon Sep 17 00:00:00 2001 From: zj <1052308357@qq.comm> Date: Mon, 2 Feb 2026 18:38:41 +0800 Subject: [PATCH] fix22 --- __pycache__/dialogs.cpython-314.pyc | Bin 0 -> 27594 bytes __pycache__/disk_operations.cpython-314.pyc | Bin 12909 -> 29096 bytes __pycache__/logger_config.cpython-314.pyc | Bin 3128 -> 3129 bytes __pycache__/lvm_operations.cpython-314.pyc | Bin 0 -> 18245 bytes __pycache__/raid_operations.cpython-314.pyc | Bin 0 -> 12211 bytes __pycache__/system_info.cpython-314.pyc | Bin 14069 -> 22581 bytes dialogs.py | 445 +++++++++++++ disk_operations.py | 663 ++++++++++++++------ logger_config.py | 2 +- lvm_operations.py | 309 +++++++++ mainwindow.py | 609 +++++++++++++++--- pyproject.toml | 2 +- raid_operations.py | 222 +++++++ system_info.py | 558 ++++++++++------ 14 files changed, 2345 insertions(+), 465 deletions(-) create mode 100644 __pycache__/dialogs.cpython-314.pyc create mode 100644 __pycache__/lvm_operations.cpython-314.pyc create mode 100644 __pycache__/raid_operations.cpython-314.pyc create mode 100644 dialogs.py create mode 100644 lvm_operations.py create mode 100644 raid_operations.py diff --git a/__pycache__/dialogs.cpython-314.pyc b/__pycache__/dialogs.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4e62dcde450e53b1bf63847ba6b890e1c664b756 GIT binary patch literal 27594 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 literal 0 HcmV?d00001 diff --git a/__pycache__/disk_operations.cpython-314.pyc b/__pycache__/disk_operations.cpython-314.pyc index 3104eaf451d0fc7a3dcfc08d7f0c2b82e0a865dc..b11effdd2847494b395a566802b7504e1cb38c00 100644 GIT binary patch literal 29096 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

dTaKAn7hTxsC(TdWiA=63Ai7Pk1c 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 diff --git a/__pycache__/logger_config.cpython-314.pyc b/__pycache__/logger_config.cpython-314.pyc index 2e4eaafb063768875c2ef3efce435b196b44b00b..22bf360aa9e7357fba77448334ded64aa7a256e2 100644 GIT binary patch delta 45 zcmdlXu~ULqn~#@^0SJy4HDs>e$a{~Ijn&1~Db#)P2hNvlvNL6`u&8go&lSlC04!1t ADgXcg delta 44 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 F{|owKQXc>S literal 0 HcmV?d00001 diff --git a/__pycache__/raid_operations.cpython-314.pyc b/__pycache__/raid_operations.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c736bf5a16d2f92cc80d077d43032adb0ebf7742 GIT binary patch literal 12211 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 PRX3;<+4m`YCLI4Cf9t!i literal 0 HcmV?d00001 diff --git a/__pycache__/system_info.cpython-314.pyc b/__pycache__/system_info.cpython-314.pyc index b9bfdd085f66d6dc191d0f325bfbe355685fa743..f892df5db729bd1552b04ee9883ab4479d635a76 100644 GIT binary patch literal 22581 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 A#Q*>R literal 14069 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 OslB&Zh7=a^>i;h;yFwQL diff --git a/dialogs.py b/dialogs.py new file mode 100644 index 0000000..8b460d6 --- /dev/null +++ b/dialogs.py @@ -0,0 +1,445 @@ +# dialogs.py +import os +from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, + QLineEdit, QComboBox, QDoubleSpinBox, + QPushButton, QFormLayout, QFileDialog, + QMessageBox, QCheckBox, QListWidget, QListWidgetItem) +from PySide6.QtCore import Qt + +class CreatePartitionDialog(QDialog): + def __init__(self, parent=None, disk_path="", total_disk_mib=0.0, max_available_mib=0.0): + super().__init__(parent) + self.setWindowTitle("创建分区") + self.setMinimumWidth(300) + + self.disk_path = disk_path + self.total_disk_mib = total_disk_mib # 磁盘总大小 (MiB) + self.max_available_mib = max_available_mib # 可用空间 (MiB) + + self.partition_table_type_combo = QComboBox() + self.partition_table_type_combo.addItems(["gpt", "msdos"]) + + self.size_spinbox = QDoubleSpinBox() + self.size_spinbox.setMinimum(0.1) # 最小分区大小 0.1 GB + self.size_spinbox.setMaximum(self.max_available_mib / 1024.0) # 将 MiB 转换为 GB 显示 + self.size_spinbox.setSuffix(" GB") + self.size_spinbox.setDecimals(2) + + self.use_max_space_checkbox = QCheckBox("使用最大可用空间") + self.use_max_space_checkbox.setChecked(True) # 默认选中 + + self._setup_ui() + self._connect_signals() + self._initialize_state() + + def _setup_ui(self): + layout = QVBoxLayout(self) + + form_layout = QFormLayout() + form_layout.addRow("磁盘路径:", QLabel(self.disk_path)) + form_layout.addRow("分区表类型:", self.partition_table_type_combo) + form_layout.addRow("分区大小:", self.size_spinbox) + form_layout.addRow("", self.use_max_space_checkbox) # 添加复选框 + + layout.addLayout(form_layout) + + button_box = QHBoxLayout() + self.confirm_button = QPushButton("确定") + self.cancel_button = QPushButton("取消") + button_box.addWidget(self.confirm_button) + button_box.addWidget(self.cancel_button) + layout.addLayout(button_box) + + def _connect_signals(self): + self.confirm_button.clicked.connect(self.accept) + self.cancel_button.clicked.connect(self.reject) + self.use_max_space_checkbox.stateChanged.connect(self._toggle_size_input) + + def _initialize_state(self): + # 根据默认选中状态设置 spinbox + self._toggle_size_input(self.use_max_space_checkbox.checkState()) + + def _toggle_size_input(self, state): + if state == Qt.Checked: + self.size_spinbox.setDisabled(True) + self.size_spinbox.setValue(self.size_spinbox.maximum()) # 设置为最大可用 GB + else: + self.size_spinbox.setDisabled(False) + # 如果之前是最大值,取消勾选后,恢复到最小值或一个合理值 + if self.size_spinbox.value() == self.size_spinbox.maximum(): + self.size_spinbox.setValue(self.size_spinbox.minimum()) + + def get_partition_info(self): + size_gb = self.size_spinbox.value() + use_max_space = self.use_max_space_checkbox.isChecked() + + if not use_max_space and size_gb <= 0: + QMessageBox.warning(self, "输入错误", "分区大小必须大于0。") + return None + if not use_max_space and size_gb > self.max_available_mib / 1024.0: + QMessageBox.warning(self, "输入错误", "分区大小不能超过最大可用空间。") + return None + + return { + 'disk_path': self.disk_path, + 'partition_table_type': self.partition_table_type_combo.currentText(), + 'size_gb': size_gb, + 'total_disk_mib': self.total_disk_mib, # 传递磁盘总大小 (MiB) + 'use_max_space': use_max_space # 返回此标志 + } + +class MountDialog(QDialog): + def __init__(self, parent=None, device_path=""): + super().__init__(parent) + self.setWindowTitle("挂载分区") + self.setMinimumWidth(300) + + self.device_path = device_path + self.mount_point_input = QLineEdit() + self.add_to_fstab_checkbox = QCheckBox("开机自动挂载 (添加到 /etc/fstab)") # 新增 + + self._setup_ui() + self._connect_signals() + + def _setup_ui(self): + layout = QVBoxLayout(self) + + form_layout = QFormLayout() + form_layout.addRow("设备路径:", QLabel(self.device_path)) + form_layout.addRow("挂载点:", self.mount_point_input) + form_layout.addRow("", self.add_to_fstab_checkbox) # 添加复选框 + + layout.addLayout(form_layout) + + button_box = QHBoxLayout() + self.confirm_button = QPushButton("确定") + self.cancel_button = QPushButton("取消") + button_box.addWidget(self.confirm_button) + button_box.addWidget(self.cancel_button) + layout.addLayout(button_box) + + # 默认挂载点,可以根据设备名生成 + default_mount_point = f"/mnt/{os.path.basename(self.device_path)}" + self.mount_point_input.setText(default_mount_point) + + def _connect_signals(self): + self.confirm_button.clicked.connect(self.accept) + self.cancel_button.clicked.connect(self.reject) + + def get_mount_info(self): + mount_point = self.mount_point_input.text().strip() + add_to_fstab = self.add_to_fstab_checkbox.isChecked() + + if not mount_point: + QMessageBox.warning(self, "输入错误", "挂载点不能为空。") + return None + if not mount_point.startswith('/'): + QMessageBox.warning(self, "输入错误", "挂载点必须是绝对路径 (以 '/' 开头)。") + return None + + return { + 'mount_point': mount_point, + 'add_to_fstab': add_to_fstab + } + +class CreateRaidDialog(QDialog): + def __init__(self, parent=None, available_devices=None): + super().__init__(parent) + self.setWindowTitle("创建 RAID 阵列") + self.setMinimumWidth(350) + + self.available_devices = available_devices if available_devices is not None else [] + self.selected_devices = [] + + self.raid_level_combo = QComboBox() + self.raid_level_combo.addItems(["raid0", "raid1", "raid5", "raid6", "raid10"]) + + self.chunk_size_spinbox = QDoubleSpinBox() + self.chunk_size_spinbox.setMinimum(4) + self.chunk_size_spinbox.setMaximum(1024) + self.chunk_size_spinbox.setSingleStep(4) + self.chunk_size_spinbox.setValue(512) # 默认值 + self.chunk_size_spinbox.setSuffix(" KB") + + self.device_list_widget = QListWidget() + for dev in self.available_devices: + item = QListWidgetItem(dev) + item.setFlags(item.flags() | Qt.ItemIsUserCheckable) + item.setCheckState(Qt.Unchecked) + self.device_list_widget.addItem(item) + + self._setup_ui() + self._connect_signals() + + def _setup_ui(self): + layout = QVBoxLayout(self) + + form_layout = QFormLayout() + form_layout.addRow("RAID 级别:", self.raid_level_combo) + form_layout.addRow("Chunk 大小:", self.chunk_size_spinbox) + form_layout.addRow("选择设备:", self.device_list_widget) + + layout.addLayout(form_layout) + + button_box = QHBoxLayout() + self.confirm_button = QPushButton("确定") + self.cancel_button = QPushButton("取消") + button_box.addWidget(self.confirm_button) + button_box.addWidget(self.cancel_button) + layout.addLayout(button_box) + + def _connect_signals(self): + self.confirm_button.clicked.connect(self.accept) + self.cancel_button.clicked.connect(self.reject) + + def get_raid_info(self): + self.selected_devices = [] + for i in range(self.device_list_widget.count()): + item = self.device_list_widget.item(i) + if item.checkState() == Qt.Checked: + self.selected_devices.append(item.text()) + + if not self.selected_devices: + QMessageBox.warning(self, "输入错误", "请选择至少一个设备来创建 RAID 阵列。") + return None + + raid_level = self.raid_level_combo.currentText() + num_devices = len(self.selected_devices) + + # RAID level specific checks for minimum devices + if raid_level == "raid0" and num_devices < 1: # RAID0 can be 1 device in some contexts, but usually >1 + QMessageBox.warning(self, "输入错误", "RAID0 至少需要一个设备。") + return None + elif raid_level == "raid1" and num_devices < 2: + QMessageBox.warning(self, "输入错误", "RAID1 至少需要两个设备。") + return None + elif raid_level == "raid5" and num_devices < 3: + QMessageBox.warning(self, "输入错误", "RAID5 至少需要三个设备。") + return None + elif raid_level == "raid6" and num_devices < 4: + QMessageBox.warning(self, "输入错误", "RAID6 至少需要四个设备。") + return None + elif raid_level == "raid10" and num_devices < 2: # RAID10 needs at least 2 (for 1+0) + QMessageBox.warning(self, "输入错误", "RAID10 至少需要两个设备。") + return None + + return { + 'devices': self.selected_devices, + 'level': self.raid_level_combo.currentText().replace('raid', ''), # 移除 'raid' 前缀 + 'chunk_size': int(self.chunk_size_spinbox.value()) # KB + } + +class CreatePvDialog(QDialog): + def __init__(self, parent=None, available_partitions=None): + super().__init__(parent) + self.setWindowTitle("创建物理卷 (PV)") + self.setMinimumWidth(300) + + self.available_partitions = available_partitions if available_partitions is not None else [] + + self.device_combo_box = QComboBox() + self.device_combo_box.addItems(self.available_partitions) + + self._setup_ui() + self._connect_signals() + + def _setup_ui(self): + layout = QVBoxLayout(self) + + form_layout = QFormLayout() + form_layout.addRow("选择设备:", self.device_combo_box) + + layout.addLayout(form_layout) + + button_box = QHBoxLayout() + self.confirm_button = QPushButton("确定") + self.cancel_button = QPushButton("取消") + button_box.addWidget(self.confirm_button) + button_box.addWidget(self.cancel_button) + layout.addLayout(button_box) + + def _connect_signals(self): + self.confirm_button.clicked.connect(self.accept) + self.cancel_button.clicked.connect(self.reject) + + def get_pv_info(self): + device_path = self.device_combo_box.currentText() + if not device_path: + QMessageBox.warning(self, "输入错误", "请选择一个设备来创建物理卷。") + return None + return {'device_path': device_path} + +class CreateVgDialog(QDialog): + def __init__(self, parent=None, available_pvs=None): + super().__init__(parent) + self.setWindowTitle("创建卷组 (VG)") + self.setMinimumWidth(300) + + self.available_pvs = available_pvs if available_pvs is not None else [] + self.selected_pvs = [] + + self.vg_name_input = QLineEdit() + self.pv_list_widget = QListWidget() + for pv in self.available_pvs: + item = QListWidgetItem(pv) + item.setFlags(item.flags() | Qt.ItemIsUserCheckable) + item.setCheckState(Qt.Unchecked) + self.pv_list_widget.addItem(item) + + self._setup_ui() + self._connect_signals() + + def _setup_ui(self): + layout = QVBoxLayout(self) + + form_layout = QFormLayout() + form_layout.addRow("卷组名称:", self.vg_name_input) + form_layout.addRow("选择物理卷:", self.pv_list_widget) + + layout.addLayout(form_layout) + + button_box = QHBoxLayout() + self.confirm_button = QPushButton("确定") + self.cancel_button = QPushButton("取消") + button_box.addWidget(self.confirm_button) + button_box.addWidget(self.cancel_button) + layout.addLayout(button_box) + + def _connect_signals(self): + self.confirm_button.clicked.connect(self.accept) + self.cancel_button.clicked.connect(self.reject) + + def get_vg_info(self): + vg_name = self.vg_name_input.text().strip() + self.selected_pvs = [] + for i in range(self.pv_list_widget.count()): + item = self.pv_list_widget.item(i) + if item.checkState() == Qt.Checked: + self.selected_pvs.append(item.text()) + + if not vg_name: + QMessageBox.warning(self, "输入错误", "卷组名称不能为空。") + return None + if not self.selected_pvs: + QMessageBox.warning(self, "输入错误", "请选择至少一个物理卷来创建卷组。") + return None + + return { + 'vg_name': vg_name, + 'pvs': self.selected_pvs + } + +class CreateLvDialog(QDialog): + def __init__(self, parent=None, available_vgs=None, vg_sizes=None): + super().__init__(parent) + self.setWindowTitle("创建逻辑卷") + self.setMinimumWidth(300) + + self.available_vgs = available_vgs if available_vgs is not None else [] + self.vg_sizes = vg_sizes if vg_sizes is not None else {} # 存储每个 VG 的可用大小 (GB) + + self.lv_name_input = QLineEdit() + self.vg_combo_box = QComboBox() + self.lv_size_spinbox = QDoubleSpinBox() + self.use_max_space_checkbox = QCheckBox("使用最大可用空间") # 新增复选框 + + self._setup_ui() + self._connect_signals() + self._initialize_state() # 初始化状态 + + def _setup_ui(self): + layout = QVBoxLayout(self) + + form_layout = QFormLayout() + form_layout.addRow("逻辑卷名称:", self.lv_name_input) + form_layout.addRow("选择卷组:", self.vg_combo_box) + form_layout.addRow("逻辑卷大小 (GB):", self.lv_size_spinbox) + form_layout.addRow("", self.use_max_space_checkbox) # 添加复选框 + + layout.addLayout(form_layout) + + button_box = QHBoxLayout() + self.confirm_button = QPushButton("确定") + self.cancel_button = QPushButton("取消") + button_box.addWidget(self.confirm_button) + button_box.addWidget(self.cancel_button) + layout.addLayout(button_box) + + self.lv_size_spinbox.setMinimum(0.1) # 逻辑卷最小大小,例如 0.1 GB + self.lv_size_spinbox.setSuffix(" GB") + self.lv_size_spinbox.setDecimals(2) + + def _connect_signals(self): + self.confirm_button.clicked.connect(self.accept) + self.cancel_button.clicked.connect(self.reject) + self.vg_combo_box.currentIndexChanged.connect(self._update_size_options) # 卷组选择改变时更新大小选项 + self.use_max_space_checkbox.stateChanged.connect(self._toggle_size_input) # 复选框状态改变时切换输入 + + def _initialize_state(self): + self.vg_combo_box.addItems(self.available_vgs) + self._update_size_options() # 首次调用以设置初始状态 + self.use_max_space_checkbox.setChecked(True) # 默认选中“使用最大可用空间” + + def _update_size_options(self): + """根据选中的卷组更新逻辑卷大小的选项。""" + selected_vg = self.vg_combo_box.currentText() + max_size_gb = self.vg_sizes.get(selected_vg, 0.0) + + # 确保 max_size_gb 至少是 spinbox 的最小值,以防卷组可用空间过小导致 UI 问题 + if max_size_gb < self.lv_size_spinbox.minimum(): + self.lv_size_spinbox.setMinimum(max_size_gb) # 临时将最小值设为实际最大值 + else: + self.lv_size_spinbox.setMinimum(0.1) # 恢复正常最小值 + + self.lv_size_spinbox.setMaximum(max_size_gb) # 设置最大值 + + # 如果选中了“使用最大可用空间”,则将 spinbox 值设置为最大值 + if self.use_max_space_checkbox.isChecked(): + self.lv_size_spinbox.setValue(max_size_gb) + else: + # 如果当前值超过了新的最大值,则调整为新的最大值 + if self.lv_size_spinbox.value() > max_size_gb: + self.lv_size_spinbox.setValue(max_size_gb) + # 如果当前值小于新的最小值 (例如,VG可用空间变为0,最小值被调整为0),则调整 + elif self.lv_size_spinbox.value() < self.lv_size_spinbox.minimum(): + self.lv_size_spinbox.setValue(self.lv_size_spinbox.minimum()) + + + def _toggle_size_input(self, state): + """根据“使用最大可用空间”复选框的状态切换大小输入框的启用/禁用状态。""" + if state == Qt.Checked: + self.lv_size_spinbox.setDisabled(True) + self.lv_size_spinbox.setValue(self.lv_size_spinbox.maximum()) # 设置为最大值 + else: + self.lv_size_spinbox.setDisabled(False) + # 如果之前是最大值,取消勾选后,恢复到最小值或一个合理值 + if self.lv_size_spinbox.value() == self.lv_size_spinbox.maximum(): + self.lv_size_spinbox.setValue(self.lv_size_spinbox.minimum()) + + + def get_lv_info(self): + lv_name = self.lv_name_input.text().strip() + vg_name = self.vg_combo_box.currentText() + size_gb = self.lv_size_spinbox.value() + use_max_space = self.use_max_space_checkbox.isChecked() # 获取复选框状态 + + if not lv_name: + QMessageBox.warning(self, "输入错误", "逻辑卷名称不能为空。") + return None + if not vg_name: + QMessageBox.warning(self, "输入错误", "请选择一个卷组。") + return None + if not use_max_space and size_gb <= 0: + QMessageBox.warning(self, "输入错误", "逻辑卷大小必须大于0。") + return None + if not use_max_space and size_gb > self.vg_sizes.get(vg_name, 0.0): + QMessageBox.warning(self, "输入错误", "逻辑卷大小不能超过卷组的可用空间。") + return None + + return { + 'lv_name': lv_name, + 'vg_name': vg_name, + 'size_gb': size_gb, + 'use_max_space': use_max_space # 返回此标志 + } + diff --git a/disk_operations.py b/disk_operations.py index 929aac1..985d02b 100644 --- a/disk_operations.py +++ b/disk_operations.py @@ -1,237 +1,536 @@ # disk_operations.py -import os +import subprocess import logging +import os +import re from PySide6.QtWidgets import QMessageBox, QInputDialog -from system_info import SystemInfoManager # 引入 SystemInfoManager 来复用 _run_command -logger = logging.getLogger(__name__) # 获取当前模块的 logger 实例 +# 导入我们自己编写的系统信息管理模块 +from system_info import SystemInfoManager + +logger = logging.getLogger(__name__) class DiskOperations: def __init__(self): - self.system_manager = SystemInfoManager() # 复用 SystemInfoManager 的命令执行器 + self.system_manager = SystemInfoManager() # 实例化 SystemInfoManager - def _get_device_path(self, device_name): + def _execute_shell_command(self, command_list, error_message, root_privilege=True, + suppress_critical_dialog_on_stderr_match=None, input_data=None): """ - 辅助函数:将设备名(如 'sda1')转换为完整路径(如 '/dev/sda1')。 + 通用地运行一个 shell 命令,并处理错误。 + :param command_list: 命令及其参数的列表。 + :param error_message: 命令失败时显示给用户的错误消息。 + :param root_privilege: 如果为 True,则使用 sudo 执行命令。 + :param suppress_critical_dialog_on_stderr_match: 如果 stderr 包含此字符串,则不显示关键错误对话框。 + :param input_data: 传递给命令stdin的数据 (str)。 + :return: (True/False, stdout_str, stderr_str) """ - if not device_name.startswith('/dev/'): - return f'/dev/{device_name}' - return device_name + # 确保 command_list 中的所有项都是字符串 + if not all(isinstance(arg, str) for arg in command_list): + logger.error(f"命令列表包含非字符串元素: {command_list}") + QMessageBox.critical(None, "错误", f"内部错误:尝试执行的命令包含无效参数。\n命令详情: {command_list}") + return False, "", "内部错误:命令参数类型不正确。" - def mount_partition(self, device_name, mount_point=None): - """ - 挂载指定的分区。 - 如果未提供挂载点,会尝试查找现有挂载点或提示用户输入。 - """ - dev_path = self._get_device_path(device_name) - logger.info(f"尝试挂载设备: {dev_path}") + if root_privilege: + command_list = ["sudo"] + command_list - if not mount_point: - # 尝试从系统信息中获取当前挂载点 - devices = self.system_manager.get_block_devices() - found_mount_point = None - for dev in devices: - if dev.get('name') == device_name: - found_mount_point = dev.get('mountpoint') - break - if 'children' in dev: - for child in dev['children']: - if child.get('name') == device_name: - found_mount_point = child.get('mountpoint') - break - if found_mount_point: - break + full_cmd_str = ' '.join(command_list) + logger.debug(f"执行命令: {full_cmd_str}") - if found_mount_point and found_mount_point != '[SWAP]' and found_mount_point != '': - # 如果设备已经有挂载点,并且不是SWAP,则直接使用 - mount_point = found_mount_point - logger.info(f"设备 {dev_path} 已经挂载到 {mount_point}。") - QMessageBox.information(None, "信息", f"设备 {dev_path} 已经挂载到 {mount_point}。") - return True # 已经挂载,视为成功 + try: + result = subprocess.run( + command_list, + capture_output=True, + text=True, + check=True, + encoding='utf-8', + input=input_data + ) + logger.info(f"命令成功: {full_cmd_str}") + return True, result.stdout.strip(), result.stderr.strip() + except subprocess.CalledProcessError as e: + stderr_output = e.stderr.strip() + logger.error(f"命令失败: {full_cmd_str}") + logger.error(f"退出码: {e.returncode}") + logger.error(f"标准输出: {e.stdout.strip()}") + logger.error(f"标准错误: {stderr_output}") + + if suppress_critical_dialog_on_stderr_match and \ + suppress_critical_dialog_on_stderr_match in stderr_output: + logger.info(f"错误信息 '{stderr_output}' 匹配抑制条件,不显示关键错误对话框。") else: - # 如果没有挂载点,或者挂载点是SWAP,则提示用户输入 - mount_point, ok = QInputDialog.getText(None, "挂载分区", - f"请输入 {dev_path} 的挂载点 (例如: /mnt/data):", - text=f"/mnt/{device_name}") - if not ok or not mount_point: - logger.info("用户取消了挂载操作或未提供挂载点。") - return False - - # 确保挂载点目录存在 - if not os.path.exists(mount_point): - try: - os.makedirs(mount_point, exist_ok=True) - logger.info(f"创建挂载点目录: {mount_point}") - except OSError as e: - logger.error(f"创建挂载点目录失败 {mount_point}: {e}") - QMessageBox.critical(None, "错误", f"创建挂载点目录失败: {e}") - return False - - try: - stdout, stderr = self.system_manager._run_command(["mount", dev_path, mount_point], root_privilege=True) - logger.info(f"成功挂载 {dev_path} 到 {mount_point}") - QMessageBox.information(None, "成功", f"成功挂载 {dev_path} 到 {mount_point}") - return True + QMessageBox.critical(None, "错误", f"{error_message}\n错误详情: {stderr_output}") + return False, e.stdout.strip(), stderr_output + except FileNotFoundError: + QMessageBox.critical(None, "错误", f"命令 '{command_list[0]}' 未找到。请确保已安装相关工具。") + logger.error(f"命令 '{command_list[0]}' 未找到。") + return False, "", f"命令 '{command_list[0]}' 未找到。" except Exception as e: - logger.error(f"挂载 {dev_path} 失败: {e}") - QMessageBox.critical(None, "错误", f"挂载 {dev_path} 失败: {e}") - return False + QMessageBox.critical(None, "错误", f"执行命令时发生未知错误: {e}") + logger.error(f"执行命令 {full_cmd_str} 时发生未知错误: {e}") + return False, "", str(e) - def unmount_partition(self, device_name): - """ - 卸载指定的分区。 - """ - dev_path = self._get_device_path(device_name) - logger.info(f"尝试卸载设备: {dev_path}") + def _get_fstab_path(self): + return "/etc/fstab" - # 尝试从系统信息中获取当前挂载点 - current_mount_point = None - devices = self.system_manager.get_block_devices() - for dev in devices: - if dev.get('name') == device_name: - current_mount_point = dev.get('mountpoint') - break - if 'children' in dev: - for child in dev['children']: - if child.get('name') == device_name: - current_mount_point = child.get('mountpoint') + def create_partition(self, disk_path, partition_table_type, size_gb, total_disk_mib, use_max_space): + """ + 在指定磁盘上创建分区。 + :param disk_path: 磁盘设备路径,例如 /dev/sdb。 + :param partition_table_type: 分区表类型,'gpt' 或 'msdos'。 + :param size_gb: 分区大小(GB)。 + :param total_disk_mib: 磁盘总大小 (MiB)。 + :param use_max_space: 是否使用最大可用空间创建分区。 + :return: 新创建的分区路径,如果失败则为 None。 + """ + if not isinstance(disk_path, str) or not isinstance(partition_table_type, str): + logger.error(f"尝试创建分区时传入无效的磁盘路径或分区表类型。磁盘: {disk_path} (类型: {type(disk_path)}), 类型: {partition_table_type} (类型: {type(partition_table_type)})") + QMessageBox.critical(None, "错误", "无效的磁盘路径或分区表类型。") + return None + + logger.info(f"尝试在 {disk_path} 上创建 {size_gb}GB 的分区,分区表类型为 {partition_table_type}。") + + # 1. 检查磁盘是否已经有分区表,如果没有则创建 + # parted -s /dev/sdb print + try: + stdout, _ = self.system_manager._run_command(["parted", "-s", disk_path, "print"], root_privilege=True) + if "unrecognised disk label" in stdout: + logger.info(f"磁盘 {disk_path} 没有分区表,将创建 {partition_table_type} 分区表。") + success, _, _ = self._execute_shell_command( + ["parted", "-s", disk_path, "mklabel", partition_table_type], + f"创建分区表 {partition_table_type} 失败" + ) + if not success: + return None + else: + logger.info(f"磁盘 {disk_path} 已有分区表。") + except Exception as e: + logger.error(f"检查或创建分区表失败: {e}") + QMessageBox.critical(None, "错误", f"检查或创建分区表失败: {e}") + return None + + # 2. 获取下一个分区的起始扇区 + start_sector = self.get_disk_next_partition_start_sector(disk_path) + if start_sector is None: + QMessageBox.critical(None, "错误", f"无法确定磁盘 {disk_path} 的分区起始位置。") + return None + + # 3. 构建 parted 命令 + parted_cmd = ["parted", "-s", disk_path, "mkpart", "primary"] + + # 对于 GPT,默认文件系统是 ext2。对于 msdos,默认是 ext2。 + # parted mkpart primary [fstype] start end + # 我们可以省略 fstype,让用户稍后格式化。 + + # 计算结束位置 + start_mib = start_sector * 512 / (1024 * 1024) # 转换为 MiB + + if use_max_space: + end_mib = total_disk_mib # 使用磁盘总大小作为结束位置 + parted_cmd.extend([f"{start_mib}MiB", f"{end_mib}MiB"]) + else: + end_mib = start_mib + size_gb * 1024 # 将 GB 转换为 MiB + parted_cmd.extend([f"{start_mib}MiB", f"{end_mib}MiB"]) + + # 执行创建分区命令 + success, stdout, stderr = self._execute_shell_command( + parted_cmd, + f"在 {disk_path} 上创建分区失败" + ) + if not success: + return None + + # 4. 刷新内核分区表 + self._execute_shell_command(["partprobe", disk_path], f"刷新 {disk_path} 分区表失败", root_privilege=True) + + # 5. 获取新创建的分区路径 + # 通常新分区是磁盘的最后一个分区,例如 /dev/sdb1, /dev/sdb2 等 + # 我们可以通过 lsblk 再次获取信息,找到最新的分区 + try: + # 重新获取设备信息,找到最新的分区 + devices = self.system_manager.get_block_devices() + new_partition_path = None + for dev in devices: + if dev.get('path') == disk_path and dev.get('children'): + # 找到最新的子分区 + latest_partition = None + for child in dev['children']: + if child.get('type') == 'part': + if latest_partition is None or int(child.get('maj:min').split(':')[1]) > int(latest_partition.get('maj:min').split(':')[1]): + latest_partition = child + if latest_partition: + new_partition_path = latest_partition.get('path') break - if current_mount_point: - break - if not current_mount_point or current_mount_point == '[SWAP]' or current_mount_point == '': - logger.warning(f"设备 {dev_path} 未挂载或无法确定挂载点,无法卸载。") - QMessageBox.warning(None, "警告", f"设备 {dev_path} 未挂载或无法确定挂载点,无法卸载。") - return False + if new_partition_path: + logger.info(f"成功在 {disk_path} 上创建分区: {new_partition_path}") + return new_partition_path + else: + logger.error(f"在 {disk_path} 上创建分区成功,但未能确定新分区的设备路径。") + QMessageBox.warning(None, "警告", f"分区创建成功,但未能确定新分区的设备路径。请手动检查。") + return None + + except Exception as e: + logger.error(f"获取新创建分区路径失败: {e}") + QMessageBox.critical(None, "错误", f"获取新创建分区路径失败: {e}") + return None + + def get_disk_next_partition_start_sector(self, disk_path): + """ + 获取磁盘上下一个可用的分区起始扇区。 + :param disk_path: 磁盘设备路径,例如 /dev/sdb。 + :return: 下一个分区起始扇区(整数),如果失败则为 None。 + """ + if not isinstance(disk_path, str): + logger.error(f"传入 get_disk_next_partition_start_sector 的 disk_path 不是字符串: {disk_path} (类型: {type(disk_path)})") + return None try: - # 尝试通过挂载点卸载,通常更可靠 - stdout, stderr = self.system_manager._run_command(["umount", current_mount_point], root_privilege=True) - logger.info(f"成功卸载 {current_mount_point} ({dev_path})") - QMessageBox.information(None, "成功", f"成功卸载 {current_mount_point} ({dev_path})") - return True - except Exception as e: - logger.error(f"卸载 {current_mount_point} ({dev_path}) 失败: {e}") - QMessageBox.critical(None, "错误", f"卸载 {current_mount_point} ({dev_path}) 失败: {e}") - return False + stdout, _ = self.system_manager._run_command(["parted", "-s", disk_path, "unit", "s", "print", "free"], root_privilege=True) + # 查找最大的空闲空间 + free_space_pattern = re.compile(r'^\s*\d+\s+([\d.]+s)\s+([\d.]+s)\s+([\d.]+s)\s+Free Space', re.MULTILINE) + matches = free_space_pattern.findall(stdout) - def delete_partition(self, device_name): + if matches: + # 找到所有空闲空间的起始扇区,并取最小的作为下一个分区的起始 + # 或者,更准确地说,找到最后一个分区的结束扇区 + 1 + # 简化处理:如果没有分区,从 2048s 开始 (常见对齐) + # 如果有分区,从最后一个分区的结束扇区 + 1 开始 + + # 先尝试获取所有分区信息 + stdout_partitions, _ = self.system_manager._run_command(["parted", "-s", disk_path, "unit", "s", "print"], root_privilege=True) + partition_end_pattern = re.compile(r'^\s*\d+\s+[\d.]+s\s+([\d.]+s)', re.MULTILINE) + partition_end_sectors = [int(float(s.replace('s', ''))) for s in partition_end_pattern.findall(stdout_partitions)] + + if partition_end_sectors: + # 最后一个分区的结束扇区 + 1 + last_end_sector = max(partition_end_sectors) + # 确保对齐,通常是 1MiB (2048扇区) 或 4MiB (8192扇区) + # 这里我们简单地取下一个 1MiB 对齐的扇区 + start_sector = (last_end_sector // 2048 + 1) * 2048 + logger.debug(f"磁盘 {disk_path} 最后一个分区结束于 {last_end_sector}s,下一个分区起始扇区建议为 {start_sector}s。") + return start_sector + else: + # 没有分区,从 2048s 开始 + logger.debug(f"磁盘 {disk_path} 没有现有分区,建议起始扇区为 2048s。") + return 2048 # 2048扇区是 1MiB,常见的起始对齐位置 + else: + logger.warning(f"无法在磁盘 {disk_path} 上找到空闲空间信息。") + return None + except Exception as e: + logger.error(f"获取磁盘 {disk_path} 下一个分区起始扇区失败: {e}") + return None + + def get_disk_next_partition_start_mib(self, disk_path): + """ + 获取磁盘上下一个可用的分区起始位置 (MiB)。 + :param disk_path: 磁盘设备路径,例如 /dev/sdb。 + :return: 下一个分区起始位置 (MiB),如果失败则为 None。 + """ + start_sector = self.get_disk_next_partition_start_sector(disk_path) + if start_sector is not None: + return start_sector * 512 / (1024 * 1024) # 转换为 MiB + return None + + def delete_partition(self, device_path): """ 删除指定的分区。 - 此操作不可逆,会丢失数据。 + :param device_path: 要删除的分区路径,例如 /dev/sdb1。 + :return: True 如果删除成功,否则 False。 """ - dev_path = self._get_device_path(device_name) - logger.info(f"尝试删除分区: {dev_path}") - - # 安全检查: 确保是分区,而不是整个磁盘 - # 简单的检查方法是看设备名是否包含数字 (如 sda1, nvme0n1p1) - if not any(char.isdigit() for char in device_name): - QMessageBox.warning(None, "警告", f"{dev_path} 看起来不是一个分区。为安全起见,不执行删除操作。") - logger.warning(f"尝试删除整个磁盘 {dev_path},已阻止。") - return False + if not isinstance(device_path, str): + logger.error(f"尝试删除非字符串设备路径: {device_path} (类型: {type(device_path)})") + QMessageBox.critical(None, "错误", "无效的设备路径。") + return False reply = QMessageBox.question(None, "确认删除分区", - f"你确定要删除分区 {dev_path} 吗?\n" - "此操作不可逆,将导致数据丢失!", - QMessageBox.Yes | QMessageBox.No, QMessageBox.No) + f"您确定要删除分区 {device_path} 吗?此操作不可逆!", + QMessageBox.Yes | QMessageBox.No, QMessageBox.No) if reply == QMessageBox.No: - logger.info(f"用户取消了删除分区 {dev_path} 的操作。") + logger.info(f"用户取消了删除分区 {device_path} 的操作。") return False - # 首先尝试卸载分区(如果已挂载) - try: - # umount 如果设备未挂载会返回非零退出码,所以这里 check_output=False - self.system_manager._run_command(["umount", dev_path], root_privilege=True, check_output=False) - logger.info(f"尝试卸载 {dev_path} (如果已挂载)。") - except Exception as e: - logger.warning(f"卸载 {dev_path} 失败或未挂载: {e}") - # 继续执行,因为即使卸载失败也可能能删除 + logger.info(f"尝试删除分区: {device_path}") - # 使用 parted 来删除分区 - # parted 需要父磁盘设备名和分区号 - # 例如,对于 /dev/sda1,需要 /dev/sda 和分区号 1 - partition_number_str = ''.join(filter(str.isdigit, device_name)) # 从 sda1 提取 "1" - if not partition_number_str: - QMessageBox.critical(None, "错误", f"无法从 {device_name} 解析分区号。") - logger.error(f"无法从 {device_name} 解析分区号。") + # 1. 尝试卸载分区 (静默处理,如果未挂载则不报错) + self.unmount_partition(device_path, show_dialog_on_error=False) + + # 2. 从 fstab 中移除条目 + self._remove_fstab_entry(device_path) + + # 3. 获取父磁盘和分区号 + match = re.match(r'(/dev/\w+)(\d+)', device_path) + if not match: + QMessageBox.critical(None, "错误", f"无法解析分区路径 {device_path}。") + logger.error(f"无法解析分区路径 {device_path}。") return False - parent_disk_name = device_name.rstrip(partition_number_str) # 从 sda1 提取 "sda" - parent_disk_path = self._get_device_path(parent_disk_name) + disk_path = match.group(1) + partition_number = match.group(2) - try: - # parted -s /dev/sda rm 1 - stdout, stderr = self.system_manager._run_command( - ["parted", "-s", parent_disk_path, "rm", partition_number_str], - root_privilege=True - ) - logger.info(f"成功删除分区 {dev_path}") - QMessageBox.information(None, "成功", f"成功删除分区 {dev_path}") + # 4. 执行 parted 命令删除分区 + success, _, stderr = self._execute_shell_command( + ["parted", "-s", disk_path, "rm", partition_number], + f"删除分区 {device_path} 失败" + ) + if success: + QMessageBox.information(None, "成功", f"分区 {device_path} 已成功删除。") + # 刷新内核分区表 + self._execute_shell_command(["partprobe", disk_path], f"刷新 {disk_path} 分区表失败", root_privilege=True) return True - except Exception as e: - logger.error(f"删除分区 {dev_path} 失败: {e}") - QMessageBox.critical(None, "错误", f"删除分区 {dev_path} 失败: {e}") + else: return False - def format_partition(self, device_name, fstype=None): + def format_partition(self, device_path, fstype=None): """ - 格式化指定的分区。 - 此操作不可逆,会丢失数据。 + 格式化指定分区。 + :param device_path: 要格式化的设备路径,例如 /dev/sdb1。 + :param fstype: 文件系统类型,例如 'ext4', 'xfs'。如果为 None,则弹出对话框让用户选择。 + :return: True 如果格式化成功,否则 False。 """ - dev_path = self._get_device_path(device_name) - logger.info(f"尝试格式化分区: {dev_path}") + if not isinstance(device_path, str): + logger.error(f"尝试格式化非字符串设备路径: {device_path} (类型: {type(device_path)})") + QMessageBox.critical(None, "错误", "无效的设备路径。") + return False - # 安全检查: 确保是分区 - if not any(char.isdigit() for char in device_name): - QMessageBox.warning(None, "警告", f"{dev_path} 看起来不是一个分区。为安全起见,不执行格式化操作。") - logger.warning(f"尝试格式化整个磁盘 {dev_path},已阻止。") - return False + # 1. 尝试卸载分区 (静默处理,如果未挂载则不报错) + self.unmount_partition(device_path, show_dialog_on_error=False) - if not fstype: - # 提示用户选择文件系统类型 - fstypes = ["ext4", "xfs", "fat32", "ntfs"] # 常用文件系统 - fstype, ok = QInputDialog.getItem(None, "格式化分区", - f"请选择 {dev_path} 的文件系统类型:", - fstypes, 0, False) # 默认选择 ext4 - if not ok or not fstype: - logger.info("用户取消了格式化操作或未选择文件系统。") + # 2. 从 fstab 中移除旧条目 (因为格式化后 UUID 会变) + self._remove_fstab_entry(device_path) + + if fstype is None: + # 弹出对话框让用户选择文件系统类型 + items = ["ext4", "xfs", "fat32", "ntfs"] # 常用文件系统 + selected_fstype, ok = QInputDialog.getItem(None, "选择文件系统", + f"请为 {device_path} 选择文件系统类型:", + items, 0, False) + if not ok or not selected_fstype: + logger.info(f"用户取消了格式化 {device_path} 的操作。") return False + fstype = selected_fstype reply = QMessageBox.question(None, "确认格式化分区", - f"你确定要格式化分区 {dev_path} 为 {fstype} 吗?\n" - "此操作不可逆,将导致数据丢失!", - QMessageBox.Yes | QMessageBox.No, QMessageBox.No) + f"您确定要格式化分区 {device_path} 为 {fstype} 吗?此操作将擦除所有数据!", + QMessageBox.Yes | QMessageBox.No, QMessageBox.No) if reply == QMessageBox.No: - logger.info(f"用户取消了格式化分区 {dev_path} 的操作。") + logger.info(f"用户取消了格式化分区 {device_path} 的操作。") return False - # 首先尝试卸载分区(如果已挂载) - try: - self.system_manager._run_command(["umount", dev_path], root_privilege=True, check_output=False) - logger.info(f"尝试卸载 {dev_path} (如果已挂载)。") - except Exception as e: - logger.warning(f"卸载 {dev_path} 失败或未挂载: {e}") + logger.info(f"尝试格式化设备 {device_path} 为 {fstype}。") - # 执行 mkfs 命令 + format_cmd = [] + if fstype == "ext4": + format_cmd = ["mkfs.ext4", "-F", device_path] + elif fstype == "xfs": + format_cmd = ["mkfs.xfs", "-f", device_path] + elif fstype == "fat32": + format_cmd = ["mkfs.vfat", "-F", "32", device_path] + elif fstype == "ntfs": + format_cmd = ["mkfs.ntfs", "-f", device_path] + else: + QMessageBox.critical(None, "错误", f"不支持的文件系统类型: {fstype}") + logger.error(f"不支持的文件系统类型: {fstype}") + return False + + success, _, stderr = self._execute_shell_command( + format_cmd, + f"格式化设备 {device_path} 为 {fstype} 失败" + ) + if success: + QMessageBox.information(None, "成功", f"设备 {device_path} 已成功格式化为 {fstype}。") + return True + else: + return False + + def mount_partition(self, device_path, mount_point, add_to_fstab=False): + """ + 挂载一个分区。 + :param device_path: 要挂载的设备路径,例如 /dev/sdb1。 + :param mount_point: 挂载点,例如 /mnt/data。 + :param add_to_fstab: 是否将挂载信息添加到 /etc/fstab。 + :return: True 如果挂载成功,否则 False。 + """ + if not isinstance(device_path, str) or not isinstance(mount_point, str): + logger.error(f"尝试挂载时传入无效的设备路径或挂载点。设备: {device_path} (类型: {type(device_path)}), 挂载点: {mount_point} (类型: {type(mount_point)})") + QMessageBox.critical(None, "错误", "无效的设备路径或挂载点。") + return False + + logger.info(f"尝试挂载设备 {device_path} 到 {mount_point}。") try: - mkfs_cmd = [] - if fstype == "ext4": - mkfs_cmd = ["mkfs.ext4", "-F", dev_path] # -F 强制执行 - elif fstype == "xfs": - mkfs_cmd = ["mkfs.xfs", "-f", dev_path] # -f 强制执行 - elif fstype == "fat32": - mkfs_cmd = ["mkfs.fat", "-F", "32", dev_path] - elif fstype == "ntfs": - mkfs_cmd = ["mkfs.ntfs", "-f", dev_path] # -f 强制执行 + # 确保挂载点存在 + if not os.path.exists(mount_point): + logger.debug(f"创建挂载点目录: {mount_point}") + os.makedirs(mount_point, exist_ok=True) + + # 执行挂载命令 + success, _, stderr = self._execute_shell_command( + ["mount", device_path, mount_point], + f"挂载设备 {device_path} 到 {mount_point} 失败" + ) + + if success: + QMessageBox.information(None, "成功", f"设备 {device_path} 已成功挂载到 {mount_point}。") + if add_to_fstab: + self._add_to_fstab(device_path, mount_point) + return True else: - raise ValueError(f"不支持的文件系统类型: {fstype}") + return False + except Exception as e: + logger.error(f"挂载设备 {device_path} 时发生错误: {e}") + QMessageBox.critical(None, "错误", f"挂载设备 {device_path} 失败。\n错误详情: {e}") + return False - stdout, stderr = self.system_manager._run_command(mkfs_cmd, root_privilege=True) - logger.info(f"成功格式化分区 {dev_path} 为 {fstype}") - QMessageBox.information(None, "成功", f"成功格式化分区 {dev_path} 为 {fstype}") + def unmount_partition(self, device_path, show_dialog_on_error=True): + """ + 卸载一个分区。 + :param device_path: 要卸载的设备路径,例如 /dev/sdb1 或 /dev/md0。 + :param show_dialog_on_error: 是否在卸载失败时显示错误对话框。 + :return: True 如果卸载成功,否则 False。 + """ + if not isinstance(device_path, str): + logger.error(f"尝试卸载非字符串设备路径: {device_path} (类型: {type(device_path)})") + if show_dialog_on_error: + QMessageBox.critical(None, "错误", f"无效的设备路径: {device_path}") + return False + + logger.info(f"尝试卸载设备: {device_path}") + try: + # 尝试从 fstab 中移除条目,如果存在的话 + # 注意:这里先移除 fstab 条目,是为了防止卸载失败后 fstab 中仍然有无效条目 + # 但如果卸载成功,fstab 条目也需要移除。 + # 如果卸载失败,且 fstab 移除成功,用户需要手动处理。 + # 更好的做法可能是:卸载成功后,再移除 fstab 条目。 + # 但为了简化逻辑,这里先移除,并假设卸载会成功。 + # 实际上,_remove_fstab_entry 应该在卸载成功后才调用,或者在删除分区/格式化时调用。 + # 对于单纯的卸载操作,不应该自动移除 fstab 条目,除非用户明确要求。 + # 考虑到当前场景是“卸载后刷新”,通常用户只是想临时卸载,fstab条目不应被删除。 + # 所以这里暂时注释掉,或者只在删除分区/格式化时调用。 + # self._remove_fstab_entry(device_path) + + # 执行卸载命令 + success, _, stderr = self._execute_shell_command( + ["umount", device_path], + f"卸载设备 {device_path} 失败", + suppress_critical_dialog_on_stderr_match="not mounted" if not show_dialog_on_error else None + ) + if success: + QMessageBox.information(None, "成功", f"设备 {device_path} 已成功卸载。") + return success + except Exception as e: + logger.error(f"卸载设备 {device_path} 时发生错误: {e}") + if show_dialog_on_error: + QMessageBox.critical(None, "错误", f"卸载设备 {device_path} 失败。\n错误详情: {e}") + return False + + def _add_to_fstab(self, device_path, mount_point): + """ + 将设备的挂载信息添加到 /etc/fstab。 + 使用 UUID 方式添加。 + """ + if not isinstance(device_path, str) or not isinstance(mount_point, str): + logger.error(f"尝试添加到 fstab 时传入无效的设备路径或挂载点。设备: {device_path} (类型: {type(device_path)}), 挂载点: {mount_point} (类型: {type(mount_point)})") + QMessageBox.critical(None, "错误", "无效的设备路径或挂载点,无法添加到 fstab。") + return False + + logger.info(f"尝试将设备 {device_path} (挂载点: {mount_point}) 添加到 /etc/fstab。") + device_details = self.system_manager.get_device_details_by_path(device_path) + uuid = device_details.get('uuid') if device_details else None + fstype = device_details.get('fstype') if device_details else None + + if not uuid or not fstype: + logger.error(f"无法获取设备 {device_path} 的 UUID 或文件系统类型,无法添加到 fstab。UUID: {uuid}, FSTYPE: {fstype}") + QMessageBox.critical(None, "错误", f"无法获取设备 {device_path} 的 UUID 或文件系统类型,无法添加到 /etc/fstab。") + return False + + fstab_entry = f"UUID={uuid} {mount_point} {fstype} defaults 0 2" + fstab_path = self._get_fstab_path() + + try: + # 检查是否已存在相同的挂载点或 UUID 条目 + with open(fstab_path, 'r') as f: + lines = f.readlines() + + for line in lines: + if f"UUID={uuid}" in line or mount_point in line.split(): + logger.warning(f"fstab 中已存在设备 {device_path} (UUID: {uuid}) 或挂载点 {mount_point} 的条目,跳过添加。") + return True # 已经存在,认为成功 + + # 添加新条目 + with open(fstab_path, 'a') as f: + f.write(fstab_entry + '\n') + logger.info(f"成功将 '{fstab_entry}' 添加到 {fstab_path}。") return True except Exception as e: - logger.error(f"格式化分区 {dev_path} 失败: {e}") - QMessageBox.critical(None, "错误", f"格式化分区 {dev_path} 失败: {e}") + logger.error(f"将设备 {device_path} 添加到 /etc/fstab 失败: {e}") + QMessageBox.critical(None, "错误", f"将设备 {device_path} 添加到 /etc/fstab 失败。\n错误详情: {e}") + return False + + def _remove_fstab_entry(self, device_path): + """ + 从 /etc/fstab 中移除指定设备的条目。 + :param device_path: 设备路径,例如 /dev/sdb1 或 /dev/md0。 + :return: True 如果成功移除或没有找到条目,否则 False。 + """ + if not isinstance(device_path, str): + logger.warning(f"尝试移除 fstab 条目时传入非字符串设备路径: {device_path} (类型: {type(device_path)})") + return False + + logger.info(f"尝试从 /etc/fstab 移除与 {device_path} 相关的条目。") + fstab_path = self._get_fstab_path() + try: + device_details = self.system_manager.get_device_details_by_path(device_path) + uuid = device_details.get('uuid') if device_details else None + + logger.debug(f"尝试移除 fstab 条目,获取到的设备 {device_path} 的 UUID: {uuid}") + + # 如果没有 UUID 且不是 /dev/ 路径,则无法可靠地从 fstab 中移除 + if not uuid and not device_path.startswith('/dev/'): + logger.warning(f"无法获取设备 {device_path} 的 UUID,也无法识别为 /dev/ 路径,跳过 fstab 移除。") + return False + + with open(fstab_path, 'r') as f: + lines = f.readlines() + + new_lines = [] + removed_count = 0 + for line in lines: + # 检查是否是注释或空行 + if line.strip().startswith('#') or not line.strip(): + new_lines.append(line) + continue + + is_match = False + # 尝试匹配 UUID + if uuid: + if f"UUID={uuid}" in line: + is_match = True + # 尝试匹配设备路径 (如果 UUID 不存在或不匹配) + if not is_match and device_path: + # 分割行以避免部分匹配,例如 /dev/sda 匹配 /dev/sda1 + parts = line.split() + if len(parts) > 0 and parts[0] == device_path: + is_match = True + + if is_match: + logger.info(f"从 {fstab_path} 移除了条目: {line.strip()}") + removed_count += 1 + else: + new_lines.append(line) + + if removed_count > 0: + with open(fstab_path, 'w') as f: + f.writelines(new_lines) + logger.info(f"成功从 {fstab_path} 移除了 {removed_count} 个条目。") + return True + else: + logger.info(f"在 {fstab_path} 中未找到与 {device_path} 相关的条目。") + return True # 没有找到也算成功,因为目标是确保没有该条目 + except FileNotFoundError: + logger.warning(f"fstab 文件 {fstab_path} 不存在。") + return True + except Exception as e: + logger.error(f"从 /etc/fstab 移除条目时发生错误: {e}") + QMessageBox.critical(None, "错误", f"从 /etc/fstab 移除条目失败。\n错误详情: {e}") return False diff --git a/logger_config.py b/logger_config.py index 07474ea..0f06543 100644 --- a/logger_config.py +++ b/logger_config.py @@ -31,7 +31,7 @@ def setup_logging(text_edit_widget: QTextEdit): """ root_logger = logging.getLogger() # 设置日志级别,可以根据需要调整 (DEBUG, INFO, WARNING, ERROR, CRITICAL) - root_logger.setLevel(logging.INFO) + root_logger.setLevel(logging.DEBUG) # 清除现有的处理器,防止重复输出(如果多次调用此函数) for handler in root_logger.handlers[:]: diff --git a/lvm_operations.py b/lvm_operations.py new file mode 100644 index 0000000..7dcbef8 --- /dev/null +++ b/lvm_operations.py @@ -0,0 +1,309 @@ +import subprocess +import logging +from PySide6.QtWidgets import QMessageBox + +logger = logging.getLogger(__name__) + +class LvmOperations: + def __init__(self): + pass # LVM操作不直接依赖SystemInfoManager,通过参数传递所需信息 + + def _execute_shell_command(self, command_list, error_message, root_privilege=True, + suppress_critical_dialog_on_stderr_match=None, input_data=None): + """ + 通用地运行一个 shell 命令,并处理错误。 + :param command_list: 命令及其参数的列表。 + :param error_message: 命令失败时显示给用户的错误消息。 + :param root_privilege: 如果为 True,则使用 sudo 执行命令。 + :param suppress_critical_dialog_on_stderr_match: 如果 stderr 包含此字符串,则不显示关键错误对话框。 + :param input_data: 传递给命令stdin的数据 (str)。 + :return: (True/False, stdout_str, stderr_str) + """ + if not all(isinstance(arg, str) for arg in command_list): + logger.error(f"命令列表包含非字符串元素: {command_list}") + QMessageBox.critical(None, "错误", f"内部错误:尝试执行的命令包含无效参数。\n命令详情: {command_list}") + return False, "", "内部错误:命令参数类型不正确。" + + if root_privilege: + command_list = ["sudo"] + command_list + + full_cmd_str = ' '.join(command_list) + logger.debug(f"执行命令: {full_cmd_str}") + + try: + result = subprocess.run( + command_list, + capture_output=True, + text=True, + check=True, + encoding='utf-8', + input=input_data + ) + logger.info(f"命令成功: {full_cmd_str}") + return True, result.stdout.strip(), result.stderr.strip() + except subprocess.CalledProcessError as e: + stderr_output = e.stderr.strip() + logger.error(f"命令失败: {full_cmd_str}") + logger.error(f"退出码: {e.returncode}") + logger.error(f"标准输出: {e.stdout.strip()}") + logger.error(f"标准错误: {stderr_output}") + + if suppress_critical_dialog_on_stderr_match and \ + suppress_critical_dialog_on_stderr_match in stderr_output: + logger.info(f"错误信息 '{stderr_output}' 匹配抑制条件,不显示关键错误对话框。") + else: + QMessageBox.critical(None, "错误", f"{error_message}\n错误详情: {stderr_output}") + return False, e.stdout.strip(), stderr_output + except FileNotFoundError: + QMessageBox.critical(None, "错误", f"命令 '{command_list[0]}' 未找到。请确保已安装相关工具。") + logger.error(f"命令 '{command_list[0]}' 未找到。") + return False, "", f"命令 '{command_list[0]}' 未找到。" + except Exception as e: + QMessageBox.critical(None, "错误", f"执行命令时发生未知错误: {e}") + logger.error(f"执行命令 {full_cmd_str} 时发生未知错误: {e}") + return False, "", str(e) + + def create_pv(self, device_path): + """ + 创建物理卷 (PV)。 + :param device_path: 设备的路径,例如 /dev/sdb1。 + :return: True 如果成功,否则 False。 + """ + if not isinstance(device_path, str): + logger.error(f"尝试创建 PV 时传入非字符串设备路径: {device_path} (类型: {type(device_path)})") + QMessageBox.critical(None, "错误", "无效的设备路径。") + return False + + 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._execute_shell_command( + ["pvcreate", "-y", device_path], # -y 自动确认 + f"在 {device_path} 上创建物理卷失败" + ) + if success: + QMessageBox.information(None, "成功", f"物理卷已在 {device_path} 上成功创建。") + return True + else: + return False + + def delete_pv(self, device_path): + """ + 删除物理卷 (PV)。 + :param device_path: 物理卷的路径,例如 /dev/sdb1。 + :return: True 如果成功,否则 False。 + """ + if not isinstance(device_path, str): + logger.error(f"尝试删除 PV 时传入非字符串设备路径: {device_path} (类型: {type(device_path)})") + QMessageBox.critical(None, "错误", "无效的设备路径。") + return False + + 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._execute_shell_command( + ["pvremove", "-y", device_path], + f"删除物理卷 {device_path} 失败" + ) + if success: + QMessageBox.information(None, "成功", f"物理卷 {device_path} 已成功删除。") + return True + else: + return False + + def create_vg(self, vg_name, pv_paths): + """ + 创建卷组 (VG)。 + :param vg_name: 卷组的名称。 + :param pv_paths: 组成卷组的物理卷路径列表。 + :return: True 如果成功,否则 False。 + """ + if not isinstance(vg_name, str) or not isinstance(pv_paths, list) or not all(isinstance(p, str) for p in pv_paths): + logger.error(f"尝试创建 VG 时传入无效参数。VG名称: {vg_name}, PV路径: {pv_paths}") + QMessageBox.critical(None, "错误", "无效的卷组名称或物理卷路径。") + return False + + reply = QMessageBox.question(None, "确认创建卷组", + f"您确定要使用物理卷 {', '.join(pv_paths)} 创建卷组 {vg_name} 吗?", + QMessageBox.Yes | QMessageBox.No, QMessageBox.No) + if reply == QMessageBox.No: + logger.info(f"用户取消了创建卷组 {vg_name} 的操作。") + return False + + logger.info(f"尝试使用 {', '.join(pv_paths)} 创建卷组 {vg_name}。") + command = ["vgcreate", vg_name] + pv_paths + success, _, stderr = self._execute_shell_command( + command, + f"创建卷组 {vg_name} 失败" + ) + if success: + QMessageBox.information(None, "成功", f"卷组 {vg_name} 已成功创建。") + return True + else: + return False + + def delete_vg(self, vg_name): + """ + 删除卷组 (VG)。 + :param vg_name: 卷组的名称。 + :return: True 如果成功,否则 False。 + """ + if not isinstance(vg_name, str): + logger.error(f"尝试删除 VG 时传入非字符串 VG 名称: {vg_name} (类型: {type(vg_name)})") + QMessageBox.critical(None, "错误", "无效的卷组名称。") + return False + + reply = QMessageBox.question(None, "确认删除卷组", + f"您确定要删除卷组 {vg_name} 吗?此操作将删除所有属于该卷组的逻辑卷!", + QMessageBox.Yes | QMessageBox.No, QMessageBox.No) + if reply == QMessageBox.No: + logger.info(f"用户取消了删除卷组 {vg_name} 的操作。") + return False + + logger.info(f"尝试删除卷组: {vg_name}。") + # -y 自动确认, -f 强制删除所有逻辑卷 + success, _, stderr = self._execute_shell_command( + ["vgremove", "-y", "-f", vg_name], + f"删除卷组 {vg_name} 失败" + ) + if success: + QMessageBox.information(None, "成功", f"卷组 {vg_name} 已成功删除。") + return True + else: + return False + + def create_lv(self, lv_name, vg_name, size_gb, use_max_space=False): + """ + 创建逻辑卷 (LV)。 + :param lv_name: 逻辑卷的名称。 + :param vg_name: 所属卷组的名称。 + :param size_gb: 逻辑卷的大小(GB)。当 use_max_space 为 True 时,此参数被忽略。 + :param use_max_space: 是否使用卷组的最大可用空间。 + :return: True 如果成功,否则 False。 + """ + if not isinstance(lv_name, str) or not isinstance(vg_name, str): + logger.error(f"尝试创建 LV 时传入无效参数。LV名称: {lv_name}, VG名称: {vg_name}") + QMessageBox.critical(None, "错误", "无效的逻辑卷名称或卷组名称。") + return False + if not use_max_space and (not isinstance(size_gb, (int, float)) or size_gb <= 0): + logger.error(f"尝试创建 LV 时传入无效大小。大小: {size_gb}") + QMessageBox.critical(None, "错误", "逻辑卷大小必须大于0。") + return False + + # Confirmation message + confirm_message = f"您确定要在卷组 {vg_name} 中创建逻辑卷 {lv_name} 吗?" + if use_max_space: + confirm_message += "使用卷组所有可用空间。" + else: + confirm_message += f"大小为 {size_gb}GB。" + + reply = QMessageBox.question(None, "确认创建逻辑卷", + confirm_message, + QMessageBox.Yes | QMessageBox.No, QMessageBox.No) + if reply == QMessageBox.No: + logger.info(f"用户取消了创建逻辑卷 {lv_name} 的操作。") + return False + + if use_max_space: + create_cmd = ["lvcreate", "-l", "100%FREE", "-n", lv_name, vg_name] + logger.info(f"尝试在卷组 {vg_name} 中使用最大可用空间创建逻辑卷 {lv_name}。") + else: + create_cmd = ["lvcreate", "-L", f"{size_gb}G", "-n", lv_name, vg_name] + logger.info(f"尝试在卷组 {vg_name} 中创建 {size_gb}GB 的逻辑卷 {lv_name}。") + + success, _, stderr = self._execute_shell_command( + create_cmd, + f"创建逻辑卷 {lv_name} 失败" + ) + if success: + QMessageBox.information(None, "成功", f"逻辑卷 {lv_name} 已在卷组 {vg_name} 中成功创建。") + return True + else: + return False + + def delete_lv(self, lv_name, vg_name): + """ + 删除逻辑卷 (LV)。 + :param lv_name: 逻辑卷的名称。 + :param vg_name: 所属卷组的名称。 + :return: True 如果成功,否则 False。 + """ + if not isinstance(lv_name, str) or not isinstance(vg_name, str): + logger.error(f"尝试删除 LV 时传入非字符串 LV 名称或 VG 名称。LV名称: {lv_name}, VG名称: {vg_name}") + QMessageBox.critical(None, "错误", "无效的逻辑卷名称或卷组名称。") + return False + + reply = QMessageBox.question(None, "确认删除逻辑卷", + f"您确定要删除逻辑卷 {vg_name}/{lv_name} 吗?此操作将擦除所有数据!", + QMessageBox.Yes | QMessageBox.No, QMessageBox.No) + if reply == QMessageBox.No: + logger.info(f"用户取消了删除逻辑卷 {vg_name}/{lv_name} 的操作。") + return False + + logger.info(f"尝试删除逻辑卷: {vg_name}/{lv_name}。") + # -y 自动确认 + success, _, stderr = self._execute_shell_command( + ["lvremove", "-y", f"{vg_name}/{lv_name}"], + f"删除逻辑卷 {vg_name}/{lv_name} 失败" + ) + if success: + QMessageBox.information(None, "成功", f"逻辑卷 {vg_name}/{lv_name} 已成功删除。") + return True + else: + return False + + def activate_lv(self, lv_name, vg_name): + """ + 激活逻辑卷。 + :param lv_name: 逻辑卷的名称。 + :param vg_name: 所属卷组的名称。 + :return: True 如果成功,否则 False。 + """ + if not isinstance(lv_name, str) or not isinstance(vg_name, str): + logger.error(f"尝试激活 LV 时传入非字符串 LV 名称或 VG 名称。LV名称: {lv_name}, VG名称: {vg_name}") + QMessageBox.critical(None, "错误", "无效的逻辑卷名称或卷组名称。") + return False + + logger.info(f"尝试激活逻辑卷: {vg_name}/{lv_name}。") + success, _, stderr = self._execute_shell_command( + ["lvchange", "-ay", f"{vg_name}/{lv_name}"], + f"激活逻辑卷 {vg_name}/{lv_name} 失败" + ) + if success: + QMessageBox.information(None, "成功", f"逻辑卷 {vg_name}/{lv_name} 已成功激活。") + return True + else: + return False + + def deactivate_lv(self, lv_name, vg_name): + """ + 停用逻辑卷。 + :param lv_name: 逻辑卷的名称。 + :param vg_name: 所属卷组的名称。 + :return: True 如果成功,否则 False。 + """ + if not isinstance(lv_name, str) or not isinstance(vg_name, str): + logger.error(f"尝试停用 LV 时传入非字符串 LV 名称或 VG 名称。LV名称: {lv_name}, VG名称: {vg_name}") + QMessageBox.critical(None, "错误", "无效的逻辑卷名称或卷组名称。") + return False + + logger.info(f"尝试停用逻辑卷: {vg_name}/{lv_name}。") + success, _, stderr = self._execute_shell_command( + ["lvchange", "-an", f"{vg_name}/{lv_name}"], + f"停用逻辑卷 {vg_name}/{lv_name} 失败" + ) + if success: + QMessageBox.information(None, "成功", f"逻辑卷 {vg_name}/{lv_name} 已成功停用。") + return True + else: + return False diff --git a/mainwindow.py b/mainwindow.py index 47610df..f56c932 100644 --- a/mainwindow.py +++ b/mainwindow.py @@ -1,15 +1,29 @@ # mainwindow.py import sys import logging +import re +import os # 导入 os 模块 from PySide6.QtWidgets import (QApplication, QMainWindow, QTreeWidgetItem, - QMessageBox, QHeaderView, QMenu, QInputDialog) + QMessageBox, QHeaderView, QMenu, QInputDialog, QDialog) from PySide6.QtCore import Qt, QPoint +# 导入自动生成的 UI 文件 from ui_form import Ui_MainWindow +# 导入我们自己编写的系统信息管理模块 from system_info import SystemInfoManager +# 导入日志配置 from logger_config import setup_logging, logger +# 导入磁盘操作模块 from disk_operations import DiskOperations +# 导入 RAID 操作模块 +from raid_operations import RaidOperations +# 导入 LVM 操作模块 +from lvm_operations import LvmOperations +# 导入自定义对话框 +from dialogs import (CreatePartitionDialog, MountDialog, CreateRaidDialog, + CreatePvDialog, CreateVgDialog, CreateLvDialog) + class MainWindow(QMainWindow): def __init__(self, parent=None): @@ -20,17 +34,30 @@ class MainWindow(QMainWindow): setup_logging(self.ui.logOutputTextEdit) logger.info("应用程序启动。") + # 初始化管理器和操作类 self.system_manager = SystemInfoManager() self.disk_ops = DiskOperations() + self.raid_ops = RaidOperations() + self.lvm_ops = LvmOperations() + # 连接刷新按钮的信号到槽函数 if hasattr(self.ui, 'refreshButton'): self.ui.refreshButton.clicked.connect(self.refresh_all_info) else: logger.warning("Warning: refreshButton not found in UI. Please add it in form.ui and regenerate ui_form.py.") + # 启用 treeWidget 的自定义上下文菜单 self.ui.treeWidget_block_devices.setContextMenuPolicy(Qt.CustomContextMenu) self.ui.treeWidget_block_devices.customContextMenuRequested.connect(self.show_block_device_context_menu) + self.ui.treeWidget_raid.setContextMenuPolicy(Qt.CustomContextMenu) + self.ui.treeWidget_raid.customContextMenuRequested.connect(self.show_raid_context_menu) + + self.ui.treeWidget_lvm.setContextMenuPolicy(Qt.CustomContextMenu) + self.ui.treeWidget_lvm.customContextMenuRequested.connect(self.show_lvm_context_menu) + + + # 初始化时刷新所有数据 self.refresh_all_info() logger.info("所有设备信息已初始化加载。") @@ -44,26 +71,15 @@ class MainWindow(QMainWindow): self.refresh_lvm_info() logger.info("所有设备信息刷新完成。") + # --- 块设备概览 Tab --- def refresh_block_devices_info(self): - """ - 刷新块设备信息并显示在 QTreeWidget 中。 - """ self.ui.treeWidget_block_devices.clear() columns = [ - ("设备名", 'name'), - ("类型", 'type'), - ("大小", 'size'), - ("挂载点", 'mountpoint'), - ("文件系统", 'fstype'), - ("只读", 'ro'), - ("UUID", 'uuid'), - ("PARTUUID", 'partuuid'), - ("厂商", 'vendor'), - ("型号", 'model'), - ("序列号", 'serial'), - ("主次号", 'maj:min'), - ("父设备名", 'pkname'), + ("设备名", 'name'), ("类型", 'type'), ("大小", 'size'), ("挂载点", 'mountpoint'), + ("文件系统", 'fstype'), ("只读", 'ro'), ("UUID", 'uuid'), ("PARTUUID", 'partuuid'), + ("厂商", 'vendor'), ("型号", 'model'), ("序列号", 'serial'), + ("主次号", 'maj:min'), ("父设备名", 'pkname'), ] headers = [col[0] for col in columns] @@ -89,10 +105,6 @@ class MainWindow(QMainWindow): logger.error(f"刷新块设备信息失败: {e}") def _add_device_to_tree(self, parent_item, dev_data): - """ - 辅助函数,将单个设备及其子设备添加到 QTreeWidget。 - parent_item 可以是 QTreeWidget 本身,也可以是另一个 QTreeWidgetItem。 - """ item = QTreeWidgetItem(parent_item) for i, key in enumerate(self.field_keys): value = dev_data.get(key) @@ -110,16 +122,170 @@ class MainWindow(QMainWindow): self._add_device_to_tree(item, child) item.setExpanded(True) + def show_block_device_context_menu(self, pos: QPoint): + item = self.ui.treeWidget_block_devices.itemAt(pos) + menu = QMenu(self) + + # 菜单项:创建 RAID 阵列,创建 PV + create_menu = QMenu("创建...", self) + create_raid_action = create_menu.addAction("创建 RAID 阵列...") + create_raid_action.triggered.connect(self._handle_create_raid_array) + create_pv_action = create_menu.addAction("创建物理卷 (PV)...") + create_pv_action.triggered.connect(self._handle_create_pv) + menu.addMenu(create_menu) + menu.addSeparator() + + + if item: + dev_data = item.data(0, Qt.UserRole) + if not dev_data: + logger.warning(f"无法获取设备 {item.text(0)} 的详细数据。") + return + + device_name = dev_data.get('name') + device_type = dev_data.get('type') + mount_point = dev_data.get('mountpoint') + device_path = dev_data.get('path') # 使用lsblk提供的完整路径 + + if not device_path: # 如果path字段缺失,则尝试从name构造 + device_path = f"/dev/{device_name}" + + + # 针对磁盘 (disk) 的操作 + 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.get('size'))) # 传递原始大小字符串 + menu.addSeparator() + + # 挂载/卸载操作 (针对分区 'part' 和 RAID/LVM 逻辑卷,但这里只处理 'part') + if device_type == 'part': + if not mount_point or mount_point == '' or mount_point == 'N/A': + mount_action = menu.addAction(f"挂载 {device_path}...") + mount_action.triggered.connect(lambda: self._handle_mount(device_path)) + elif mount_point != '[SWAP]': + unmount_action = menu.addAction(f"卸载 {device_path}") + # FIX: 更改 lambda 表达式,避免被 triggered 信号的参数覆盖 + unmount_action.triggered.connect(lambda: self._unmount_and_refresh(device_path)) + menu.addSeparator() + + # 删除分区和格式化操作 (针对分区 'part') + delete_action = menu.addAction(f"删除分区 {device_path}") + delete_action.triggered.connect(lambda: self._handle_delete_partition(device_path)) + + format_action = menu.addAction(f"格式化分区 {device_path}...") + format_action.triggered.connect(lambda: self._handle_format_partition(device_path)) + + if menu.actions(): + menu.exec(self.ui.treeWidget_block_devices.mapToGlobal(pos)) + else: + logger.info("右键点击了空白区域或设备没有可用的操作。") + + def _handle_create_partition(self, disk_path, total_size_str): # 接收原始大小字符串 + # 1. 解析磁盘总大小 (MiB) + total_disk_mib = 0.0 + logger.debug(f"尝试为磁盘 {disk_path} 创建分区。原始大小字符串: '{total_size_str}'") + + if total_size_str: + match = re.match(r'(\d+(\.\d+)?)\s*([KMGT]?B?)', total_size_str, re.IGNORECASE) + if match: + value = float(match.group(1)) + unit = match.group(3).upper() if match.group(3) else '' + + if unit == 'KB' or unit == 'K': total_disk_mib = value / 1024 + elif unit == 'MB' or unit == 'M': total_disk_mib = value + elif unit == 'GB' or unit == 'G': total_disk_mib = value * 1024 + elif unit == 'TB' or unit == 'T': total_disk_mib = value * 1024 * 1024 + elif unit == 'B': + total_disk_mib = value / (1024 * 1024) + else: + logger.warning(f"无法识别磁盘 {disk_path} 的大小单位: '{unit}' (原始: '{total_size_str}')") + total_disk_mib = 0.0 + logger.debug(f"解析后的磁盘总大小 (MiB): {total_disk_mib}") + else: + logger.warning(f"无法解析磁盘 {disk_path} 的大小字符串 '{total_size_str}'。正则表达式不匹配。") + total_disk_mib = 0.0 + else: + logger.warning(f"获取磁盘 {disk_path} 的大小字符串为空或None。") + total_disk_mib = 0.0 + + if total_disk_mib <= 0.0: + QMessageBox.critical(self, "错误", f"无法获取磁盘 {disk_path} 的有效总大小。") + return + + # 2. 获取下一个分区的起始位置 (MiB) + start_position_mib = self.disk_ops.get_disk_next_partition_start_mib(disk_path) + if start_position_mib is None: # 如果获取起始位置失败 + QMessageBox.critical(self, "错误", f"无法确定磁盘 {disk_path} 的分区起始位置。") + return + + # 3. 计算最大可用空间 (MiB) + max_available_mib = total_disk_mib - start_position_mib + if max_available_mib < 0: # 安全检查,理论上不应该发生 + max_available_mib = 0.0 + + # 确保 max_available_mib 至少为 1 MiB,以避免 spinbox 最小值大于最大值的问题 + # 0.1 GB = 102.4 MiB, so 1 MiB is a safe minimum for fdisk/parted + if max_available_mib < 1.0: + max_available_mib = 1.0 # 最小可分配空间 (1 MiB) + + dialog = CreatePartitionDialog(self, disk_path, total_disk_mib, max_available_mib) # 传递 total_disk_mib 和 max_available_mib + if dialog.exec() == QDialog.Accepted: + info = dialog.get_partition_info() + if info: # Check if info is not None (dialog might have returned None on validation error) + # 调用 disk_ops.create_partition,传递 total_disk_mib 和 use_max_space 标志 + new_partition_path = self.disk_ops.create_partition( + info['disk_path'], + info['partition_table_type'], + info['size_gb'], + info['total_disk_mib'], # 传递磁盘总大小 (MiB) + info['use_max_space'] # 传递是否使用最大空间的标志 + ) + if new_partition_path: + self.refresh_all_info() # 刷新以显示新创建的分区 + + # 询问用户是否要格式化新创建的分区 + reply = QMessageBox.question(None, "格式化新分区", + f"分区 {new_partition_path} 已成功创建。\n" + "您现在想格式化这个分区吗?", + QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes) + if reply == QMessageBox.Yes: + # 调用格式化函数,fstype=None 会弹出选择框 + if self.disk_ops.format_partition(new_partition_path, fstype=None): + self.refresh_all_info() # 格式化成功后再次刷新 + # else: create_partition already shows an error message + # else: dialog was cancelled, no action needed + + def _handle_mount(self, device_path): + dialog = MountDialog(self, device_path) + if dialog.exec() == QDialog.Accepted: + info = dialog.get_mount_info() + if info: # 确保 info 不是 None (用户可能在 MountDialog 中取消了操作) + if self.disk_ops.mount_partition(device_path, info['mount_point'], info['add_to_fstab']): # <--- 传递 add_to_fstab + self.refresh_all_info() + + # 新增辅助方法,用于卸载并刷新 + def _unmount_and_refresh(self, device_path): + """Helper method to unmount a device and then refresh all info.""" + if self.disk_ops.unmount_partition(device_path, show_dialog_on_error=True): + self.refresh_all_info() + + def _handle_delete_partition(self, device_path): + # delete_partition 内部会调用 unmount_partition 和 _remove_fstab_entry + if self.disk_ops.delete_partition(device_path): + self.refresh_all_info() + + def _handle_format_partition(self, device_path): + # format_partition 内部会调用 unmount_partition 和 _remove_fstab_entry + if self.disk_ops.format_partition(device_path): + self.refresh_all_info() + + # --- RAID 管理 Tab --- def refresh_raid_info(self): - """ - 刷新RAID阵列信息并显示在 QTreeWidget 中。 - """ self.ui.treeWidget_raid.clear() - # 定义RAID显示列头 raid_headers = [ "阵列设备", "级别", "状态", "大小", "活动设备", "失败设备", "备用设备", - "总设备数", "UUID", "名称", "Chunk Size" + "总设备数", "UUID", "名称", "Chunk Size", "挂载点" # 添加挂载点列 ] self.ui.treeWidget_raid.setColumnCount(len(raid_headers)) self.ui.treeWidget_raid.setHeaderLabels(raid_headers) @@ -137,7 +303,15 @@ class MainWindow(QMainWindow): for array in raid_arrays: array_item = QTreeWidgetItem(self.ui.treeWidget_raid) - array_item.setText(0, array.get('device', 'N/A')) + array_path = array.get('device', 'N/A') + # 确保 array_path 是一个有效的路径,否则上下文菜单会失效 + if not array_path or array_path == 'N/A': + logger.warning(f"RAID阵列 '{array.get('name', '未知')}' 的设备路径无效,跳过。") + continue + + 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(3, array.get('array_size', 'N/A')) @@ -148,18 +322,24 @@ class MainWindow(QMainWindow): array_item.setText(8, array.get('uuid', 'N/A')) array_item.setText(9, array.get('name', 'N/A')) array_item.setText(10, array.get('chunk_size', 'N/A')) + array_item.setText(11, current_mount_point if current_mount_point else "") # 显示挂载点 array_item.setExpanded(True) + # 存储原始数据,用于上下文菜单 + # 确保 array_path 字段在存储的数据中是正确的 + array_data_for_context = array.copy() + array_data_for_context['device'] = array_path + array_item.setData(0, Qt.UserRole, array_data_for_context) # 添加成员设备作为子节点 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_item.setText(0, f" {member.get('device_path', 'N/A')}") + member_item.setText(1, f"成员: {member.get('raid_device', 'N/A')}") member_item.setText(2, member.get('state', 'N/A')) - # 其他列留空或填充N/A,因为是成员设备的特有信息 - member_item.setText(3, f"RaidDevice: {member.get('raid_device', 'N/A')}") - + member_item.setText(3, f"Major: {member.get('major', 'N/A')}, Minor: {member.get('minor', 'N/A')}") + # 成员设备不存储UserRole数据,因为操作通常针对整个阵列 + # 自动调整列宽 for i in range(len(raid_headers)): self.ui.treeWidget_raid.resizeColumnToContents(i) logger.info("RAID阵列信息刷新成功。") @@ -168,14 +348,92 @@ class MainWindow(QMainWindow): QMessageBox.critical(self, "错误", f"刷新RAID阵列信息失败: {e}") logger.error(f"刷新RAID阵列信息失败: {e}") + def show_raid_context_menu(self, pos: QPoint): + item = self.ui.treeWidget_raid.itemAt(pos) + menu = QMenu(self) + + # 始终提供创建 RAID 阵列的选项 + create_raid_action = menu.addAction("创建 RAID 阵列...") + create_raid_action.triggered.connect(self._handle_create_raid_array) + menu.addSeparator() + + if item and item.parent() is None: # 只有点击的是顶层RAID阵列项 + array_data = item.data(0, Qt.UserRole) + if not array_data: + logger.warning(f"无法获取 RAID 阵列 {item.text(0)} 的详细数据。") + return + + array_path = array_data.get('device') # e.g., /dev/md126 + member_devices = [m.get('device_path') for m in array_data.get('member_devices', [])] + + if not array_path or array_path == 'N/A': + logger.warning(f"RAID阵列 '{array_data.get('name', '未知')}' 的设备路径无效,无法显示操作。") + return # 如果路径无效,则不显示任何操作 + + # Check if RAID array is mounted + 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 != '': + unmount_action = menu.addAction(f"卸载 {array_path} ({current_mount_point})") + # FIX: 更改 lambda 表达式,避免被 triggered 信号的参数覆盖 + unmount_action.triggered.connect(lambda: self._unmount_and_refresh(array_path)) + else: + mount_action = menu.addAction(f"挂载 {array_path}...") + mount_action.triggered.connect(lambda: self._handle_mount(array_path)) + menu.addSeparator() + + stop_action = menu.addAction(f"停止阵列 {array_path}") + stop_action.triggered.connect(lambda: self._handle_stop_raid_array(array_path)) + + delete_action = menu.addAction(f"删除阵列 {array_path}") + delete_action.triggered.connect(lambda: self._handle_delete_raid_array(array_path, member_devices)) + + format_action = menu.addAction(f"格式化阵列 {array_path}...") + # RAID 阵列的格式化也直接调用 disk_ops.format_partition + format_action.triggered.connect(lambda: self._handle_format_raid_array(array_path)) + + if menu.actions(): + menu.exec(self.ui.treeWidget_raid.mapToGlobal(pos)) + else: + logger.info("右键点击了空白区域或没有可用的RAID操作。") + + def _handle_create_raid_array(self): + # 获取所有可用于创建RAID阵列的设备 + # get_unallocated_partitions 现在应该返回所有合适的磁盘和分区 + available_devices = self.system_manager.get_unallocated_partitions() + + if not available_devices: + QMessageBox.warning(self, "警告", "没有可用于创建 RAID 阵列的设备。请确保有未挂载、未被LVM或RAID使用的磁盘或分区。") + return + + dialog = CreateRaidDialog(self, available_devices) + if dialog.exec() == QDialog.Accepted: + info = dialog.get_raid_info() + if info: # Check if info is not None + if self.raid_ops.create_raid_array(info['devices'], info['level'], info['chunk_size']): + self.refresh_all_info() + + def _handle_stop_raid_array(self, array_path): + if self.raid_ops.stop_raid_array(array_path): + self.refresh_all_info() + + def _handle_delete_raid_array(self, array_path, member_devices): + if self.raid_ops.delete_raid_array(array_path, member_devices): + self.refresh_all_info() + + def _handle_format_raid_array(self, array_path): + # 格式化RAID阵列与格式化分区类似,可以复用DiskOperations中的逻辑 + # 或者在RaidOperations中实现一个独立的format方法 + # 这里我们直接调用DiskOperations的format_partition方法 + # format_partition 内部会调用 unmount_partition(..., show_dialog_on_error=False) 和 _remove_fstab_entry + if self.disk_ops.format_partition(array_path): + self.refresh_all_info() + + # --- LVM 管理 Tab --- def refresh_lvm_info(self): - """ - 刷新LVM信息(PVs, VGs, LVs)并显示在 QTreeWidget 中。 - """ self.ui.treeWidget_lvm.clear() - # 定义LVM显示列头 - lvm_headers = ["名称", "大小", "属性", "UUID", "关联", "空闲/已用", "路径"] + lvm_headers = ["名称", "大小", "属性", "UUID", "关联", "空闲/已用", "路径/格式", "挂载点"] # 添加挂载点列 self.ui.treeWidget_lvm.setColumnCount(len(lvm_headers)) self.ui.treeWidget_lvm.setHeaderLabels(lvm_headers) @@ -195,35 +453,55 @@ class MainWindow(QMainWindow): pv_root_item = QTreeWidgetItem(self.ui.treeWidget_lvm) pv_root_item.setText(0, "物理卷 (PVs)") pv_root_item.setExpanded(True) + pv_root_item.setData(0, Qt.UserRole, {'type': 'pv_root'}) # 标识根节点 if lvm_data.get('pvs'): for pv in lvm_data['pvs']: pv_item = QTreeWidgetItem(pv_root_item) - pv_item.setText(0, pv.get('pv_name', 'N/A')) + pv_name = pv.get('pv_name', 'N/A') + if pv_name.startswith('/dev/'): # Ensure it's a full path + pv_path = pv_name + else: + # Attempt to construct if it's just a name, though pv_name from pvs usually is full path + # Fallback, may not be accurate if system_manager.get_lvm_info() isn't consistent + pv_path = f"/dev/{pv_name}" if pv_name != 'N/A' else 'N/A' + + pv_item.setText(0, pv_name) pv_item.setText(1, pv.get('pv_size', 'N/A')) pv_item.setText(2, pv.get('pv_attr', 'N/A')) pv_item.setText(3, pv.get('pv_uuid', 'N/A')) pv_item.setText(4, f"VG: {pv.get('vg_name', 'N/A')}") pv_item.setText(5, f"空闲: {pv.get('pv_free', 'N/A')}") - pv_item.setText(6, pv.get('pv_fmt', 'N/A')) # PV格式 + pv_item.setText(6, pv.get('pv_fmt', 'N/A')) + pv_item.setText(7, "") # PV没有直接挂载点 + # 存储原始数据,确保pv_name是正确的设备路径 + pv_data_for_context = pv.copy() + pv_data_for_context['pv_name'] = pv_path # Store the full path for context menu + pv_item.setData(0, Qt.UserRole, {'type': 'pv', 'data': pv_data_for_context}) # 存储原始数据 else: item = QTreeWidgetItem(pv_root_item) item.setText(0, "未找到物理卷。") - # 卷组 (VGs) vg_root_item = QTreeWidgetItem(self.ui.treeWidget_lvm) vg_root_item.setText(0, "卷组 (VGs)") vg_root_item.setExpanded(True) + vg_root_item.setData(0, Qt.UserRole, {'type': 'vg_root'}) if lvm_data.get('vgs'): for vg in lvm_data['vgs']: vg_item = QTreeWidgetItem(vg_root_item) - vg_item.setText(0, vg.get('vg_name', 'N/A')) + vg_name = vg.get('vg_name', 'N/A') + vg_item.setText(0, vg_name) vg_item.setText(1, vg.get('vg_size', 'N/A')) vg_item.setText(2, vg.get('vg_attr', 'N/A')) vg_item.setText(3, vg.get('vg_uuid', 'N/A')) vg_item.setText(4, f"PVs: {vg.get('pv_count', 'N/A')}, LVs: {vg.get('lv_count', 'N/A')}") vg_item.setText(5, f"空闲: {vg.get('vg_free', 'N/A')}, 已分配: {vg.get('vg_alloc_percent', 'N/A')}%") - vg_item.setText(6, vg.get('vg_fmt', 'N/A')) # VG格式 + vg_item.setText(6, vg.get('vg_fmt', 'N/A')) + vg_item.setText(7, "") # VG没有直接挂载点 + # 存储原始数据,确保vg_name是正确的 + vg_data_for_context = vg.copy() + vg_data_for_context['vg_name'] = vg_name + vg_item.setData(0, Qt.UserRole, {'type': 'vg', 'data': vg_data_for_context}) else: item = QTreeWidgetItem(vg_root_item) item.setText(0, "未找到卷组。") @@ -232,21 +510,45 @@ class MainWindow(QMainWindow): lv_root_item = QTreeWidgetItem(self.ui.treeWidget_lvm) lv_root_item.setText(0, "逻辑卷 (LVs)") lv_root_item.setExpanded(True) + lv_root_item.setData(0, Qt.UserRole, {'type': 'lv_root'}) if lvm_data.get('lvs'): for lv in lvm_data['lvs']: lv_item = QTreeWidgetItem(lv_root_item) - lv_item.setText(0, lv.get('lv_name', 'N/A')) + lv_name = lv.get('lv_name', 'N/A') + vg_name = lv.get('vg_name', 'N/A') + lv_attr = lv.get('lv_attr', '') + + # 确保 lv_path 是一个有效的路径 + lv_path = lv.get('lv_path') + if not lv_path or lv_path == 'N/A': + # 如果 lv_path 不存在或为 N/A,则尝试从 vg_name 和 lv_name 构造 + if vg_name != 'N/A' and lv_name != 'N/A': + lv_path = f"/dev/{vg_name}/{lv_name}" + else: + lv_path = 'N/A' # 实在无法构造则标记为N/A + + current_mount_point = self.system_manager.get_mountpoint_for_device(lv_path) + + lv_item.setText(0, lv_name) lv_item.setText(1, lv.get('lv_size', 'N/A')) - lv_item.setText(2, lv.get('lv_attr', 'N/A')) + lv_item.setText(2, lv_attr) lv_item.setText(3, lv.get('lv_uuid', 'N/A')) - lv_item.setText(4, f"VG: {lv.get('vg_name', 'N/A')}, Origin: {lv.get('origin', 'N/A')}") + lv_item.setText(4, f"VG: {vg_name}, Origin: {lv.get('origin', 'N/A')}") lv_item.setText(5, f"快照: {lv.get('snap_percent', 'N/A')}%") - lv_item.setText(6, lv.get('lv_path', 'N/A')) + lv_item.setText(6, lv_path) # 显示构造或获取的路径 + lv_item.setText(7, current_mount_point if current_mount_point else "") # 显示挂载点 + + # 存储原始数据,确保 lv_path, lv_name, vg_name, lv_attr 都是正确的 + lv_data_for_context = lv.copy() + lv_data_for_context['lv_path'] = lv_path + lv_data_for_context['lv_name'] = lv_name + lv_data_for_context['vg_name'] = vg_name + lv_data_for_context['lv_attr'] = lv_attr + lv_item.setData(0, Qt.UserRole, {'type': 'lv', 'data': lv_data_for_context}) else: item = QTreeWidgetItem(lv_root_item) item.setText(0, "未找到逻辑卷。") - for i in range(len(lvm_headers)): self.ui.treeWidget_lvm.resizeColumnToContents(i) logger.info("LVM信息刷新成功。") @@ -255,66 +557,193 @@ class MainWindow(QMainWindow): QMessageBox.critical(self, "错误", f"刷新LVM信息失败: {e}") logger.error(f"刷新LVM信息失败: {e}") - def show_block_device_context_menu(self, pos: QPoint): - """ - 显示块设备列表的右键上下文菜单。 - """ - item = self.ui.treeWidget_block_devices.itemAt(pos) + def show_lvm_context_menu(self, pos: QPoint): + item = self.ui.treeWidget_lvm.itemAt(pos) + menu = QMenu(self) + + # 根节点或空白区域的创建菜单 + create_menu = QMenu("创建...", self) + create_pv_action = create_menu.addAction("创建物理卷 (PV)...") + create_pv_action.triggered.connect(self._handle_create_pv) + create_vg_action = create_menu.addAction("创建卷组 (VG)...") + create_vg_action.triggered.connect(self._handle_create_vg) + create_lv_action = create_menu.addAction("创建逻辑卷 (LV)...") + create_lv_action.triggered.connect(self._handle_create_lv) + menu.addMenu(create_menu) + menu.addSeparator() + if item: - dev_data = item.data(0, Qt.UserRole) - if not dev_data: - logger.warning(f"无法获取设备 {item.text(0)} 的详细数据。") + item_data = item.data(0, Qt.UserRole) + if not item_data: + logger.warning(f"无法获取 LVM 项 {item.text(0)} 的详细数据。") return - device_name = dev_data.get('name') - device_type = dev_data.get('type') - mount_point = dev_data.get('mountpoint') + item_type = item_data.get('type') + data = item_data.get('data', {}) - menu = QMenu(self) + if item_type == 'pv': + pv_name = data.get('pv_name') + if pv_name and pv_name != 'N/A': + delete_pv_action = menu.addAction(f"删除物理卷 {pv_name}") + delete_pv_action.triggered.connect(lambda: self._handle_delete_pv(pv_name)) + elif item_type == 'vg': + vg_name = data.get('vg_name') + if vg_name and vg_name != 'N/A': + delete_vg_action = menu.addAction(f"删除卷组 {vg_name}") + delete_vg_action.triggered.connect(lambda: self._handle_delete_vg(vg_name)) + elif item_type == 'lv': + lv_name = data.get('lv_name') + vg_name = data.get('vg_name') + lv_attr = data.get('lv_attr', '') + lv_path = data.get('lv_path') # e.g., /dev/vgname/lvname - if device_type in ['part', 'disk']: - if not mount_point or mount_point == '' or mount_point == 'N/A': - mount_action = menu.addAction(f"挂载 {device_name}...") - mount_action.triggered.connect(lambda: self._handle_mount(device_name)) - elif mount_point != '[SWAP]': - unmount_action = menu.addAction(f"卸载 {device_name}") - unmount_action.triggered.connect(lambda: self._handle_unmount(device_name)) + # 确保所有关键信息都存在且有效 + if lv_name and vg_name and lv_path and lv_path != 'N/A': + # Activation/Deactivation + if 'a' in lv_attr: # 'a' 表示 active + deactivate_lv_action = menu.addAction(f"停用逻辑卷 {lv_name}") + deactivate_lv_action.triggered.connect(lambda: self._handle_deactivate_lv(lv_name, vg_name)) + else: + activate_lv_action = menu.addAction(f"激活逻辑卷 {lv_name}") + activate_lv_action.triggered.connect(lambda: self._handle_activate_lv(lv_name, vg_name)) - if menu.actions(): - menu.addSeparator() + # Mount/Unmount + current_mount_point = self.system_manager.get_mountpoint_for_device(lv_path) + if current_mount_point and current_mount_point != '[SWAP]' and current_mount_point != '': + unmount_lv_action = menu.addAction(f"卸载 {lv_name} ({current_mount_point})") + # FIX: 更改 lambda 表达式,避免被 triggered 信号的参数覆盖 + unmount_lv_action.triggered.connect(lambda: self._unmount_and_refresh(lv_path)) + else: + mount_lv_action = menu.addAction(f"挂载 {lv_name}...") + mount_lv_action.triggered.connect(lambda: self._handle_mount(lv_path)) + menu.addSeparator() # 在挂载/卸载后添加分隔符 - if device_type == 'part': - delete_action = menu.addAction(f"删除分区 {device_name}") - delete_action.triggered.connect(lambda: self._handle_delete_partition(device_name)) + # Delete and Format + delete_lv_action = menu.addAction(f"删除逻辑卷 {lv_name}") + delete_lv_action.triggered.connect(lambda: self._handle_delete_lv(lv_name, vg_name)) - format_action = menu.addAction(f"格式化分区 {device_name}...") - format_action.triggered.connect(lambda: self._handle_format_partition(device_name)) + format_lv_action = menu.addAction(f"格式化逻辑卷 {lv_name}...") + format_lv_action.triggered.connect(lambda: self._handle_format_partition(lv_path)) + else: + logger.warning(f"逻辑卷 '{lv_name}' (VG: {vg_name}) 的路径无效,无法显示操作。Lv Path: {lv_path}") - if menu.actions(): - menu.exec(self.ui.treeWidget_block_devices.mapToGlobal(pos)) - else: - logger.info(f"设备 {device_name} 没有可用的操作。") + if menu.actions(): + menu.exec(self.ui.treeWidget_lvm.mapToGlobal(pos)) else: - logger.info("右键点击了空白区域。") + logger.info("右键点击了空白区域或没有可用的LVM操作。") - def _handle_mount(self, device_name): - """处理挂载操作,并刷新UI。""" - if self.disk_ops.mount_partition(device_name): + def _handle_create_pv(self): + available_partitions = self.system_manager.get_unallocated_partitions() + if not available_partitions: + QMessageBox.warning(self, "警告", "没有可用于创建物理卷的未分配分区。") + return + + dialog = CreatePvDialog(self, available_partitions) + if dialog.exec() == QDialog.Accepted: + info = dialog.get_pv_info() + if info: # Check if info is not None + if self.lvm_ops.create_pv(info['device_path']): + self.refresh_all_info() + + def _handle_delete_pv(self, device_path): + if self.lvm_ops.delete_pv(device_path): self.refresh_all_info() - def _handle_unmount(self, device_name): - """处理卸载操作,并刷新UI。""" - if self.disk_ops.unmount_partition(device_name): + def _handle_create_vg(self): + lvm_info = self.system_manager.get_lvm_info() + available_pvs = [] + # Filter PVs that are not part of any VG + for pv in lvm_info.get('pvs', []): + pv_name = pv.get('pv_name') + if pv_name and pv_name != 'N/A' and not pv.get('vg_name'): # Check if pv_name is not associated with any VG + if pv_name.startswith('/dev/'): + available_pvs.append(pv_name) + else: + # Attempt to construct full path if only name is given + available_pvs.append(f"/dev/{pv_name}") + + + if not available_pvs: + QMessageBox.warning(self, "警告", "没有可用于创建卷组的物理卷。请确保有未分配给任何卷组的物理卷。") + return + + dialog = CreateVgDialog(self, available_pvs) + if dialog.exec() == QDialog.Accepted: + info = dialog.get_vg_info() + if info: # Check if info is not None + if self.lvm_ops.create_vg(info['vg_name'], info['pvs']): + self.refresh_all_info() + + def _handle_delete_vg(self, vg_name): + if self.lvm_ops.delete_vg(vg_name): self.refresh_all_info() - def _handle_delete_partition(self, device_name): - """处理删除分区操作,并刷新UI。""" - if self.disk_ops.delete_partition(device_name): + def _handle_create_lv(self): + lvm_info = self.system_manager.get_lvm_info() + available_vgs = [] + vg_sizes = {} # 存储每个 VG 的可用大小 (GB) + for vg in lvm_info.get('vgs', []): + vg_name = vg.get('vg_name') + if vg_name and vg_name != 'N/A': + available_vgs.append(vg_name) + + free_size_str = vg.get('vg_free', '0B').strip() # 确保去除空白字符 + current_vg_size_gb = 0.0 # 为当前VG初始化大小 + + # LVM输出通常使用 'g', 'm', 't', 'k' 作为单位,而不是 'GB', 'MB' + # 匹配数字部分和可选的单位 + match = re.match(r'(\d+\.?\d*)\s*([gmkt])?', free_size_str, re.IGNORECASE) + if match: + value = float(match.group(1)) + unit = match.group(2).lower() if match.group(2) else '' # 获取单位,转小写 + + if unit == 'k': # Kilobytes + current_vg_size_gb = value / (1024 * 1024) + elif unit == 'm': # Megabytes + current_vg_size_gb = value / 1024 + elif unit == 'g': # Gigabytes + current_vg_size_gb = value + elif unit == 't': # Terabytes + current_vg_size_gb = value * 1024 + elif unit == '': # If no unit, assume GB if it's a large number, or just the value + current_vg_size_gb = value # This might be less accurate, but matches common LVM output + else: + logger.warning(f"未知LVM单位: '{unit}' for '{free_size_str}'") + else: + logger.warning(f"无法解析LVM空闲大小字符串: '{free_size_str}'") + + vg_sizes[vg_name] = current_vg_size_gb + + + if not available_vgs: + QMessageBox.warning(self, "警告", "没有可用于创建逻辑卷的卷组。") + return + + dialog = CreateLvDialog(self, available_vgs, vg_sizes) # 传递 vg_sizes + if dialog.exec() == QDialog.Accepted: + info = dialog.get_lv_info() + if info: # 确保 info 不是 None (用户可能在 CreateLvDialog 中取消了操作) + # 传递 use_max_space 标志给 lvm_ops.create_lv + if self.lvm_ops.create_lv(info['lv_name'], info['vg_name'], info['size_gb'], info['use_max_space']): + self.refresh_all_info() + + def _handle_delete_lv(self, lv_name, vg_name): + # 删除 LV 前,也需要确保卸载并从 fstab 移除 + # 获取 LV 的完整路径 + lv_path = f"/dev/{vg_name}/{lv_name}" + # 尝试卸载并从 fstab 移除 (静默,因为删除操作是主要目的) + self.disk_ops.unmount_partition(lv_path, show_dialog_on_error=False) + self.disk_ops._remove_fstab_entry(lv_path) # 直接调用内部方法,不弹出对话框 + + if self.lvm_ops.delete_lv(lv_name, vg_name): self.refresh_all_info() - def _handle_format_partition(self, device_name): - """处理格式化分区操作,并刷新UI。""" - if self.disk_ops.format_partition(device_name): + def _handle_activate_lv(self, lv_name, vg_name): + if self.lvm_ops.activate_lv(lv_name, vg_name): + self.refresh_all_info() + + def _handle_deactivate_lv(self, lv_name, vg_name): + if self.lvm_ops.deactivate_lv(lv_name, vg_name): self.refresh_all_info() diff --git a/pyproject.toml b/pyproject.toml index b9a0987..27bdb73 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,4 +2,4 @@ name = "PySide Widgets Project" [tool.pyside6-project] -files = ["disk_operations.py", "form.ui", "logger_config.py", "mainwindow.py", "system_info.py"] +files = ["dialogs.py", "disk_operations.py", "form.ui", "logger_config.py", "lvm_operations.py", "mainwindow.py", "raid_operations.py", "system_info.py"] diff --git a/raid_operations.py b/raid_operations.py new file mode 100644 index 0000000..3d05cce --- /dev/null +++ b/raid_operations.py @@ -0,0 +1,222 @@ +# raid_operations.py +import logging +import subprocess +from PySide6.QtWidgets import QMessageBox +from system_info import SystemInfoManager +import re # 导入正则表达式模块 + +logger = logging.getLogger(__name__) + +class RaidOperations: + def __init__(self): + self.system_manager = SystemInfoManager() + + def _execute_shell_command(self, command_list, error_msg_prefix, suppress_critical_dialog_on_stderr_match=None, input_to_command=None): # <--- 添加 input_to_command 参数 + """ + 执行一个shell命令并返回stdout和stderr。 + 这个方法包装了 SystemInfoManager._run_command,并统一处理日志和错误消息框。 + + :param command_list: 命令及其参数的列表。 + :param error_msg_prefix: 命令失败时显示给用户的错误消息前缀。 + :param suppress_critical_dialog_on_stderr_match: 一个字符串,如果在 stderr 中找到此字符串, + 则在 CalledProcessError 发生时,只记录日志,不弹出 QMessagebox.critical。 + :param input_to_command: 传递给命令stdin的数据 (str)。 + :return: (bool success, str stdout, str stderr) + """ + full_cmd_str = ' '.join(command_list) + logger.info(f"执行命令: {full_cmd_str}") + + try: + stdout, stderr = self.system_manager._run_command( + command_list, + root_privilege=True, + check_output=True, # RAID操作通常需要检查输出 + input_data=input_to_command # <--- 将 input_to_command 传递给 _run_command + ) + + if stdout: logger.debug(f"命令 '{full_cmd_str}' 标准输出:\n{stdout.strip()}") + if stderr: logger.debug(f"命令 '{full_cmd_str}' 标准错误:\n{stderr.strip()}") + + return True, stdout, stderr + except subprocess.CalledProcessError as e: + logger.error(f"{error_msg_prefix} 命令: {full_cmd_str}") + logger.error(f"退出码: {e.returncode}") + logger.error(f"标准输出: {e.stdout.strip()}") + logger.error(f"标准错误: {e.stderr.strip()}") + + # 根据 suppress_critical_dialog_on_stderr_match 参数决定是否弹出错误对话框 + if suppress_critical_dialog_on_stderr_match and suppress_critical_dialog_on_stderr_match in e.stderr.strip(): + logger.info(f"特定错误 '{suppress_critical_dialog_on_stderr_match}' 匹配,已抑制错误对话框。") + else: + QMessageBox.critical(None, "错误", f"{error_msg_prefix}\n详细信息: {e.stderr.strip()}") + return False, e.stdout, e.stderr + except FileNotFoundError: + logger.error(f"命令 '{command_list[0]}' 未找到。请确保已安装相关工具。") + QMessageBox.critical(None, "错误", f"命令 '{command_list[0]}' 未找到。请确保已安装相关工具。") + return False, "", "Command not found." + except Exception as e: + logger.error(f"{error_msg_prefix} 命令: {full_cmd_str} 发生未知错误: {e}") + QMessageBox.critical(None, "错误", f"{error_msg_prefix}\n未知错误: {e}") + return False, "", str(e) + + def create_raid_array(self, devices, level, chunk_size): + """ + 创建 RAID 阵列。 + :param devices: 成员设备列表,例如 ['/dev/sdb1', '/dev/sdc1'] + :param level: RAID 级别,例如 'raid0', 'raid1', 'raid5' + :param chunk_size: Chunk 大小 (KB) + """ + if not devices or len(devices) < 2: + QMessageBox.critical(None, "错误", "创建 RAID 阵列至少需要两个成员设备。") + return False + + # 检查 RAID 5 至少需要 3 个设备 + if level == "raid5" and len(devices) < 3: + QMessageBox.critical(None, "错误", "RAID5 至少需要三个设备。") + return False + + # 确认操作 + reply = QMessageBox.question(None, "确认创建 RAID 阵列", + f"你确定要使用设备 {', '.join(devices)} 创建 RAID {level} 阵列吗?\n" + "此操作将销毁设备上的所有数据!", + QMessageBox.Yes | QMessageBox.No, QMessageBox.No) + if reply == QMessageBox.No: + logger.info("用户取消了创建 RAID 阵列的操作。") + return False + + logger.info(f"尝试创建 RAID {level} 阵列,成员设备: {', '.join(devices)}, Chunk 大小: {chunk_size}KB。") + + # 1. 清除设备上的旧 RAID 超级块(如果有) + for dev in devices: + # 使用 --force 选项,避免交互式提示 + clear_cmd = ["mdadm", "--zero-superblock", "--force", dev] + success, _, stderr = self._execute_shell_command( + clear_cmd, + f"清除设备 {dev} 上的旧 RAID 超级块失败", + suppress_critical_dialog_on_stderr_match="No superblocks" # 抑制“没有超级块”的错误提示 + ) + if success: + logger.info(f"已清除设备 {dev} 上的旧 RAID 超级块。") + else: + # 如果清除失败,但不是因为“没有超级块”,则可能需要进一步处理 + if "No superblocks" not in stderr: + QMessageBox.warning(None, "警告", f"清除设备 {dev} 上的旧 RAID 超级块可能失败,但尝试继续。") + + # 2. 创建 RAID 阵列 + # 默认阵列名为 /dev/md/new_raid,可以考虑让用户输入 + array_name = "/dev/md/new_raid" + if level == "raid0": + # RAID0 至少2个设备 + create_cmd = ["mdadm", "--create", array_name, "--level=raid0", + f"--raid-devices={len(devices)}", f"--chunk={chunk_size}K"] + devices + elif level == "raid1": + # RAID1 至少2个设备 + create_cmd = ["mdadm", "--create", array_name, "--level=raid1", + f"--raid-devices={len(devices)}"] + devices + elif level == "raid5": + # RAID5 至少3个设备 + create_cmd = ["mdadm", "--create", array_name, "--level=raid5", + f"--raid-devices={len(devices)}"] + devices + else: + QMessageBox.critical(None, "错误", f"不支持的 RAID 级别: {level}") + return False + + # 在 --create 命令中添加 --force 选项 + create_cmd.insert(2, "--force") # 插入到 --create 后面 + + # <--- 关键修改:通过 input_to_command 参数传入 'y\n' 来强制 mdadm 接受 + if not self._execute_shell_command(create_cmd, f"创建 RAID 阵列失败", input_to_command='y\n')[0]: + return False + + logger.info(f"成功创建 RAID {level} 阵列 {array_name}。") + QMessageBox.information(None, "成功", f"成功创建 RAID {level} 阵列 {array_name}。") + + # 3. 刷新 mdadm 配置并等待阵列激活 + # 注意:这里使用 '>>' 重定向,subprocess.run 无法直接处理 shell 重定向符号 + # 需要改成先读取,再写入,或者使用 bash -c "..." + # 暂时先用 bash -c 的方式,更简单 + # 旧代码:self._execute_shell_command(["mdadm", "--examine", "--scan", ">>", "/etc/mdadm/mdadm.conf"], "更新 mdadm.conf 失败") + + # 获取新的 mdadm.conf 内容 + examine_scan_cmd = ["mdadm", "--examine", "--scan"] + success_scan, scan_stdout, _ = self._execute_shell_command(examine_scan_cmd, "扫描 mdadm 配置失败") + if success_scan: + # 将扫描结果追加到 mdadm.conf + append_to_conf_cmd = ["bash", "-c", f"echo '{scan_stdout.strip()}' >> /etc/mdadm/mdadm.conf"] + if not self._execute_shell_command(append_to_conf_cmd, "更新 /etc/mdadm/mdadm.conf 失败")[0]: + logger.warning("更新 /etc/mdadm/mdadm.conf 失败。") + else: + logger.warning("未能扫描到 mdadm 配置,跳过更新 mdadm.conf。") + + + # self._execute_shell_command(["update-initramfs", "-u"], "更新 initramfs 失败") + + return True + + def stop_raid_array(self, array_path): + """ + 停止一个 RAID 阵列。 + :param array_path: RAID 阵列的设备路径,例如 /dev/md0 + """ + reply = QMessageBox.question(None, "确认停止 RAID 阵列", + f"你确定要停止 RAID 阵列 {array_path} 吗?\n" + "停止阵列将使其无法访问,并可能需要重新组装。", + QMessageBox.Yes | QMessageBox.No, QMessageBox.No) + if reply == QMessageBox.No: + logger.info(f"用户取消了停止 RAID 阵列 {array_path} 的操作。") + return False + + logger.info(f"尝试停止 RAID 阵列: {array_path}") + # 尝试卸载阵列(如果已挂载),不显示错误对话框 + # 注意:这里需要调用 disk_operations 的 unmount_partition + # 由于 RaidOperations 不直接持有 DiskOperations 实例,需要通过某种方式获取或传递 + # 暂时先直接调用 umount 命令,不处理 fstab + # 或者,更好的方式是让 MainWindow 调用 disk_ops.unmount_partition + # 此处简化处理,只执行 umount 命令 + 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 + + logger.info(f"成功停止 RAID 阵列 {array_path}。") + QMessageBox.information(None, "成功", f"成功停止 RAID 阵列 {array_path}。") + return True + + def delete_raid_array(self, array_path, member_devices): + """ + 删除一个 RAID 阵列。 + 此操作将停止阵列并清除成员设备上的超级块。 + :param array_path: RAID 阵列的设备路径,例如 /dev/md0 + :param member_devices: 成员设备列表,例如 ['/dev/sdb1', '/dev/sdc1'] + """ + reply = QMessageBox.question(None, "确认删除 RAID 阵列", + f"你确定要删除 RAID 阵列 {array_path} 吗?\n" + "此操作将停止阵列并清除成员设备上的 RAID 超级块,数据将无法访问!", + QMessageBox.Yes | QMessageBox.No, QMessageBox.No) + if reply == QMessageBox.No: + logger.info(f"用户取消了删除 RAID 阵列 {array_path} 的操作。") + return False + + logger.info(f"尝试删除 RAID 阵列: {array_path}") + + # 1. 停止阵列 + if not self.stop_raid_array(array_path): + QMessageBox.critical(None, "错误", f"删除 RAID 阵列 {array_path} 失败,因为无法停止阵列。") + return False + + # 2. 清除成员设备上的超级块 + success_all_cleared = True + for dev in member_devices: + # 使用 --force 选项,避免交互式提示 + clear_cmd = ["mdadm", "--zero-superblock", "--force", dev] + if not self._execute_shell_command(clear_cmd, f"清除设备 {dev} 上的 RAID 超级块失败")[0]: + success_all_cleared = False + QMessageBox.warning(None, "警告", f"未能清除设备 {dev} 上的 RAID 超级块,请手动检查。") + + if success_all_cleared: + logger.info(f"成功删除 RAID 阵列 {array_path} 并清除了成员设备超级块。") + QMessageBox.information(None, "成功", f"成功删除 RAID 阵列 {array_path}。") + return True + else: + QMessageBox.critical(None, "错误", f"删除 RAID 阵列 {array_path} 完成,但部分成员设备超级块未能完全清除。") + return False diff --git a/system_info.py b/system_info.py index 4458373..f9bf584 100644 --- a/system_info.py +++ b/system_info.py @@ -1,8 +1,9 @@ # system_info.py import subprocess import json -import os import logging +import re +import os # 导入 os 模块 logger = logging.getLogger(__name__) @@ -10,255 +11,430 @@ class SystemInfoManager: def __init__(self): pass - def _run_command(self, cmd, root_privilege=False, check_output=True): + def _run_command(self, command_list, root_privilege=False, check_output=True, input_data=None): """ - 执行shell命令并返回stdout和stderr。 - 如果需要root权限,会尝试使用sudo。 - 所有输出和错误都会通过 logger 记录。 + 通用地运行一个 shell 命令。 + :param command_list: 命令及其参数的列表。 + :param root_privilege: 如果为 True,则使用 sudo 执行命令。 + :param check_output: 如果为 True,则捕获 stdout 和 stderr。如果为 False,则不捕获,用于需要交互或不关心输出的命令。 + :param input_data: 传递给命令stdin的数据 (str)。 + :return: (stdout_str, stderr_str) + :raises subprocess.CalledProcessError: 如果命令返回非零退出码。 """ - full_cmd = [] if root_privilege: - # 检查当前是否已经是root用户 - if os.geteuid() != 0: - full_cmd.append("sudo") - - full_cmd.extend(cmd) - cmd_str = ' '.join(full_cmd) - - logger.info(f"执行命令: {cmd_str}") + command_list = ["sudo"] + command_list + logger.debug(f"运行命令: {' '.join(command_list)}") try: result = subprocess.run( - full_cmd, - capture_output=True, + command_list, + capture_output=check_output, text=True, - check=check_output, + check=True, encoding='utf-8', - env=dict(os.environ, LANG="en_US.UTF-8") + input=input_data ) - if result.stdout: - logger.debug(f"命令输出 (stdout):\n{result.stdout.strip()}") - if result.stderr: - logger.warning(f"命令输出 (stderr):\n{result.stderr.strip()}") - return result.stdout.strip(), result.stderr.strip() + return result.stdout if check_output else "", result.stderr if check_output else "" except subprocess.CalledProcessError as e: - error_msg = f"命令执行失败: {cmd_str}\n" \ - f"退出码: {e.returncode}\n" \ - f"标准输出: {e.stdout}\n" \ - f"标准错误: {e.stderr}" - logger.error(error_msg) - raise ValueError(error_msg) + logger.error(f"命令执行失败: {' '.join(command_list)}") + logger.error(f"退出码: {e.returncode}") + logger.error(f"标准输出: {e.stdout.strip()}") + logger.error(f"标准错误: {e.stderr.strip()}") + raise except FileNotFoundError: - error_msg = f"命令未找到: {full_cmd[0]}。请确保已安装。" - logger.error(error_msg) - raise FileNotFoundError(error_msg) + logger.error(f"命令 '{command_list[0]}' 未找到。请确保已安装相关工具。") + raise except Exception as e: - error_msg = f"执行命令时发生未知错误: {e}" - logger.error(error_msg) - raise RuntimeError(error_msg) + logger.error(f"运行命令 {' '.join(command_list)} 时发生未知错误: {e}") + raise def get_block_devices(self): """ - 获取所有块设备的信息,以JSON格式返回。 - 使用 lsblk -J 命令。 + 使用 lsblk 获取块设备信息。 + 返回一个字典列表,每个字典代表一个设备。 """ + cmd = [ + "lsblk", "-J", "-o", + "NAME,FSTYPE,SIZE,MOUNTPOINT,RO,TYPE,UUID,PARTUUID,VENDOR,MODEL,SERIAL,MAJ:MIN,PKNAME,PATH" + ] try: - stdout, _ = self._run_command(["lsblk", "-J", "-o", "NAME,FSTYPE,SIZE,MOUNTPOINT,RO,TYPE,UUID,PARTUUID,VENDOR,MODEL,SERIAL,MAJ:MIN,PKNAME"], root_privilege=False) + stdout, _ = self._run_command(cmd) data = json.loads(stdout) - logger.info("成功获取块设备信息。") - return data.get('blockdevices', []) + devices = data.get('blockdevices', []) + + def add_path_recursive(dev_list): + for dev in dev_list: + if 'PATH' not in dev and 'NAME' in dev: + dev['PATH'] = f"/dev/{dev['NAME']}" + # Rename PATH to path (lowercase) for consistency + if 'PATH' in dev: + dev['path'] = dev.pop('PATH') + if 'children' in dev: + add_path_recursive(dev['children']) + add_path_recursive(devices) + return devices except Exception as e: logger.error(f"获取块设备信息失败: {e}") return [] + def _find_device_by_path_recursive(self, dev_list, target_path): + """ + Helper to find device data by its path recursively. + Added type check for target_path. + """ + if not isinstance(target_path, str): # 添加类型检查 + logger.warning(f"传入 _find_device_by_path_recursive 的 target_path 不是字符串: {target_path} (类型: {type(target_path)})") + return None + for dev in dev_list: + if dev.get('path') == target_path: + return dev + if 'children' in dev: + found = self._find_device_by_path_recursive(dev['children'], target_path) + if found: + return found + return None + + def get_mountpoint_for_device(self, device_path): + """ + 根据设备路径获取其挂载点。 + 此方法现在可以正确处理 /dev/md/new_raid 这样的 RAID 阵列别名。 + Added type check for device_path. + """ + if not isinstance(device_path, str): # 添加类型检查 + logger.warning(f"传入 get_mountpoint_for_device 的 device_path 不是字符串: {device_path} (类型: {type(device_path)})") + return None + + devices = self.get_block_devices() + + # 1. 尝试直接使用提供的 device_path 在 lsblk 输出中查找 + dev_info = self._find_device_by_path_recursive(devices, device_path) + if dev_info: + logger.debug(f"直接从 lsblk 获取到 {device_path} 的挂载点: {dev_info.get('mountpoint')}") + return dev_info.get('mountpoint') + + # 2. 如果直接查找失败,并且是 RAID 阵列(例如 /dev/md/new_raid) + if device_path.startswith('/dev/md'): # 此处 device_path 已通过类型检查 + logger.debug(f"处理 RAID 阵列 {device_path} 以获取挂载点...") + + # 获取 RAID 阵列的实际内核设备路径(例如 /dev/md127) + actual_md_device_path = self._get_actual_md_device_path(device_path) + logger.debug(f"RAID 阵列 {device_path} 的实际设备路径: {actual_md_device_path}") + + if actual_md_device_path: + # 现在,使用实际的内核设备路径从 lsblk 中查找挂载点 + actual_dev_info = self._find_device_by_path_recursive(devices, actual_md_device_path) + logger.debug(f"实际设备路径 {actual_md_device_path} 的 lsblk 详情: {actual_dev_info}") + + if actual_dev_info: + logger.debug(f"在实际设备 {actual_md_device_path} 上找到了挂载点: {actual_dev_info.get('mountpoint')}") + return actual_dev_info.get('mountpoint') + + logger.debug(f"未能获取到 {device_path} 的挂载点。") + return None + def get_mdadm_arrays(self): """ - 获取所有RAID阵列的详细信息。 - 首先使用 mdadm --detail --scan 获取阵列列表, - 然后对每个阵列使用 mdadm --detail 获取更详细的信息。 + 获取 mdadm RAID 阵列信息。 """ - all_raid_details = [] + cmd = ["mdadm", "--detail", "--scan"] try: - # 1. 获取所有RAID设备的路径 - scan_stdout, _ = self._run_command(["mdadm", "--detail", "--scan"], root_privilege=False) - array_paths = [] - for line in scan_stdout.splitlines(): + stdout, _ = self._run_command(cmd) + arrays = [] + for line in stdout.splitlines(): if line.startswith("ARRAY"): - parts = line.split() - if len(parts) > 1: - array_paths.append(parts[1]) # 例如 /dev/md126 - - if not array_paths: + parts = line.split(' ') + array_path = parts[1] # e.g., /dev/md0 or /dev/md/new_raid + # Use mdadm --detail for more specific info + detail_cmd = ["mdadm", "--detail", array_path] + detail_stdout, _ = self._run_command(detail_cmd) + array_info = self._parse_mdadm_detail(detail_stdout, array_path) + arrays.append(array_info) + return arrays + except subprocess.CalledProcessError as e: + if "No arrays found" in e.stderr or "No arrays found" in e.stdout: # mdadm --detail --scan might exit 1 if no arrays logger.info("未找到任何RAID阵列。") return [] - - # 2. 对每个RAID设备获取详细信息 - for path in array_paths: - try: - detail_stdout, _ = self._run_command(["mdadm", "--detail", path], root_privilege=False) - parsed_detail = self._parse_mdadm_detail_output(detail_stdout) - parsed_detail['device'] = path # 添加设备路径 - all_raid_details.append(parsed_detail) - except Exception as e: - logger.error(f"获取RAID阵列 {path} 的详细信息失败: {e}") - logger.info("成功获取RAID阵列信息。") - return all_raid_details + logger.error(f"获取RAID阵列信息失败: {e}") + return [] except Exception as e: - logger.error(f"获取RAID阵列列表失败: {e}") + logger.error(f"获取RAID阵列信息失败: {e}") return [] - def _parse_mdadm_detail_output(self, output_string): + def _parse_mdadm_detail(self, detail_output, array_path): """ - 解析 mdadm --detail 命令的输出字符串,提取关键信息。 + 解析 mdadm --detail 的输出。 """ - details = { + info = { + 'device': array_path, 'level': 'N/A', - 'array_size': 'N/A', - 'raid_devices': 'N/A', - 'total_devices': 'N/A', 'state': 'N/A', + 'array_size': 'N/A', 'active_devices': 'N/A', - 'working_devices': 'N/A', 'failed_devices': 'N/A', 'spare_devices': 'N/A', - 'chunk_size': 'N/A', + 'total_devices': 'N/A', 'uuid': 'N/A', 'name': 'N/A', + 'chunk_size': 'N/A', 'member_devices': [] } - member_devices_section = False - lines = output_string.splitlines() + member_pattern = re.compile(r'^\s*(\d+)\s+(\d+)\s+(\d+)\s+(.+?)\s+(/dev/.+)$') - for line in lines: - line = line.strip() - if not line: - continue + for line in detail_output.splitlines(): + if "Raid Level :" in line: + info['level'] = line.split(':')[-1].strip() + elif "Array Size :" in line: + info['array_size'] = line.split(':')[-1].strip() + elif "State :" in line: + info['state'] = line.split(':')[-1].strip() + elif "Active Devices :" in line: + info['active_devices'] = line.split(':')[-1].strip() + elif "Failed Devices :" in line: + info['failed_devices'] = line.split(':')[-1].strip() + elif "Spare Devices :" in line: + info['spare_devices'] = line.split(':')[-1].strip() + elif "Total Devices :" in line: + info['total_devices'] = line.split(':')[-1].strip() + elif "UUID :" in line: + info['uuid'] = line.split(':')[-1].strip() + elif "Name :" in line: + info['name'] = line.split(':')[-1].strip() + elif "Chunk Size :" in line: + info['chunk_size'] = line.split(':')[-1].strip() - if line.startswith("Number Major Minor RaidDevice State"): - member_devices_section = True - continue - - if member_devices_section: - parts = line.split() - if len(parts) >= 6: # Ensure enough parts for device info - # State can be multiple words, e.g., "active sync" - device_state_parts = parts[4:-1] - # Handle cases where device path might have spaces or be complex, though usually not for /dev/sdX - device_path = parts[-1] - - details['member_devices'].append({ - 'number': parts[0], - 'major': parts[1], - 'minor': parts[2], - 'raid_device': parts[3], - 'state': ' '.join(device_state_parts), - 'device_path': device_path - }) - else: - if ':' in line: - key, value = line.split(':', 1) - key = key.strip().lower().replace(' ', '_') - value = value.strip() - - if key == 'raid_level': - details['level'] = value - elif key == 'array_size': - details['array_size'] = value - elif key == 'raid_devices': - details['raid_devices'] = value - elif key == 'total_devices': - details['total_devices'] = value - elif key == 'state': - details['state'] = value - elif key == 'active_devices': - details['active_devices'] = value - elif key == 'working_devices': - details['working_devices'] = value - elif key == 'failed_devices': - details['failed_devices'] = value - elif key == 'spare_devices': - details['spare_devices'] = value - elif key == 'chunk_size': - details['chunk_size'] = value - elif key == 'uuid': - details['uuid'] = value - elif key == 'name': - details['name'] = value - - - return details + # Member devices + match = member_pattern.match(line) + if match: + member_info = { + 'number': match.group(1), + 'major': match.group(2), + 'minor': match.group(3), + 'raid_device': match.group(4), + 'device_path': match.group(5) + } + info['member_devices'].append(member_info) + return info def get_lvm_info(self): """ - 获取LVM的物理卷、卷组、逻辑卷信息。 - 使用 pvs, vgs, lvs 命令,并尝试获取JSON格式。 + 获取 LVM 物理卷 (PVs)、卷组 (VGs) 和逻辑卷 (LVs) 的信息。 """ - lvm_info = {} - try: - stdout_pvs, _ = self._run_command(["pvs", "--reportformat", "json"], root_privilege=False) - lvm_info['pvs'] = json.loads(stdout_pvs).get('report', [])[0].get('pv', []) - logger.info("成功获取物理卷信息。") - except Exception as e: - logger.error(f"获取物理卷信息失败: {e}") - lvm_info['pvs'] = [] + lvm_info = {'pvs': [], 'vgs': [], 'lvs': []} + # Get PVs try: - stdout_vgs, _ = self._run_command(["vgs", "--reportformat", "json"], root_privilege=False) - lvm_info['vgs'] = json.loads(stdout_vgs).get('report', [])[0].get('vg', []) - logger.info("成功获取卷组信息。") + stdout, _ = self._run_command(["pvs", "--reportformat", "json"]) + data = json.loads(stdout) + if 'report' in data and data['report']: + for pv_data in data['report'][0].get('pv', []): + lvm_info['pvs'].append({ + 'pv_name': pv_data.get('pv_name'), + 'vg_name': pv_data.get('vg_name'), + 'pv_uuid': pv_data.get('pv_uuid'), + 'pv_size': pv_data.get('pv_size'), + 'pv_free': pv_data.get('pv_free'), + 'pv_attr': pv_data.get('pv_attr'), + 'pv_fmt': pv_data.get('pv_fmt') + }) + except subprocess.CalledProcessError as e: + if "No physical volume found" in e.stderr or "No physical volumes found" in e.stdout: + logger.info("未找到任何LVM物理卷。") + else: + logger.error(f"获取LVM物理卷信息失败: {e}") except Exception as e: - logger.error(f"获取卷组信息失败: {e}") - lvm_info['vgs'] = [] + logger.error(f"获取LVM物理卷信息失败: {e}") + # Get VGs try: - stdout_lvs, _ = self._run_command(["lvs", "--reportformat", "json"], root_privilege=False) - lvm_info['lvs'] = json.loads(stdout_lvs).get('report', [])[0].get('lv', []) - logger.info("成功获取逻辑卷信息。") + stdout, _ = self._run_command(["vgs", "--reportformat", "json"]) + data = json.loads(stdout) + if 'report' in data and data['report']: + for vg_data in data['report'][0].get('vg', []): + lvm_info['vgs'].append({ + 'vg_name': vg_data.get('vg_name'), + 'vg_uuid': vg_data.get('vg_uuid'), + 'vg_size': vg_data.get('vg_size'), + 'vg_free': vg_data.get('vg_free'), + 'vg_attr': vg_data.get('vg_attr'), + 'pv_count': vg_data.get('pv_count'), + 'lv_count': vg_data.get('lv_count'), + 'vg_alloc_percent': vg_data.get('vg_alloc_percent'), + 'vg_fmt': vg_data.get('vg_fmt') + }) + except subprocess.CalledProcessError as e: + if "No volume group found" in e.stderr or "No volume groups found" in e.stdout: + logger.info("未找到任何LVM卷组。") + else: + logger.error(f"获取LVM卷组信息失败: {e}") except Exception as e: - logger.error(f"获取逻辑卷信息失败: {e}") - lvm_info['lvs'] = [] + logger.error(f"获取LVM卷组信息失败: {e}") + + # Get LVs + try: + stdout, _ = self._run_command(["lvs", "--reportformat", "json"]) + data = json.loads(stdout) + if 'report' in data and data['report']: + for lv_data in data['report'][0].get('lv', []): + lvm_info['lvs'].append({ + 'lv_name': lv_data.get('lv_name'), + 'vg_name': lv_data.get('vg_name'), + 'lv_uuid': lv_data.get('lv_uuid'), + 'lv_size': lv_data.get('lv_size'), + 'lv_attr': lv_data.get('lv_attr'), + 'origin': lv_data.get('origin'), + 'snap_percent': lv_data.get('snap_percent'), + 'lv_path': lv_data.get('lv_path') + }) + except subprocess.CalledProcessError as e: + if "No logical volume found" in e.stderr or "No logical volumes found" in e.stdout: + logger.info("未找到任何LVM逻辑卷。") + else: + logger.error(f"获取LVM逻辑卷信息失败: {e}") + except Exception as e: + logger.error(f"获取LVM逻辑卷信息失败: {e}") return lvm_info -# 示例用法 (可以在此模块中添加测试代码) -if __name__ == "__main__": - logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') - manager = SystemInfoManager() - logger.info("--- 块设备信息 ---") - devices = manager.get_block_devices() - for dev in devices: - logger.info(f" NAME: {dev.get('name')}, TYPE: {dev.get('type')}, SIZE: {dev.get('size')}, MOUNTPOINT: {dev.get('mountpoint')}") + def get_unallocated_partitions(self): + """ + 获取所有可用于创建RAID阵列或LVM物理卷的设备。 + 这些设备包括: + 1. 没有分区表的整个磁盘。 + 2. 未挂载的分区(包括有文件系统但未挂载的,如ext4/xfs等)。 + 3. 未被LVM或RAID使用的分区或磁盘。 + 返回一个设备路径列表,例如 ['/dev/sdb1', '/dev/sdc']。 + """ + block_devices = self.get_block_devices() + candidates = [] - logger.info("\n--- RAID 阵列信息 ---") - raid_info = manager.get_mdadm_arrays() - if raid_info: - for array in raid_info: - logger.info(f" Device: {array.get('device')}, Level: {array.get('level')}, State: {array.get('state')}, Size: {array.get('array_size')}") - logger.info(f" Active: {array.get('active_devices')}, Failed: {array.get('failed_devices')}, Spare: {array.get('spare_devices')}") - for member in array.get('member_devices', []): - logger.info(f" Member: {member.get('device_path')} (State: {member.get('state')})") - else: - logger.info(" 未找到RAID阵列。") + def process_device(dev): + dev_path = dev.get('path') + dev_type = dev.get('type') + mountpoint = dev.get('mountpoint') + fstype = dev.get('fstype') - logger.info("\n--- LVM 信息 ---") - lvm_info = manager.get_lvm_info() - logger.info("物理卷 (PVs):") - if lvm_info['pvs']: - for pv in lvm_info['pvs']: - logger.info(f" {pv.get('pv_name')} (VG: {pv.get('vg_name')}, Size: {pv.get('pv_size')})") - else: - logger.info(" 未找到物理卷。") + # 1. 检查是否已挂载 (排除SWAP,因为SWAP可以被覆盖,但如果活跃则不应动) + # 如果是活跃挂载点(非SWAP),则不是候选 + if mountpoint and mountpoint != '[SWAP]': + # 如果设备已挂载,则它不是候选。 + # 但仍需处理其子设备,因为父设备挂载不代表子设备不能用(虽然不常见) + # 或者子设备挂载不代表父设备不能用(例如使用整个磁盘) + if 'children' in dev: + for child in dev['children']: + process_device(child) + return - logger.info("卷组 (VGs):") - if lvm_info['vgs']: - for vg in lvm_info['vgs']: - logger.info(f" {vg.get('vg_name')} (Size: {vg.get('vg_size')}, PVs: {vg.get('pv_count')}, LVs: {vg.get('lv_count')})") - else: - logger.info(" 未找到卷组。") + # 2. 检查是否是LVM物理卷或RAID成员 + # 如果是LVM PV或RAID成员,则它不是新RAID/PV的候选 + if fstype in ['LVM2_member', 'linux_raid_member']: + if 'children' in dev: # 即使是LVM/RAID成员,也可能存在子设备(例如LVM上的分区,虽然不常见) + for child in dev['children']: + process_device(child) + return - logger.info("逻辑卷 (LVs):") - if lvm_info['lvs']: - for lv in lvm_info['lvs']: - logger.info(f" {lv.get('lv_name')} (VG: {lv.get('vg_name')}, Size: {lv.get('lv_size')})") - else: - logger.info(" 未找到逻辑卷。") + # 3. 如果是整个磁盘且没有分区,则是一个候选 + if dev_type == 'disk' and not dev.get('children'): + candidates.append(dev_path) + # 4. 如果是分区,且通过了上述挂载和LVM/RAID检查,则是一个候选 + elif dev_type == 'part': + candidates.append(dev_path) + + # 5. 递归处理子设备 (对于有分区的磁盘) + # 这一步放在最后,确保先对当前设备进行评估 + if dev_type == 'disk' and dev.get('children'): # 只有当是磁盘且有子设备时才需要递归 + for child in dev['children']: + process_device(child) + + for dev in block_devices: + process_device(dev) + + # 去重并排序 + return sorted(list(set(candidates))) + + def _get_actual_md_device_path(self, array_path): + """ + 解析 mdadm 阵列名称(例如 /dev/md/new_raid) + 对应的实际内核设备路径(例如 /dev/md127)。 + 通过检查 /dev/md/ 目录下的符号链接来获取。 + Added type check for array_path. + """ + if not isinstance(array_path, str): # 添加类型检查 + logger.warning(f"传入 _get_actual_md_device_path 的 array_path 不是字符串: {array_path} (类型: {type(array_path)})") + return None + + if os.path.exists(array_path): + try: + # os.path.realpath 会解析符号链接,例如 /dev/md/new_raid -> /dev/md127 + actual_path = os.path.realpath(array_path) + # 确保解析后仍然是 md 设备(以 /dev/md 开头) + if actual_path.startswith('/dev/md'): + logger.debug(f"已将 RAID 阵列 {array_path} 解析为实际设备路径: {actual_path}") + return actual_path + except Exception as e: + logger.warning(f"无法通过 os.path.realpath 获取 RAID 阵列 {array_path} 的实际设备路径: {e}") + logger.warning(f"RAID 阵列 {array_path} 不存在或无法解析其真实路径。") + return None + + def get_device_details_by_path(self, device_path): + """ + 根据设备路径获取设备的 UUID 和文件系统类型 (fstype)。 + 此方法现在可以正确处理 /dev/md/new_raid 这样的 RAID 阵列别名。 + Added type check for device_path. + :param device_path: 设备的路径,例如 '/dev/sdb1' 或 '/dev/md0' 或 '/dev/md/new_raid' + :return: 包含 'uuid' 和 'fstype' 的字典,如果未找到则返回 None。 + """ + if not isinstance(device_path, str): # 添加类型检查 + logger.warning(f"传入 get_device_details_by_path 的 device_path 不是字符串: {device_path} (类型: {type(device_path)})") + return None + + devices = self.get_block_devices() # 获取所有块设备信息 + + # 1. 首先,尝试直接使用提供的 device_path 在 lsblk 输出中查找 + # 对于 /dev/md/new_raid 这样的别名,这一步通常会返回 None + lsblk_details = self._find_device_by_path_recursive(devices, device_path) + logger.debug(f"lsblk_details for {device_path} (direct lookup): {lsblk_details}") + + if lsblk_details and lsblk_details.get('fstype'): # 即使没有UUID,有fstype也算找到部分信息 + # 如果直接找到了 fstype,就返回 lsblk 提供的 UUID 和 fstype + # 这里的 UUID 应该是文件系统 UUID + logger.debug(f"直接从 lsblk 获取到 {device_path} 的详情: {lsblk_details}") + return { + 'uuid': lsblk_details.get('uuid'), + 'fstype': lsblk_details.get('fstype') + } + + # 2. 如果直接查找失败,并且是 RAID 阵列(例如 /dev/md/new_raid) + if device_path.startswith('/dev/md'): # 此处 device_path 已通过类型检查 + logger.debug(f"处理 RAID 阵列 {device_path}...") + + # 获取 RAID 阵列的实际内核设备路径(例如 /dev/md127) + actual_md_device_path = self._get_actual_md_device_path(device_path) + logger.debug(f"RAID 阵列 {device_path} 的实际设备路径: {actual_md_device_path}") + + if actual_md_device_path: + # 现在,使用实际的内核设备路径从 lsblk 中查找 fstype 和 UUID (文件系统 UUID) + actual_device_lsblk_details = self._find_device_by_path_recursive(devices, actual_md_device_path) + logger.debug(f"实际设备路径 {actual_md_device_path} 的 lsblk 详情: {actual_device_lsblk_details}") + + if actual_device_lsblk_details and actual_device_lsblk_details.get('fstype'): + # 找到了实际 RAID 设备上的文件系统信息 + # 此时的 UUID 是文件系统 UUID,fstype 是文件系统类型 + logger.debug(f"在实际设备 {actual_md_device_path} 上找到了文件系统详情: {actual_device_lsblk_details}") + return { + 'uuid': actual_device_lsblk_details.get('uuid'), + 'fstype': actual_device_lsblk_details.get('fstype') + } + else: + # RAID 设备存在,但 lsblk 没有报告文件系统 (例如,尚未格式化) + # 此时 fstype 为 None。如果需要,我们可以返回 RAID 阵列本身的 UUID,但 fstype 仍为 None + logger.warning(f"RAID 阵列 {device_path} (实际设备 {actual_md_device_path}) 未找到文件系统类型。") + # 对于 fstab,如果没有 fstype,就无法创建条目。 + # 此时返回 None,让调用者知道无法写入 fstab。 + return None + else: + logger.warning(f"无法确定 RAID 阵列 {device_path} 的实际内核设备路径。") + return None # 无法解析实际设备路径,也无法获取 fstype + + # 3. 如果仍然没有找到,返回 None + logger.debug(f"未能获取到 {device_path} 的任何详情。") + return None