Compare commits

...

10 Commits

Author SHA1 Message Date
zj
ce84191365 fix 2026-02-05 03:12:45 +08:00
zj
c7e0dc1036 添加解除占用功能 2026-02-05 01:41:11 +08:00
zj
6c4257532b rm dist bui;d 2026-02-04 21:28:16 +08:00
zj
c6fc0d7f40 fix2222 2026-02-04 21:23:16 +08:00
zj
62219201b8 fix [6~[6~ 2026-02-04 14:33:24 +08:00
zj
dd51a10239 fix 44 2026-02-03 15:18:43 +08:00
zj
5862588fd1 fix 2026-02-03 04:49:37 +08:00
zj
c6c9ece1e9 fix 33 2026-02-03 03:27:14 +08:00
zj
10cc7d1c91 fix 23 2026-02-03 03:25:29 +08:00
zj
30ab5711be adadd build-app.sh 2026-02-02 22:57:34 +08:00
19 changed files with 1499 additions and 499 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/dist/
/build/

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE QtCreatorProject>
<!-- Written by QtCreator 18.0.2, 2026-02-02T21:58:57. -->
<!-- Written by QtCreator 18.0.2, 2026-02-02T22:37:52. -->
<qtcreator>
<data>
<variable>EnvironmentId</variable>
@@ -155,7 +155,7 @@
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DisplayName">disk_operations.py</value>
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">PythonEditor.RunConfiguration.</value>
<value type="QString" key="ProjectExplorer.RunConfiguration.BuildKey">/home/jing/qtpj/diskmanager/disk_operations.py</value>
<value type="bool" key="ProjectExplorer.RunConfiguration.Customized">false</value>
<value type="bool" key="ProjectExplorer.RunConfiguration.Customized">true</value>
<value type="QString" key="ProjectExplorer.RunConfiguration.UniqueId"></value>
<value type="QString" key="PythonEditor.RunConfiguation.Script">/home/jing/qtpj/diskmanager/disk_operations.py</value>
<value type="bool" key="RunConfiguration.UseCppDebuggerAuto">true</value>
@@ -176,7 +176,7 @@
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DisplayName">logger_config.py</value>
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">PythonEditor.RunConfiguration.</value>
<value type="QString" key="ProjectExplorer.RunConfiguration.BuildKey">/home/jing/qtpj/diskmanager/logger_config.py</value>
<value type="bool" key="ProjectExplorer.RunConfiguration.Customized">false</value>
<value type="bool" key="ProjectExplorer.RunConfiguration.Customized">true</value>
<value type="QString" key="ProjectExplorer.RunConfiguration.UniqueId"></value>
<value type="QString" key="PythonEditor.RunConfiguation.Script">/home/jing/qtpj/diskmanager/logger_config.py</value>
<value type="bool" key="RunConfiguration.UseCppDebuggerAuto">true</value>
@@ -197,7 +197,7 @@
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DisplayName">mainwindow.py</value>
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">PythonEditor.RunConfiguration.</value>
<value type="QString" key="ProjectExplorer.RunConfiguration.BuildKey">/home/jing/qtpj/diskmanager/mainwindow.py</value>
<value type="bool" key="ProjectExplorer.RunConfiguration.Customized">false</value>
<value type="bool" key="ProjectExplorer.RunConfiguration.Customized">true</value>
<value type="QString" key="ProjectExplorer.RunConfiguration.UniqueId"></value>
<value type="QString" key="PythonEditor.RunConfiguation.Script">/home/jing/qtpj/diskmanager/mainwindow.py</value>
<value type="bool" key="RunConfiguration.UseCppDebuggerAuto">true</value>
@@ -218,7 +218,7 @@
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DisplayName">system_info.py</value>
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">PythonEditor.RunConfiguration.</value>
<value type="QString" key="ProjectExplorer.RunConfiguration.BuildKey">/home/jing/qtpj/diskmanager/system_info.py</value>
<value type="bool" key="ProjectExplorer.RunConfiguration.Customized">false</value>
<value type="bool" key="ProjectExplorer.RunConfiguration.Customized">true</value>
<value type="QString" key="ProjectExplorer.RunConfiguration.UniqueId"></value>
<value type="QString" key="PythonEditor.RunConfiguation.Script">/home/jing/qtpj/diskmanager/system_info.py</value>
<value type="bool" key="RunConfiguration.UseCppDebuggerAuto">true</value>
@@ -239,7 +239,7 @@
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DisplayName">dialogs.py</value>
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">PythonEditor.RunConfiguration.</value>
<value type="QString" key="ProjectExplorer.RunConfiguration.BuildKey">/home/jing/qtpj/diskmanager/dialogs.py</value>
<value type="bool" key="ProjectExplorer.RunConfiguration.Customized">false</value>
<value type="bool" key="ProjectExplorer.RunConfiguration.Customized">true</value>
<value type="QString" key="ProjectExplorer.RunConfiguration.UniqueId"></value>
<value type="QString" key="PythonEditor.RunConfiguation.Script">/home/jing/qtpj/diskmanager/dialogs.py</value>
<value type="bool" key="RunConfiguration.UseCppDebuggerAuto">true</value>
@@ -260,7 +260,7 @@
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DisplayName">raid_operations.py</value>
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">PythonEditor.RunConfiguration.</value>
<value type="QString" key="ProjectExplorer.RunConfiguration.BuildKey">/home/jing/qtpj/diskmanager/raid_operations.py</value>
<value type="bool" key="ProjectExplorer.RunConfiguration.Customized">false</value>
<value type="bool" key="ProjectExplorer.RunConfiguration.Customized">true</value>
<value type="QString" key="ProjectExplorer.RunConfiguration.UniqueId"></value>
<value type="QString" key="PythonEditor.RunConfiguation.Script">/home/jing/qtpj/diskmanager/raid_operations.py</value>
<value type="bool" key="RunConfiguration.UseCppDebuggerAuto">true</value>
@@ -281,7 +281,7 @@
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DisplayName">lvm_operations.py</value>
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">PythonEditor.RunConfiguration.</value>
<value type="QString" key="ProjectExplorer.RunConfiguration.BuildKey">/home/jing/qtpj/diskmanager/lvm_operations.py</value>
<value type="bool" key="ProjectExplorer.RunConfiguration.Customized">false</value>
<value type="bool" key="ProjectExplorer.RunConfiguration.Customized">true</value>
<value type="QString" key="ProjectExplorer.RunConfiguration.UniqueId"></value>
<value type="QString" key="PythonEditor.RunConfiguation.Script">/home/jing/qtpj/diskmanager/lvm_operations.py</value>
<value type="bool" key="RunConfiguration.UseCppDebuggerAuto">true</value>
@@ -320,7 +320,7 @@
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DisplayName">disk_operations.py</value>
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">PythonEditor.RunConfiguration.</value>
<value type="QString" key="ProjectExplorer.RunConfiguration.BuildKey">/home/jing/qtpj/diskmanager/disk_operations.py</value>
<value type="bool" key="ProjectExplorer.RunConfiguration.Customized">false</value>
<value type="bool" key="ProjectExplorer.RunConfiguration.Customized">true</value>
<value type="QString" key="ProjectExplorer.RunConfiguration.UniqueId"></value>
<value type="QString" key="PythonEditor.RunConfiguation.Script">/home/jing/qtpj/diskmanager/disk_operations.py</value>
<value type="bool" key="RunConfiguration.UseCppDebuggerAuto">true</value>
@@ -341,7 +341,7 @@
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DisplayName">logger_config.py</value>
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">PythonEditor.RunConfiguration.</value>
<value type="QString" key="ProjectExplorer.RunConfiguration.BuildKey">/home/jing/qtpj/diskmanager/logger_config.py</value>
<value type="bool" key="ProjectExplorer.RunConfiguration.Customized">false</value>
<value type="bool" key="ProjectExplorer.RunConfiguration.Customized">true</value>
<value type="QString" key="ProjectExplorer.RunConfiguration.UniqueId"></value>
<value type="QString" key="PythonEditor.RunConfiguation.Script">/home/jing/qtpj/diskmanager/logger_config.py</value>
<value type="bool" key="RunConfiguration.UseCppDebuggerAuto">true</value>
@@ -362,7 +362,7 @@
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DisplayName">mainwindow.py</value>
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">PythonEditor.RunConfiguration.</value>
<value type="QString" key="ProjectExplorer.RunConfiguration.BuildKey">/home/jing/qtpj/diskmanager/mainwindow.py</value>
<value type="bool" key="ProjectExplorer.RunConfiguration.Customized">false</value>
<value type="bool" key="ProjectExplorer.RunConfiguration.Customized">true</value>
<value type="QString" key="ProjectExplorer.RunConfiguration.UniqueId"></value>
<value type="QString" key="PythonEditor.RunConfiguation.Script">/home/jing/qtpj/diskmanager/mainwindow.py</value>
<value type="bool" key="RunConfiguration.UseCppDebuggerAuto">true</value>
@@ -383,7 +383,7 @@
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DisplayName">system_info.py</value>
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">PythonEditor.RunConfiguration.</value>
<value type="QString" key="ProjectExplorer.RunConfiguration.BuildKey">/home/jing/qtpj/diskmanager/system_info.py</value>
<value type="bool" key="ProjectExplorer.RunConfiguration.Customized">false</value>
<value type="bool" key="ProjectExplorer.RunConfiguration.Customized">true</value>
<value type="QString" key="ProjectExplorer.RunConfiguration.UniqueId"></value>
<value type="QString" key="PythonEditor.RunConfiguation.Script">/home/jing/qtpj/diskmanager/system_info.py</value>
<value type="bool" key="RunConfiguration.UseCppDebuggerAuto">true</value>
@@ -404,7 +404,7 @@
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DisplayName">dialogs.py</value>
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">PythonEditor.RunConfiguration.</value>
<value type="QString" key="ProjectExplorer.RunConfiguration.BuildKey">/home/jing/qtpj/diskmanager/dialogs.py</value>
<value type="bool" key="ProjectExplorer.RunConfiguration.Customized">false</value>
<value type="bool" key="ProjectExplorer.RunConfiguration.Customized">true</value>
<value type="QString" key="ProjectExplorer.RunConfiguration.UniqueId"></value>
<value type="QString" key="PythonEditor.RunConfiguation.Script">/home/jing/qtpj/diskmanager/dialogs.py</value>
<value type="bool" key="RunConfiguration.UseCppDebuggerAuto">true</value>
@@ -425,7 +425,7 @@
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DisplayName">raid_operations.py</value>
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">PythonEditor.RunConfiguration.</value>
<value type="QString" key="ProjectExplorer.RunConfiguration.BuildKey">/home/jing/qtpj/diskmanager/raid_operations.py</value>
<value type="bool" key="ProjectExplorer.RunConfiguration.Customized">false</value>
<value type="bool" key="ProjectExplorer.RunConfiguration.Customized">true</value>
<value type="QString" key="ProjectExplorer.RunConfiguration.UniqueId"></value>
<value type="QString" key="PythonEditor.RunConfiguation.Script">/home/jing/qtpj/diskmanager/raid_operations.py</value>
<value type="bool" key="RunConfiguration.UseCppDebuggerAuto">true</value>
@@ -446,7 +446,7 @@
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DisplayName">lvm_operations.py</value>
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">PythonEditor.RunConfiguration.</value>
<value type="QString" key="ProjectExplorer.RunConfiguration.BuildKey">/home/jing/qtpj/diskmanager/lvm_operations.py</value>
<value type="bool" key="ProjectExplorer.RunConfiguration.Customized">false</value>
<value type="bool" key="ProjectExplorer.RunConfiguration.Customized">true</value>
<value type="QString" key="ProjectExplorer.RunConfiguration.UniqueId"></value>
<value type="QString" key="PythonEditor.RunConfiguation.Script">/home/jing/qtpj/diskmanager/lvm_operations.py</value>
<value type="bool" key="RunConfiguration.UseCppDebuggerAuto">true</value>

38
Linux存儲管理器.spec Normal file
View File

@@ -0,0 +1,38 @@
# -*- mode: python ; coding: utf-8 -*-
a = Analysis(
['mainwindow.py'],
pathex=[],
binaries=[],
datas=[],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='Linux存儲管理器',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)

Binary file not shown.

Binary file not shown.

3
build-app.sh Normal file
View File

@@ -0,0 +1,3 @@
#!/bin/bash
sudo /home/jing/qtpj/diskmanager/.qtcreator/Python_3_14_2venv_2/bin/pyinstaller -F --name "disk-manager" mainwindow.py

View File

@@ -6,6 +6,8 @@ from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QMessageBox, QCheckBox, QListWidget, QListWidgetItem)
from PySide6.QtCore import Qt
from logger_config import setup_logging, logger
class CreatePartitionDialog(QDialog):
def __init__(self, parent=None, disk_path="", total_disk_mib=0.0, max_available_mib=0.0):
super().__init__(parent)
@@ -15,14 +17,17 @@ class CreatePartitionDialog(QDialog):
self.disk_path = disk_path
self.total_disk_mib = total_disk_mib # 磁盘总大小 (MiB)
self.max_available_mib = max_available_mib # 可用空间 (MiB)
logger.debug(f"CreatePartitionDialog initialized for {disk_path}. Total MiB: {total_disk_mib}, Max Available MiB: {max_available_mib}")
self.partition_table_type_combo = QComboBox()
self.partition_table_type_combo.addItems(["gpt", "msdos"])
self.size_spinbox = QDoubleSpinBox()
# 调整最小值为 0.01 GB (约 10MB),并确保最大值不小于最小值
calculated_max_gb = self.max_available_mib / 1024.0
self.size_spinbox.setMinimum(0.01)
self.size_spinbox.setMaximum(max(0.01, self.max_available_mib / 1024.0)) # 将 MiB 转换为 GB 显示
self.size_spinbox.setMaximum(max(0.01, calculated_max_gb)) # 将 MiB 转换为 GB 显示
logger.debug(f"Size spinbox max set to: {self.size_spinbox.maximum()} GB (calculated from {calculated_max_gb} GB)")
self.size_spinbox.setSuffix(" GB")
self.size_spinbox.setDecimals(2)
@@ -54,21 +59,34 @@ class CreatePartitionDialog(QDialog):
def _connect_signals(self):
self.confirm_button.clicked.connect(self.accept)
self.cancel_button.clicked.connect(self.reject)
# 信号连接保持不变,但 _toggle_size_input 方法内部将直接查询复选框状态
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)
# 如果之前是最大值,取消勾选后,恢复到最小值或一个合理值
def _toggle_size_input(self, state): # state 参数仍然接收,但不再直接用于判断
# 直接查询复选框的当前状态,而不是依赖信号传递的 state 参数
is_checked = self.use_max_space_checkbox.isChecked()
logger.debug(f"[_toggle_size_input] Called. Checkbox isChecked(): {is_checked}, signal state: {state}")
if is_checked:
logger.debug(f"[_toggle_size_input] Checkbox is CHECKED. Disabling spinbox and setting value to max.")
self.size_spinbox.setEnabled(False) # 禁用 spinbox
max_val = self.size_spinbox.maximum()
self.size_spinbox.setValue(max_val) # 设置为最大可用 GB
logger.debug(f"[_toggle_size_input] Spinbox value set to max: {max_val} GB.")
else: # is_checked is False
logger.debug(f"[_toggle_size_input] Checkbox is UNCHECKED. Enabling spinbox.")
self.size_spinbox.setEnabled(True) # 启用 spinbox
# 如果之前是最大值,取消勾选后,恢复到最小值,方便用户输入自定义值
if self.size_spinbox.value() == self.size_spinbox.maximum():
self.size_spinbox.setValue(self.size_spinbox.minimum())
min_val = self.size_spinbox.minimum()
self.size_spinbox.setValue(min_val)
logger.debug(f"[_toggle_size_input] Spinbox was at max, reset to min: {min_val} GB.")
else:
logger.debug(f"[_toggle_size_input] Spinbox was not at max, keeping current value: {self.size_spinbox.value()} GB.")
def get_partition_info(self):
size_gb = self.size_spinbox.value()
@@ -339,15 +357,16 @@ class CreateLvDialog(QDialog):
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)
logger.debug(f"CreateLvDialog initialized. Available VGs: {self.available_vgs}, VG Sizes: {self.vg_sizes}")
self.lv_name_input = QLineEdit()
self.vg_combo_box = QComboBox()
self.lv_size_spinbox = QDoubleSpinBox()
self.use_max_space_checkbox = QCheckBox("使用最大可用空间") # 新增复选框
self.use_max_space_checkbox = QCheckBox("使用最大可用空间")
self._setup_ui()
self._connect_signals()
self._initialize_state() # 初始化状态
self._initialize_state()
def _setup_ui(self):
layout = QVBoxLayout(self)
@@ -356,7 +375,7 @@ class CreateLvDialog(QDialog):
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) # 添加复选框
form_layout.addRow("", self.use_max_space_checkbox)
layout.addLayout(form_layout)
@@ -367,68 +386,75 @@ class CreateLvDialog(QDialog):
button_box.addWidget(self.cancel_button)
layout.addLayout(button_box)
self.lv_size_spinbox.setMinimum(0.1) # 逻辑卷最小大小,例如 0.1 GB
self.lv_size_spinbox.setMinimum(0.01) # 逻辑卷最小大小,例如 0.01 GB
self.lv_size_spinbox.setSuffix(" GB")
self.lv_size_spinbox.setDecimals(2)
# 设置一个较大的初始最大值,它将在 _update_size_options 中被实际的 VG 大小覆盖
self.lv_size_spinbox.setMaximum(100000.0) # 初始设置一个足够大的最大值
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) # 复选框状态改变时切换输入
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) # 默认选中“使用最大可用空间”
# 默认选中“使用最大可用空间”,这将触发 _toggle_size_input
self.use_max_space_checkbox.setChecked(True)
# 首次调用以设置初始状态和根据复选框状态设置值
# 这一步是必要的,即使 setChecked(True) 也会触发一次 _toggle_size_input
# 但 _update_size_options 会在设置了正确的最大值后再次调用 _toggle_size_input确保最终状态正确。
self._update_size_options()
logger.debug("CreateLvDialog _initialize_state completed.")
def _update_size_options(self):
"""根据选中的卷组更新逻辑卷大小的选项。"""
"""
根据选中的卷组更新逻辑卷大小的范围 (最小值和最大值)。
并根据当前“使用最大可用空间”复选框的状态调整 spinbox 的值和启用状态。
"""
selected_vg = self.vg_combo_box.currentText()
max_size_gb = self.vg_sizes.get(selected_vg, 0.0)
logger.debug(f"[_update_size_options] Selected VG: {selected_vg}, Max available GB: {max_size_gb}")
# 确保 max_size_gb 至少是 spinbox 的最小值,以防卷组可用空间过小导致 UI 问题
current_min_limit = self.lv_size_spinbox.minimum() # 获取当前的最小值限制
if max_size_gb < current_min_limit:
# 如果实际最大可用空间小于当前最小值限制,则将最小值临时调整为实际最大值
self.lv_size_spinbox.setMinimum(max_size_gb if max_size_gb > 0 else 0.01) # 至少0.01GB
else:
self.lv_size_spinbox.setMinimum(0.1) # 恢复正常最小值
# 确保实际最大可用空间至少大于或等于 spinbox 的最小值
# 避免当可用空间非常小时spinbox 无法设置有效值
effective_max_size_gb = max(self.lv_size_spinbox.minimum(), max_size_gb)
self.lv_size_spinbox.setMaximum(effective_max_size_gb)
logger.debug(f"[_update_size_options] Spinbox max set to: {effective_max_size_gb} GB.")
# 计算并设置 spinbox 的最大值
clamped_max_gb = max(self.lv_size_spinbox.minimum(), max_size_gb)
self.lv_size_spinbox.setMaximum(clamped_max_gb)
# 在设置了新的最大值后,重新应用复选框的状态来更新值和启用状态
self._toggle_size_input() # 调用不带参数的槽函数
logger.debug("[_update_size_options] Completed.")
# 如果选中了“使用最大可用空间”,则将 spinbox 值设置为最大值
if self.use_max_space_checkbox.isChecked():
self.lv_size_spinbox.setValue(clamped_max_gb) # 使用计算出的最大值
else:
# 如果当前值超过了新的最大值,则调整为新的最大值
if self.lv_size_spinbox.value() > clamped_max_gb:
self.lv_size_spinbox.setValue(clamped_max_gb)
# 如果当前值小于新的最小值,则调整
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' 参数
"""
根据“使用最大可用空间”复选框的状态切换大小输入框的启用/禁用状态和值。
"""
is_checked = self.use_max_space_checkbox.isChecked() # 直接查询复选框的当前状态
logger.debug(f"[_toggle_size_input] Called. Checkbox isChecked(): {is_checked}")
def _toggle_size_input(self, state):
"""根据“使用最大可用空间”复选框的状态切换大小输入框的启用/禁用状态。"""
# 获取当前选中的卷组的实际最大可用空间GB
selected_vg = self.vg_combo_box.currentText()
actual_max_gb = self.vg_sizes.get(selected_vg, 0.0)
# 确保计算出的最大值不小于spinbox的最小值
clamped_max_gb = max(self.lv_size_spinbox.minimum(), actual_max_gb)
if state == Qt.Checked:
self.lv_size_spinbox.setDisabled(True)
self.lv_size_spinbox.setValue(clamped_max_gb) # 使用计算出的最大值
else:
self.lv_size_spinbox.setDisabled(False)
# 如果之前是最大值,取消勾选后,恢复到最小值或一个合理值
if self.lv_size_spinbox.value() == clamped_max_gb: # 比较时也使用计算出的最大值
self.lv_size_spinbox.setValue(self.lv_size_spinbox.minimum())
# 否则,保持当前值(用户手动输入的值)
current_max_spinbox_value = self.lv_size_spinbox.maximum()
current_spinbox_value = self.lv_size_spinbox.value()
if is_checked:
logger.debug(f"[_toggle_size_input] Checkbox is CHECKED. Disabling spinbox and setting value to max.")
self.lv_size_spinbox.setDisabled(True) # 禁用 spinbox
self.lv_size_spinbox.setValue(current_max_spinbox_value) # 设置为最大可用 GB
logger.debug(f"[_toggle_size_input] Spinbox value set to max: {current_max_spinbox_value} GB.")
else: # is_checked is False
logger.debug(f"[_toggle_size_input] Checkbox is UNCHECKED. Enabling spinbox.")
self.lv_size_spinbox.setDisabled(False) # 启用 spinbox
# 如果之前是最大值(因为复选框被勾选),取消勾选后,恢复到最小值,方便用户输入自定义值
if current_spinbox_value == current_max_spinbox_value:
min_val = self.lv_size_spinbox.minimum()
self.lv_size_spinbox.setValue(min_val)
logger.debug(f"[_toggle_size_input] Spinbox was at max, reset to min: {min_val} GB.")
else:
logger.debug(f"[_toggle_size_input] Spinbox was not at max, keeping current value: {self.lv_size_spinbox.value()} GB.")
logger.debug("[_toggle_size_input] Completed.")
def get_lv_info(self):
lv_name = self.lv_name_input.text().strip()
@@ -442,13 +468,15 @@ class CreateLvDialog(QDialog):
if not vg_name:
QMessageBox.warning(self, "输入错误", "请选择一个卷组。")
return None
if not use_max_space and size_gb <= 0:
QMessageBox.warning(self, "输入错误", "逻辑卷大小必须大于0。")
return None
# 这里的检查应该使用 self.lv_size_spinbox.maximum() 来判断,因为它是实际的最大值
if not use_max_space and size_gb > self.lv_size_spinbox.maximum():
QMessageBox.warning(self, "输入错误", "逻辑卷大小不能超过卷组的可用空间。")
return None
# 如果没有使用最大空间,则检查输入大小的有效性
if not use_max_space:
if size_gb <= 0:
QMessageBox.warning(self, "输入错误", "逻辑卷大小必须大于0。")
return None
# 这里的检查应该使用 self.lv_size_spinbox.maximum() 来判断,因为它是实际的最大值
if size_gb > self.lv_size_spinbox.maximum():
QMessageBox.warning(self, "输入错误", "逻辑卷大小不能超过卷组的可用空间。")
return None
return {
'lv_name': lv_name,

38
disk-manager.spec Normal file
View File

@@ -0,0 +1,38 @@
# -*- mode: python ; coding: utf-8 -*-
a = Analysis(
['mainwindow.py'],
pathex=[],
binaries=[],
datas=[],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='disk-manager',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)

View File

@@ -1,80 +1,77 @@
# disk_operations.py
import subprocess
import logging
import re
import os
from PySide6.QtWidgets import QMessageBox, QInputDialog
from PySide6.QtCore import QObject, Signal, QThread
from system_info import SystemInfoManager
logger = logging.getLogger(__name__)
class DiskOperations:
def __init__(self, system_manager: SystemInfoManager): # NEW: 接收 system_manager 实例
self.system_manager = system_manager
# --- 后台格式化工作线程 ---
class FormatWorker(QObject):
# 定义信号,用于向主线程发送格式化结果
finished = Signal(bool, str, str, str) # 成功状态, 设备路径, 标准输出, 标准错误
started = Signal(str) # 设备路径
def __init__(self, device_path, fs_type, execute_shell_command_func, parent=None):
super().__init__(parent)
self.device_path = device_path
self.fs_type = fs_type
self._execute_shell_command_func = execute_shell_command_func
def run(self):
"""
在单独线程中执行格式化命令。
"""
self.started.emit(self.device_path)
logger.info(f"后台格式化开始: 设备 {self.device_path}, 文件系统 {self.fs_type}")
command_list = []
if self.fs_type == "ext4":
command_list = ["mkfs.ext4", "-F", self.device_path] # -F 强制执行
elif self.fs_type == "xfs":
command_list = ["mkfs.xfs", "-f", self.device_path] # -f 强制执行
elif self.fs_type == "ntfs":
command_list = ["mkfs.ntfs", "-f", self.device_path] # -f 强制执行
elif self.fs_type == "fat32":
command_list = ["mkfs.vfat", "-F", "32", self.device_path] # -F 32 指定FAT32
else:
logger.error(f"不支持的文件系统类型: {self.fs_type}")
self.finished.emit(False, self.device_path, "", f"不支持的文件系统类型: {self.fs_type}")
return
# 调用传入的shell命令执行函数并禁用其内部的QMessageBox显示
success, stdout, stderr = self._execute_shell_command_func(
command_list,
f"格式化设备 {self.device_path}{self.fs_type} 失败",
root_privilege=True,
show_dialog=False # 阻止工作线程弹出 QMessageBox
)
self.finished.emit(success, self.device_path, stdout, stderr)
logger.info(f"后台格式化完成: 设备 {self.device_path}, 成功: {success}")
class DiskOperations(QObject): # 继承 QObject 以便发出信号
# 定义信号,用于通知主线程格式化操作的开始和结束
formatting_finished = Signal(bool, str, str, str) # 成功状态, 设备路径, 标准输出, 标准错误
formatting_started = Signal(str) # 设备路径
def __init__(self, system_manager: SystemInfoManager, lvm_ops, parent=None):
super().__init__(parent)
self.system_manager = system_manager
self._lvm_ops = lvm_ops # 保存 LvmOperations 实例,以便调用其 _execute_shell_command
self.active_format_workers = {} # 用于跟踪正在运行的格式化线程
# DiskOperations 内部的 _execute_shell_command 只是对 LvmOperations._execute_shell_command 的包装
# 这样所有 shell 命令都通过 LvmOperations 的统一入口,可以控制 show_dialog
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}")
# Determine if the error dialog should be suppressed by this function
should_suppress_dialog_here = False
if suppress_critical_dialog_on_stderr_match:
if isinstance(suppress_critical_dialog_on_stderr_match, str):
if suppress_critical_dialog_on_stderr_match in stderr_output:
should_suppress_dialog_here = True
elif isinstance(suppress_critical_dialog_on_stderr_match, tuple):
if any(s in stderr_output for s in suppress_critical_dialog_on_stderr_match):
should_suppress_dialog_here = True
if should_suppress_dialog_here:
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)
suppress_critical_dialog_on_stderr_match=None, input_data=None,
show_dialog=True):
return self._lvm_ops._execute_shell_command(
command_list, error_message, root_privilege, suppress_critical_dialog_on_stderr_match, input_data, show_dialog
)
def _add_to_fstab(self, device_path, mount_point, fstype, uuid):
"""
@@ -91,8 +88,6 @@ class DiskOperations:
try:
# 检查 fstab 中是否已存在相同 UUID 的条目
# 使用 _execute_shell_command 来读取 fstab尽管通常不需要 sudo
# 这里为了简化,直接读取文件,但写入时使用 _execute_shell_command
with open(fstab_path, 'r') as f:
fstab_content = f.readlines()
@@ -103,7 +98,6 @@ class DiskOperations:
return True # 认为成功,因为目标已达成
# 如果不存在,则追加到 fstab
# 使用 _execute_shell_command for sudo write
success, _, stderr = self._execute_shell_command(
["sh", "-c", f"echo '{fstab_entry}' >> {fstab_path}"],
f"{device_path} 添加到 {fstab_path} 失败",
@@ -125,7 +119,6 @@ class DiskOperations:
从 /etc/fstab 中移除指定设备的条目。
此方法需要 SystemInfoManager 来获取设备的 UUID。
"""
# NEW: 使用 system_manager 获取 UUID它会处理路径解析
device_details = self.system_manager.get_device_details_by_path(device_path)
if not device_details or not device_details.get('uuid'):
logger.warning(f"无法获取设备 {device_path} 的 UUID无法从 fstab 中移除。")
@@ -133,10 +126,7 @@ class DiskOperations:
uuid = device_details.get('uuid')
fstab_path = "/etc/fstab"
# Use sed for robust removal with sudo
# suppress_critical_dialog_on_stderr_match is added to handle cases where fstab might not exist
# or sed reports no changes, which is not a critical error for removal.
command = ["sed", "-i", f"/UUID={uuid}/d", fstab_path]
command = ["sed", "-i", f"/{re.escape(uuid)}/d", fstab_path] # Use re.escape for UUID
success, _, stderr = self._execute_shell_command(
command,
f"{fstab_path} 中删除 UUID={uuid} 的条目失败",
@@ -151,10 +141,6 @@ class DiskOperations:
logger.info(f"已从 {fstab_path} 中删除 UUID={uuid} 的条目。")
return True
else:
# If sed failed, it might be because the entry wasn't found, which is fine.
# _execute_shell_command would have suppressed the dialog if it matched the suppress_critical_dialog_on_stderr_match.
# So, if we reach here, it's either a real error (dialog shown by _execute_shell_command)
# or a suppressed "no changes" type of error. In both cases, if no real error, we return True.
if any(s in stderr for s in (
f"sed: {fstab_path}: No such file or directory",
f"sed: {fstab_path}: 没有那个文件或目录",
@@ -162,8 +148,8 @@ class DiskOperations:
"No such file or directory" # more general check
)):
logger.info(f"fstab 中未找到 UUID={uuid} 的条目,无需删除。")
return True # Consider it a success if the entry is not there
return False # Other errors are already handled by _execute_shell_command
return True
return False
def mount_partition(self, device_path, mount_point, add_to_fstab=False):
"""
@@ -190,7 +176,6 @@ class DiskOperations:
if success:
QMessageBox.information(None, "成功", f"设备 {device_path} 已成功挂载到 {mount_point}")
if add_to_fstab:
# NEW: 使用 self.system_manager 获取 fstype 和 UUID
device_details = self.system_manager.get_device_details_by_path(device_path)
if device_details:
fstype = device_details.get('fstype')
@@ -215,34 +200,24 @@ class DiskOperations:
:return: True 如果成功,否则 False。
"""
logger.info(f"尝试卸载设备 {device_path}")
# 定义表示设备已未挂载的错误信息(中英文)
# 增加了 "未指定挂载点"
already_unmounted_errors = ("not mounted", "未挂载", "未指定挂载点")
# 调用 _execute_shell_command并告诉它在遇到“已未挂载”错误时不要弹出其自身的关键错误对话框。
success, _, stderr = self._execute_shell_command(
["umount", device_path],
f"卸载设备 {device_path} 失败",
suppress_critical_dialog_on_stderr_match=already_unmounted_errors
suppress_critical_dialog_on_stderr_match=already_unmounted_errors,
show_dialog=show_dialog_on_error
)
if success:
# 如果命令成功执行,则卸载成功。
QMessageBox.information(None, "成功", f"设备 {device_path} 已成功卸载。")
return True
else:
# 如果命令失败,检查是否是因为设备已经未挂载或挂载点未指定。
is_already_unmounted_error = any(s in stderr for s in already_unmounted_errors)
if is_already_unmounted_error:
logger.info(f"设备 {device_path} 已经处于未挂载状态(或挂载点未指定),无需重复卸载。")
# 这种情况下,操作结果符合预期(设备已是未挂载),不弹出对话框,并返回 True。
return True
else:
# 对于其他类型的卸载失败(例如设备忙、权限不足等),
# _execute_shell_command 应该已经弹出了关键错误对话框
# (因为它没有匹配到 `already_unmounted_errors` 进行抑制)。
# 所以,这里我们不需要再次弹出对话框,直接返回 False。
return False
def get_disk_free_space_info_mib(self, disk_path, total_disk_mib):
@@ -254,8 +229,6 @@ class DiskOperations:
如果磁盘有分区表但没有空闲空间,返回 (None, None)。
"""
logger.debug(f"尝试获取磁盘 {disk_path} 的空闲空间信息 (MiB)。")
# suppress_critical_dialog_on_stderr_match is added to handle cases where parted might complain about
# an unrecognized disk label, which is expected for a fresh disk.
success, stdout, stderr = self._execute_shell_command(
["parted", "-s", disk_path, "unit", "MiB", "print", "free"],
f"获取磁盘 {disk_path} 分区信息失败",
@@ -264,9 +237,6 @@ class DiskOperations:
)
if not success:
# If parted failed and it wasn't due to an unrecognized label (handled by suppress_critical_dialog_on_stderr_match),
# then _execute_shell_command would have shown a dialog.
# We just log and return None.
logger.error(f"获取磁盘 {disk_path} 空闲空间信息失败: {stderr}")
return None, None
@@ -274,18 +244,14 @@ class DiskOperations:
free_spaces = []
lines = stdout.splitlines()
# Regex to capture StartMiB and SizeMiB from a "Free Space" line
# It's made more flexible to match "Free Space", "空闲空间", and "可用空间"
# Example: " 0.02MiB 8192MiB 8192MiB 可用空间"
# We need to capture the first numeric value (Start) and the third numeric value (Size).
free_space_line_pattern = re.compile(r'^\s*(\d+\.?\d*)MiB\s+(\d+\.?\d*)MiB\s+(\d+\.?\d*)MiB\s+(?:Free Space|空闲空间|可用空间)')
for line in lines:
match = free_space_line_pattern.match(line)
if match:
try:
start_mib = float(match.group(1)) # Capture StartMiB
size_mib = float(match.group(3)) # Capture SizeMiB (the third numeric value)
start_mib = float(match.group(1))
size_mib = float(match.group(3))
free_spaces.append({'start_mib': start_mib, 'size_mib': size_mib})
except ValueError as ve:
logger.warning(f"解析 parted free space 行 '{line}' 失败: {ve}")
@@ -295,7 +261,6 @@ class DiskOperations:
logger.warning(f"在磁盘 {disk_path} 上未找到空闲空间。")
return None, None
# 找到最大的空闲空间块
largest_free_space = max(free_spaces, key=lambda x: x['size_mib'])
start_mib = largest_free_space['start_mib']
size_mib = largest_free_space['size_mib']
@@ -320,32 +285,26 @@ class DiskOperations:
# 1. 检查磁盘是否有分区表
has_partition_table = False
# Use suppress_critical_dialog_on_stderr_match for "unrecognized disk label" when checking
success_check, stdout_check, stderr_check = self._execute_shell_command(
["parted", "-s", disk_path, "print"],
f"检查磁盘 {disk_path} 分区表失败",
suppress_critical_dialog_on_stderr_match=("无法辨识的磁盘卷标", "unrecognized disk label"),
root_privilege=True
)
# If success_check is False, it means _execute_shell_command encountered an error.
# If that error was "unrecognized disk label", it was suppressed, and we treat it as no partition table.
# If it was another error, a dialog was shown by _execute_shell_command, and we should stop.
if not success_check:
# Check if the failure was due to "unrecognized disk label" (which means no partition table)
if any(s in stderr_check for s in ("无法辨识的磁盘卷标", "unrecognized disk label")):
logger.info(f"磁盘 {disk_path} 没有可识别的分区表。")
has_partition_table = False # Explicitly set to False
has_partition_table = False
else:
logger.error(f"检查磁盘 {disk_path} 分区表时发生非预期的错误: {stderr_check}")
return False # Other critical error, stop operation.
else: # success_check is True
return False
else:
if "Partition Table: unknown" not in stdout_check and "分区表unknown" not in stdout_check:
has_partition_table = True
else:
logger.info(f"parted print 报告磁盘 {disk_path} 的分区表为 'unknown'")
has_partition_table = False
actual_start_mib_for_parted = 0.0
# 2. 如果没有分区表,则创建分区表
@@ -365,7 +324,6 @@ class DiskOperations:
)
if not success:
return False
# 对于新创建分区表的磁盘,第一个分区通常从 1MiB 开始以确保兼容性和对齐
actual_start_mib_for_parted = 1.0
else:
# 如果有分区表,获取下一个可用分区的起始位置
@@ -374,10 +332,7 @@ class DiskOperations:
QMessageBox.critical(None, "错误", f"无法确定磁盘 {disk_path} 的分区起始位置或没有空闲空间。")
return False
# 如果 parted 报告的起始位置非常接近 0 MiB (例如 0.0 MiB 或 0.02 MiB)
# 为了安全和兼容性,也将其调整为 1.0 MiB。
# 否则,使用 parted 报告的精确起始位置。
if start_mib_from_parted < 1.0: # covers 0.0MiB and values like 0.017MiB (17.4kB)
if start_mib_from_parted < 1.0:
actual_start_mib_for_parted = 1.0
else:
actual_start_mib_for_parted = start_mib_from_parted
@@ -387,12 +342,7 @@ class DiskOperations:
end_pos = "100%"
size_for_log = "最大可用空间"
else:
# 计算结束 MiB
# 注意:这里计算的 end_mib 是基于用户请求的大小,
# parted 会根据实际可用空间和对齐进行微调。
end_mib = actual_start_mib_for_parted + size_gb * 1024
# 简单检查,如果请求的大小导致结束位置超出磁盘总大小,则使用最大可用
if end_mib > total_disk_mib:
QMessageBox.warning(None, "警告", f"请求的分区大小 ({size_gb}GB) 超出了可用空间。将调整为最大可用空间。")
end_pos = "100%"
@@ -428,16 +378,12 @@ class DiskOperations:
return False
# 尝试卸载分区
# The unmount_partition method now handles "not mounted" gracefully without dialog and returns True.
# So, we just call it.
self.unmount_partition(device_path, show_dialog_on_error=False) # show_dialog_on_error=False means no dialog for other errors too.
self.unmount_partition(device_path, show_dialog_on_error=False)
# 从 fstab 中移除条目
# _remove_fstab_entry also handles "not found" gracefully.
self._remove_fstab_entry(device_path)
# 获取父磁盘和分区号
# 例如 /dev/sdb1 -> /dev/sdb, 1
match = re.match(r'(/dev/[a-z]+)(\d+)', device_path)
if not match:
QMessageBox.critical(None, "错误", f"无法解析设备路径 {device_path}")
@@ -460,52 +406,64 @@ class DiskOperations:
def format_partition(self, device_path, fstype=None):
"""
格式化指定分区
启动一个 QInputDialog 让用户选择文件系统类型,然后将格式化操作提交到后台线程
:param device_path: 要格式化的分区路径。
:param fstype: 文件系统类型 (例如 'ext4', 'xfs', 'fat32', 'ntfs')。如果为 None则弹出对话框让用户选择。
:return: True 如果成功,否则 False。
:return: True 如果成功启动后台任务,否则 False。
"""
if fstype is None:
items = ("ext4", "xfs", "fat32", "ntfs")
fstype, ok = QInputDialog.getItem(None, "选择文件系统", "请选择要使用的文件系统类型:", items, 0, False)
fstype, ok = QInputDialog.getItem(None, "选择文件系统", f"请选择要使用的文件系统类型 for {device_path}:", items, 0, False)
if not ok or not fstype:
logger.info("用户取消了文件系统选择。")
return False
reply = QMessageBox.question(None, "确认格式化分区",
f"您确定要将分区 {device_path} 格式化{fstype} 吗?此操作将擦除分区上的所有数据!",
reply = QMessageBox.question(None, "确认格式化",
f"您确定要格式化设备 {device_path}{fstype} 吗?此操作将擦除所有数据!",
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
if reply == QMessageBox.No:
logger.info(f"用户取消了格式化分区 {device_path} 的操作。")
logger.info(f"用户取消了格式化 {device_path} 的操作。")
return False
# 尝试卸载分区
self.unmount_partition(device_path, show_dialog_on_error=False) # 静默卸载
# 检查是否已有格式化任务正在进行
if device_path in self.active_format_workers:
QMessageBox.warning(None, "警告", f"设备 {device_path} 正在格式化中,请勿重复操作。")
return False
# 尝试卸载分区(静默,不弹对话框)
self.unmount_partition(device_path, show_dialog_on_error=False)
# 从 fstab 中移除条目 (因为格式化会改变 UUID)
self._remove_fstab_entry(device_path)
logger.info(f"尝试将分区 {device_path} 格式化为 {fstype}")
format_cmd = []
if fstype == "ext4":
format_cmd = ["mkfs.ext4", "-F", device_path] # -F 强制执行
elif fstype == "xfs":
format_cmd = ["mkfs.xfs", "-f", device_path] # -f 强制执行
elif fstype == "fat32":
format_cmd = ["mkfs.fat", "-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
# 创建 QThread 和 FormatWorker 实例
thread = QThread()
worker = FormatWorker(device_path, fstype, self._execute_shell_command)
worker.moveToThread(thread)
# 连接信号和槽
thread.started.connect(worker.run)
worker.finished.connect(lambda s, dp, o, e: self._on_formatting_finished(s, dp, o, e))
worker.finished.connect(thread.quit)
worker.finished.connect(worker.deleteLater)
thread.finished.connect(thread.deleteLater)
self.active_format_workers[device_path] = thread
self.formatting_started.emit(device_path)
thread.start()
QMessageBox.information(None, "开始格式化", f"设备 {device_path} 正在后台格式化为 {fstype}。完成后将通知您。")
return True
def _on_formatting_finished(self, success, device_path, stdout, stderr):
"""
处理格式化工作线程完成后的结果。此槽函数在主线程中执行。
"""
if device_path in self.active_format_workers:
del self.active_format_workers[device_path]
success, _, stderr = self._execute_shell_command(
format_cmd,
f"格式化分区 {device_path} 失败"
)
if success:
QMessageBox.information(None, "成功", f"分区 {device_path} 已成功格式化{fstype}")
return True
QMessageBox.information(None, "格式化成功", f"设备 {device_path} 已成功格式化。")
else:
return False
QMessageBox.critical(None, "格式化失败", f"格式化设备 {device_path} 失败。\n错误详情: {stderr}")
self.formatting_finished.emit(success, device_path, stdout, stderr)

View File

@@ -1,3 +1,4 @@
# lvm_operations.py
import subprocess
import logging
from PySide6.QtWidgets import QMessageBox
@@ -6,22 +7,27 @@ logger = logging.getLogger(__name__)
class LvmOperations:
def __init__(self):
pass # LVM操作不直接依赖SystemInfoManager通过参数传递所需信息
pass
def _execute_shell_command(self, command_list, error_message, root_privilege=True,
suppress_critical_dialog_on_stderr_match=None, input_data=None):
suppress_critical_dialog_on_stderr_match=None, input_data=None,
show_dialog=True, expected_non_zero_exit_codes=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)。
:param show_dialog: 如果为 False则不显示关键错误对话框。
:param expected_non_zero_exit_codes: 预期为非零但仍视为成功的退出码列表。
:return: (True/False, stdout_str, stderr_str)
"""
if not all(isinstance(arg, str) for arg in command_list):
logger.error(f"命令列表包含非字符串元素: {command_list}")
QMessageBox.critical(None, "错误", f"内部错误:尝试执行的命令包含无效参数。\n命令详情: {command_list}")
if show_dialog:
QMessageBox.critical(None, "错误", f"内部错误:尝试执行的命令包含无效参数。\n命令详情: {command_list}")
return False, "", "内部错误:命令参数类型不正确。"
if root_privilege:
@@ -48,25 +54,42 @@ class LvmOperations:
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:
# NEW LOGIC: Check if the exit code is expected as non-critical
if expected_non_zero_exit_codes and e.returncode in expected_non_zero_exit_codes:
logger.info(f"命令 '{full_cmd_str}' 以预期非零退出码 {e.returncode} 结束,仍视为成功")
return True, e.stdout.strip(), stderr_output # Treat as success
should_suppress_dialog = False
if suppress_critical_dialog_on_stderr_match:
if isinstance(suppress_critical_dialog_on_stderr_match, str):
if suppress_critical_dialog_on_stderr_match in stderr_output:
should_suppress_dialog = True
elif isinstance(suppress_critical_dialog_on_stderr_match, (list, tuple)):
for pattern in suppress_critical_dialog_on_stderr_match:
if pattern in stderr_output:
should_suppress_dialog = True
break
if show_dialog and not should_suppress_dialog:
QMessageBox.critical(None, "错误", f"{error_message}\n错误详情: {stderr_output}")
return False, e.stdout.strip(), stderr_output
except FileNotFoundError:
QMessageBox.critical(None, "错误", f"命令 '{command_list[0]}' 未找到。请确保已安装相关工具。")
if show_dialog:
QMessageBox.critical(None, "错误", f"命令 '{command_list[0]}' 未找到。请确保已安装相关工具。")
logger.error(f"命令 '{command_list[0]}' 未找到。")
return False, "", f"命令 '{command_list[0]}' 未找到。"
except Exception as e:
QMessageBox.critical(None, "错误", f"执行命令时发生未知错误: {e}")
if show_dialog:
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。
:param device_path: 设备的路径,例如 /dev/sdb1 或 /dev/sdb
:return: True 如果成功,否则 False。
"""
if not isinstance(device_path, str):
@@ -83,14 +106,47 @@ class LvmOperations:
logger.info(f"尝试在 {device_path} 上创建物理卷。")
success, _, stderr = self._execute_shell_command(
["pvcreate", "-y", device_path], # -y 自动确认
f"{device_path} 上创建物理卷失败"
["pvcreate", "-y", device_path],
f"{device_path} 上创建物理卷失败",
suppress_critical_dialog_on_stderr_match="device is partitioned"
)
if success:
QMessageBox.information(None, "成功", f"物理卷已在 {device_path} 上成功创建。")
return True
else:
return False
if "device is partitioned" in stderr:
wipe_reply = QMessageBox.question(
None, "设备已分区",
f"设备 {device_path} 已分区。您是否要擦除设备上的所有分区表,"
f"并将其整个用于物理卷?此操作将导致所有数据丢失!",
QMessageBox.Yes | QMessageBox.No, QMessageBox.No
)
if wipe_reply == QMessageBox.Yes:
logger.warning(f"用户选择擦除 {device_path} 上的分区表并创建物理卷。")
mklabel_success, _, mklabel_stderr = self._execute_shell_command(
["parted", "-s", device_path, "mklabel", "gpt"],
f"擦除 {device_path} 上的分区表失败"
)
if mklabel_success:
logger.info(f"已成功擦除 {device_path} 上的分区表。重试创建物理卷。")
retry_success, _, retry_stderr = self._execute_shell_command(
["pvcreate", "-y", device_path],
f"{device_path} 上创建物理卷失败 (重试)"
)
if retry_success:
QMessageBox.information(None, "成功", f"物理卷已在 {device_path} 上成功创建。")
return True
else:
return False
else:
return False
else:
logger.info(f"用户取消了擦除 {device_path} 分区表的操作。")
QMessageBox.information(None, "信息", f"未在已分区设备 {device_path} 上创建物理卷。")
return False
else:
return False
def delete_pv(self, device_path):
"""

View File

@@ -1,3 +1,4 @@
# mainwindow.py
import sys
import logging
import re
@@ -5,7 +6,7 @@ import os
from PySide6.QtWidgets import (QApplication, QMainWindow, QTreeWidgetItem,
QMessageBox, QHeaderView, QMenu, QInputDialog, QDialog)
from PySide6.QtCore import Qt, QPoint
from PySide6.QtCore import Qt, QPoint, QThread
# 导入自动生成的 UI 文件
from ui_form import Ui_MainWindow
@@ -19,6 +20,8 @@ from disk_operations import DiskOperations
from raid_operations import RaidOperations
# 导入 LVM 操作模块
from lvm_operations import LvmOperations
# 导入新的 resolver 类
from occupation_resolver import OccupationResolver
# 导入自定义对话框
from dialogs import (CreatePartitionDialog, MountDialog, CreateRaidDialog,
CreatePvDialog, CreateVgDialog, CreateLvDialog)
@@ -33,11 +36,14 @@ class MainWindow(QMainWindow):
setup_logging(self.ui.logOutputTextEdit)
logger.info("应用程序启动。")
# 初始化管理器和操作类
# 初始化管理器和操作类 (恢复原始的初始化顺序和依赖关系)
self.system_manager = SystemInfoManager()
self.disk_ops = DiskOperations(self.system_manager)
self.raid_ops = RaidOperations()
self.lvm_ops = LvmOperations()
# DiskOperations 仍然需要 lvm_ops 的 _execute_shell_command 方法
self.disk_ops = DiskOperations(self.system_manager, self.lvm_ops)
# RaidOperations 仍然需要 system_manager
self.raid_ops = RaidOperations(self.system_manager)
# 连接刷新按钮的信号到槽函数
if hasattr(self.ui, 'refreshButton'):
@@ -55,6 +61,17 @@ class MainWindow(QMainWindow):
self.ui.treeWidget_lvm.setContextMenuPolicy(Qt.CustomContextMenu)
self.ui.treeWidget_lvm.customContextMenuRequested.connect(self.show_lvm_context_menu)
# 连接 DiskOperations 的格式化完成信号
self.disk_ops.formatting_finished.connect(self.on_disk_formatting_finished)
# 实例化 OccupationResolver
# 将 lvm_ops._execute_shell_command 和 system_manager.get_mountpoint_for_device 传递给它
# 确保 lvm_ops 和 system_manager 已经被正确初始化
self.occupation_resolver = OccupationResolver(
shell_executor_func=self.lvm_ops._execute_shell_command,
mount_info_getter_func=self.system_manager.get_mountpoint_for_device
)
# 初始化时刷新所有数据
self.refresh_all_info()
logger.info("所有设备信息已初始化加载。")
@@ -149,6 +166,8 @@ class MainWindow(QMainWindow):
if device_type == 'disk':
create_partition_action = menu.addAction(f"创建分区 {device_path}...")
create_partition_action.triggered.connect(lambda: self._handle_create_partition(device_path, dev_data))
wipe_action = menu.addAction(f"擦除分区表 {device_path}...")
wipe_action.triggered.connect(lambda: self._handle_wipe_partition_table(device_path))
menu.addSeparator()
if device_type == 'part':
@@ -165,12 +184,95 @@ class MainWindow(QMainWindow):
format_action = menu.addAction(f"格式化分区 {device_path}...")
format_action.triggered.connect(lambda: self._handle_format_partition(device_path))
menu.addSeparator() # Add separator before new occupation options
# --- 新增:解除设备占用选项 ---
# This option is available for any disk or partition that might be occupied
if device_type in ['disk', 'part']:
resolve_occupation_action = menu.addAction(f"解除占用 {device_path}")
resolve_occupation_action.triggered.connect(lambda: self._handle_resolve_device_occupation(device_path))
# --- 新增:关闭交换分区选项 ---
# This option is only available for active swap partitions
if device_type == 'part' and mount_point == '[SWAP]':
deactivate_swap_action = menu.addAction(f"关闭交换分区 {device_path}")
deactivate_swap_action.triggered.connect(lambda: self._handle_deactivate_swap(device_path))
if menu.actions():
menu.exec(self.ui.treeWidget_block_devices.mapToGlobal(pos))
else:
logger.info("右键点击了空白区域或设备没有可用的操作。")
# --- 新增:处理解除设备占用 ---
def _handle_resolve_device_occupation(self, device_path: str) -> bool:
"""
处理设备占用问题,通过调用 OccupationResolver 进行全面检查和修复。
"""
success = self.occupation_resolver.resolve_occupation(device_path)
if success:
# 如果成功解除占用,刷新 UI 显示最新的设备状态
self.refresh_all_info()
return success
# --- 新增:处理关闭交换分区 ---
def _handle_deactivate_swap(self, device_path):
"""
处理关闭交换分区的操作。
"""
reply = QMessageBox.question(
None,
"确认关闭交换分区",
f"您确定要关闭交换分区 {device_path} 吗?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No
)
if reply == QMessageBox.No:
logger.info(f"用户取消了关闭交换分区 {device_path}")
return False
logger.info(f"尝试关闭交换分区 {device_path}")
success, _, stderr = self.lvm_ops._execute_shell_command(
["swapoff", device_path],
f"关闭交换分区 {device_path} 失败",
show_dialog=True
)
if success:
QMessageBox.information(None, "成功", f"交换分区 {device_path} 已成功关闭。")
self.refresh_all_info()
return True
else:
logger.error(f"关闭交换分区 {device_path} 失败: {stderr}")
return False
def _handle_wipe_partition_table(self, device_path):
"""
处理擦除物理盘分区表的操作。
"""
reply = QMessageBox.question(
self,
"确认擦除分区表",
f"您确定要擦除设备 {device_path} 上的所有分区表吗?\n"
f"**此操作将永久删除设备上的所有分区和数据,且不可恢复!**\n"
f"请谨慎操作!",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No
)
if reply == QMessageBox.No:
logger.info(f"用户取消了擦除 {device_path} 分区表的操作。")
return
logger.warning(f"尝试擦除 {device_path} 上的分区表。")
success, _, stderr = self.lvm_ops._execute_shell_command(
["parted", "-s", device_path, "mklabel", "gpt"],
f"擦除 {device_path} 上的分区表失败"
)
if success:
QMessageBox.information(self, "成功", f"设备 {device_path} 上的分区表已成功擦除。")
self.refresh_all_info()
else:
pass
def _handle_create_partition(self, disk_path, dev_data):
total_disk_mib = 0.0
total_size_str = dev_data.get('size')
@@ -220,7 +322,7 @@ class MainWindow(QMainWindow):
logger.debug(f"磁盘 {disk_path} 没有现有分区,假定从 0 MiB 开始,最大可用空间为整个磁盘。")
max_available_mib = max(0.0, total_disk_mib - 1.0)
start_position_mib = 1.0
logger.debug(f"磁盘 /dev/sdd 没有现有分区,假定从 {start_position_mib} MiB 开始,最大可用空间为 {max_available_mib} MiB。")
logger.debug(f"磁盘 {disk_path} 没有现有分区,假定从 {start_position_mib} MiB 开始,最大可用空间为 {max_available_mib} MiB。")
dialog = CreatePartitionDialog(self, disk_path, total_disk_mib, max_available_mib)
if dialog.exec() == QDialog.Accepted:
@@ -252,8 +354,17 @@ class MainWindow(QMainWindow):
self.refresh_all_info()
def _handle_format_partition(self, device_path):
if self.disk_ops.format_partition(device_path):
self.refresh_all_info()
"""
调用 DiskOperations 的异步格式化方法。
"""
self.disk_ops.format_partition(device_path)
def on_disk_formatting_finished(self, success, device_path, stdout, stderr):
"""
接收 DiskOperations 发出的格式化完成信号,并刷新界面。
"""
logger.info(f"格式化完成信号接收: 设备 {device_path}, 成功: {success}")
self.refresh_all_info()
# --- RAID 管理 Tab ---
def refresh_raid_info(self):
@@ -284,7 +395,9 @@ class MainWindow(QMainWindow):
logger.warning(f"RAID阵列 '{array.get('name', '未知')}' 的设备路径无效,跳过。")
continue
current_mount_point = self.system_manager.get_mountpoint_for_device(array_path)
current_mount_point = ""
if array.get('state') != 'Stopped (Configured)':
current_mount_point = self.system_manager.get_mountpoint_for_device(array_path)
array_item.setText(0, array_path)
array_item.setText(1, array.get('level', 'N/A'))
@@ -299,16 +412,15 @@ class MainWindow(QMainWindow):
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_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.get('raid_device', 'N/A')}")
member_item.setText(2, member.get('state', 'N/A'))
member_item.setText(3, f"Major: {member.get('major', 'N/A')}, Minor: {member.get('minor', 'N/A')}")
array_item.setData(0, Qt.UserRole, array)
if array.get('state') != 'Stopped (Configured)':
for member in array.get('member_devices', []):
member_item = QTreeWidgetItem(array_item)
member_item.setText(0, f" {member.get('device_path', 'N/A')}")
member_item.setText(1, f"成员: {member.get('raid_device', 'N/A')}")
member_item.setText(2, member.get('state', 'N/A'))
for i in range(len(raid_headers)):
self.ui.treeWidget_raid.resizeColumnToContents(i)
@@ -319,49 +431,98 @@ class MainWindow(QMainWindow):
logger.error(f"刷新RAID阵列信息失败: {e}")
def show_raid_context_menu(self, pos: QPoint):
item = self.ui.treeWidget_raid.itemAt(pos)
menu = QMenu(self)
item = self.ui.treeWidget_raid.itemAt(pos)
menu = QMenu(self)
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:
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')
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
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})")
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))
create_raid_action = menu.addAction("创建 RAID 阵列...")
create_raid_action.triggered.connect(self._handle_create_raid_array)
menu.addSeparator()
stop_action = menu.addAction(f"停止阵列 {array_path}")
stop_action.triggered.connect(lambda: self._handle_stop_raid_array(array_path))
if item and item.parent() is None:
array_data = item.data(0, Qt.UserRole)
if not array_data:
logger.warning(f"无法获取 RAID 阵列 {item.text(0)} 的详细数据。")
return
delete_action = menu.addAction(f"删除阵列 {array_path}")
delete_action.triggered.connect(lambda: self._handle_delete_raid_array(array_path, member_devices))
array_path = array_data.get('device')
array_state = array_data.get('state', 'N/A')
member_devices = [m.get('device_path') for m in array_data.get('member_devices', [])]
array_uuid = array_data.get('uuid')
format_action = menu.addAction(f"格式化阵列 {array_path}...")
format_action.triggered.connect(lambda: self._handle_format_raid_array(array_path))
if not array_path or array_path == 'N/A':
logger.warning(f"RAID阵列 '{array_data.get('name', '未知')}' 的设备路径无效,无法显示操作。")
return
if menu.actions():
menu.exec(self.ui.treeWidget_raid.mapToGlobal(pos))
else:
logger.info("右键点击了空白区域或没有可用的RAID操作。")
if array_state == 'Stopped (Configured)':
activate_action = menu.addAction(f"激活阵列 {array_path}")
activate_action.triggered.connect(lambda: self._handle_activate_raid_array(array_path))
delete_config_action = menu.addAction(f"删除配置文件条目 (UUID: {array_uuid})")
delete_config_action.triggered.connect(lambda: self._handle_delete_configured_raid_array(array_uuid))
else:
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})")
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()
# --- 新增RAID 阵列解除占用选项 ---
resolve_occupation_action = menu.addAction(f"解除占用 {array_path}")
resolve_occupation_action.triggered.connect(lambda: self._handle_resolve_device_occupation(array_path))
menu.addSeparator() # 在解除占用后添加分隔符
stop_action = menu.addAction(f"停止阵列 {array_path}")
stop_action.triggered.connect(lambda: self._handle_stop_raid_array(array_path))
delete_action = menu.addAction(f"删除阵列 {array_path}")
delete_action.triggered.connect(lambda: self._handle_delete_active_raid_array(array_path, member_devices, array_uuid))
format_action = menu.addAction(f"格式化阵列 {array_path}...")
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_activate_raid_array(self, array_path):
"""
处理激活已停止的 RAID 阵列。
"""
item = self.ui.treeWidget_raid.currentItem()
if not item or item.parent() is not None:
logger.warning("未选择有效的 RAID 阵列进行激活。")
return
array_data = item.data(0, Qt.UserRole)
if not array_data or array_data.get('device') != array_path:
logger.error(f"获取 RAID 阵列 {array_path} 的数据失败或数据不匹配。")
return
array_uuid = array_data.get('uuid')
if not array_uuid or array_uuid == 'N/A':
QMessageBox.critical(None, "错误", f"无法激活 RAID 阵列 {array_path}:缺少 UUID 信息。")
logger.error(f"激活 RAID 阵列 {array_path} 失败:缺少 UUID。")
return
reply = QMessageBox.question(
self,
"确认激活 RAID 阵列",
f"您确定要激活 RAID 阵列 {array_path} (UUID: {array_uuid}) 吗?\n"
"这将尝试重新组装阵列。",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No
)
if reply == QMessageBox.No:
logger.info(f"用户取消了激活 RAID 阵列 {array_path} 的操作。")
return
if self.raid_ops.activate_raid_array(array_path, array_uuid):
self.refresh_all_info()
def _handle_create_raid_array(self):
available_devices = self.system_manager.get_unallocated_partitions()
@@ -381,13 +542,25 @@ class MainWindow(QMainWindow):
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):
def _handle_delete_active_raid_array(self, array_path, member_devices, uuid):
"""
处理删除一个活动的 RAID 阵列的操作。
"""
if self.raid_ops.delete_active_raid_array(array_path, member_devices, uuid):
self.refresh_all_info()
def _handle_delete_configured_raid_array(self, uuid):
"""
处理停止状态 RAID 阵列的配置文件条目。
"""
if self.raid_ops.delete_configured_raid_array(uuid):
self.refresh_all_info()
def _handle_format_raid_array(self, array_path):
if self.disk_ops.format_partition(array_path):
self.refresh_all_info()
"""
处理 RAID 阵列的格式化。
"""
self.disk_ops.format_partition(array_path)
# --- LVM 管理 Tab ---
def refresh_lvm_info(self):
@@ -566,6 +739,11 @@ class MainWindow(QMainWindow):
mount_lv_action.triggered.connect(lambda: self._handle_mount(lv_path))
menu.addSeparator()
# --- 新增:逻辑卷解除占用选项 ---
resolve_occupation_action = menu.addAction(f"解除占用 {lv_path}")
resolve_occupation_action.triggered.connect(lambda: self._handle_resolve_device_occupation(lv_path))
menu.addSeparator() # 在解除占用后添加分隔符
delete_lv_action = menu.addAction(f"删除逻辑卷 {lv_name}")
delete_lv_action.triggered.connect(lambda: self._handle_delete_lv(lv_name, vg_name))
@@ -608,7 +786,7 @@ class MainWindow(QMainWindow):
available_pvs.append(f"/dev/{pv_name}")
if not available_pvs:
QMessageBox.warning(self, "警告", "没有可用于创建卷组的物理卷。请确保有未分配给任何卷组的物理卷。")
QMessageBox.warning(self, "警告", "没有可用于创建卷组的物理卷。")
return
dialog = CreateVgDialog(self, available_pvs)
@@ -619,49 +797,49 @@ class MainWindow(QMainWindow):
self.refresh_all_info()
def _handle_create_lv(self):
lvm_info = self.system_manager.get_lvm_info()
available_vgs = []
vg_sizes = {}
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)
lvm_info = self.system_manager.get_lvm_info()
available_vgs = []
vg_sizes = {}
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
free_size_str = vg.get('vg_free', '0B').strip().lower()
current_vg_size_gb = 0.0
match = re.match(r'(\d+\.?\d*)\s*([gmktb])?', free_size_str, re.IGNORECASE)
if match:
value = float(match.group(1))
unit = match.group(2).lower() if match.group(2) else ''
match = re.match(r'<?(\d+\.?\d*)\s*([gmktb])?', free_size_str)
if match:
value = float(match.group(1))
unit = match.group(2)
if unit == 'k':
current_vg_size_gb = value / (1024 * 1024)
elif unit == 'm':
current_vg_size_gb = value / 1024
elif unit == 'g':
current_vg_size_gb = value
elif unit == 't':
current_vg_size_gb = value * 1024
elif unit == 'b' or unit == '':
current_vg_size_gb = value / (1024 * 1024 * 1024)
else:
logger.warning(f"未知LVM单位: '{unit}' for '{free_size_str}'")
if unit == 'k':
current_vg_size_gb = value / (1024 * 1024)
elif unit == 'm':
current_vg_size_gb = value / 1024
elif unit == 'g' or unit is None:
current_vg_size_gb = value
elif unit == 't':
current_vg_size_gb = value * 1024
elif unit == 'b':
current_vg_size_gb = value / (1024 * 1024 * 1024)
else:
logger.warning(f"无法解析LVM空闲大小字符串: '{free_size_str}'")
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
vg_sizes[vg_name] = current_vg_size_gb
if not available_vgs:
QMessageBox.warning(self, "警告", "没有可用于创建逻辑卷的卷组。")
return
if not available_vgs:
QMessageBox.warning(self, "警告", "没有可用于创建逻辑卷的卷组。")
return
dialog = CreateLvDialog(self, available_vgs, vg_sizes)
if dialog.exec() == QDialog.Accepted:
info = dialog.get_lv_info()
if info:
if self.lvm_ops.create_lv(info['lv_name'], info['vg_name'], info['size_gb'], info['use_max_space']):
self.refresh_all_info()
dialog = CreateLvDialog(self, available_vgs, vg_sizes)
if dialog.exec() == QDialog.Accepted:
info = dialog.get_lv_info()
if info:
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_path = f"/dev/{vg_name}/{lv_name}"

317
occupation_resolver.py Normal file
View File

@@ -0,0 +1,317 @@
# occupation_resolver.py
import logging
import re
from typing import List, Tuple, Callable, Optional
# 假设 QApplication 和 QMessageBox 在主 UI 线程中可用,
# 并且我们在这里直接使用它们进行用户交互。
# 在更严格的架构中UI交互应该通过回调或事件机制与核心逻辑分离。
# from PyQt5.QtWidgets import QMessageBox
from PySide6.QtWidgets import (QApplication, QMainWindow, QTreeWidgetItem,
QMessageBox, QHeaderView, QMenu, QInputDialog, QDialog)
logger = logging.getLogger(__name__)
class OccupationResolver:
"""
负责检查和解除设备占用的类。
处理包括内核挂载、嵌套挂载和用户进程占用等情况。
"""
def __init__(self, shell_executor_func: Callable, mount_info_getter_func: Callable):
"""
初始化设备占用解决器。
Args:
shell_executor_func: 一个可调用对象,用于执行 shell 命令。
期望签名: (cmd: List[str], error_msg: str, show_dialog: bool,
suppress_critical_dialog_on_stderr_match: Tuple,
expected_non_zero_exit_codes: List[int]) -> Tuple[bool, str, str]
mount_info_getter_func: 一个可调用对象,用于获取设备的挂载点。
期望签名: (device_path: str) -> Optional[str]
"""
self._execute_shell_command = shell_executor_func
self._get_mountpoint_for_device = mount_info_getter_func
def _run_command(self, cmd: List[str], error_msg: str, **kwargs) -> Tuple[bool, str, str]:
"""
执行 shell 命令的包装器。
"""
return self._execute_shell_command(cmd, error_msg, **kwargs)
def get_all_mounts_under_path(self, base_path: str) -> List[str]:
"""
获取指定路径下所有嵌套的挂载点。
返回一个挂载点列表,从最深层到最浅层排序。
"""
mounts = []
# 尝试使用 findmnt 命令更可靠地获取嵌套挂载信息
# -l: 列出所有挂载
# -o TARGET: 只输出目标路径
# -n: 不显示标题行
# -r: 原始输出,方便解析
# --target <base_path>: 限制只查找 base_path 下的挂载
# -t noautofs,nosnapfs,nofuse,nocifs,nonfs,notmpfs,nobind: 排除一些不相关的或虚拟的挂载
findmnt_cmd = ["findmnt", "-l", "-o", "TARGET", "-n", "-r", "--target", base_path,
"-t", "noautofs,nosnapfs,nofuse,nocifs,nonfs,notmpfs,nobind"]
success, stdout, stderr = self._run_command(findmnt_cmd, f"获取 {base_path} 下的挂载信息失败", show_dialog=False)
if not success:
logger.warning(f"findmnt 命令失败,尝试解析 'mount' 命令输出: {stderr}")
# 如果 findmnt 失败或不可用,则回退到解析 'mount' 命令输出
success, stdout, stderr = self._run_command(["mount"], "获取挂载信息失败", show_dialog=False)
if not success:
logger.error(f"获取挂载信息失败: {stderr}")
return []
for line in stdout.splitlines():
match = re.search(r' on (\S+) type ', line)
if match:
mount_point = match.group(1)
# 确保是嵌套挂载点而不是基础挂载点本身
if mount_point.startswith(base_path) and mount_point != base_path:
mounts.append(mount_point)
else:
for line in stdout.splitlines():
mount_point = line.strip()
# findmnt --target 已经过滤了,这里只需确保不是空行
if mount_point and mount_point.startswith(base_path) and mount_point != base_path:
mounts.append(mount_point)
# 排序:从最深层到最浅层,以便正确卸载。路径越长,通常越深层。
mounts.sort(key=lambda x: len(x.split('/')), reverse=True)
logger.debug(f"{base_path} 下找到的嵌套挂载点(从深到浅): {mounts}")
return mounts
def _unmount_nested_mounts(self, base_mount_point: str) -> bool:
"""
尝试卸载指定基础挂载点下的所有嵌套挂载。
"""
nested_mounts = self.get_all_mounts_under_path(base_mount_point)
if not nested_mounts:
logger.debug(f"{base_mount_point} 下没有找到嵌套挂载点。")
return True # 没有需要卸载的嵌套挂载
logger.info(f"开始卸载 {base_mount_point} 下的嵌套挂载点: {nested_mounts}")
all_nested_unmounted = True
for mount_point in nested_mounts:
if not self._unmount_target(mount_point):
logger.error(f"未能卸载嵌套挂载点 {mount_point}")
all_nested_unmounted = False
# 即使一个失败,也尝试卸载其他,但标记整体失败
return all_nested_unmounted
def _unmount_target(self, target_path: str) -> bool:
"""
尝试卸载一个目标(设备或挂载点)。
先尝试普通卸载,如果失败则尝试强制卸载,再失败则尝试懒惰卸载。
"""
logger.info(f"尝试卸载 {target_path}")
success, stdout, stderr = self._run_command(
["umount", target_path],
f"卸载 {target_path} 失败",
show_dialog=False,
expected_non_zero_exit_codes=[1] # umount 返回 1 表示未挂载或忙碌
)
if success:
logger.info(f"成功卸载 {target_path}")
return True
else:
# 检查是否因为未挂载而失败
if "not mounted" in stderr.lower() or "未挂载" in stderr:
logger.info(f"目标 {target_path} 未挂载,无需卸载。")
return True
logger.warning(f"卸载 {target_path} 失败: {stderr}")
# 尝试强制卸载 -f
logger.info(f"尝试强制卸载 {target_path} (umount -f)。")
success_force, stdout_force, stderr_force = self._run_command(
["umount", "-f", target_path],
f"强制卸载 {target_path} 失败",
show_dialog=False,
expected_non_zero_exit_codes=[1]
)
if success_force:
logger.info(f"成功强制卸载 {target_path}")
return True
else:
logger.warning(f"强制卸载 {target_path} 失败: {stderr_force}")
# 尝试懒惰卸载 -l
logger.info(f"尝试懒惰卸载 {target_path} (umount -l)。")
success_lazy, stdout_lazy, stderr_lazy = self._run_command(
["umount", "-l", target_path],
f"懒惰卸载 {target_path} 失败",
show_dialog=False,
expected_non_zero_exit_codes=[1]
)
if success_lazy:
logger.info(f"成功懒惰卸载 {target_path}")
return True
else:
logger.error(f"懒惰卸载 {target_path} 失败: {stderr_lazy}")
return False
def _kill_pids(self, pids: List[str]) -> bool:
"""
终止指定PID的进程。
"""
all_killed = True
for pid in pids:
logger.info(f"尝试终止进程 {pid}")
kill_success, _, kill_stderr = self._run_command(
["kill", "-9", pid],
f"终止进程 {pid} 失败",
show_dialog=False # UI 交互在更高层处理
)
if not kill_success:
logger.error(f"终止进程 {pid} 失败: {kill_stderr}")
all_killed = False
else:
logger.info(f"成功终止进程 {pid}")
return all_killed
def resolve_occupation(self, device_path: str) -> bool:
"""
尝试解除设备占用,包括处理嵌套挂载和终止进程。
返回 True 如果成功解除占用,否则返回 False。
"""
logger.info(f"开始尝试解除设备 {device_path} 的占用。")
# 1. 获取设备的当前主挂载点
main_mount_point = self._get_mountpoint_for_device(device_path)
# 2. 如果设备有主挂载点,先尝试卸载所有嵌套挂载
if main_mount_point and main_mount_point != 'N/A' and main_mount_point != '[SWAP]':
logger.debug(f"设备 {device_path} 的主挂载点是 {main_mount_point}")
max_unmount_attempts = 3
for attempt in range(max_unmount_attempts):
logger.info(f"尝试卸载嵌套挂载点 (第 {attempt + 1} 次尝试)。")
if self._unmount_nested_mounts(main_mount_point):
logger.info(f"所有嵌套挂载点已成功卸载或已不存在。")
break
else:
logger.warning(f"卸载嵌套挂载点失败 (第 {attempt + 1} 次尝试),可能需要重试。")
if attempt == max_unmount_attempts - 1:
QMessageBox.critical(None, "错误", f"未能卸载设备 {device_path} 下的所有嵌套挂载点。")
return False
# 尝试卸载主挂载点本身
logger.info(f"尝试卸载主挂载点 {main_mount_point}")
if self._unmount_target(main_mount_point):
QMessageBox.information(None, "成功", f"设备 {device_path} 及其所有挂载点已成功卸载。")
return True # 主挂载点已卸载,设备应该已空闲
# 3. 检查设备本身或其(可能已不存在的)主挂载点是否仍被占用 (fuser)
fuser_targets = [device_path]
if main_mount_point and main_mount_point != 'N/A' and main_mount_point != '[SWAP]' and main_mount_point not in fuser_targets:
fuser_targets.append(main_mount_point)
pids_to_kill = set()
kernel_mounts_found = False
occupation_info_lines = []
for target in fuser_targets:
logger.debug(f"执行 fuser -vm {target} 检查占用。")
success_fuser, stdout_fuser, stderr_fuser = self._run_command(
["fuser", "-vm", target],
f"检查 {target} 占用失败",
show_dialog=False,
suppress_critical_dialog_on_stderr_match=(
"No such file or directory", "not found", "Usage:", "not mounted"
),
expected_non_zero_exit_codes=[1]
)
if success_fuser and stdout_fuser:
logger.debug(f"fuser -vm {target} 输出:\n{stdout_fuser}")
for line in stdout_fuser.splitlines():
line = line.strip() # 清除行首尾空白
if not line: # 跳过空行
continue
# 收集所有非空行用于最终显示
occupation_info_lines.append(line)
# 检查是否包含 "kernel" 关键字
if "kernel" in line:
kernel_mounts_found = True
# 尝试从详细格式的行中提取 PID (例如: /dev/sda2: root 42032 .rce. gpg-agent)
match_verbose = re.match(r'^\S+:\s+\S+\s+(?P<id>\d+)\s+.*', line)
if match_verbose:
pids_to_kill.add(match_verbose.group('id'))
else:
# 如果不是详细格式,检查是否是纯数字 PID (例如: 42032)
# 这种情况下fuser -vm 可能只输出了 PID
if line.isdigit():
pids_to_kill.add(line)
pids_to_kill_list = list(pids_to_kill)
if not kernel_mounts_found and not pids_to_kill_list:
QMessageBox.information(None, "信息", f"设备 {device_path} 未被任何进程占用。")
logger.info(f"设备 {device_path} 未被任何进程占用。")
return True
# 4. 处理剩余的内核挂载(如果嵌套挂载和主挂载卸载失败,这里会再次捕捉到)
if kernel_mounts_found:
process_list_str = "\n".join(sorted(list(set(occupation_info_lines))))
reply = QMessageBox.question(
None,
"设备占用 - 内核挂载",
f"设备 {device_path} 仍被内核挂载占用:\n{process_list_str}\n\n您确定要尝试卸载此设备吗?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No
)
if reply == QMessageBox.No:
logger.info(f"用户取消了卸载设备 {device_path}")
QMessageBox.information(None, "信息", f"已取消卸载设备 {device_path}")
return False
logger.info(f"再次尝试卸载设备 {device_path}")
if self._unmount_target(device_path): # 尝试直接卸载设备路径
QMessageBox.information(None, "成功", f"设备 {device_path} 已成功卸载。")
return True
else:
QMessageBox.critical(None, "错误", f"未能卸载设备 {device_path}")
return False
# 5. 处理用户进程占用
if pids_to_kill_list:
process_list_str = "\n".join(sorted(list(set(occupation_info_lines))))
reply = QMessageBox.question(
None,
"设备占用 - 进程",
f"设备 {device_path} 正在被以下进程占用:\n{process_list_str}\n\n您要强制终止这些进程吗?这可能会导致数据丢失或系统不稳定!",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No
)
if reply == QMessageBox.No:
logger.info(f"用户取消了终止占用设备 {device_path} 的进程。")
QMessageBox.information(None, "信息", f"已取消终止占用设备 {device_path} 的进程。")
return False
if self._kill_pids(pids_to_kill_list):
QMessageBox.information(None, "成功", f"已尝试终止占用设备 {device_path} 的所有进程。")
# 进程终止后,设备可能仍然被挂载,因此再次尝试卸载。
if main_mount_point and main_mount_point != 'N/A' and main_mount_point != '[SWAP]':
logger.info(f"进程终止后,尝试再次卸载主挂载点 {main_mount_point}")
if self._unmount_target(main_mount_point):
QMessageBox.information(None, "信息", f"设备 {device_path} 已成功卸载。")
return True
else: # 如果没有主挂载点,检查设备本身
logger.info(f"进程终止后,尝试再次卸载设备 {device_path}")
if self._unmount_target(device_path):
QMessageBox.information(None, "信息", f"设备 {device_path} 已成功卸载。")
return True
# 如果进程终止后仍未能卸载
QMessageBox.warning(None, "警告", f"进程已终止,但未能成功卸载设备 {device_path}。可能仍有其他问题。")
return False # 表示部分成功或需要进一步手动干预
else:
QMessageBox.critical(None, "错误", f"未能终止所有占用设备 {device_path} 的进程。")
return False
logger.warning(f"设备 {device_path} 占用解决逻辑未能完全处理。")
return False

View File

@@ -2,4 +2,4 @@
name = "PySide Widgets Project"
[tool.pyside6-project]
files = ["dialogs.py", "disk_operations.py", "form.ui", "logger_config.py", "lvm_operations.py", "mainwindow.py", "raid_operations.py", "system_info.py"]
files = ["dialogs.py", "disk_operations.py", "form.ui", "logger_config.py", "lvm_operations.py", "mainwindow.py", "occupation_resolver.py", "raid_operations.py", "system_info.py"]

View File

@@ -1,24 +1,29 @@
# raid_operations.py
import logging
import subprocess
from PySide6.QtWidgets import QMessageBox
from PySide6.QtWidgets import QMessageBox, QInputDialog, QLineEdit
from PySide6.QtCore import Qt
from system_info import SystemInfoManager
import re # 导入正则表达式模块
import re
import pexpect
import sys
logger = logging.getLogger(__name__)
class RaidOperations:
def __init__(self):
self.system_manager = SystemInfoManager()
def __init__(self, system_manager: SystemInfoManager):
self.system_manager = system_manager
def _execute_shell_command(self, command_list, error_msg_prefix, suppress_critical_dialog_on_stderr_match=None, input_to_command=None): # <--- 添加 input_to_command 参数
def _execute_shell_command(self, command_list, error_msg_prefix, suppress_critical_dialog_on_stderr_match=None, input_to_command=None):
"""
执行一个shell命令并返回stdout和stderr。
这个方法包装了 SystemInfoManager._run_command并统一处理日志和错误消息框。
它会通过 SystemInfoManager 自动处理 sudo。
:param command_list: 命令及其参数的列表。
:param error_msg_prefix: 命令失败时显示给用户的错误消息前缀。
:param suppress_critical_dialog_on_stderr_match: 一个字符串,如果在 stderr 中找到此字符串,
:param suppress_critical_dialog_on_stderr_match: 一个字符串或字符串元组。
如果在 stderr 中找到此字符串(或元组中的任一字符串),
则在 CalledProcessError 发生时,只记录日志,不弹出 QMessagebox.critical。
:param input_to_command: 传递给命令stdin的数据 (str)。
:return: (bool success, str stdout, str stderr)
@@ -30,8 +35,8 @@ class RaidOperations:
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
check_output=True,
input_data=input_to_command
)
if stdout: logger.debug(f"命令 '{full_cmd_str}' 标准输出:\n{stdout.strip()}")
@@ -39,18 +44,29 @@ class RaidOperations:
return True, stdout, stderr
except subprocess.CalledProcessError as e:
stderr_output = e.stderr.strip() # 获取标准错误输出
stderr_output = e.stderr.strip()
logger.error(f"{error_msg_prefix} 命令: {full_cmd_str}")
logger.error(f"退出码: {e.returncode}")
logger.error(f"标准输出: {e.stdout.strip()}")
logger.error(f"标准错误: {stderr_output}")
# 根据 suppress_critical_dialog_on_stderr_match 参数决定是否弹出错误对话框
if suppress_critical_dialog_on_stderr_match and suppress_critical_dialog_on_stderr_match in stderr_output:
logger.info(f"特定错误 '{suppress_critical_dialog_on_stderr_match}' 匹配,已抑制错误对话框。")
should_suppress_dialog = False
if suppress_critical_dialog_on_stderr_match:
if isinstance(suppress_critical_dialog_on_stderr_match, str):
if suppress_critical_dialog_on_stderr_match in stderr_output:
should_suppress_dialog = True
elif isinstance(suppress_critical_dialog_on_stderr_match, (list, tuple)):
for pattern in suppress_critical_dialog_on_stderr_match:
if pattern in stderr_output:
should_suppress_dialog = True
break
if should_suppress_dialog:
logger.info(f"特定错误 '{stderr_output}' 匹配抑制条件,已抑制错误对话框。")
else:
QMessageBox.critical(None, "错误", f"{error_msg_prefix}\n详细信息: {stderr_output}")
return False, e.stdout, stderr_output # 返回 stderr_output
return False, e.stdout, stderr_output
except FileNotFoundError:
logger.error(f"命令 '{command_list[0]}' 未找到。请确保已安装相关工具。")
QMessageBox.critical(None, "错误", f"命令 '{command_list[0]}' 未找到。请确保已安装相关工具。")
@@ -60,6 +76,118 @@ class RaidOperations:
QMessageBox.critical(None, "错误", f"{error_msg_prefix}\n未知错误: {e}")
return False, "", str(e)
def _execute_interactive_mdadm_command(self, command_list, error_msg_prefix):
"""
专门处理 mdadm --create 命令的交互式执行。
通过 pexpect 监听提示并弹出 QMessageBox 让用户选择。
"""
full_cmd_str = "sudo " + ' '.join(command_list)
logger.info(f"执行交互式命令: {full_cmd_str}")
stdout_buffer = []
try:
child = pexpect.spawn(full_cmd_str, encoding='utf-8', timeout=300)
child.logfile = sys.stdout
try:
index = child.expect(['[pP]assword for', pexpect.TIMEOUT], timeout=5)
if index == 0:
stdout_buffer.append(child.before)
password, ok = QInputDialog.getText(
None, "Sudo 密码", "请输入您的 sudo 密码:", QLineEdit.Password
)
if not ok or not password:
QMessageBox.critical(None, "错误", "sudo 密码未提供或取消。")
child.close()
return False, "".join(stdout_buffer), "Sudo 密码未提供或取消。"
child.sendline(password)
stdout_buffer.append(child.after)
logger.info("已发送 sudo 密码。")
elif index == 1:
logger.info("Sudo 未提示输入密码(可能已配置 NOPASSWD 或密码已缓存)。")
stdout_buffer.append(child.before)
except pexpect.exceptions.EOF:
stdout_buffer.append(child.before)
logger.error("命令在 sudo 密码提示前退出。")
child.close()
return False, "".join(stdout_buffer), "命令在 sudo 密码提示前退出。"
except pexpect.exceptions.TIMEOUT:
logger.info("Sudo 密码提示超时,可能已配置 NOPASSWD 或密码已缓存。")
stdout_buffer.append(child.before)
prompts_data = [
(r"To optimalize recovery speed, .* write-(?:intent|indent) bitmap, do you want to enable it now\? \[y/N\]\s*",
"mdadm 建议启用写入意图位图以优化恢复速度。您希望现在启用它吗?"),
(r"Continue creating array \[y/N\]\s*",
"mdadm 警告:检测到驱动器大小不一致或分区表。创建阵列将覆盖数据。是否仍要继续创建阵列?")
]
patterns_to_expect = [p for p, _ in prompts_data]
while True:
try:
index = child.expect(patterns_to_expect, timeout=10)
stdout_buffer.append(child.before)
matched_description = prompts_data[index][1]
reply = QMessageBox.question(None, "mdadm 提示", matched_description,
QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes)
if reply == QMessageBox.Yes:
user_choice = 'y'
logger.info(f"用户对提示 '{matched_description}' 选择 ''")
else:
user_choice = 'n'
logger.info(f"用户对提示 '{matched_description}' 选择 ''")
child.sendline(user_choice)
stdout_buffer.append(user_choice + '\n')
except pexpect.exceptions.TIMEOUT:
logger.debug("在等待 mdadm 提示时超时,可能没有更多交互。")
stdout_buffer.append(child.before)
break
except pexpect.exceptions.EOF:
logger.warning("在等待 mdadm 提示时遇到 EOF。")
stdout_buffer.append(child.before)
break
try:
child.expect(pexpect.EOF, timeout=120)
stdout_buffer.append(child.before)
logger.info("mdadm 命令已正常结束 (通过 EOF 捕获)。")
except pexpect.exceptions.TIMEOUT:
stdout_buffer.append(child.before)
logger.error(f"mdadm 命令在所有交互完成后,等待 EOF 超时。")
QMessageBox.critical(None, "错误", f"{error_msg_prefix}\n详细信息: mdadm 命令在交互完成后未正常退出,等待 EOF 超时。")
child.close()
return False, "".join(stdout_buffer), "mdadm 命令等待 EOF 超时。"
except pexpect.exceptions.EOF:
stdout_buffer.append(child.before)
logger.info("mdadm 命令已正常结束 (通过 EOF 捕获)。")
child.close()
final_output = "".join(stdout_buffer)
if child.exitstatus == 0:
logger.info(f"交互式命令 {full_cmd_str} 成功完成。")
return True, final_output, ""
else:
logger.error(f"交互式命令 {full_cmd_str} 失败,退出码: {child.exitstatus}")
QMessageBox.critical(None, "错误", f"{error_msg_prefix}\n详细信息: 命令执行失败,退出码 {child.exitstatus}\n输出: {final_output}")
return False, final_output, f"命令执行失败,退出码 {child.exitstatus}"
except pexpect.exceptions.ExceptionPexpect as e:
logger.error(f"pexpect 内部错误: {e}")
QMessageBox.critical(None, "错误", f"{error_msg_prefix}\n详细信息: pexpect 内部错误: {e}")
return False, "", str(e)
except Exception as e:
logger.error(f"执行交互式命令 {full_cmd_str} 时发生未知错误: {e}")
QMessageBox.critical(None, "错误", f"{error_msg_prefix}\n未知错误: {e}")
return False, "", str(e)
def _get_next_available_md_device_name(self):
"""
查找下一个可用的 /dev/mdX 设备名称(例如 /dev/md0, /dev/md1, ...)。
@@ -70,7 +198,6 @@ class RaidOperations:
for array in raid_arrays:
device_path = array.get('device')
if device_path and device_path.startswith('/dev/md'):
# 匹配 /dev/mdX 形式的设备名
match = re.match(r'/dev/md(\d+)', device_path)
if match:
existing_md_numbers.add(int(match.group(1)))
@@ -90,11 +217,10 @@ class RaidOperations:
:param level: RAID 级别,例如 'raid0', 'raid1', 'raid5' (也可以是整数 0, 1, 5)
:param chunk_size: Chunk 大小 (KB)
"""
# --- 标准化 RAID 级别输入 ---
if isinstance(level, int):
level_str = f"raid{level}"
elif isinstance(level, str):
if level in ["0", "1", "5", "6", "10"]: # 增加 "6", "10" 确保全面
if level in ["0", "1", "5", "6", "10"]:
level_str = f"raid{level}"
else:
level_str = level
@@ -102,20 +228,17 @@ class RaidOperations:
QMessageBox.critical(None, "错误", f"不支持的 RAID 级别类型: {type(level)}")
return False
level = level_str
# --- 标准化 RAID 级别输入结束 ---
if not devices or len(devices) < 2:
QMessageBox.critical(None, "错误", "创建 RAID 阵列至少需要两个成员设备")
if not devices or len(devices) < 1:
QMessageBox.critical(None, "错误", "创建 RAID 阵列至少需要一个设备RAID0")
return False
# 检查 RAID 5 至少需要 3 个设备
if level == "raid5" and len(devices) < 3:
QMessageBox.critical(None, "错误", "RAID5 至少需要三个设备。")
return False
# 补充其他 RAID 级别设备数量检查,与 dialogs.py 保持一致
if level == "raid1" and len(devices) < 2:
QMessageBox.critical(None, "错误", "RAID1 至少需要两个设备。")
return False
if level == "raid5" and len(devices) < 3:
QMessageBox.critical(None, "错误", "RAID5 至少需要三个设备。")
return False
if level == "raid6" and len(devices) < 4:
QMessageBox.critical(None, "错误", "RAID6 至少需要四个设备。")
return False
@@ -123,8 +246,6 @@ class RaidOperations:
QMessageBox.critical(None, "错误", "RAID10 至少需要两个设备。")
return False
# 确认操作
reply = QMessageBox.question(None, "确认创建 RAID 阵列",
f"你确定要使用设备 {', '.join(devices)} 创建 RAID {level} 阵列吗?\n"
"此操作将销毁设备上的所有数据!",
@@ -137,10 +258,7 @@ class RaidOperations:
# 1. 清除设备上的旧 RAID 超级块(如果有)
for dev in devices:
# 使用 --force 选项,避免交互式提示
clear_cmd = ["mdadm", "--zero-superblock", "--force", dev]
# 定义表示设备上没有超级块的错误信息,根据日志调整
no_superblock_error_match = "Unrecognised md component device"
success, _, stderr = self._execute_shell_command(
@@ -149,7 +267,6 @@ class RaidOperations:
suppress_critical_dialog_on_stderr_match=no_superblock_error_match
)
# 检查 stderr 是否包含“未识别的 MD 组件设备”信息
if no_superblock_error_match in stderr:
logger.info(f"设备 {dev} 上未找到旧 RAID 超级块,已确保其干净。")
elif success:
@@ -159,41 +276,25 @@ class RaidOperations:
return False
# 2. 创建 RAID 阵列
# --- 修改点:自动生成唯一的阵列名称 ---
array_name = self._get_next_available_md_device_name()
logger.info(f"将使用自动生成的 RAID 阵列名称: {array_name}")
# --- 修改点结束 ---
if level == "raid0":
create_cmd = ["mdadm", "--create", array_name, "--level=raid0",
f"--raid-devices={len(devices)}", f"--chunk={chunk_size}K"] + devices
elif level == "raid1":
create_cmd = ["mdadm", "--create", array_name, "--level=raid1",
f"--raid-devices={len(devices)}"] + devices
elif level == "raid5":
create_cmd = ["mdadm", "--create", array_name, "--level=raid5",
f"--raid-devices={len(devices)}"] + devices
elif level == "raid6": # 增加 RAID6
create_cmd = ["mdadm", "--create", array_name, "--level=raid6",
f"--raid-devices={len(devices)}", f"--chunk={chunk_size}K"] + devices
elif level == "raid10": # 增加 RAID10
create_cmd = ["mdadm", "--create", array_name, "--level=raid10",
f"--raid-devices={len(devices)}", f"--chunk={chunk_size}K"] + devices
else:
QMessageBox.critical(None, "错误", f"不支持的 RAID 级别: {level}")
return False
create_cmd_base = ["mdadm", "--create", array_name, "--level=" + level.replace("raid", ""),
f"--raid-devices={len(devices)}"]
# 在 --create 命令中添加 --force 选项
if level in ["raid0", "raid5", "raid6", "raid10"]:
create_cmd_base.append(f"--chunk={chunk_size}K")
create_cmd = create_cmd_base + devices
create_cmd.insert(2, "--force")
success_create, stdout_create, stderr_create = self._execute_shell_command(
success_create, stdout_create, stderr_create = self._execute_interactive_mdadm_command(
create_cmd,
f"创建 RAID 阵列失败",
input_to_command='y\n' # 尝试通过 stdin 传递 'y'
f"创建 RAID 阵列失败"
)
if not success_create:
# 检查是否是由于 "Array name ... is in use already." 导致的失败
if "Array name" in stderr_create and "is in use already" in stderr_create:
if "Array name" in stdout_create and "is in use already" in stdout_create:
QMessageBox.critical(None, "错误", f"创建 RAID 阵列失败:阵列名称 {array_name} 已被占用。请尝试停止或删除现有阵列。")
return False
@@ -204,9 +305,25 @@ class RaidOperations:
examine_scan_cmd = ["mdadm", "--examine", "--scan"]
success_scan, scan_stdout, _ = self._execute_shell_command(examine_scan_cmd, "扫描 mdadm 配置失败")
if success_scan:
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 失败。")
mkdir_cmd = ["mkdir", "-p", "/etc"]
success_mkdir, _, stderr_mkdir = self._execute_shell_command(
mkdir_cmd,
"创建 /etc 目录失败"
)
if not success_mkdir:
logger.error(f"无法创建 /etc 目录,更新 mdadm.conf 失败。错误: {stderr_mkdir}")
QMessageBox.critical(None, "错误", f"无法创建 /etc 目录,更新 mdadm.conf 失败。\n详细信息: {stderr_mkdir}")
return False
logger.info("已确保 /etc 目录存在。")
success_append, _, _ = self._execute_shell_command(
["bash", "-c", f"echo '{scan_stdout.strip()}' | tee -a /etc/mdadm.conf > /dev/null"],
"更新 /etc/mdadm/mdadm.conf 失败"
)
if not success_append:
logger.warning("更新 /etc/mdadm.conf 失败。")
else:
logger.info("已成功更新 /etc/mdadm.conf。")
else:
logger.warning("未能扫描到 mdadm 配置,跳过更新 mdadm.conf。")
@@ -227,15 +344,13 @@ class RaidOperations:
return False
logger.info(f"尝试停止 RAID 阵列: {array_path}")
# 尝试卸载阵列(如果已挂载),不显示错误对话框
# 注意:这里需要调用 disk_operations 的 unmount_partition
# 由于 RaidOperations 不直接持有 DiskOperations 实例,需要通过某种方式获取或传递
# 暂时先直接调用 umount 命令,不处理 fstab
# 或者,更好的方式是让 MainWindow 调用 disk_ops.unmount_partition
# 此处简化处理,只执行 umount 命令
# 这里的 suppress_critical_dialog_on_stderr_match 应该与 DiskOperations 中的定义保持一致
self._execute_shell_command(["umount", array_path], f"尝试卸载 {array_path} 失败",
suppress_critical_dialog_on_stderr_match="not mounted") # 仅抑制英文,因为这里没有访问 DiskOperations 的 already_unmounted_errors
# Original umount call (not using disk_ops.unmount_partition)
self._execute_shell_command(
["umount", array_path],
f"尝试卸载 {array_path} 失败",
suppress_critical_dialog_on_stderr_match=("not mounted", "未挂载")
)
if not self._execute_shell_command(["mdadm", "--stop", array_path], f"停止 RAID 阵列 {array_path} 失败")[0]:
return False
@@ -244,22 +359,23 @@ class RaidOperations:
QMessageBox.information(None, "成功", f"成功停止 RAID 阵列 {array_path}")
return True
def delete_raid_array(self, array_path, member_devices):
def delete_active_raid_array(self, array_path, member_devices, uuid):
"""
删除一个 RAID 阵列。
此操作将停止阵列清除成员设备上的超级块。
删除一个活动的 RAID 阵列。
此操作将停止阵列清除成员设备上的超级块,并删除 mdadm.conf 中的配置
:param array_path: RAID 阵列的设备路径,例如 /dev/md0
:param member_devices: 成员设备列表,例如 ['/dev/sdb1', '/dev/sdc1']
:param uuid: RAID 阵列的 UUID
"""
reply = QMessageBox.question(None, "确认删除 RAID 阵列",
f"你确定要删除 RAID 阵列 {array_path} 吗?\n"
"此操作将停止阵列清除成员设备上的 RAID 超级块,数据将无法访问!",
"此操作将停止阵列清除成员设备上的 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}")
logger.info(f"尝试删除活动 RAID 阵列: {array_path} (UUID: {uuid})")
# 1. 停止阵列
if not self.stop_raid_array(array_path):
@@ -269,10 +385,7 @@ class RaidOperations:
# 2. 清除成员设备上的超级块
success_all_cleared = True
for dev in member_devices:
# 使用 --force 选项,避免交互式提示
clear_cmd = ["mdadm", "--zero-superblock", "--force", dev]
# 定义表示设备上没有超级块的错误信息,根据日志调整
no_superblock_error_match = "Unrecognised md component device"
success, _, stderr = self._execute_shell_command(
@@ -287,9 +400,17 @@ class RaidOperations:
logger.info(f"已清除设备 {dev} 上的旧 RAID 超级块。")
else:
success_all_cleared = False
# 错误对话框已由 _execute_shell_command 弹出,这里只记录警告
logger.warning(f"未能清除设备 {dev} 上的 RAID 超级块,请手动检查。")
# 3. 从 mdadm.conf 中删除配置条目
try:
self.system_manager.delete_raid_array_config(uuid)
logger.info(f"已成功从 mdadm.conf 中删除 UUID 为 {uuid} 的 RAID 阵列配置。")
except Exception as e:
QMessageBox.critical(None, "错误", f"删除 RAID 阵列 {array_path} 的配置失败: {e}")
logger.error(f"删除 RAID 阵列 {array_path} 的配置失败: {e}")
return False
if success_all_cleared:
logger.info(f"成功删除 RAID 阵列 {array_path} 并清除了成员设备超级块。")
QMessageBox.information(None, "成功", f"成功删除 RAID 阵列 {array_path}")
@@ -297,3 +418,54 @@ class RaidOperations:
else:
QMessageBox.critical(None, "错误", f"删除 RAID 阵列 {array_path} 完成,但部分成员设备超级块未能完全清除。")
return False
def delete_configured_raid_array(self, uuid):
"""
只删除 mdadm.conf 中停止状态 RAID 阵列的配置条目。
:param uuid: 要删除的 RAID 阵列的 UUID。
:return: True 如果成功False 如果失败。
"""
reply = QMessageBox.question(None, "确认删除 RAID 阵列配置",
f"您确定要删除 UUID 为 {uuid} 的 RAID 阵列配置吗?\n"
"此操作将从配置文件中移除该条目,但不会影响实际的磁盘数据或活动阵列。",
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
if reply == QMessageBox.No:
logger.info(f"用户取消了删除 UUID 为 {uuid} 的 RAID 阵列配置的操作。")
return False
logger.info(f"尝试删除 UUID 为 {uuid} 的 RAID 阵列配置文件条目。")
try:
self.system_manager.delete_raid_array_config(uuid)
QMessageBox.information(None, "成功", f"成功删除 UUID 为 {uuid} 的 RAID 阵列配置。")
return True
except Exception as e:
QMessageBox.critical(None, "错误", f"删除 RAID 阵列配置失败: {e}")
logger.error(f"删除 RAID 阵列配置失败: {e}")
return False
def activate_raid_array(self, array_path, array_uuid):
"""
激活一个已停止的 RAID 阵列。
使用 mdadm --assemble <device> --uuid=<uuid>
"""
if not array_uuid or array_uuid == 'N/A':
QMessageBox.critical(None, "错误", f"无法激活 RAID 阵列 {array_path}:缺少 UUID 信息。")
logger.error(f"激活 RAID 阵列 {array_path} 失败:缺少 UUID。")
return False
logger.info(f"尝试激活 RAID 阵列: {array_path} (UUID: {array_uuid})")
success, stdout, stderr = self._execute_shell_command(
["mdadm", "--assemble", array_path, "--uuid", array_uuid],
f"激活 RAID 阵列 {array_path} 失败",
suppress_critical_dialog_on_stderr_match=("mdadm: device /dev/md", "already active", "No such file or directory")
)
if success:
logger.info(f"成功激活 RAID 阵列 {array_path} (UUID: {array_uuid})。")
QMessageBox.information(None, "成功", f"成功激活 RAID 阵列 {array_path}")
return True
else:
logger.error(f"激活 RAID 阵列 {array_path} 失败。")
return False

View File

@@ -73,8 +73,14 @@ class SystemInfoManager:
add_path_recursive(dev['children'])
add_path_recursive(devices)
return devices
except subprocess.CalledProcessError as e:
logger.error(f"获取块设备信息失败: {e.stderr.strip()}")
return []
except json.JSONDecodeError as e:
logger.error(f"解析 lsblk JSON 输出失败: {e}")
return []
except Exception as e:
logger.error(f"获取块设备信息失败: {e}")
logger.error(f"获取块设备信息失败 (未知错误): {e}")
return []
def _find_device_by_path_recursive(self, dev_list, target_path):
@@ -199,40 +205,12 @@ class SystemInfoManager:
logger.debug(f"get_mountpoint_for_device: 未能获取到 {original_device_path} 的挂载点。")
return None
def get_mdadm_arrays(self):
def _parse_mdadm_detail_output(self, output, device_path):
"""
获取 mdadm RAID 阵列信息
解析 mdadm --detail 命令的输出
"""
cmd = ["mdadm", "--detail", "--scan"]
try:
stdout, _ = self._run_command(cmd)
arrays = []
for line in stdout.splitlines():
if line.startswith("ARRAY"):
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 []
logger.error(f"获取RAID阵列信息失败: {e}")
return []
except Exception as e:
logger.error(f"获取RAID阵列信息失败: {e}")
return []
def _parse_mdadm_detail(self, detail_output, array_path):
"""
解析 mdadm --detail 的输出。
"""
info = {
'device': array_path,
array_info = {
'device': device_path, # Default to input path, will be updated by canonical_device_path
'level': 'N/A',
'state': 'N/A',
'array_size': 'N/A',
@@ -241,46 +219,202 @@ class SystemInfoManager:
'spare_devices': 'N/A',
'total_devices': 'N/A',
'uuid': 'N/A',
'name': 'N/A',
'name': os.path.basename(device_path), # Default name, will be overridden
'chunk_size': 'N/A',
'member_devices': []
}
member_pattern = re.compile(r'^\s*(\d+)\s+(\d+)\s+(\d+)\s+(.+?)\s+(/dev/.+)$')
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()
# 首先,尝试从 mdadm --detail 输出的第一行获取规范的设备路径
device_path_header_match = re.match(r'^(?P<canonical_path>/dev/md\d+):', output)
if device_path_header_match:
array_info['device'] = device_path_header_match.group('canonical_path')
array_info['name'] = os.path.basename(array_info['device']) # 根据规范路径更新默认名称
# 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
# 逐行解析键值对
for line in output.splitlines():
line = line.strip()
if not line:
continue
# 尝试匹配 "Key : Value" 格式
kv_match = re.match(r'^(?P<key>[A-Za-z ]+)\s*:\s*(?P<value>.+)', line)
if kv_match:
key = kv_match.group('key').strip().lower().replace(' ', '_')
value = kv_match.group('value').strip()
if key == 'raid_level':
array_info['level'] = value
elif key == 'state':
array_info['state'] = value
elif key == 'array_size':
array_info['array_size'] = value
elif key == 'uuid':
array_info['uuid'] = value
elif key == 'raid_devices':
array_info['total_devices'] = value
elif key == 'active_devices':
array_info['active_devices'] = value
elif key == 'failed_devices':
array_info['failed_devices'] = value
elif key == 'spare_devices':
array_info['spare_devices'] = value
elif key == 'name':
# 只取名字的第一部分,忽略括号内的额外信息
array_info['name'] = value.split(' ')[0]
elif key == 'chunk_size':
array_info['chunk_size'] = value
# 成员设备解析 (独立于键值对,因为它有固定格式)
member_pattern = re.match(r'^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+([^\s]+(?:\s+[^\s]+)*)\s+(/dev/\S+)$', line)
if member_pattern:
array_info['member_devices'].append({
'number': member_pattern.group(1),
'major': member_pattern.group(2),
'minor': member_pattern.group(3),
'raid_device': member_pattern.group(4),
'state': member_pattern.group(5).strip(),
'device_path': member_pattern.group(6)
})
# 最终状态标准化
if array_info['state'] and 'active' in array_info['state'].lower():
array_info['state'] = array_info['state'].replace('active', 'Active')
elif array_info['state'] == 'clean':
array_info['state'] = 'Active, Clean'
elif array_info['state'] == 'N/A': # 如果未明确找到状态,则回退到推断
if "clean" in output.lower() and "active" in output.lower():
array_info['state'] = "Active, Clean"
elif "degraded" in output.lower():
array_info['state'] = "Degraded"
elif "inactive" in output.lower() or "stopped" in output.lower():
array_info['state'] = "Stopped"
return array_info
def get_mdadm_arrays(self):
"""
获取所有 RAID 阵列的信息,包括活动的和已停止的。
"""
all_arrays_info = {} # 使用 UUID 作为键,存储阵列的详细信息
# 1. 获取活动的 RAID 阵列信息 (通过 mdadm --detail --scan 和 /proc/mdstat)
active_md_devices = []
# 从 mdadm --detail --scan 获取
try:
stdout_scan, stderr_scan = self._run_command(["mdadm", "--detail", "--scan"], check_output=True)
for line in stdout_scan.splitlines():
if line.startswith("ARRAY"):
match = re.match(r'ARRAY\s+(\S+)(?:\s+\S+=\S+)*\s+UUID=([0-9a-f:]+)', line)
if match:
dev_path = match.group(1)
if dev_path not in active_md_devices:
active_md_devices.append(dev_path)
except subprocess.CalledProcessError as e:
if "No arrays found" not in e.stderr and "No arrays found" not in e.stdout:
logger.warning(f"执行 mdadm --detail --scan 失败: {e.stderr.strip()}")
except Exception as e:
logger.warning(f"处理 mdadm --detail --scan 输出时发生错误: {e}")
# 从 /proc/mdstat 获取 (补充可能未被 --scan 报告的活动阵列)
try:
with open("/proc/mdstat", "r") as f:
mdstat_content = f.read()
for line in mdstat_content.splitlines():
if line.startswith("md") and "active" in line:
device_name_match = re.match(r'^(md\d+)\s+:', line)
if device_name_match:
dev_path = f"/dev/{device_name_match.group(1)}"
if dev_path not in active_md_devices:
active_md_devices.append(dev_path)
except FileNotFoundError:
logger.debug("/proc/mdstat not found. Cannot check active arrays from /proc/mdstat.")
except Exception as e:
logger.error(f"Error reading /proc/mdstat: {e}")
# 现在,对每个找到的活动 MD 设备获取其详细信息
for device_path in active_md_devices:
try:
# 解析符号链接以获取规范路径,例如 /dev/md/0 -> /dev/md0
canonical_device_path = self._get_actual_md_device_path(device_path)
if not canonical_device_path:
canonical_device_path = device_path # 如果解析失败,使用原始路径作为回退
detail_stdout, detail_stderr = self._run_command(["mdadm", "--detail", canonical_device_path], check_output=True)
array_info = self._parse_mdadm_detail_output(detail_stdout, canonical_device_path)
if array_info and array_info.get('uuid') and array_info.get('device'):
# _parse_mdadm_detail_output 现在会更新 array_info['device'] 为规范路径
# 使用 UUID 作为键,如果同一个阵列有多个表示,只保留一个(通常是活动的)
all_arrays_info[array_info['uuid']] = array_info
else:
logger.warning(f"无法从 mdadm --detail {canonical_device_path} 输出中解析 RAID 阵列信息: {detail_stdout[:100]}...")
except subprocess.CalledProcessError as e:
logger.warning(f"获取 RAID 阵列 {device_path} 详细信息失败: {e.stderr.strip()}")
except Exception as e:
logger.warning(f"处理 RAID 阵列 {device_path} 详细信息时发生错误: {e}")
# 2. 从 /etc/mdadm.conf 获取配置的 RAID 阵列信息 (可能包含已停止的)
mdadm_conf_paths = ["/etc/mdadm/mdadm.conf", "/etc/mdadm.conf"]
found_conf = False
for mdadm_conf_path in mdadm_conf_paths:
if os.path.exists(mdadm_conf_path):
found_conf = True
try:
with open(mdadm_conf_path, 'r') as f:
for line in f:
line = line.strip()
if line.startswith("ARRAY"):
match_base = re.match(r'ARRAY\s+(\S+)\s*(.*)', line)
if match_base:
device_path = match_base.group(1)
rest_of_line = match_base.group(2)
uuid = 'N/A'
name = os.path.basename(device_path) # Default name
uuid_match_conf = re.search(r'UUID=([0-9a-f:]+)', rest_of_line)
if uuid_match_conf:
uuid = uuid_match_conf.group(1)
name_match_conf = re.search(r'NAME=(\S+)', rest_of_line)
if name_match_conf:
name = name_match_conf.group(1)
if uuid != 'N/A': # 只有成功提取到 UUID 才添加
# 只有当此 UUID 对应的阵列尚未被识别为活动状态时,才添加为停止状态
if uuid not in all_arrays_info:
all_arrays_info[uuid] = {
'device': device_path,
'uuid': uuid,
'name': name,
'level': 'Unknown',
'state': 'Stopped (Configured)',
'array_size': 'N/A',
'active_devices': 'N/A',
'failed_devices': 'N/A',
'spare_devices': 'N/A',
'total_devices': 'N/A',
'chunk_size': 'N/A',
'member_devices': []
}
else:
logger.warning(f"无法从 mdadm.conf 行 '{line}' 中提取 UUID。")
else:
logger.warning(f"无法解析 mdadm.conf 中的 ARRAY 行: '{line}'")
except Exception as e:
logger.warning(f"读取或解析 {mdadm_conf_path} 失败: {e}")
break # 找到并处理了一个配置文件就退出循环
if not found_conf:
logger.info(f"未找到 mdadm 配置文件。")
# 返回所有阵列的列表
# 排序:活动阵列在前,然后是停止的
sorted_arrays = sorted(all_arrays_info.values(), key=lambda x: (x.get('state') != 'Active', x.get('device')))
return sorted_arrays
def get_lvm_info(self):
"""
@@ -290,7 +424,7 @@ class SystemInfoManager:
# Get PVs
try:
stdout, _ = self._run_command(["pvs", "--reportformat", "json"])
stdout, stderr = 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', []):
@@ -307,13 +441,15 @@ class SystemInfoManager:
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}")
logger.error(f"获取LVM物理卷信息失败: {e.stderr.strip()}")
except json.JSONDecodeError as e:
logger.error(f"解析LVM物理卷JSON输出失败: {e}")
except Exception as e:
logger.error(f"获取LVM物理卷信息失败: {e}")
logger.error(f"获取LVM物理卷信息失败 (未知错误): {e}")
# Get VGs
try:
stdout, _ = self._run_command(["vgs", "--reportformat", "json"])
stdout, stderr = 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', []):
@@ -332,14 +468,16 @@ class SystemInfoManager:
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}")
logger.error(f"获取LVM卷组信息失败: {e.stderr.strip()}")
except json.JSONDecodeError as e:
logger.error(f"解析LVM卷组JSON输出失败: {e}")
except Exception as e:
logger.error(f"获取LVM卷组信息失败: {e}")
logger.error(f"获取LVM卷组信息失败 (未知错误): {e}")
# Get LVs (MODIFIED: added -o lv_path)
try:
# 明确请求 lv_path因为默认的 --reportformat json 不包含它
stdout, _ = self._run_command(["lvs", "-o", "lv_name,vg_name,lv_uuid,lv_size,lv_attr,origin,snap_percent,lv_path", "--reportformat", "json"])
stdout, stderr = self._run_command(["lvs", "-o", "lv_name,vg_name,lv_uuid,lv_size,lv_attr,origin,snap_percent,lv_path", "--reportformat", "json"])
data = json.loads(stdout)
if 'report' in data and data['report']:
for lv_data in data['report'][0].get('lv', []):
@@ -357,9 +495,11 @@ class SystemInfoManager:
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}")
logger.error(f"获取LVM逻辑卷信息失败: {e.stderr.strip()}")
except json.JSONDecodeError as e:
logger.error(f"解析LVM逻辑卷JSON输出失败: {e}")
except Exception as e:
logger.error(f"获取LVM逻辑卷信息失败: {e}")
logger.error(f"获取LVM逻辑卷信息失败 (未知错误): {e}")
return lvm_info
@@ -430,6 +570,10 @@ class SystemInfoManager:
logger.warning(f"传入 _get_actual_md_device_path 的 array_path 不是字符串: {array_path} (类型: {type(array_path)})")
return None
# 如果已经是规范的 /dev/mdX 路径,直接返回
if re.match(r'^/dev/md\d+$', array_path):
return array_path
if os.path.exists(array_path):
try:
# os.path.realpath 会解析符号链接,例如 /dev/md/new_raid -> /dev/md127
@@ -546,3 +690,69 @@ class SystemInfoManager:
# 5. 如果仍然没有找到,返回 None
logger.debug(f"get_device_details_by_path: 未能获取到 {original_device_path} 的任何详情。")
return None
def delete_raid_array_config(self, uuid):
"""
从 mdadm.conf 文件中删除指定 UUID 的 RAID 阵列配置条目。
:param uuid: 要删除的 RAID 阵列的 UUID。
:return: True 如果成功删除或未找到条目False 如果发生错误。
:raises Exception: 如果删除失败。
"""
logger.info(f"尝试从 mdadm.conf 中删除 UUID 为 {uuid} 的 RAID 阵列配置。")
mdadm_conf_paths = ["/etc/mdadm/mdadm.conf", "/etc/mdadm.conf"]
target_conf_path = None
# 找到存在的 mdadm.conf 文件
for path in mdadm_conf_paths:
if os.path.exists(path):
target_conf_path = path
break
if not target_conf_path:
logger.warning("未找到 mdadm 配置文件,无法删除配置条目。")
raise FileNotFoundError("mdadm 配置文件未找到。")
original_lines = []
try:
with open(target_conf_path, 'r') as f:
original_lines = f.readlines()
except Exception as e:
logger.error(f"读取 mdadm 配置文件 {target_conf_path} 失败: {e}")
raise Exception(f"读取 mdadm 配置文件失败: {e}")
new_lines = []
entry_found = False
for line in original_lines:
stripped_line = line.strip()
if stripped_line.startswith("ARRAY"):
# 尝试匹配 UUID
uuid_match = re.search(r'UUID=([0-9a-f:]+)', stripped_line)
if uuid_match and uuid_match.group(1) == uuid:
logger.debug(f"找到并跳过 UUID 为 {uuid} 的配置行: {stripped_line}")
entry_found = True
continue # 跳过此行,即删除
new_lines.append(line)
if not entry_found:
logger.warning(f"在 mdadm.conf 中未找到 UUID 为 {uuid} 的 RAID 阵列配置条目。")
# 即使未找到,也不视为错误,因为可能已经被手动删除或从未写入
return True
# 将修改后的内容写回文件 (需要 root 权限)
try:
# 使用临时文件进行原子写入,防止数据损坏
temp_file_path = f"{target_conf_path}.tmp"
with open(temp_file_path, 'w') as f:
f.writelines(new_lines)
# 替换原文件
self._run_command(["mv", temp_file_path, target_conf_path], root_privilege=True)
logger.info(f"成功从 {target_conf_path} 中删除 UUID 为 {uuid} 的 RAID 阵列配置。")
return True
except subprocess.CalledProcessError as e:
logger.error(f"删除 mdadm 配置条目失败 (mv 命令错误): {e.stderr.strip()}")
raise Exception(f"删除 mdadm 配置条目失败: {e.stderr.strip()}")
except Exception as e:
logger.error(f"写入 mdadm 配置文件 {target_conf_path} 失败: {e}")
raise Exception(f"写入 mdadm 配置文件失败: {e}")