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"?> <?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE QtCreatorProject> <!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> <qtcreator>
<data> <data>
<variable>EnvironmentId</variable> <variable>EnvironmentId</variable>
@@ -155,7 +155,7 @@
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DisplayName">disk_operations.py</value> <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.ProjectConfiguration.Id">PythonEditor.RunConfiguration.</value>
<value type="QString" key="ProjectExplorer.RunConfiguration.BuildKey">/home/jing/qtpj/diskmanager/disk_operations.py</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="ProjectExplorer.RunConfiguration.UniqueId"></value>
<value type="QString" key="PythonEditor.RunConfiguation.Script">/home/jing/qtpj/diskmanager/disk_operations.py</value> <value type="QString" key="PythonEditor.RunConfiguation.Script">/home/jing/qtpj/diskmanager/disk_operations.py</value>
<value type="bool" key="RunConfiguration.UseCppDebuggerAuto">true</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.DisplayName">logger_config.py</value>
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">PythonEditor.RunConfiguration.</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="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="ProjectExplorer.RunConfiguration.UniqueId"></value>
<value type="QString" key="PythonEditor.RunConfiguation.Script">/home/jing/qtpj/diskmanager/logger_config.py</value> <value type="QString" key="PythonEditor.RunConfiguation.Script">/home/jing/qtpj/diskmanager/logger_config.py</value>
<value type="bool" key="RunConfiguration.UseCppDebuggerAuto">true</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.DisplayName">mainwindow.py</value>
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">PythonEditor.RunConfiguration.</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="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="ProjectExplorer.RunConfiguration.UniqueId"></value>
<value type="QString" key="PythonEditor.RunConfiguation.Script">/home/jing/qtpj/diskmanager/mainwindow.py</value> <value type="QString" key="PythonEditor.RunConfiguation.Script">/home/jing/qtpj/diskmanager/mainwindow.py</value>
<value type="bool" key="RunConfiguration.UseCppDebuggerAuto">true</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.DisplayName">system_info.py</value>
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">PythonEditor.RunConfiguration.</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="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="ProjectExplorer.RunConfiguration.UniqueId"></value>
<value type="QString" key="PythonEditor.RunConfiguation.Script">/home/jing/qtpj/diskmanager/system_info.py</value> <value type="QString" key="PythonEditor.RunConfiguation.Script">/home/jing/qtpj/diskmanager/system_info.py</value>
<value type="bool" key="RunConfiguration.UseCppDebuggerAuto">true</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.DisplayName">dialogs.py</value>
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">PythonEditor.RunConfiguration.</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="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="ProjectExplorer.RunConfiguration.UniqueId"></value>
<value type="QString" key="PythonEditor.RunConfiguation.Script">/home/jing/qtpj/diskmanager/dialogs.py</value> <value type="QString" key="PythonEditor.RunConfiguation.Script">/home/jing/qtpj/diskmanager/dialogs.py</value>
<value type="bool" key="RunConfiguration.UseCppDebuggerAuto">true</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.DisplayName">raid_operations.py</value>
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">PythonEditor.RunConfiguration.</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="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="ProjectExplorer.RunConfiguration.UniqueId"></value>
<value type="QString" key="PythonEditor.RunConfiguation.Script">/home/jing/qtpj/diskmanager/raid_operations.py</value> <value type="QString" key="PythonEditor.RunConfiguation.Script">/home/jing/qtpj/diskmanager/raid_operations.py</value>
<value type="bool" key="RunConfiguration.UseCppDebuggerAuto">true</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.DisplayName">lvm_operations.py</value>
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">PythonEditor.RunConfiguration.</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="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="ProjectExplorer.RunConfiguration.UniqueId"></value>
<value type="QString" key="PythonEditor.RunConfiguation.Script">/home/jing/qtpj/diskmanager/lvm_operations.py</value> <value type="QString" key="PythonEditor.RunConfiguation.Script">/home/jing/qtpj/diskmanager/lvm_operations.py</value>
<value type="bool" key="RunConfiguration.UseCppDebuggerAuto">true</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.DisplayName">disk_operations.py</value>
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">PythonEditor.RunConfiguration.</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="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="ProjectExplorer.RunConfiguration.UniqueId"></value>
<value type="QString" key="PythonEditor.RunConfiguation.Script">/home/jing/qtpj/diskmanager/disk_operations.py</value> <value type="QString" key="PythonEditor.RunConfiguation.Script">/home/jing/qtpj/diskmanager/disk_operations.py</value>
<value type="bool" key="RunConfiguration.UseCppDebuggerAuto">true</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.DisplayName">logger_config.py</value>
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">PythonEditor.RunConfiguration.</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="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="ProjectExplorer.RunConfiguration.UniqueId"></value>
<value type="QString" key="PythonEditor.RunConfiguation.Script">/home/jing/qtpj/diskmanager/logger_config.py</value> <value type="QString" key="PythonEditor.RunConfiguation.Script">/home/jing/qtpj/diskmanager/logger_config.py</value>
<value type="bool" key="RunConfiguration.UseCppDebuggerAuto">true</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.DisplayName">mainwindow.py</value>
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">PythonEditor.RunConfiguration.</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="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="ProjectExplorer.RunConfiguration.UniqueId"></value>
<value type="QString" key="PythonEditor.RunConfiguation.Script">/home/jing/qtpj/diskmanager/mainwindow.py</value> <value type="QString" key="PythonEditor.RunConfiguation.Script">/home/jing/qtpj/diskmanager/mainwindow.py</value>
<value type="bool" key="RunConfiguration.UseCppDebuggerAuto">true</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.DisplayName">system_info.py</value>
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">PythonEditor.RunConfiguration.</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="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="ProjectExplorer.RunConfiguration.UniqueId"></value>
<value type="QString" key="PythonEditor.RunConfiguation.Script">/home/jing/qtpj/diskmanager/system_info.py</value> <value type="QString" key="PythonEditor.RunConfiguation.Script">/home/jing/qtpj/diskmanager/system_info.py</value>
<value type="bool" key="RunConfiguration.UseCppDebuggerAuto">true</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.DisplayName">dialogs.py</value>
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">PythonEditor.RunConfiguration.</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="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="ProjectExplorer.RunConfiguration.UniqueId"></value>
<value type="QString" key="PythonEditor.RunConfiguation.Script">/home/jing/qtpj/diskmanager/dialogs.py</value> <value type="QString" key="PythonEditor.RunConfiguation.Script">/home/jing/qtpj/diskmanager/dialogs.py</value>
<value type="bool" key="RunConfiguration.UseCppDebuggerAuto">true</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.DisplayName">raid_operations.py</value>
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">PythonEditor.RunConfiguration.</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="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="ProjectExplorer.RunConfiguration.UniqueId"></value>
<value type="QString" key="PythonEditor.RunConfiguation.Script">/home/jing/qtpj/diskmanager/raid_operations.py</value> <value type="QString" key="PythonEditor.RunConfiguation.Script">/home/jing/qtpj/diskmanager/raid_operations.py</value>
<value type="bool" key="RunConfiguration.UseCppDebuggerAuto">true</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.DisplayName">lvm_operations.py</value>
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">PythonEditor.RunConfiguration.</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="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="ProjectExplorer.RunConfiguration.UniqueId"></value>
<value type="QString" key="PythonEditor.RunConfiguation.Script">/home/jing/qtpj/diskmanager/lvm_operations.py</value> <value type="QString" key="PythonEditor.RunConfiguation.Script">/home/jing/qtpj/diskmanager/lvm_operations.py</value>
<value type="bool" key="RunConfiguration.UseCppDebuggerAuto">true</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) QMessageBox, QCheckBox, QListWidget, QListWidgetItem)
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from logger_config import setup_logging, logger
class CreatePartitionDialog(QDialog): class CreatePartitionDialog(QDialog):
def __init__(self, parent=None, disk_path="", total_disk_mib=0.0, max_available_mib=0.0): def __init__(self, parent=None, disk_path="", total_disk_mib=0.0, max_available_mib=0.0):
super().__init__(parent) super().__init__(parent)
@@ -15,14 +17,17 @@ class CreatePartitionDialog(QDialog):
self.disk_path = disk_path self.disk_path = disk_path
self.total_disk_mib = total_disk_mib # 磁盘总大小 (MiB) self.total_disk_mib = total_disk_mib # 磁盘总大小 (MiB)
self.max_available_mib = max_available_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 = QComboBox()
self.partition_table_type_combo.addItems(["gpt", "msdos"]) self.partition_table_type_combo.addItems(["gpt", "msdos"])
self.size_spinbox = QDoubleSpinBox() self.size_spinbox = QDoubleSpinBox()
# 调整最小值为 0.01 GB (约 10MB),并确保最大值不小于最小值 # 调整最小值为 0.01 GB (约 10MB),并确保最大值不小于最小值
calculated_max_gb = self.max_available_mib / 1024.0
self.size_spinbox.setMinimum(0.01) 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.setSuffix(" GB")
self.size_spinbox.setDecimals(2) self.size_spinbox.setDecimals(2)
@@ -54,21 +59,34 @@ class CreatePartitionDialog(QDialog):
def _connect_signals(self): def _connect_signals(self):
self.confirm_button.clicked.connect(self.accept) self.confirm_button.clicked.connect(self.accept)
self.cancel_button.clicked.connect(self.reject) self.cancel_button.clicked.connect(self.reject)
# 信号连接保持不变,但 _toggle_size_input 方法内部将直接查询复选框状态
self.use_max_space_checkbox.stateChanged.connect(self._toggle_size_input) self.use_max_space_checkbox.stateChanged.connect(self._toggle_size_input)
def _initialize_state(self): def _initialize_state(self):
# 根据默认选中状态设置 spinbox # 根据默认选中状态设置 spinbox
self._toggle_size_input(self.use_max_space_checkbox.checkState()) self._toggle_size_input(self.use_max_space_checkbox.checkState())
def _toggle_size_input(self, state): def _toggle_size_input(self, state): # state 参数仍然接收,但不再直接用于判断
if state == Qt.Checked: # 直接查询复选框的当前状态,而不是依赖信号传递的 state 参数
self.size_spinbox.setDisabled(True) is_checked = self.use_max_space_checkbox.isChecked()
self.size_spinbox.setValue(self.size_spinbox.maximum()) # 设置为最大可用 GB logger.debug(f"[_toggle_size_input] Called. Checkbox isChecked(): {is_checked}, signal state: {state}")
else:
self.size_spinbox.setDisabled(False) 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(): 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): def get_partition_info(self):
size_gb = self.size_spinbox.value() 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.available_vgs = available_vgs if available_vgs is not None else []
self.vg_sizes = vg_sizes if vg_sizes is not None else {} # 存储每个 VG 的可用大小 (GB) self.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.lv_name_input = QLineEdit()
self.vg_combo_box = QComboBox() self.vg_combo_box = QComboBox()
self.lv_size_spinbox = QDoubleSpinBox() self.lv_size_spinbox = QDoubleSpinBox()
self.use_max_space_checkbox = QCheckBox("使用最大可用空间") # 新增复选框 self.use_max_space_checkbox = QCheckBox("使用最大可用空间")
self._setup_ui() self._setup_ui()
self._connect_signals() self._connect_signals()
self._initialize_state() # 初始化状态 self._initialize_state()
def _setup_ui(self): def _setup_ui(self):
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
@@ -356,7 +375,7 @@ class CreateLvDialog(QDialog):
form_layout.addRow("逻辑卷名称:", self.lv_name_input) form_layout.addRow("逻辑卷名称:", self.lv_name_input)
form_layout.addRow("选择卷组:", self.vg_combo_box) form_layout.addRow("选择卷组:", self.vg_combo_box)
form_layout.addRow("逻辑卷大小 (GB):", self.lv_size_spinbox) 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) layout.addLayout(form_layout)
@@ -367,68 +386,75 @@ class CreateLvDialog(QDialog):
button_box.addWidget(self.cancel_button) button_box.addWidget(self.cancel_button)
layout.addLayout(button_box) 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.setSuffix(" GB")
self.lv_size_spinbox.setDecimals(2) self.lv_size_spinbox.setDecimals(2)
# 设置一个较大的初始最大值,它将在 _update_size_options 中被实际的 VG 大小覆盖
self.lv_size_spinbox.setMaximum(100000.0) # 初始设置一个足够大的最大值
def _connect_signals(self): def _connect_signals(self):
self.confirm_button.clicked.connect(self.accept) self.confirm_button.clicked.connect(self.accept)
self.cancel_button.clicked.connect(self.reject) self.cancel_button.clicked.connect(self.reject)
self.vg_combo_box.currentIndexChanged.connect(self._update_size_options) # 卷组选择改变时更新大小选项 self.vg_combo_box.currentIndexChanged.connect(self._update_size_options)
self.use_max_space_checkbox.stateChanged.connect(self._toggle_size_input) # 复选框状态改变时切换输入 # 信号连接到不带参数的槽函数
self.use_max_space_checkbox.stateChanged.connect(self._toggle_size_input)
def _initialize_state(self): def _initialize_state(self):
self.vg_combo_box.addItems(self.available_vgs) self.vg_combo_box.addItems(self.available_vgs)
self._update_size_options() # 首次调用以设置初始状态 # 默认选中“使用最大可用空间”,这将触发 _toggle_size_input
self.use_max_space_checkbox.setChecked(True) # 默认选中“使用最大可用空间” 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): def _update_size_options(self):
"""根据选中的卷组更新逻辑卷大小的选项。""" """
根据选中的卷组更新逻辑卷大小的范围 (最小值和最大值)。
并根据当前“使用最大可用空间”复选框的状态调整 spinbox 的值和启用状态。
"""
selected_vg = self.vg_combo_box.currentText() selected_vg = self.vg_combo_box.currentText()
max_size_gb = self.vg_sizes.get(selected_vg, 0.0) 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 问题 # 确保实际最大可用空间至少大于或等于 spinbox 的最小值
current_min_limit = self.lv_size_spinbox.minimum() # 获取当前的最小值限制 # 避免当可用空间非常小时spinbox 无法设置有效值
if max_size_gb < current_min_limit: effective_max_size_gb = max(self.lv_size_spinbox.minimum(), max_size_gb)
# 如果实际最大可用空间小于当前最小值限制,则将最小值临时调整为实际最大值 self.lv_size_spinbox.setMaximum(effective_max_size_gb)
self.lv_size_spinbox.setMinimum(max_size_gb if max_size_gb > 0 else 0.01) # 至少0.01GB logger.debug(f"[_update_size_options] Spinbox max set to: {effective_max_size_gb} GB.")
else:
self.lv_size_spinbox.setMinimum(0.1) # 恢复正常最小值
# 计算并设置 spinbox 的最大值 # 在设置了新的最大值后,重新应用复选框的状态来更新值和启用状态
clamped_max_gb = max(self.lv_size_spinbox.minimum(), max_size_gb) self._toggle_size_input() # 调用不带参数的槽函数
self.lv_size_spinbox.setMaximum(clamped_max_gb) logger.debug("[_update_size_options] Completed.")
# 如果选中了“使用最大可用空间”,则将 spinbox 值设置为最大值 def _toggle_size_input(self): # 不再接收 'state' 参数
if self.use_max_space_checkbox.isChecked(): """
self.lv_size_spinbox.setValue(clamped_max_gb) # 使用计算出的最大值 根据“使用最大可用空间”复选框的状态切换大小输入框的启用/禁用状态和值。
else: """
# 如果当前值超过了新的最大值,则调整为新的最大值 is_checked = self.use_max_space_checkbox.isChecked() # 直接查询复选框的当前状态
if self.lv_size_spinbox.value() > clamped_max_gb: logger.debug(f"[_toggle_size_input] Called. Checkbox isChecked(): {is_checked}")
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): current_max_spinbox_value = self.lv_size_spinbox.maximum()
"""根据“使用最大可用空间”复选框的状态切换大小输入框的启用/禁用状态。""" current_spinbox_value = self.lv_size_spinbox.value()
# 获取当前选中的卷组的实际最大可用空间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())
# 否则,保持当前值(用户手动输入的值)
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): def get_lv_info(self):
lv_name = self.lv_name_input.text().strip() lv_name = self.lv_name_input.text().strip()
@@ -442,13 +468,15 @@ class CreateLvDialog(QDialog):
if not vg_name: if not vg_name:
QMessageBox.warning(self, "输入错误", "请选择一个卷组。") QMessageBox.warning(self, "输入错误", "请选择一个卷组。")
return None return None
if not use_max_space and size_gb <= 0: # 如果没有使用最大空间,则检查输入大小的有效性
QMessageBox.warning(self, "输入错误", "逻辑卷大小必须大于0。") if not use_max_space:
return None if size_gb <= 0:
# 这里的检查应该使用 self.lv_size_spinbox.maximum() 来判断,因为它是实际的最大值 QMessageBox.warning(self, "输入错误", "逻辑卷大小必须大于0。")
if not use_max_space and size_gb > self.lv_size_spinbox.maximum(): return None
QMessageBox.warning(self, "输入错误", "逻辑卷大小不能超过卷组的可用空间。") # 这里的检查应该使用 self.lv_size_spinbox.maximum() 来判断,因为它是实际的最大值
return None if size_gb > self.lv_size_spinbox.maximum():
QMessageBox.warning(self, "输入错误", "逻辑卷大小不能超过卷组的可用空间。")
return None
return { return {
'lv_name': lv_name, '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 subprocess
import logging import logging
import re import re
import os import os
from PySide6.QtWidgets import QMessageBox, QInputDialog from PySide6.QtWidgets import QMessageBox, QInputDialog
from PySide6.QtCore import QObject, Signal, QThread
from system_info import SystemInfoManager from system_info import SystemInfoManager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class DiskOperations: # --- 后台格式化工作线程 ---
def __init__(self, system_manager: SystemInfoManager): # NEW: 接收 system_manager 实例 class FormatWorker(QObject):
self.system_manager = system_manager # 定义信号,用于向主线程发送格式化结果
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, 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):
通用地运行一个 shell 命令,并处理错误。 return self._lvm_ops._execute_shell_command(
:param command_list: 命令及其参数的列表。 command_list, error_message, root_privilege, suppress_critical_dialog_on_stderr_match, input_data, show_dialog
: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)
def _add_to_fstab(self, device_path, mount_point, fstype, uuid): def _add_to_fstab(self, device_path, mount_point, fstype, uuid):
""" """
@@ -91,8 +88,6 @@ class DiskOperations:
try: try:
# 检查 fstab 中是否已存在相同 UUID 的条目 # 检查 fstab 中是否已存在相同 UUID 的条目
# 使用 _execute_shell_command 来读取 fstab尽管通常不需要 sudo
# 这里为了简化,直接读取文件,但写入时使用 _execute_shell_command
with open(fstab_path, 'r') as f: with open(fstab_path, 'r') as f:
fstab_content = f.readlines() fstab_content = f.readlines()
@@ -103,7 +98,6 @@ class DiskOperations:
return True # 认为成功,因为目标已达成 return True # 认为成功,因为目标已达成
# 如果不存在,则追加到 fstab # 如果不存在,则追加到 fstab
# 使用 _execute_shell_command for sudo write
success, _, stderr = self._execute_shell_command( success, _, stderr = self._execute_shell_command(
["sh", "-c", f"echo '{fstab_entry}' >> {fstab_path}"], ["sh", "-c", f"echo '{fstab_entry}' >> {fstab_path}"],
f"{device_path} 添加到 {fstab_path} 失败", f"{device_path} 添加到 {fstab_path} 失败",
@@ -125,7 +119,6 @@ class DiskOperations:
从 /etc/fstab 中移除指定设备的条目。 从 /etc/fstab 中移除指定设备的条目。
此方法需要 SystemInfoManager 来获取设备的 UUID。 此方法需要 SystemInfoManager 来获取设备的 UUID。
""" """
# NEW: 使用 system_manager 获取 UUID它会处理路径解析
device_details = self.system_manager.get_device_details_by_path(device_path) device_details = self.system_manager.get_device_details_by_path(device_path)
if not device_details or not device_details.get('uuid'): if not device_details or not device_details.get('uuid'):
logger.warning(f"无法获取设备 {device_path} 的 UUID无法从 fstab 中移除。") logger.warning(f"无法获取设备 {device_path} 的 UUID无法从 fstab 中移除。")
@@ -133,10 +126,7 @@ class DiskOperations:
uuid = device_details.get('uuid') uuid = device_details.get('uuid')
fstab_path = "/etc/fstab" fstab_path = "/etc/fstab"
# Use sed for robust removal with sudo command = ["sed", "-i", f"/{re.escape(uuid)}/d", fstab_path] # Use re.escape for UUID
# 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]
success, _, stderr = self._execute_shell_command( success, _, stderr = self._execute_shell_command(
command, command,
f"{fstab_path} 中删除 UUID={uuid} 的条目失败", f"{fstab_path} 中删除 UUID={uuid} 的条目失败",
@@ -151,10 +141,6 @@ class DiskOperations:
logger.info(f"已从 {fstab_path} 中删除 UUID={uuid} 的条目。") logger.info(f"已从 {fstab_path} 中删除 UUID={uuid} 的条目。")
return True return True
else: 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 ( if any(s in stderr for s in (
f"sed: {fstab_path}: No such file or directory", f"sed: {fstab_path}: No such file or directory",
f"sed: {fstab_path}: 没有那个文件或目录", f"sed: {fstab_path}: 没有那个文件或目录",
@@ -162,8 +148,8 @@ class DiskOperations:
"No such file or directory" # more general check "No such file or directory" # more general check
)): )):
logger.info(f"fstab 中未找到 UUID={uuid} 的条目,无需删除。") logger.info(f"fstab 中未找到 UUID={uuid} 的条目,无需删除。")
return True # Consider it a success if the entry is not there return True
return False # Other errors are already handled by _execute_shell_command return False
def mount_partition(self, device_path, mount_point, add_to_fstab=False): def mount_partition(self, device_path, mount_point, add_to_fstab=False):
""" """
@@ -190,7 +176,6 @@ class DiskOperations:
if success: if success:
QMessageBox.information(None, "成功", f"设备 {device_path} 已成功挂载到 {mount_point}") QMessageBox.information(None, "成功", f"设备 {device_path} 已成功挂载到 {mount_point}")
if add_to_fstab: if add_to_fstab:
# NEW: 使用 self.system_manager 获取 fstype 和 UUID
device_details = self.system_manager.get_device_details_by_path(device_path) device_details = self.system_manager.get_device_details_by_path(device_path)
if device_details: if device_details:
fstype = device_details.get('fstype') fstype = device_details.get('fstype')
@@ -215,34 +200,24 @@ class DiskOperations:
:return: True 如果成功,否则 False。 :return: True 如果成功,否则 False。
""" """
logger.info(f"尝试卸载设备 {device_path}") logger.info(f"尝试卸载设备 {device_path}")
# 定义表示设备已未挂载的错误信息(中英文)
# 增加了 "未指定挂载点"
already_unmounted_errors = ("not mounted", "未挂载", "未指定挂载点") already_unmounted_errors = ("not mounted", "未挂载", "未指定挂载点")
# 调用 _execute_shell_command并告诉它在遇到“已未挂载”错误时不要弹出其自身的关键错误对话框。
success, _, stderr = self._execute_shell_command( success, _, stderr = self._execute_shell_command(
["umount", device_path], ["umount", device_path],
f"卸载设备 {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: if success:
# 如果命令成功执行,则卸载成功。
QMessageBox.information(None, "成功", f"设备 {device_path} 已成功卸载。") QMessageBox.information(None, "成功", f"设备 {device_path} 已成功卸载。")
return True return True
else: else:
# 如果命令失败,检查是否是因为设备已经未挂载或挂载点未指定。
is_already_unmounted_error = any(s in stderr for s in already_unmounted_errors) is_already_unmounted_error = any(s in stderr for s in already_unmounted_errors)
if is_already_unmounted_error: if is_already_unmounted_error:
logger.info(f"设备 {device_path} 已经处于未挂载状态(或挂载点未指定),无需重复卸载。") logger.info(f"设备 {device_path} 已经处于未挂载状态(或挂载点未指定),无需重复卸载。")
# 这种情况下,操作结果符合预期(设备已是未挂载),不弹出对话框,并返回 True。
return True return True
else: else:
# 对于其他类型的卸载失败(例如设备忙、权限不足等),
# _execute_shell_command 应该已经弹出了关键错误对话框
# (因为它没有匹配到 `already_unmounted_errors` 进行抑制)。
# 所以,这里我们不需要再次弹出对话框,直接返回 False。
return False return False
def get_disk_free_space_info_mib(self, disk_path, total_disk_mib): def get_disk_free_space_info_mib(self, disk_path, total_disk_mib):
@@ -254,8 +229,6 @@ class DiskOperations:
如果磁盘有分区表但没有空闲空间,返回 (None, None)。 如果磁盘有分区表但没有空闲空间,返回 (None, None)。
""" """
logger.debug(f"尝试获取磁盘 {disk_path} 的空闲空间信息 (MiB)。") 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( success, stdout, stderr = self._execute_shell_command(
["parted", "-s", disk_path, "unit", "MiB", "print", "free"], ["parted", "-s", disk_path, "unit", "MiB", "print", "free"],
f"获取磁盘 {disk_path} 分区信息失败", f"获取磁盘 {disk_path} 分区信息失败",
@@ -264,9 +237,6 @@ class DiskOperations:
) )
if not success: 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}") logger.error(f"获取磁盘 {disk_path} 空闲空间信息失败: {stderr}")
return None, None return None, None
@@ -274,18 +244,14 @@ class DiskOperations:
free_spaces = [] free_spaces = []
lines = stdout.splitlines() 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|空闲空间|可用空间)') 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: for line in lines:
match = free_space_line_pattern.match(line) match = free_space_line_pattern.match(line)
if match: if match:
try: try:
start_mib = float(match.group(1)) # Capture StartMiB start_mib = float(match.group(1))
size_mib = float(match.group(3)) # Capture SizeMiB (the third numeric value) size_mib = float(match.group(3))
free_spaces.append({'start_mib': start_mib, 'size_mib': size_mib}) free_spaces.append({'start_mib': start_mib, 'size_mib': size_mib})
except ValueError as ve: except ValueError as ve:
logger.warning(f"解析 parted free space 行 '{line}' 失败: {ve}") logger.warning(f"解析 parted free space 行 '{line}' 失败: {ve}")
@@ -295,7 +261,6 @@ class DiskOperations:
logger.warning(f"在磁盘 {disk_path} 上未找到空闲空间。") logger.warning(f"在磁盘 {disk_path} 上未找到空闲空间。")
return None, None return None, None
# 找到最大的空闲空间块
largest_free_space = max(free_spaces, key=lambda x: x['size_mib']) largest_free_space = max(free_spaces, key=lambda x: x['size_mib'])
start_mib = largest_free_space['start_mib'] start_mib = largest_free_space['start_mib']
size_mib = largest_free_space['size_mib'] size_mib = largest_free_space['size_mib']
@@ -320,32 +285,26 @@ class DiskOperations:
# 1. 检查磁盘是否有分区表 # 1. 检查磁盘是否有分区表
has_partition_table = False 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( success_check, stdout_check, stderr_check = self._execute_shell_command(
["parted", "-s", disk_path, "print"], ["parted", "-s", disk_path, "print"],
f"检查磁盘 {disk_path} 分区表失败", f"检查磁盘 {disk_path} 分区表失败",
suppress_critical_dialog_on_stderr_match=("无法辨识的磁盘卷标", "unrecognized disk label"), suppress_critical_dialog_on_stderr_match=("无法辨识的磁盘卷标", "unrecognized disk label"),
root_privilege=True 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: 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")): if any(s in stderr_check for s in ("无法辨识的磁盘卷标", "unrecognized disk label")):
logger.info(f"磁盘 {disk_path} 没有可识别的分区表。") logger.info(f"磁盘 {disk_path} 没有可识别的分区表。")
has_partition_table = False # Explicitly set to False has_partition_table = False
else: else:
logger.error(f"检查磁盘 {disk_path} 分区表时发生非预期的错误: {stderr_check}") logger.error(f"检查磁盘 {disk_path} 分区表时发生非预期的错误: {stderr_check}")
return False # Other critical error, stop operation. return False
else: # success_check is True else:
if "Partition Table: unknown" not in stdout_check and "分区表unknown" not in stdout_check: if "Partition Table: unknown" not in stdout_check and "分区表unknown" not in stdout_check:
has_partition_table = True has_partition_table = True
else: else:
logger.info(f"parted print 报告磁盘 {disk_path} 的分区表为 'unknown'") logger.info(f"parted print 报告磁盘 {disk_path} 的分区表为 'unknown'")
has_partition_table = False has_partition_table = False
actual_start_mib_for_parted = 0.0 actual_start_mib_for_parted = 0.0
# 2. 如果没有分区表,则创建分区表 # 2. 如果没有分区表,则创建分区表
@@ -365,7 +324,6 @@ class DiskOperations:
) )
if not success: if not success:
return False return False
# 对于新创建分区表的磁盘,第一个分区通常从 1MiB 开始以确保兼容性和对齐
actual_start_mib_for_parted = 1.0 actual_start_mib_for_parted = 1.0
else: else:
# 如果有分区表,获取下一个可用分区的起始位置 # 如果有分区表,获取下一个可用分区的起始位置
@@ -374,10 +332,7 @@ class DiskOperations:
QMessageBox.critical(None, "错误", f"无法确定磁盘 {disk_path} 的分区起始位置或没有空闲空间。") QMessageBox.critical(None, "错误", f"无法确定磁盘 {disk_path} 的分区起始位置或没有空闲空间。")
return False return False
# 如果 parted 报告的起始位置非常接近 0 MiB (例如 0.0 MiB 或 0.02 MiB) if start_mib_from_parted < 1.0:
# 为了安全和兼容性,也将其调整为 1.0 MiB。
# 否则,使用 parted 报告的精确起始位置。
if start_mib_from_parted < 1.0: # covers 0.0MiB and values like 0.017MiB (17.4kB)
actual_start_mib_for_parted = 1.0 actual_start_mib_for_parted = 1.0
else: else:
actual_start_mib_for_parted = start_mib_from_parted actual_start_mib_for_parted = start_mib_from_parted
@@ -387,12 +342,7 @@ class DiskOperations:
end_pos = "100%" end_pos = "100%"
size_for_log = "最大可用空间" size_for_log = "最大可用空间"
else: else:
# 计算结束 MiB
# 注意:这里计算的 end_mib 是基于用户请求的大小,
# parted 会根据实际可用空间和对齐进行微调。
end_mib = actual_start_mib_for_parted + size_gb * 1024 end_mib = actual_start_mib_for_parted + size_gb * 1024
# 简单检查,如果请求的大小导致结束位置超出磁盘总大小,则使用最大可用
if end_mib > total_disk_mib: if end_mib > total_disk_mib:
QMessageBox.warning(None, "警告", f"请求的分区大小 ({size_gb}GB) 超出了可用空间。将调整为最大可用空间。") QMessageBox.warning(None, "警告", f"请求的分区大小 ({size_gb}GB) 超出了可用空间。将调整为最大可用空间。")
end_pos = "100%" end_pos = "100%"
@@ -428,16 +378,12 @@ class DiskOperations:
return False return False
# 尝试卸载分区 # 尝试卸载分区
# The unmount_partition method now handles "not mounted" gracefully without dialog and returns True. self.unmount_partition(device_path, show_dialog_on_error=False)
# 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.
# 从 fstab 中移除条目 # 从 fstab 中移除条目
# _remove_fstab_entry also handles "not found" gracefully.
self._remove_fstab_entry(device_path) self._remove_fstab_entry(device_path)
# 获取父磁盘和分区号 # 获取父磁盘和分区号
# 例如 /dev/sdb1 -> /dev/sdb, 1
match = re.match(r'(/dev/[a-z]+)(\d+)', device_path) match = re.match(r'(/dev/[a-z]+)(\d+)', device_path)
if not match: if not match:
QMessageBox.critical(None, "错误", f"无法解析设备路径 {device_path}") QMessageBox.critical(None, "错误", f"无法解析设备路径 {device_path}")
@@ -460,52 +406,64 @@ class DiskOperations:
def format_partition(self, device_path, fstype=None): def format_partition(self, device_path, fstype=None):
""" """
格式化指定分区 启动一个 QInputDialog 让用户选择文件系统类型,然后将格式化操作提交到后台线程
:param device_path: 要格式化的分区路径。 :param device_path: 要格式化的分区路径。
:param fstype: 文件系统类型 (例如 'ext4', 'xfs', 'fat32', 'ntfs')。如果为 None则弹出对话框让用户选择。 :param fstype: 文件系统类型 (例如 'ext4', 'xfs', 'fat32', 'ntfs')。如果为 None则弹出对话框让用户选择。
:return: True 如果成功,否则 False。 :return: True 如果成功启动后台任务,否则 False。
""" """
if fstype is None: if fstype is None:
items = ("ext4", "xfs", "fat32", "ntfs") 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: if not ok or not fstype:
logger.info("用户取消了文件系统选择。") logger.info("用户取消了文件系统选择。")
return False return False
reply = QMessageBox.question(None, "确认格式化分区", reply = QMessageBox.question(None, "确认格式化",
f"您确定要将分区 {device_path} 格式化{fstype} 吗?此操作将擦除分区上的所有数据!", f"您确定要格式化设备 {device_path}{fstype} 吗?此操作将擦除所有数据!",
QMessageBox.Yes | QMessageBox.No, QMessageBox.No) QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
if reply == QMessageBox.No: if reply == QMessageBox.No:
logger.info(f"用户取消了格式化分区 {device_path} 的操作。") logger.info(f"用户取消了格式化 {device_path} 的操作。")
return False 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) # 从 fstab 中移除条目 (因为格式化会改变 UUID)
self._remove_fstab_entry(device_path) self._remove_fstab_entry(device_path)
logger.info(f"尝试将分区 {device_path} 格式化为 {fstype}") # 创建 QThread 和 FormatWorker 实例
format_cmd = [] thread = QThread()
if fstype == "ext4": worker = FormatWorker(device_path, fstype, self._execute_shell_command)
format_cmd = ["mkfs.ext4", "-F", device_path] # -F 强制执行 worker.moveToThread(thread)
elif fstype == "xfs":
format_cmd = ["mkfs.xfs", "-f", device_path] # -f 强制执行 # 连接信号和槽
elif fstype == "fat32": thread.started.connect(worker.run)
format_cmd = ["mkfs.fat", "-F", "32", device_path] worker.finished.connect(lambda s, dp, o, e: self._on_formatting_finished(s, dp, o, e))
elif fstype == "ntfs": worker.finished.connect(thread.quit)
format_cmd = ["mkfs.ntfs", "-f", device_path] worker.finished.connect(worker.deleteLater)
else: thread.finished.connect(thread.deleteLater)
QMessageBox.critical(None, "错误", f"不支持的文件系统类型: {fstype}")
logger.error(f"不支持的文件系统类型: {fstype}") self.active_format_workers[device_path] = thread
return False 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: if success:
QMessageBox.information(None, "成功", f"分区 {device_path} 已成功格式化{fstype}") QMessageBox.information(None, "格式化成功", f"设备 {device_path} 已成功格式化。")
return True
else: 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 subprocess
import logging import logging
from PySide6.QtWidgets import QMessageBox from PySide6.QtWidgets import QMessageBox
@@ -6,22 +7,27 @@ logger = logging.getLogger(__name__)
class LvmOperations: class LvmOperations:
def __init__(self): def __init__(self):
pass # LVM操作不直接依赖SystemInfoManager通过参数传递所需信息 pass
def _execute_shell_command(self, command_list, error_message, root_privilege=True, 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 命令,并处理错误。 通用地运行一个 shell 命令,并处理错误。
:param command_list: 命令及其参数的列表。 :param command_list: 命令及其参数的列表。
:param error_message: 命令失败时显示给用户的错误消息。 :param error_message: 命令失败时显示给用户的错误消息。
:param root_privilege: 如果为 True则使用 sudo 执行命令。 :param root_privilege: 如果为 True则使用 sudo 执行命令。
:param suppress_critical_dialog_on_stderr_match: 如果 stderr 包含此字符串,则不显示关键错误对话框。 :param suppress_critical_dialog_on_stderr_match: 如果 stderr 包含此字符串,则不显示关键错误对话框。
可以是字符串或字符串元组/列表。
:param input_data: 传递给命令stdin的数据 (str)。 :param input_data: 传递给命令stdin的数据 (str)。
:param show_dialog: 如果为 False则不显示关键错误对话框。
:param expected_non_zero_exit_codes: 预期为非零但仍视为成功的退出码列表。
:return: (True/False, stdout_str, stderr_str) :return: (True/False, stdout_str, stderr_str)
""" """
if not all(isinstance(arg, str) for arg in command_list): if not all(isinstance(arg, str) for arg in command_list):
logger.error(f"命令列表包含非字符串元素: {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, "", "内部错误:命令参数类型不正确。" return False, "", "内部错误:命令参数类型不正确。"
if root_privilege: if root_privilege:
@@ -48,25 +54,42 @@ class LvmOperations:
logger.error(f"标准输出: {e.stdout.strip()}") logger.error(f"标准输出: {e.stdout.strip()}")
logger.error(f"标准错误: {stderr_output}") logger.error(f"标准错误: {stderr_output}")
if suppress_critical_dialog_on_stderr_match and \ # NEW LOGIC: Check if the exit code is expected as non-critical
suppress_critical_dialog_on_stderr_match in stderr_output: if expected_non_zero_exit_codes and e.returncode in expected_non_zero_exit_codes:
logger.info(f"错误信息 '{stderr_output}' 匹配抑制条件,不显示关键错误对话框") logger.info(f"命令 '{full_cmd_str}' 以预期非零退出码 {e.returncode} 结束,仍视为成功")
else: 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}") QMessageBox.critical(None, "错误", f"{error_message}\n错误详情: {stderr_output}")
return False, e.stdout.strip(), stderr_output return False, e.stdout.strip(), stderr_output
except FileNotFoundError: 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]}' 未找到。") logger.error(f"命令 '{command_list[0]}' 未找到。")
return False, "", f"命令 '{command_list[0]}' 未找到。" return False, "", f"命令 '{command_list[0]}' 未找到。"
except Exception as e: except Exception as e:
QMessageBox.critical(None, "错误", f"执行命令时发生未知错误: {e}") if show_dialog:
QMessageBox.critical(None, "错误", f"执行命令时发生未知错误: {e}")
logger.error(f"执行命令 {full_cmd_str} 时发生未知错误: {e}") logger.error(f"执行命令 {full_cmd_str} 时发生未知错误: {e}")
return False, "", str(e) return False, "", str(e)
def create_pv(self, device_path): def create_pv(self, device_path):
""" """
创建物理卷 (PV)。 创建物理卷 (PV)。
:param device_path: 设备的路径,例如 /dev/sdb1。 :param device_path: 设备的路径,例如 /dev/sdb1 或 /dev/sdb
:return: True 如果成功,否则 False。 :return: True 如果成功,否则 False。
""" """
if not isinstance(device_path, str): if not isinstance(device_path, str):
@@ -83,14 +106,47 @@ class LvmOperations:
logger.info(f"尝试在 {device_path} 上创建物理卷。") logger.info(f"尝试在 {device_path} 上创建物理卷。")
success, _, stderr = self._execute_shell_command( success, _, stderr = self._execute_shell_command(
["pvcreate", "-y", device_path], # -y 自动确认 ["pvcreate", "-y", device_path],
f"{device_path} 上创建物理卷失败" f"{device_path} 上创建物理卷失败",
suppress_critical_dialog_on_stderr_match="device is partitioned"
) )
if success: if success:
QMessageBox.information(None, "成功", f"物理卷已在 {device_path} 上成功创建。") QMessageBox.information(None, "成功", f"物理卷已在 {device_path} 上成功创建。")
return True return True
else: 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): def delete_pv(self, device_path):
""" """

View File

@@ -1,3 +1,4 @@
# mainwindow.py
import sys import sys
import logging import logging
import re import re
@@ -5,7 +6,7 @@ import os
from PySide6.QtWidgets import (QApplication, QMainWindow, QTreeWidgetItem, from PySide6.QtWidgets import (QApplication, QMainWindow, QTreeWidgetItem,
QMessageBox, QHeaderView, QMenu, QInputDialog, QDialog) QMessageBox, QHeaderView, QMenu, QInputDialog, QDialog)
from PySide6.QtCore import Qt, QPoint from PySide6.QtCore import Qt, QPoint, QThread
# 导入自动生成的 UI 文件 # 导入自动生成的 UI 文件
from ui_form import Ui_MainWindow from ui_form import Ui_MainWindow
@@ -19,6 +20,8 @@ from disk_operations import DiskOperations
from raid_operations import RaidOperations from raid_operations import RaidOperations
# 导入 LVM 操作模块 # 导入 LVM 操作模块
from lvm_operations import LvmOperations from lvm_operations import LvmOperations
# 导入新的 resolver 类
from occupation_resolver import OccupationResolver
# 导入自定义对话框 # 导入自定义对话框
from dialogs import (CreatePartitionDialog, MountDialog, CreateRaidDialog, from dialogs import (CreatePartitionDialog, MountDialog, CreateRaidDialog,
CreatePvDialog, CreateVgDialog, CreateLvDialog) CreatePvDialog, CreateVgDialog, CreateLvDialog)
@@ -33,11 +36,14 @@ class MainWindow(QMainWindow):
setup_logging(self.ui.logOutputTextEdit) setup_logging(self.ui.logOutputTextEdit)
logger.info("应用程序启动。") logger.info("应用程序启动。")
# 初始化管理器和操作类 # 初始化管理器和操作类 (恢复原始的初始化顺序和依赖关系)
self.system_manager = SystemInfoManager() self.system_manager = SystemInfoManager()
self.disk_ops = DiskOperations(self.system_manager)
self.raid_ops = RaidOperations()
self.lvm_ops = LvmOperations() 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'): if hasattr(self.ui, 'refreshButton'):
@@ -55,6 +61,17 @@ class MainWindow(QMainWindow):
self.ui.treeWidget_lvm.setContextMenuPolicy(Qt.CustomContextMenu) self.ui.treeWidget_lvm.setContextMenuPolicy(Qt.CustomContextMenu)
self.ui.treeWidget_lvm.customContextMenuRequested.connect(self.show_lvm_context_menu) 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() self.refresh_all_info()
logger.info("所有设备信息已初始化加载。") logger.info("所有设备信息已初始化加载。")
@@ -149,6 +166,8 @@ class MainWindow(QMainWindow):
if device_type == 'disk': if device_type == 'disk':
create_partition_action = menu.addAction(f"创建分区 {device_path}...") create_partition_action = menu.addAction(f"创建分区 {device_path}...")
create_partition_action.triggered.connect(lambda: self._handle_create_partition(device_path, dev_data)) 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() menu.addSeparator()
if device_type == 'part': if device_type == 'part':
@@ -165,12 +184,95 @@ class MainWindow(QMainWindow):
format_action = menu.addAction(f"格式化分区 {device_path}...") format_action = menu.addAction(f"格式化分区 {device_path}...")
format_action.triggered.connect(lambda: self._handle_format_partition(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(): if menu.actions():
menu.exec(self.ui.treeWidget_block_devices.mapToGlobal(pos)) menu.exec(self.ui.treeWidget_block_devices.mapToGlobal(pos))
else: else:
logger.info("右键点击了空白区域或设备没有可用的操作。") 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): def _handle_create_partition(self, disk_path, dev_data):
total_disk_mib = 0.0 total_disk_mib = 0.0
total_size_str = dev_data.get('size') total_size_str = dev_data.get('size')
@@ -220,7 +322,7 @@ class MainWindow(QMainWindow):
logger.debug(f"磁盘 {disk_path} 没有现有分区,假定从 0 MiB 开始,最大可用空间为整个磁盘。") logger.debug(f"磁盘 {disk_path} 没有现有分区,假定从 0 MiB 开始,最大可用空间为整个磁盘。")
max_available_mib = max(0.0, total_disk_mib - 1.0) max_available_mib = max(0.0, total_disk_mib - 1.0)
start_position_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) dialog = CreatePartitionDialog(self, disk_path, total_disk_mib, max_available_mib)
if dialog.exec() == QDialog.Accepted: if dialog.exec() == QDialog.Accepted:
@@ -252,8 +354,17 @@ class MainWindow(QMainWindow):
self.refresh_all_info() self.refresh_all_info()
def _handle_format_partition(self, device_path): 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 --- # --- RAID 管理 Tab ---
def refresh_raid_info(self): def refresh_raid_info(self):
@@ -284,7 +395,9 @@ class MainWindow(QMainWindow):
logger.warning(f"RAID阵列 '{array.get('name', '未知')}' 的设备路径无效,跳过。") logger.warning(f"RAID阵列 '{array.get('name', '未知')}' 的设备路径无效,跳过。")
continue 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(0, array_path)
array_item.setText(1, array.get('level', 'N/A')) 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(10, array.get('chunk_size', 'N/A'))
array_item.setText(11, current_mount_point if current_mount_point else "") array_item.setText(11, current_mount_point if current_mount_point else "")
array_item.setExpanded(True) 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', []): array_item.setData(0, Qt.UserRole, array)
member_item = QTreeWidgetItem(array_item)
member_item.setText(0, f" {member.get('device_path', 'N/A')}") if array.get('state') != 'Stopped (Configured)':
member_item.setText(1, f"成员: {member.get('raid_device', 'N/A')}") for member in array.get('member_devices', []):
member_item.setText(2, member.get('state', 'N/A')) member_item = QTreeWidgetItem(array_item)
member_item.setText(3, f"Major: {member.get('major', 'N/A')}, Minor: {member.get('minor', 'N/A')}") 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)): for i in range(len(raid_headers)):
self.ui.treeWidget_raid.resizeColumnToContents(i) self.ui.treeWidget_raid.resizeColumnToContents(i)
@@ -319,49 +431,98 @@ class MainWindow(QMainWindow):
logger.error(f"刷新RAID阵列信息失败: {e}") logger.error(f"刷新RAID阵列信息失败: {e}")
def show_raid_context_menu(self, pos: QPoint): def show_raid_context_menu(self, pos: QPoint):
item = self.ui.treeWidget_raid.itemAt(pos) item = self.ui.treeWidget_raid.itemAt(pos)
menu = QMenu(self) menu = QMenu(self)
create_raid_action = menu.addAction("创建 RAID 阵列...") create_raid_action = menu.addAction("创建 RAID 阵列...")
create_raid_action.triggered.connect(self._handle_create_raid_array) 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))
menu.addSeparator() menu.addSeparator()
stop_action = menu.addAction(f"停止阵列 {array_path}") if item and item.parent() is None:
stop_action.triggered.connect(lambda: self._handle_stop_raid_array(array_path)) 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}") array_path = array_data.get('device')
delete_action.triggered.connect(lambda: self._handle_delete_raid_array(array_path, member_devices)) 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}...") if not array_path or array_path == 'N/A':
format_action.triggered.connect(lambda: self._handle_format_raid_array(array_path)) logger.warning(f"RAID阵列 '{array_data.get('name', '未知')}' 的设备路径无效,无法显示操作。")
return
if menu.actions(): if array_state == 'Stopped (Configured)':
menu.exec(self.ui.treeWidget_raid.mapToGlobal(pos)) activate_action = menu.addAction(f"激活阵列 {array_path}")
else: activate_action.triggered.connect(lambda: self._handle_activate_raid_array(array_path))
logger.info("右键点击了空白区域或没有可用的RAID操作。") 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): def _handle_create_raid_array(self):
available_devices = self.system_manager.get_unallocated_partitions() available_devices = self.system_manager.get_unallocated_partitions()
@@ -381,13 +542,25 @@ class MainWindow(QMainWindow):
if self.raid_ops.stop_raid_array(array_path): if self.raid_ops.stop_raid_array(array_path):
self.refresh_all_info() self.refresh_all_info()
def _handle_delete_raid_array(self, array_path, member_devices): def _handle_delete_active_raid_array(self, array_path, member_devices, uuid):
if self.raid_ops.delete_raid_array(array_path, member_devices): """
处理删除一个活动的 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() self.refresh_all_info()
def _handle_format_raid_array(self, array_path): 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 --- # --- LVM 管理 Tab ---
def refresh_lvm_info(self): def refresh_lvm_info(self):
@@ -566,6 +739,11 @@ class MainWindow(QMainWindow):
mount_lv_action.triggered.connect(lambda: self._handle_mount(lv_path)) mount_lv_action.triggered.connect(lambda: self._handle_mount(lv_path))
menu.addSeparator() 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 = menu.addAction(f"删除逻辑卷 {lv_name}")
delete_lv_action.triggered.connect(lambda: self._handle_delete_lv(lv_name, vg_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}") available_pvs.append(f"/dev/{pv_name}")
if not available_pvs: if not available_pvs:
QMessageBox.warning(self, "警告", "没有可用于创建卷组的物理卷。请确保有未分配给任何卷组的物理卷。") QMessageBox.warning(self, "警告", "没有可用于创建卷组的物理卷。")
return return
dialog = CreateVgDialog(self, available_pvs) dialog = CreateVgDialog(self, available_pvs)
@@ -619,49 +797,49 @@ class MainWindow(QMainWindow):
self.refresh_all_info() self.refresh_all_info()
def _handle_create_lv(self): def _handle_create_lv(self):
lvm_info = self.system_manager.get_lvm_info() lvm_info = self.system_manager.get_lvm_info()
available_vgs = [] available_vgs = []
vg_sizes = {} vg_sizes = {}
for vg in lvm_info.get('vgs', []): for vg in lvm_info.get('vgs', []):
vg_name = vg.get('vg_name') vg_name = vg.get('vg_name')
if vg_name and vg_name != 'N/A': if vg_name and vg_name != 'N/A':
available_vgs.append(vg_name) available_vgs.append(vg_name)
free_size_str = vg.get('vg_free', '0B').strip() free_size_str = vg.get('vg_free', '0B').strip().lower()
current_vg_size_gb = 0.0 current_vg_size_gb = 0.0
match = re.match(r'(\d+\.?\d*)\s*([gmktb])?', free_size_str, re.IGNORECASE) match = re.match(r'<?(\d+\.?\d*)\s*([gmktb])?', free_size_str)
if match: if match:
value = float(match.group(1)) value = float(match.group(1))
unit = match.group(2).lower() if match.group(2) else '' unit = match.group(2)
if unit == 'k': if unit == 'k':
current_vg_size_gb = value / (1024 * 1024) current_vg_size_gb = value / (1024 * 1024)
elif unit == 'm': elif unit == 'm':
current_vg_size_gb = value / 1024 current_vg_size_gb = value / 1024
elif unit == 'g': elif unit == 'g' or unit is None:
current_vg_size_gb = value current_vg_size_gb = value
elif unit == 't': elif unit == 't':
current_vg_size_gb = value * 1024 current_vg_size_gb = value * 1024
elif unit == 'b' or unit == '': elif unit == 'b':
current_vg_size_gb = value / (1024 * 1024 * 1024) current_vg_size_gb = value / (1024 * 1024 * 1024)
else:
logger.warning(f"未知LVM单位: '{unit}' for '{free_size_str}'")
else: 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: if not available_vgs:
QMessageBox.warning(self, "警告", "没有可用于创建逻辑卷的卷组。") QMessageBox.warning(self, "警告", "没有可用于创建逻辑卷的卷组。")
return return
dialog = CreateLvDialog(self, available_vgs, vg_sizes) dialog = CreateLvDialog(self, available_vgs, vg_sizes)
if dialog.exec() == QDialog.Accepted: if dialog.exec() == QDialog.Accepted:
info = dialog.get_lv_info() info = dialog.get_lv_info()
if info: if info:
if self.lvm_ops.create_lv(info['lv_name'], info['vg_name'], info['size_gb'], info['use_max_space']): if self.lvm_ops.create_lv(info['lv_name'], info['vg_name'], info['size_gb'], info['use_max_space']):
self.refresh_all_info() self.refresh_all_info()
def _handle_delete_lv(self, lv_name, vg_name): def _handle_delete_lv(self, lv_name, vg_name):
lv_path = f"/dev/{vg_name}/{lv_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" name = "PySide Widgets Project"
[tool.pyside6-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 # raid_operations.py
import logging import logging
import subprocess import subprocess
from PySide6.QtWidgets import QMessageBox from PySide6.QtWidgets import QMessageBox, QInputDialog, QLineEdit
from PySide6.QtCore import Qt
from system_info import SystemInfoManager from system_info import SystemInfoManager
import re # 导入正则表达式模块 import re
import pexpect
import sys
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class RaidOperations: class RaidOperations:
def __init__(self): def __init__(self, system_manager: SystemInfoManager):
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。 执行一个shell命令并返回stdout和stderr。
这个方法包装了 SystemInfoManager._run_command并统一处理日志和错误消息框。 这个方法包装了 SystemInfoManager._run_command并统一处理日志和错误消息框。
它会通过 SystemInfoManager 自动处理 sudo。
:param command_list: 命令及其参数的列表。 :param command_list: 命令及其参数的列表。
:param error_msg_prefix: 命令失败时显示给用户的错误消息前缀。 :param error_msg_prefix: 命令失败时显示给用户的错误消息前缀。
:param suppress_critical_dialog_on_stderr_match: 一个字符串,如果在 stderr 中找到此字符串, :param suppress_critical_dialog_on_stderr_match: 一个字符串或字符串元组。
如果在 stderr 中找到此字符串(或元组中的任一字符串),
则在 CalledProcessError 发生时,只记录日志,不弹出 QMessagebox.critical。 则在 CalledProcessError 发生时,只记录日志,不弹出 QMessagebox.critical。
:param input_to_command: 传递给命令stdin的数据 (str)。 :param input_to_command: 传递给命令stdin的数据 (str)。
:return: (bool success, str stdout, str stderr) :return: (bool success, str stdout, str stderr)
@@ -30,8 +35,8 @@ class RaidOperations:
stdout, stderr = self.system_manager._run_command( stdout, stderr = self.system_manager._run_command(
command_list, command_list,
root_privilege=True, root_privilege=True,
check_output=True, # RAID操作通常需要检查输出 check_output=True,
input_data=input_to_command # <--- 将 input_to_command 传递给 _run_command input_data=input_to_command
) )
if stdout: logger.debug(f"命令 '{full_cmd_str}' 标准输出:\n{stdout.strip()}") if stdout: logger.debug(f"命令 '{full_cmd_str}' 标准输出:\n{stdout.strip()}")
@@ -39,18 +44,29 @@ class RaidOperations:
return True, stdout, stderr return True, stdout, stderr
except subprocess.CalledProcessError as e: 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"{error_msg_prefix} 命令: {full_cmd_str}")
logger.error(f"退出码: {e.returncode}") logger.error(f"退出码: {e.returncode}")
logger.error(f"标准输出: {e.stdout.strip()}") logger.error(f"标准输出: {e.stdout.strip()}")
logger.error(f"标准错误: {stderr_output}") logger.error(f"标准错误: {stderr_output}")
# 根据 suppress_critical_dialog_on_stderr_match 参数决定是否弹出错误对话框 should_suppress_dialog = False
if suppress_critical_dialog_on_stderr_match and suppress_critical_dialog_on_stderr_match in stderr_output: if suppress_critical_dialog_on_stderr_match:
logger.info(f"特定错误 '{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: else:
QMessageBox.critical(None, "错误", f"{error_msg_prefix}\n详细信息: {stderr_output}") 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: except FileNotFoundError:
logger.error(f"命令 '{command_list[0]}' 未找到。请确保已安装相关工具。") logger.error(f"命令 '{command_list[0]}' 未找到。请确保已安装相关工具。")
QMessageBox.critical(None, "错误", 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}") QMessageBox.critical(None, "错误", f"{error_msg_prefix}\n未知错误: {e}")
return False, "", str(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): def _get_next_available_md_device_name(self):
""" """
查找下一个可用的 /dev/mdX 设备名称(例如 /dev/md0, /dev/md1, ...)。 查找下一个可用的 /dev/mdX 设备名称(例如 /dev/md0, /dev/md1, ...)。
@@ -70,7 +198,6 @@ class RaidOperations:
for array in raid_arrays: for array in raid_arrays:
device_path = array.get('device') device_path = array.get('device')
if device_path and device_path.startswith('/dev/md'): if device_path and device_path.startswith('/dev/md'):
# 匹配 /dev/mdX 形式的设备名
match = re.match(r'/dev/md(\d+)', device_path) match = re.match(r'/dev/md(\d+)', device_path)
if match: if match:
existing_md_numbers.add(int(match.group(1))) existing_md_numbers.add(int(match.group(1)))
@@ -90,11 +217,10 @@ class RaidOperations:
:param level: RAID 级别,例如 'raid0', 'raid1', 'raid5' (也可以是整数 0, 1, 5) :param level: RAID 级别,例如 'raid0', 'raid1', 'raid5' (也可以是整数 0, 1, 5)
:param chunk_size: Chunk 大小 (KB) :param chunk_size: Chunk 大小 (KB)
""" """
# --- 标准化 RAID 级别输入 ---
if isinstance(level, int): if isinstance(level, int):
level_str = f"raid{level}" level_str = f"raid{level}"
elif isinstance(level, str): 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}" level_str = f"raid{level}"
else: else:
level_str = level level_str = level
@@ -102,20 +228,17 @@ class RaidOperations:
QMessageBox.critical(None, "错误", f"不支持的 RAID 级别类型: {type(level)}") QMessageBox.critical(None, "错误", f"不支持的 RAID 级别类型: {type(level)}")
return False return False
level = level_str level = level_str
# --- 标准化 RAID 级别输入结束 ---
if not devices or len(devices) < 2: if not devices or len(devices) < 1:
QMessageBox.critical(None, "错误", "创建 RAID 阵列至少需要两个成员设备") QMessageBox.critical(None, "错误", "创建 RAID 阵列至少需要一个设备RAID0")
return False 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: if level == "raid1" and len(devices) < 2:
QMessageBox.critical(None, "错误", "RAID1 至少需要两个设备。") QMessageBox.critical(None, "错误", "RAID1 至少需要两个设备。")
return False return False
if level == "raid5" and len(devices) < 3:
QMessageBox.critical(None, "错误", "RAID5 至少需要三个设备。")
return False
if level == "raid6" and len(devices) < 4: if level == "raid6" and len(devices) < 4:
QMessageBox.critical(None, "错误", "RAID6 至少需要四个设备。") QMessageBox.critical(None, "错误", "RAID6 至少需要四个设备。")
return False return False
@@ -123,8 +246,6 @@ class RaidOperations:
QMessageBox.critical(None, "错误", "RAID10 至少需要两个设备。") QMessageBox.critical(None, "错误", "RAID10 至少需要两个设备。")
return False return False
# 确认操作
reply = QMessageBox.question(None, "确认创建 RAID 阵列", reply = QMessageBox.question(None, "确认创建 RAID 阵列",
f"你确定要使用设备 {', '.join(devices)} 创建 RAID {level} 阵列吗?\n" f"你确定要使用设备 {', '.join(devices)} 创建 RAID {level} 阵列吗?\n"
"此操作将销毁设备上的所有数据!", "此操作将销毁设备上的所有数据!",
@@ -137,10 +258,7 @@ class RaidOperations:
# 1. 清除设备上的旧 RAID 超级块(如果有) # 1. 清除设备上的旧 RAID 超级块(如果有)
for dev in devices: for dev in devices:
# 使用 --force 选项,避免交互式提示
clear_cmd = ["mdadm", "--zero-superblock", "--force", dev] clear_cmd = ["mdadm", "--zero-superblock", "--force", dev]
# 定义表示设备上没有超级块的错误信息,根据日志调整
no_superblock_error_match = "Unrecognised md component device" no_superblock_error_match = "Unrecognised md component device"
success, _, stderr = self._execute_shell_command( success, _, stderr = self._execute_shell_command(
@@ -149,7 +267,6 @@ class RaidOperations:
suppress_critical_dialog_on_stderr_match=no_superblock_error_match suppress_critical_dialog_on_stderr_match=no_superblock_error_match
) )
# 检查 stderr 是否包含“未识别的 MD 组件设备”信息
if no_superblock_error_match in stderr: if no_superblock_error_match in stderr:
logger.info(f"设备 {dev} 上未找到旧 RAID 超级块,已确保其干净。") logger.info(f"设备 {dev} 上未找到旧 RAID 超级块,已确保其干净。")
elif success: elif success:
@@ -159,41 +276,25 @@ class RaidOperations:
return False return False
# 2. 创建 RAID 阵列 # 2. 创建 RAID 阵列
# --- 修改点:自动生成唯一的阵列名称 ---
array_name = self._get_next_available_md_device_name() array_name = self._get_next_available_md_device_name()
logger.info(f"将使用自动生成的 RAID 阵列名称: {array_name}") logger.info(f"将使用自动生成的 RAID 阵列名称: {array_name}")
# --- 修改点结束 ---
if level == "raid0": create_cmd_base = ["mdadm", "--create", array_name, "--level=" + level.replace("raid", ""),
create_cmd = ["mdadm", "--create", array_name, "--level=raid0", f"--raid-devices={len(devices)}"]
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 命令中添加 --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") 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, create_cmd,
f"创建 RAID 阵列失败", f"创建 RAID 阵列失败"
input_to_command='y\n' # 尝试通过 stdin 传递 'y'
) )
if not success_create: if not success_create:
# 检查是否是由于 "Array name ... is in use already." 导致的失败 if "Array name" in stdout_create and "is in use already" in stdout_create:
if "Array name" in stderr_create and "is in use already" in stderr_create:
QMessageBox.critical(None, "错误", f"创建 RAID 阵列失败:阵列名称 {array_name} 已被占用。请尝试停止或删除现有阵列。") QMessageBox.critical(None, "错误", f"创建 RAID 阵列失败:阵列名称 {array_name} 已被占用。请尝试停止或删除现有阵列。")
return False return False
@@ -204,9 +305,25 @@ class RaidOperations:
examine_scan_cmd = ["mdadm", "--examine", "--scan"] examine_scan_cmd = ["mdadm", "--examine", "--scan"]
success_scan, scan_stdout, _ = self._execute_shell_command(examine_scan_cmd, "扫描 mdadm 配置失败") success_scan, scan_stdout, _ = self._execute_shell_command(examine_scan_cmd, "扫描 mdadm 配置失败")
if success_scan: if success_scan:
append_to_conf_cmd = ["bash", "-c", f"echo '{scan_stdout.strip()}' >> /etc/mdadm/mdadm.conf"] mkdir_cmd = ["mkdir", "-p", "/etc"]
if not self._execute_shell_command(append_to_conf_cmd, "更新 /etc/mdadm/mdadm.conf 失败")[0]: success_mkdir, _, stderr_mkdir = self._execute_shell_command(
logger.warning("更新 /etc/mdadm/mdadm.conf 失败。") 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: else:
logger.warning("未能扫描到 mdadm 配置,跳过更新 mdadm.conf。") logger.warning("未能扫描到 mdadm 配置,跳过更新 mdadm.conf。")
@@ -227,15 +344,13 @@ class RaidOperations:
return False return False
logger.info(f"尝试停止 RAID 阵列: {array_path}") logger.info(f"尝试停止 RAID 阵列: {array_path}")
# 尝试卸载阵列(如果已挂载),不显示错误对话框
# 注意:这里需要调用 disk_operations 的 unmount_partition # Original umount call (not using disk_ops.unmount_partition)
# 由于 RaidOperations 不直接持有 DiskOperations 实例,需要通过某种方式获取或传递 self._execute_shell_command(
# 暂时先直接调用 umount 命令,不处理 fstab ["umount", array_path],
# 或者,更好的方式是让 MainWindow 调用 disk_ops.unmount_partition f"尝试卸载 {array_path} 失败",
# 此处简化处理,只执行 umount 命令 suppress_critical_dialog_on_stderr_match=("not mounted", "未挂载")
# 这里的 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
if not self._execute_shell_command(["mdadm", "--stop", array_path], f"停止 RAID 阵列 {array_path} 失败")[0]: if not self._execute_shell_command(["mdadm", "--stop", array_path], f"停止 RAID 阵列 {array_path} 失败")[0]:
return False return False
@@ -244,22 +359,23 @@ class RaidOperations:
QMessageBox.information(None, "成功", f"成功停止 RAID 阵列 {array_path}") QMessageBox.information(None, "成功", f"成功停止 RAID 阵列 {array_path}")
return True 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 array_path: RAID 阵列的设备路径,例如 /dev/md0
:param member_devices: 成员设备列表,例如 ['/dev/sdb1', '/dev/sdc1'] :param member_devices: 成员设备列表,例如 ['/dev/sdb1', '/dev/sdc1']
:param uuid: RAID 阵列的 UUID
""" """
reply = QMessageBox.question(None, "确认删除 RAID 阵列", reply = QMessageBox.question(None, "确认删除 RAID 阵列",
f"你确定要删除 RAID 阵列 {array_path} 吗?\n" f"你确定要删除 RAID 阵列 {array_path} 吗?\n"
"此操作将停止阵列清除成员设备上的 RAID 超级块,数据将无法访问!", "此操作将停止阵列清除成员设备上的 RAID 超级块,并删除其配置,数据将无法访问!",
QMessageBox.Yes | QMessageBox.No, QMessageBox.No) QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
if reply == QMessageBox.No: if reply == QMessageBox.No:
logger.info(f"用户取消了删除 RAID 阵列 {array_path} 的操作。") logger.info(f"用户取消了删除 RAID 阵列 {array_path} 的操作。")
return False return False
logger.info(f"尝试删除 RAID 阵列: {array_path}") logger.info(f"尝试删除活动 RAID 阵列: {array_path} (UUID: {uuid})")
# 1. 停止阵列 # 1. 停止阵列
if not self.stop_raid_array(array_path): if not self.stop_raid_array(array_path):
@@ -269,10 +385,7 @@ class RaidOperations:
# 2. 清除成员设备上的超级块 # 2. 清除成员设备上的超级块
success_all_cleared = True success_all_cleared = True
for dev in member_devices: for dev in member_devices:
# 使用 --force 选项,避免交互式提示
clear_cmd = ["mdadm", "--zero-superblock", "--force", dev] clear_cmd = ["mdadm", "--zero-superblock", "--force", dev]
# 定义表示设备上没有超级块的错误信息,根据日志调整
no_superblock_error_match = "Unrecognised md component device" no_superblock_error_match = "Unrecognised md component device"
success, _, stderr = self._execute_shell_command( success, _, stderr = self._execute_shell_command(
@@ -287,9 +400,17 @@ class RaidOperations:
logger.info(f"已清除设备 {dev} 上的旧 RAID 超级块。") logger.info(f"已清除设备 {dev} 上的旧 RAID 超级块。")
else: else:
success_all_cleared = False success_all_cleared = False
# 错误对话框已由 _execute_shell_command 弹出,这里只记录警告
logger.warning(f"未能清除设备 {dev} 上的 RAID 超级块,请手动检查。") 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: if success_all_cleared:
logger.info(f"成功删除 RAID 阵列 {array_path} 并清除了成员设备超级块。") logger.info(f"成功删除 RAID 阵列 {array_path} 并清除了成员设备超级块。")
QMessageBox.information(None, "成功", f"成功删除 RAID 阵列 {array_path}") QMessageBox.information(None, "成功", f"成功删除 RAID 阵列 {array_path}")
@@ -297,3 +418,54 @@ class RaidOperations:
else: else:
QMessageBox.critical(None, "错误", f"删除 RAID 阵列 {array_path} 完成,但部分成员设备超级块未能完全清除。") QMessageBox.critical(None, "错误", f"删除 RAID 阵列 {array_path} 完成,但部分成员设备超级块未能完全清除。")
return False 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(dev['children'])
add_path_recursive(devices) add_path_recursive(devices)
return 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: except Exception as e:
logger.error(f"获取块设备信息失败: {e}") logger.error(f"获取块设备信息失败 (未知错误): {e}")
return [] return []
def _find_device_by_path_recursive(self, dev_list, target_path): 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} 的挂载点。") logger.debug(f"get_mountpoint_for_device: 未能获取到 {original_device_path} 的挂载点。")
return None return None
def get_mdadm_arrays(self): def _parse_mdadm_detail_output(self, output, device_path):
""" """
获取 mdadm RAID 阵列信息 解析 mdadm --detail 命令的输出
""" """
cmd = ["mdadm", "--detail", "--scan"] array_info = {
try: 'device': device_path, # Default to input path, will be updated by canonical_device_path
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,
'level': 'N/A', 'level': 'N/A',
'state': 'N/A', 'state': 'N/A',
'array_size': 'N/A', 'array_size': 'N/A',
@@ -241,46 +219,202 @@ class SystemInfoManager:
'spare_devices': 'N/A', 'spare_devices': 'N/A',
'total_devices': 'N/A', 'total_devices': 'N/A',
'uuid': 'N/A', 'uuid': 'N/A',
'name': 'N/A', 'name': os.path.basename(device_path), # Default name, will be overridden
'chunk_size': 'N/A', 'chunk_size': 'N/A',
'member_devices': [] 'member_devices': []
} }
member_pattern = re.compile(r'^\s*(\d+)\s+(\d+)\s+(\d+)\s+(.+?)\s+(/dev/.+)$')
for line in detail_output.splitlines(): # 首先,尝试从 mdadm --detail 输出的第一行获取规范的设备路径
if "Raid Level :" in line: device_path_header_match = re.match(r'^(?P<canonical_path>/dev/md\d+):', output)
info['level'] = line.split(':')[-1].strip() if device_path_header_match:
elif "Array Size :" in line: array_info['device'] = device_path_header_match.group('canonical_path')
info['array_size'] = line.split(':')[-1].strip() array_info['name'] = os.path.basename(array_info['device']) # 根据规范路径更新默认名称
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()
# Member devices # 逐行解析键值对
match = member_pattern.match(line) for line in output.splitlines():
if match: line = line.strip()
member_info = { if not line:
'number': match.group(1), continue
'major': match.group(2),
'minor': match.group(3), # 尝试匹配 "Key : Value" 格式
'raid_device': match.group(4), kv_match = re.match(r'^(?P<key>[A-Za-z ]+)\s*:\s*(?P<value>.+)', line)
'device_path': match.group(5) if kv_match:
} key = kv_match.group('key').strip().lower().replace(' ', '_')
info['member_devices'].append(member_info) value = kv_match.group('value').strip()
return info
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): def get_lvm_info(self):
""" """
@@ -290,7 +424,7 @@ class SystemInfoManager:
# Get PVs # Get PVs
try: try:
stdout, _ = self._run_command(["pvs", "--reportformat", "json"]) stdout, stderr = self._run_command(["pvs", "--reportformat", "json"])
data = json.loads(stdout) data = json.loads(stdout)
if 'report' in data and data['report']: if 'report' in data and data['report']:
for pv_data in data['report'][0].get('pv', []): 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: if "No physical volume found" in e.stderr or "No physical volumes found" in e.stdout:
logger.info("未找到任何LVM物理卷。") logger.info("未找到任何LVM物理卷。")
else: 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: except Exception as e:
logger.error(f"获取LVM物理卷信息失败: {e}") logger.error(f"获取LVM物理卷信息失败 (未知错误): {e}")
# Get VGs # Get VGs
try: try:
stdout, _ = self._run_command(["vgs", "--reportformat", "json"]) stdout, stderr = self._run_command(["vgs", "--reportformat", "json"])
data = json.loads(stdout) data = json.loads(stdout)
if 'report' in data and data['report']: if 'report' in data and data['report']:
for vg_data in data['report'][0].get('vg', []): 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: if "No volume group found" in e.stderr or "No volume groups found" in e.stdout:
logger.info("未找到任何LVM卷组。") logger.info("未找到任何LVM卷组。")
else: 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: except Exception as e:
logger.error(f"获取LVM卷组信息失败: {e}") logger.error(f"获取LVM卷组信息失败 (未知错误): {e}")
# Get LVs (MODIFIED: added -o lv_path) # Get LVs (MODIFIED: added -o lv_path)
try: try:
# 明确请求 lv_path因为默认的 --reportformat json 不包含它 # 明确请求 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) data = json.loads(stdout)
if 'report' in data and data['report']: if 'report' in data and data['report']:
for lv_data in data['report'][0].get('lv', []): 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: if "No logical volume found" in e.stderr or "No logical volumes found" in e.stdout:
logger.info("未找到任何LVM逻辑卷。") logger.info("未找到任何LVM逻辑卷。")
else: 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: except Exception as e:
logger.error(f"获取LVM逻辑卷信息失败: {e}") logger.error(f"获取LVM逻辑卷信息失败 (未知错误): {e}")
return lvm_info return lvm_info
@@ -430,6 +570,10 @@ class SystemInfoManager:
logger.warning(f"传入 _get_actual_md_device_path 的 array_path 不是字符串: {array_path} (类型: {type(array_path)})") logger.warning(f"传入 _get_actual_md_device_path 的 array_path 不是字符串: {array_path} (类型: {type(array_path)})")
return None return None
# 如果已经是规范的 /dev/mdX 路径,直接返回
if re.match(r'^/dev/md\d+$', array_path):
return array_path
if os.path.exists(array_path): if os.path.exists(array_path):
try: try:
# os.path.realpath 会解析符号链接,例如 /dev/md/new_raid -> /dev/md127 # os.path.realpath 会解析符号链接,例如 /dev/md/new_raid -> /dev/md127
@@ -546,3 +690,69 @@ class SystemInfoManager:
# 5. 如果仍然没有找到,返回 None # 5. 如果仍然没有找到,返回 None
logger.debug(f"get_device_details_by_path: 未能获取到 {original_device_path} 的任何详情。") logger.debug(f"get_device_details_by_path: 未能获取到 {original_device_path} 的任何详情。")
return None 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}")