first commit

This commit is contained in:
zj
2026-02-07 14:58:56 +08:00
commit 8c53b9c0a0
19 changed files with 4540 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
/dist/
/build/
/.qtcreator/
/__pycache__/

275
AGENTS.md Normal file
View File

@@ -0,0 +1,275 @@
# Linux 存储管理器 (Linux Storage Manager) - AI Agent Guide
## 项目概述
这是一个基于 PySide6 开发的 Linux 存储管理图形界面工具,提供磁盘管理、逻辑卷管理(LVM)和软阵列管理(RAID)三大核心功能。
**主要功能模块:**
1. **磁盘管理** - 分区创建/删除、格式化(ext4/xfs/ntfs/fat32)、挂载/卸载、分区表擦除
2. **LVM 管理** - 物理卷(PV)、卷组(VG)、逻辑卷(LV)的创建和管理
3. **RAID 管理** - 软阵列创建、管理、停止/启动阵列 (支持 RAID0/1/5/6/10)
## 技术栈
| 组件 | 说明 |
|------|------|
| Python 3.14+ | 主要编程语言 |
| PySide6 | Qt6 的 Python 绑定,用于 GUI |
| Qt Designer (.ui 文件) | UI 设计文件 |
| PyInstaller | 打包工具,生成独立可执行文件 |
| 系统工具 | parted, mkfs.*, mount, umount, lvm2, mdadm, lsblk, findmnt |
## 项目结构
```
.
├── mainwindow.py # 主窗口UI 与业务逻辑控制器
├── ui_form.py # 自动生成的 UI 代码 (从 form.ui 生成)
├── form.ui # Qt Designer UI 定义文件
├── system_info.py # 系统信息获取模块 (块设备、RAID、挂载点)
├── disk_operations.py # 磁盘操作 (分区、格式化、挂载)
├── lvm_operations.py # LVM 操作 (PV/VG/LV 管理)
├── raid_operations.py # RAID 操作 (mdadm 管理)
├── dialogs.py # 自定义对话框 (创建分区、RAID、PV/VG/LV 等)
├── occupation_resolver.py # 设备占用解除模块 (处理挂载、进程占用)
├── logger_config.py # 日志配置 (输出到 QTextEdit 和控制台)
├── pyproject.toml # PySide6 项目配置
├── disk-manager.spec # PyInstaller 打包配置 (英文版)
├── Linux存儲管理器.spec # PyInstaller 打包配置 (中文版)
└── build-app.sh # 打包脚本
```
## 代码组织
### 模块依赖关系
```
mainwindow.py (入口)
├── ui_form.py (自动生成的 UI)
├── system_info.py (系统信息)
├── disk_operations.py (磁盘操作)
│ └── 依赖: system_info.py, lvm_operations.py
├── lvm_operations.py (LVM 操作,提供 _execute_shell_command)
├── raid_operations.py (RAID 操作)
│ └── 依赖: system_info.py
├── occupation_resolver.py (占用解除)
│ └── 依赖: lvm_operations.py, system_info.py
├── dialogs.py (对话框)
└── logger_config.py (日志)
```
### 核心类说明
| 类名 | 文件 | 职责 |
|------|------|------|
| `MainWindow` | mainwindow.py | 主窗口,整合所有功能,处理用户交互 |
| `SystemInfoManager` | system_info.py | 获取系统块设备信息、RAID 信息、挂载点 |
| `DiskOperations` | disk_operations.py | 磁盘分区、格式化、挂载操作 |
| `LvmOperations` | lvm_operations.py | LVM 相关操作,提供统一的 shell 命令执行接口 |
| `RaidOperations` | raid_operations.py | RAID 阵列管理 |
| `OccupationResolver` | occupation_resolver.py | 解除设备占用 (嵌套挂载、进程占用) |
| `FormatWorker` | disk_operations.py | 后台格式化工作线程 |
| `QTextEditLogger` | logger_config.py | 自定义日志处理器,输出到 UI |
### 对话框类 (dialogs.py)
- `CreatePartitionDialog` - 创建分区对话框
- `MountDialog` - 挂载分区对话框
- `CreateRaidDialog` - 创建 RAID 阵列对话框
- `CreatePvDialog` - 创建物理卷对话框
- `CreateVgDialog` - 创建卷组对话框
- `CreateLvDialog` - 创建逻辑卷对话框
## 构建和打包
### 依赖安装
```bash
pip install PySide6 pyinstaller pexpect
```
### 开发运行
```bash
# 直接运行
python mainwindow.py
# 注意: 部分功能需要 root 权限
sudo python mainwindow.py
```
### 生成可执行文件
```bash
# 使用 PyInstaller (需要配置虚拟环境路径)
sudo /path/to/venv/bin/pyinstaller -F --name "disk-manager" mainwindow.py
# 或使用提供的打包脚本 (需修改路径)
bash build-app.sh
```
打包配置文件:
- `disk-manager.spec` - 英文名称配置
- `Linux存儲管理器.spec` - 中文名称配置
## UI 设计规范
### 界面布局 (form.ui)
主窗口包含:
1. **刷新按钮** - 刷新所有设备信息
2. **标签页控件** - 三个标签页:
- 块设备概览 (treeWidget_block_devices)
- RAID 管理 (treeWidget_raid)
- LVM 管理 (treeWidget_lvm)
3. **日志输出区** (logOutputTextEdit) - 显示操作日志
### 修改 UI 的流程
1. 使用 Qt Designer 编辑 `form.ui`
2. 使用 `pyside6-uic` 重新生成 `ui_form.py`:
```bash
pyside6-uic form.ui -o ui_form.py
```
3. 注意:`ui_form.py` 是自动生成的,不要手动修改
## 编码规范
### 代码风格
- 使用 4 空格缩进
- 函数和变量使用小写加下划线命名 (snake_case)
- 类名使用大驼峰命名 (PascalCase)
- 注释和日志使用中文
### 错误处理
所有 shell 命令执行通过 `_execute_shell_command` 方法统一处理:
- 自动处理 sudo 权限
- 统一的错误对话框显示
- 详细的日志记录
示例:
```python
success, stdout, stderr = self.lvm_ops._execute_shell_command(
["parted", "-s", device_path, "mklabel", "gpt"],
f"擦除 {device_path} 上的分区表失败"
)
```
### 日志记录
使用标准 logging 模块,配置在 `logger_config.py`:
- DEBUG: 详细的调试信息
- INFO: 常规操作信息
- WARNING: 警告信息
- ERROR: 错误信息
日志同时输出到:
1. 控制台 (标准输出)
2. UI 的 QTextEdit 控件 (通过 QTextEditLogger)
## 关键实现细节
### 1. 权限处理
所有需要 root 权限的操作都通过 `sudo` 执行。项目中没有直接处理密码输入,依赖系统的 sudo 配置。
### 2. 异步操作
格式化操作使用 `QThread` + `FormatWorker` 在后台执行,避免阻塞 UI:
```python
# 信号定义
formatting_finished = Signal(bool, str, str, str) # success, device, stdout, stderr
formatting_started = Signal(str)
```
### 3. 设备识别
- 使用 `lsblk -J` 获取块设备信息 (JSON 格式)
- 支持通过设备路径或 maj:min 号识别设备
- 正确处理符号链接 (如 LVM 设备、RAID 别名)
### 4. 占用解除
`OccupationResolver` 类处理复杂的设备占用情况:
- 嵌套挂载点卸载
- 用户进程占用检测和终止
- 交换分区关闭
## 测试策略
当前项目没有自动化测试套件。测试主要依赖:
1. **手动测试** - 在真实 Linux 环境中测试各项功能
2. **日志验证** - 通过日志输出验证操作执行
**测试环境要求:**
- Linux 系统 (建议 Ubuntu/Debian 或 Arch Linux)
- 安装必要工具: `parted`, `lvm2`, `mdadm`, `dosfstools`, `ntfs-3g`
- 测试磁盘建议使用虚拟机或空闲磁盘,避免数据丢失
## 安全注意事项
⚠️ **危险操作警告:**
1. **格式化操作** - 会永久删除数据
2. **分区删除** - 会丢失分区数据
3. **分区表擦除** - 会清除整个磁盘分区信息
4. **RAID 操作** - 错误的操作可能导致阵列损坏
**安全准则:**
- 所有危险操作都有确认对话框
- 操作前会记录警告日志
- 建议只在测试环境或已备份的系统上使用
- 部分操作不可逆,请谨慎操作
## 外部依赖工具
应用程序依赖以下系统工具:
| 工具 | 用途 |
|------|------|
| lsblk | 获取块设备信息 |
| parted | 分区管理 |
| mkfs.ext4/mkfs.xfs/mkfs.vfat/mkfs.ntfs | 文件系统格式化 |
| mount/umount | 挂载/卸载 |
| lvm (pvcreate/vgcreate/lvcreate 等) | LVM 管理 |
| mdadm | RAID 管理 |
| findmnt | 挂载点查询 |
| fuser | 进程占用查询 |
| stat | 设备信息获取 |
| pexpect | 交互式命令执行 (RAID 创建) |
## 配置说明
### pyproject.toml
PySide6 项目配置文件,定义项目文件列表:
```toml
[tool.pyside6-project]
files = ["dialogs.py", "disk_operations.py", ...]
```
### .gitignore
排除目录:
- /dist/ - PyInstaller 输出
- /build/ - 构建缓存
- /.qtcreator/ - Qt Creator 配置
- /__pycache__/ - Python 缓存
## 开发工作流
1. **修改 UI**: 编辑 `form.ui` → 运行 `pyside6-uic` 生成 `ui_form.py`
2. **添加功能**: 在相应模块添加逻辑,在 `MainWindow` 添加槽函数
3. **测试**: 在 Linux 环境中运行 `python mainwindow.py`
4. **打包**: 运行 `build-app.sh` 或直接使用 PyInstaller
5. **提交**: 使用 `up+.sh` 脚本辅助 git 提交 (需配置)
## 常见问题
1. **UI 更新不生效** - 检查是否重新生成了 `ui_form.py`
2. **权限错误** - 确保以 sudo 运行或配置 sudo 免密
3. **命令未找到** - 安装对应的系统工具包
4. **RAID 创建卡住** - pexpect 交互处理可能需要调整

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,
)

5
README.md Normal file
View File

@@ -0,0 +1,5 @@
python pyqt
### 实现功能
1.磁盘管理
2.逻辑卷管理
3.软阵列管理

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

486
dialogs.py Normal file
View File

@@ -0,0 +1,486 @@
# dialogs.py
import os
from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QLineEdit, QComboBox, QDoubleSpinBox,
QPushButton, QFormLayout, QFileDialog,
QMessageBox, QCheckBox, QListWidget, QListWidgetItem)
from PySide6.QtCore import Qt
from logger_config import setup_logging, logger
class CreatePartitionDialog(QDialog):
def __init__(self, parent=None, disk_path="", total_disk_mib=0.0, max_available_mib=0.0):
super().__init__(parent)
self.setWindowTitle("创建分区")
self.setMinimumWidth(300)
self.disk_path = disk_path
self.total_disk_mib = total_disk_mib # 磁盘总大小 (MiB)
self.max_available_mib = max_available_mib # 可用空间 (MiB)
logger.debug(f"CreatePartitionDialog initialized for {disk_path}. Total MiB: {total_disk_mib}, Max Available MiB: {max_available_mib}")
self.partition_table_type_combo = QComboBox()
self.partition_table_type_combo.addItems(["gpt", "msdos"])
self.size_spinbox = QDoubleSpinBox()
# 调整最小值为 0.01 GB (约 10MB),并确保最大值不小于最小值
calculated_max_gb = self.max_available_mib / 1024.0
self.size_spinbox.setMinimum(0.01)
self.size_spinbox.setMaximum(max(0.01, calculated_max_gb)) # 将 MiB 转换为 GB 显示
logger.debug(f"Size spinbox max set to: {self.size_spinbox.maximum()} GB (calculated from {calculated_max_gb} GB)")
self.size_spinbox.setSuffix(" GB")
self.size_spinbox.setDecimals(2)
self.use_max_space_checkbox = QCheckBox("使用最大可用空间")
self.use_max_space_checkbox.setChecked(True) # 默认选中
self._setup_ui()
self._connect_signals()
self._initialize_state()
def _setup_ui(self):
layout = QVBoxLayout(self)
form_layout = QFormLayout()
form_layout.addRow("磁盘路径:", QLabel(self.disk_path))
form_layout.addRow("分区表类型:", self.partition_table_type_combo)
form_layout.addRow("分区大小:", self.size_spinbox)
form_layout.addRow("", self.use_max_space_checkbox) # 添加复选框
layout.addLayout(form_layout)
button_box = QHBoxLayout()
self.confirm_button = QPushButton("确定")
self.cancel_button = QPushButton("取消")
button_box.addWidget(self.confirm_button)
button_box.addWidget(self.cancel_button)
layout.addLayout(button_box)
def _connect_signals(self):
self.confirm_button.clicked.connect(self.accept)
self.cancel_button.clicked.connect(self.reject)
# 信号连接保持不变,但 _toggle_size_input 方法内部将直接查询复选框状态
self.use_max_space_checkbox.stateChanged.connect(self._toggle_size_input)
def _initialize_state(self):
# 根据默认选中状态设置 spinbox
self._toggle_size_input(self.use_max_space_checkbox.checkState())
def _toggle_size_input(self, state): # state 参数仍然接收,但不再直接用于判断
# 直接查询复选框的当前状态,而不是依赖信号传递的 state 参数
is_checked = self.use_max_space_checkbox.isChecked()
logger.debug(f"[_toggle_size_input] Called. Checkbox isChecked(): {is_checked}, signal state: {state}")
if is_checked:
logger.debug(f"[_toggle_size_input] Checkbox is CHECKED. Disabling spinbox and setting value to max.")
self.size_spinbox.setEnabled(False) # 禁用 spinbox
max_val = self.size_spinbox.maximum()
self.size_spinbox.setValue(max_val) # 设置为最大可用 GB
logger.debug(f"[_toggle_size_input] Spinbox value set to max: {max_val} GB.")
else: # is_checked is False
logger.debug(f"[_toggle_size_input] Checkbox is UNCHECKED. Enabling spinbox.")
self.size_spinbox.setEnabled(True) # 启用 spinbox
# 如果之前是最大值,取消勾选后,恢复到最小值,方便用户输入自定义值
if self.size_spinbox.value() == self.size_spinbox.maximum():
min_val = self.size_spinbox.minimum()
self.size_spinbox.setValue(min_val)
logger.debug(f"[_toggle_size_input] Spinbox was at max, reset to min: {min_val} GB.")
else:
logger.debug(f"[_toggle_size_input] Spinbox was not at max, keeping current value: {self.size_spinbox.value()} GB.")
def get_partition_info(self):
size_gb = self.size_spinbox.value()
use_max_space = self.use_max_space_checkbox.isChecked()
if not use_max_space and size_gb <= 0:
QMessageBox.warning(self, "输入错误", "分区大小必须大于0。")
return None
# 这里的检查应该使用 self.size_spinbox.maximum() 来判断,因为它是实际的最大值
if not use_max_space and size_gb > self.size_spinbox.maximum():
QMessageBox.warning(self, "输入错误", "分区大小不能超过最大可用空间。")
return None
return {
'disk_path': self.disk_path,
'partition_table_type': self.partition_table_type_combo.currentText(),
'size_gb': size_gb,
'total_disk_mib': self.total_disk_mib, # 传递磁盘总大小 (MiB)
'use_max_space': use_max_space # 返回此标志
}
class MountDialog(QDialog):
def __init__(self, parent=None, device_path=""):
super().__init__(parent)
self.setWindowTitle("挂载分区")
self.setMinimumWidth(300)
self.device_path = device_path
self.mount_point_input = QLineEdit()
self.add_to_fstab_checkbox = QCheckBox("开机自动挂载 (添加到 /etc/fstab)") # 新增
self._setup_ui()
self._connect_signals()
def _setup_ui(self):
layout = QVBoxLayout(self)
form_layout = QFormLayout()
form_layout.addRow("设备路径:", QLabel(self.device_path))
form_layout.addRow("挂载点:", self.mount_point_input)
form_layout.addRow("", self.add_to_fstab_checkbox) # 添加复选框
layout.addLayout(form_layout)
button_box = QHBoxLayout()
self.confirm_button = QPushButton("确定")
self.cancel_button = QPushButton("取消")
button_box.addWidget(self.confirm_button)
button_box.addWidget(self.cancel_button)
layout.addLayout(button_box)
# 默认挂载点,可以根据设备名生成
default_mount_point = f"/mnt/{os.path.basename(self.device_path)}"
self.mount_point_input.setText(default_mount_point)
def _connect_signals(self):
self.confirm_button.clicked.connect(self.accept)
self.cancel_button.clicked.connect(self.reject)
def get_mount_info(self):
mount_point = self.mount_point_input.text().strip()
add_to_fstab = self.add_to_fstab_checkbox.isChecked()
if not mount_point:
QMessageBox.warning(self, "输入错误", "挂载点不能为空。")
return None
if not mount_point.startswith('/'):
QMessageBox.warning(self, "输入错误", "挂载点必须是绝对路径 (以 '/' 开头)。")
return None
return {
'mount_point': mount_point,
'add_to_fstab': add_to_fstab
}
class CreateRaidDialog(QDialog):
def __init__(self, parent=None, available_devices=None):
super().__init__(parent)
self.setWindowTitle("创建 RAID 阵列")
self.setMinimumWidth(350)
self.available_devices = available_devices if available_devices is not None else []
self.selected_devices = []
self.raid_level_combo = QComboBox()
self.raid_level_combo.addItems(["raid0", "raid1", "raid5", "raid6", "raid10"])
self.chunk_size_spinbox = QDoubleSpinBox()
self.chunk_size_spinbox.setMinimum(4)
self.chunk_size_spinbox.setMaximum(1024)
self.chunk_size_spinbox.setSingleStep(4)
self.chunk_size_spinbox.setValue(512) # 默认值
self.chunk_size_spinbox.setSuffix(" KB")
self.device_list_widget = QListWidget()
for dev in self.available_devices:
item = QListWidgetItem(dev)
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
item.setCheckState(Qt.Unchecked)
self.device_list_widget.addItem(item)
self._setup_ui()
self._connect_signals()
def _setup_ui(self):
layout = QVBoxLayout(self)
form_layout = QFormLayout()
form_layout.addRow("RAID 级别:", self.raid_level_combo)
form_layout.addRow("Chunk 大小:", self.chunk_size_spinbox)
form_layout.addRow("选择设备:", self.device_list_widget)
layout.addLayout(form_layout)
button_box = QHBoxLayout()
self.confirm_button = QPushButton("确定")
self.cancel_button = QPushButton("取消")
button_box.addWidget(self.confirm_button)
button_box.addWidget(self.cancel_button)
layout.addLayout(button_box)
def _connect_signals(self):
self.confirm_button.clicked.connect(self.accept)
self.cancel_button.clicked.connect(self.reject)
def get_raid_info(self):
self.selected_devices = []
for i in range(self.device_list_widget.count()):
item = self.device_list_widget.item(i)
if item.checkState() == Qt.Checked:
self.selected_devices.append(item.text())
if not self.selected_devices:
QMessageBox.warning(self, "输入错误", "请选择至少一个设备来创建 RAID 阵列。")
return None
raid_level = self.raid_level_combo.currentText()
num_devices = len(self.selected_devices)
# RAID level specific checks for minimum devices
if raid_level == "raid0" and num_devices < 1: # RAID0 can be 1 device in some contexts, but usually >1
QMessageBox.warning(self, "输入错误", "RAID0 至少需要一个设备。")
return None
elif raid_level == "raid1" and num_devices < 2:
QMessageBox.warning(self, "输入错误", "RAID1 至少需要两个设备。")
return None
elif raid_level == "raid5" and num_devices < 3:
QMessageBox.warning(self, "输入错误", "RAID5 至少需要三个设备。")
return None
elif raid_level == "raid6" and num_devices < 4:
QMessageBox.warning(self, "输入错误", "RAID6 至少需要四个设备。")
return None
elif raid_level == "raid10" and num_devices < 2: # RAID10 needs at least 2 (for 1+0)
QMessageBox.warning(self, "输入错误", "RAID10 至少需要两个设备。")
return None
return {
'devices': self.selected_devices,
'level': self.raid_level_combo.currentText().replace('raid', ''), # 移除 'raid' 前缀
'chunk_size': int(self.chunk_size_spinbox.value()) # KB
}
class CreatePvDialog(QDialog):
def __init__(self, parent=None, available_partitions=None):
super().__init__(parent)
self.setWindowTitle("创建物理卷 (PV)")
self.setMinimumWidth(300)
self.available_partitions = available_partitions if available_partitions is not None else []
self.device_combo_box = QComboBox()
self.device_combo_box.addItems(self.available_partitions)
self._setup_ui()
self._connect_signals()
def _setup_ui(self):
layout = QVBoxLayout(self)
form_layout = QFormLayout()
form_layout.addRow("选择设备:", self.device_combo_box)
layout.addLayout(form_layout)
button_box = QHBoxLayout()
self.confirm_button = QPushButton("确定")
self.cancel_button = QPushButton("取消")
button_box.addWidget(self.confirm_button)
button_box.addWidget(self.cancel_button)
layout.addLayout(button_box)
def _connect_signals(self):
self.confirm_button.clicked.connect(self.accept)
self.cancel_button.clicked.connect(self.reject)
def get_pv_info(self):
device_path = self.device_combo_box.currentText()
if not device_path:
QMessageBox.warning(self, "输入错误", "请选择一个设备来创建物理卷。")
return None
return {'device_path': device_path}
class CreateVgDialog(QDialog):
def __init__(self, parent=None, available_pvs=None):
super().__init__(parent)
self.setWindowTitle("创建卷组 (VG)")
self.setMinimumWidth(300)
self.available_pvs = available_pvs if available_pvs is not None else []
self.selected_pvs = []
self.vg_name_input = QLineEdit()
self.pv_list_widget = QListWidget()
for pv in self.available_pvs:
item = QListWidgetItem(pv)
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
item.setCheckState(Qt.Unchecked)
self.pv_list_widget.addItem(item)
self._setup_ui()
self._connect_signals()
def _setup_ui(self):
layout = QVBoxLayout(self)
form_layout = QFormLayout()
form_layout.addRow("卷组名称:", self.vg_name_input)
form_layout.addRow("选择物理卷:", self.pv_list_widget)
layout.addLayout(form_layout)
button_box = QHBoxLayout()
self.confirm_button = QPushButton("确定")
self.cancel_button = QPushButton("取消")
button_box.addWidget(self.confirm_button)
button_box.addWidget(self.cancel_button)
layout.addLayout(button_box)
def _connect_signals(self):
self.confirm_button.clicked.connect(self.accept)
self.cancel_button.clicked.connect(self.reject)
def get_vg_info(self):
vg_name = self.vg_name_input.text().strip()
self.selected_pvs = []
for i in range(self.pv_list_widget.count()):
item = self.pv_list_widget.item(i)
if item.checkState() == Qt.Checked:
self.selected_pvs.append(item.text())
if not vg_name:
QMessageBox.warning(self, "输入错误", "卷组名称不能为空。")
return None
if not self.selected_pvs:
QMessageBox.warning(self, "输入错误", "请选择至少一个物理卷来创建卷组。")
return None
return {
'vg_name': vg_name,
'pvs': self.selected_pvs
}
class CreateLvDialog(QDialog):
def __init__(self, parent=None, available_vgs=None, vg_sizes=None):
super().__init__(parent)
self.setWindowTitle("创建逻辑卷")
self.setMinimumWidth(300)
self.available_vgs = available_vgs if available_vgs is not None else []
self.vg_sizes = vg_sizes if vg_sizes is not None else {} # 存储每个 VG 的可用大小 (GB)
logger.debug(f"CreateLvDialog initialized. Available VGs: {self.available_vgs}, VG Sizes: {self.vg_sizes}")
self.lv_name_input = QLineEdit()
self.vg_combo_box = QComboBox()
self.lv_size_spinbox = QDoubleSpinBox()
self.use_max_space_checkbox = QCheckBox("使用最大可用空间")
self._setup_ui()
self._connect_signals()
self._initialize_state()
def _setup_ui(self):
layout = QVBoxLayout(self)
form_layout = QFormLayout()
form_layout.addRow("逻辑卷名称:", self.lv_name_input)
form_layout.addRow("选择卷组:", self.vg_combo_box)
form_layout.addRow("逻辑卷大小 (GB):", self.lv_size_spinbox)
form_layout.addRow("", self.use_max_space_checkbox)
layout.addLayout(form_layout)
button_box = QHBoxLayout()
self.confirm_button = QPushButton("确定")
self.cancel_button = QPushButton("取消")
button_box.addWidget(self.confirm_button)
button_box.addWidget(self.cancel_button)
layout.addLayout(button_box)
self.lv_size_spinbox.setMinimum(0.01) # 逻辑卷最小大小,例如 0.01 GB
self.lv_size_spinbox.setSuffix(" GB")
self.lv_size_spinbox.setDecimals(2)
# 设置一个较大的初始最大值,它将在 _update_size_options 中被实际的 VG 大小覆盖
self.lv_size_spinbox.setMaximum(100000.0) # 初始设置一个足够大的最大值
def _connect_signals(self):
self.confirm_button.clicked.connect(self.accept)
self.cancel_button.clicked.connect(self.reject)
self.vg_combo_box.currentIndexChanged.connect(self._update_size_options)
# 信号连接到不带参数的槽函数
self.use_max_space_checkbox.stateChanged.connect(self._toggle_size_input)
def _initialize_state(self):
self.vg_combo_box.addItems(self.available_vgs)
# 默认选中“使用最大可用空间”,这将触发 _toggle_size_input
self.use_max_space_checkbox.setChecked(True)
# 首次调用以设置初始状态和根据复选框状态设置值
# 这一步是必要的,即使 setChecked(True) 也会触发一次 _toggle_size_input
# 但 _update_size_options 会在设置了正确的最大值后再次调用 _toggle_size_input确保最终状态正确。
self._update_size_options()
logger.debug("CreateLvDialog _initialize_state completed.")
def _update_size_options(self):
"""
根据选中的卷组更新逻辑卷大小的范围 (最小值和最大值)。
并根据当前“使用最大可用空间”复选框的状态调整 spinbox 的值和启用状态。
"""
selected_vg = self.vg_combo_box.currentText()
max_size_gb = self.vg_sizes.get(selected_vg, 0.0)
logger.debug(f"[_update_size_options] Selected VG: {selected_vg}, Max available GB: {max_size_gb}")
# 确保实际最大可用空间至少大于或等于 spinbox 的最小值
# 避免当可用空间非常小时spinbox 无法设置有效值
effective_max_size_gb = max(self.lv_size_spinbox.minimum(), max_size_gb)
self.lv_size_spinbox.setMaximum(effective_max_size_gb)
logger.debug(f"[_update_size_options] Spinbox max set to: {effective_max_size_gb} GB.")
# 在设置了新的最大值后,重新应用复选框的状态来更新值和启用状态
self._toggle_size_input() # 调用不带参数的槽函数
logger.debug("[_update_size_options] Completed.")
def _toggle_size_input(self): # 不再接收 'state' 参数
"""
根据“使用最大可用空间”复选框的状态切换大小输入框的启用/禁用状态和值。
"""
is_checked = self.use_max_space_checkbox.isChecked() # 直接查询复选框的当前状态
logger.debug(f"[_toggle_size_input] Called. Checkbox isChecked(): {is_checked}")
current_max_spinbox_value = self.lv_size_spinbox.maximum()
current_spinbox_value = self.lv_size_spinbox.value()
if is_checked:
logger.debug(f"[_toggle_size_input] Checkbox is CHECKED. Disabling spinbox and setting value to max.")
self.lv_size_spinbox.setDisabled(True) # 禁用 spinbox
self.lv_size_spinbox.setValue(current_max_spinbox_value) # 设置为最大可用 GB
logger.debug(f"[_toggle_size_input] Spinbox value set to max: {current_max_spinbox_value} GB.")
else: # is_checked is False
logger.debug(f"[_toggle_size_input] Checkbox is UNCHECKED. Enabling spinbox.")
self.lv_size_spinbox.setDisabled(False) # 启用 spinbox
# 如果之前是最大值(因为复选框被勾选),取消勾选后,恢复到最小值,方便用户输入自定义值
if current_spinbox_value == current_max_spinbox_value:
min_val = self.lv_size_spinbox.minimum()
self.lv_size_spinbox.setValue(min_val)
logger.debug(f"[_toggle_size_input] Spinbox was at max, reset to min: {min_val} GB.")
else:
logger.debug(f"[_toggle_size_input] Spinbox was not at max, keeping current value: {self.lv_size_spinbox.value()} GB.")
logger.debug("[_toggle_size_input] Completed.")
def get_lv_info(self):
lv_name = self.lv_name_input.text().strip()
vg_name = self.vg_combo_box.currentText()
size_gb = self.lv_size_spinbox.value()
use_max_space = self.use_max_space_checkbox.isChecked() # 获取复选框状态
if not lv_name:
QMessageBox.warning(self, "输入错误", "逻辑卷名称不能为空。")
return None
if not vg_name:
QMessageBox.warning(self, "输入错误", "请选择一个卷组。")
return None
# 如果没有使用最大空间,则检查输入大小的有效性
if not use_max_space:
if size_gb <= 0:
QMessageBox.warning(self, "输入错误", "逻辑卷大小必须大于0。")
return None
# 这里的检查应该使用 self.lv_size_spinbox.maximum() 来判断,因为它是实际的最大值
if size_gb > self.lv_size_spinbox.maximum():
QMessageBox.warning(self, "输入错误", "逻辑卷大小不能超过卷组的可用空间。")
return None
return {
'lv_name': lv_name,
'vg_name': vg_name,
'size_gb': size_gb,
'use_max_space': use_max_space # 返回此标志
}

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,
)

469
disk_operations.py Normal file
View File

@@ -0,0 +1,469 @@
# disk_operations.py
import subprocess
import logging
import re
import os
from PySide6.QtWidgets import QMessageBox, QInputDialog
from PySide6.QtCore import QObject, Signal, QThread
from system_info import SystemInfoManager
logger = logging.getLogger(__name__)
# --- 后台格式化工作线程 ---
class FormatWorker(QObject):
# 定义信号,用于向主线程发送格式化结果
finished = Signal(bool, str, str, str) # 成功状态, 设备路径, 标准输出, 标准错误
started = Signal(str) # 设备路径
def __init__(self, device_path, fs_type, execute_shell_command_func, parent=None):
super().__init__(parent)
self.device_path = device_path
self.fs_type = fs_type
self._execute_shell_command_func = execute_shell_command_func
def run(self):
"""
在单独线程中执行格式化命令。
"""
self.started.emit(self.device_path)
logger.info(f"后台格式化开始: 设备 {self.device_path}, 文件系统 {self.fs_type}")
command_list = []
if self.fs_type == "ext4":
command_list = ["mkfs.ext4", "-F", self.device_path] # -F 强制执行
elif self.fs_type == "xfs":
command_list = ["mkfs.xfs", "-f", self.device_path] # -f 强制执行
elif self.fs_type == "ntfs":
command_list = ["mkfs.ntfs", "-f", self.device_path] # -f 强制执行
elif self.fs_type == "fat32":
command_list = ["mkfs.vfat", "-F", "32", self.device_path] # -F 32 指定FAT32
else:
logger.error(f"不支持的文件系统类型: {self.fs_type}")
self.finished.emit(False, self.device_path, "", f"不支持的文件系统类型: {self.fs_type}")
return
# 调用传入的shell命令执行函数并禁用其内部的QMessageBox显示
success, stdout, stderr = self._execute_shell_command_func(
command_list,
f"格式化设备 {self.device_path}{self.fs_type} 失败",
root_privilege=True,
show_dialog=False # 阻止工作线程弹出 QMessageBox
)
self.finished.emit(success, self.device_path, stdout, stderr)
logger.info(f"后台格式化完成: 设备 {self.device_path}, 成功: {success}")
class DiskOperations(QObject): # 继承 QObject 以便发出信号
# 定义信号,用于通知主线程格式化操作的开始和结束
formatting_finished = Signal(bool, str, str, str) # 成功状态, 设备路径, 标准输出, 标准错误
formatting_started = Signal(str) # 设备路径
def __init__(self, system_manager: SystemInfoManager, lvm_ops, parent=None):
super().__init__(parent)
self.system_manager = system_manager
self._lvm_ops = lvm_ops # 保存 LvmOperations 实例,以便调用其 _execute_shell_command
self.active_format_workers = {} # 用于跟踪正在运行的格式化线程
# DiskOperations 内部的 _execute_shell_command 只是对 LvmOperations._execute_shell_command 的包装
# 这样所有 shell 命令都通过 LvmOperations 的统一入口,可以控制 show_dialog
def _execute_shell_command(self, command_list, error_message, root_privilege=True,
suppress_critical_dialog_on_stderr_match=None, input_data=None,
show_dialog=True):
return self._lvm_ops._execute_shell_command(
command_list, error_message, root_privilege, suppress_critical_dialog_on_stderr_match, input_data, show_dialog
)
def _add_to_fstab(self, device_path, mount_point, fstype, uuid):
"""
将设备的挂载信息添加到 /etc/fstab。
使用 UUID 识别设备以提高稳定性。
"""
if not uuid or not mount_point or not fstype:
logger.error(f"无法将设备 {device_path} 添加到 fstab缺少 UUID/挂载点/文件系统类型。")
QMessageBox.warning(None, "警告", f"无法将设备 {device_path} 添加到 fstab缺少 UUID/挂载点/文件系统类型。")
return False
fstab_entry = f"UUID={uuid} {mount_point} {fstype} defaults 0 2"
fstab_path = "/etc/fstab"
try:
# 检查 fstab 中是否已存在相同 UUID 的条目
with open(fstab_path, 'r') as f:
fstab_content = f.readlines()
for line in fstab_content:
if f"UUID={uuid}" in line:
logger.warning(f"设备 {device_path} (UUID={uuid}) 已存在于 fstab 中。跳过添加。")
QMessageBox.information(None, "信息", f"设备 {device_path} (UUID={uuid}) 已存在于 fstab 中。")
return True # 认为成功,因为目标已达成
# 如果不存在,则追加到 fstab
success, _, stderr = self._execute_shell_command(
["sh", "-c", f"echo '{fstab_entry}' >> {fstab_path}"],
f"{device_path} 添加到 {fstab_path} 失败",
root_privilege=True
)
if success:
logger.info(f"已将 {fstab_entry} 添加到 {fstab_path}")
QMessageBox.information(None, "成功", f"设备 {device_path} 已成功添加到 /etc/fstab。")
return True
else:
return False # Error handled by _execute_shell_command
except Exception as e:
logger.error(f"处理 {fstab_path} 失败: {e}")
QMessageBox.critical(None, "错误", f"处理 {fstab_path} 失败: {e}")
return False
def _remove_fstab_entry(self, device_path):
"""
从 /etc/fstab 中移除指定设备的条目。
此方法需要 SystemInfoManager 来获取设备的 UUID。
"""
device_details = self.system_manager.get_device_details_by_path(device_path)
if not device_details or not device_details.get('uuid'):
logger.warning(f"无法获取设备 {device_path} 的 UUID无法从 fstab 中移除。")
return False
uuid = device_details.get('uuid')
fstab_path = "/etc/fstab"
command = ["sed", "-i", f"/{re.escape(uuid)}/d", fstab_path] # Use re.escape for UUID
success, _, stderr = self._execute_shell_command(
command,
f"{fstab_path} 中删除 UUID={uuid} 的条目失败",
root_privilege=True,
suppress_critical_dialog_on_stderr_match=(
f"sed: {fstab_path}: No such file or directory", # English
f"sed: {fstab_path}: 没有那个文件或目录", # Chinese
"no changes were made" # if sed finds nothing to delete
)
)
if success:
logger.info(f"已从 {fstab_path} 中删除 UUID={uuid} 的条目。")
return True
else:
if any(s in stderr for s in (
f"sed: {fstab_path}: No such file or directory",
f"sed: {fstab_path}: 没有那个文件或目录",
"no changes were made",
"No such file or directory" # more general check
)):
logger.info(f"fstab 中未找到 UUID={uuid} 的条目,无需删除。")
return True
return False
def mount_partition(self, device_path, mount_point, add_to_fstab=False):
"""
挂载指定设备到指定挂载点。
:param device_path: 要挂载的设备路径。
:param mount_point: 挂载点。
:param add_to_fstab: 是否添加到 /etc/fstab。
:return: True 如果成功,否则 False。
"""
if not os.path.exists(mount_point):
try:
os.makedirs(mount_point)
logger.info(f"创建挂载点目录: {mount_point}")
except OSError as e:
QMessageBox.critical(None, "错误", f"创建挂载点目录 {mount_point} 失败: {e}")
logger.error(f"创建挂载点目录 {mount_point} 失败: {e}")
return False
logger.info(f"尝试挂载设备 {device_path}{mount_point}")
success, _, stderr = self._execute_shell_command(
["mount", device_path, mount_point],
f"挂载设备 {device_path} 失败"
)
if success:
QMessageBox.information(None, "成功", f"设备 {device_path} 已成功挂载到 {mount_point}")
if add_to_fstab:
device_details = self.system_manager.get_device_details_by_path(device_path)
if device_details:
fstype = device_details.get('fstype')
uuid = device_details.get('uuid')
if fstype and uuid:
self._add_to_fstab(device_path, mount_point, fstype, uuid)
else:
logger.error(f"无法获取设备 {device_path} 的文件系统类型或 UUID 以添加到 fstab。")
QMessageBox.warning(None, "警告", f"设备 {device_path} 已挂载,但无法获取文件系统类型或 UUID 以添加到 fstab。")
else:
logger.error(f"无法获取设备 {device_path} 的详细信息以添加到 fstab。")
QMessageBox.warning(None, "警告", f"设备 {device_path} 已挂载,但无法获取详细信息以添加到 fstab。")
return True
else:
return False
def unmount_partition(self, device_path, show_dialog_on_error=True):
"""
卸载指定设备。
:param device_path: 要卸载的设备路径。
:param show_dialog_on_error: 是否在发生错误时显示对话框。
:return: True 如果成功,否则 False。
"""
logger.info(f"尝试卸载设备 {device_path}")
already_unmounted_errors = ("not mounted", "未挂载", "未指定挂载点")
success, _, stderr = self._execute_shell_command(
["umount", device_path],
f"卸载设备 {device_path} 失败",
suppress_critical_dialog_on_stderr_match=already_unmounted_errors,
show_dialog=show_dialog_on_error
)
if success:
QMessageBox.information(None, "成功", f"设备 {device_path} 已成功卸载。")
return True
else:
is_already_unmounted_error = any(s in stderr for s in already_unmounted_errors)
if is_already_unmounted_error:
logger.info(f"设备 {device_path} 已经处于未挂载状态(或挂载点未指定),无需重复卸载。")
return True
else:
return False
def get_disk_free_space_info_mib(self, disk_path, total_disk_mib):
"""
获取磁盘上最大的空闲空间块的起始位置 (MiB) 和大小 (MiB)。
:param disk_path: 磁盘路径。
:param total_disk_mib: 磁盘的总大小 (MiB),用于全新磁盘的计算。
:return: (start_mib, size_mib) 元组。如果磁盘是全新的,返回 (0.0, total_disk_mib)。
如果磁盘有分区表但没有空闲空间,返回 (None, None)。
"""
logger.debug(f"尝试获取磁盘 {disk_path} 的空闲空间信息 (MiB)。")
success, stdout, stderr = self._execute_shell_command(
["parted", "-s", disk_path, "unit", "MiB", "print", "free"],
f"获取磁盘 {disk_path} 分区信息失败",
root_privilege=True,
suppress_critical_dialog_on_stderr_match=("无法辨识的磁盘卷标", "unrecognized disk label")
)
if not success:
logger.error(f"获取磁盘 {disk_path} 空闲空间信息失败: {stderr}")
return None, None
logger.debug(f"parted print free 命令原始输出:\n{stdout}")
free_spaces = []
lines = stdout.splitlines()
free_space_line_pattern = re.compile(r'^\s*(\d+\.?\d*)MiB\s+(\d+\.?\d*)MiB\s+(\d+\.?\d*)MiB\s+(?:Free Space|空闲空间|可用空间)')
for line in lines:
match = free_space_line_pattern.match(line)
if match:
try:
start_mib = float(match.group(1))
size_mib = float(match.group(3))
free_spaces.append({'start_mib': start_mib, 'size_mib': size_mib})
except ValueError as ve:
logger.warning(f"解析 parted free space 行 '{line}' 失败: {ve}")
continue
if not free_spaces:
logger.warning(f"在磁盘 {disk_path} 上未找到空闲空间。")
return None, None
largest_free_space = max(free_spaces, key=lambda x: x['size_mib'])
start_mib = largest_free_space['start_mib']
size_mib = largest_free_space['size_mib']
logger.debug(f"磁盘 {disk_path} 的最大空闲空间块起始于 {start_mib:.2f} MiB大小为 {size_mib:.2f} MiB。")
return start_mib, size_mib
def create_partition(self, disk_path, partition_table_type, size_gb, total_disk_mib, use_max_space):
"""
在指定磁盘上创建分区。
:param disk_path: 磁盘路径 (例如 /dev/sdb)。
:param partition_table_type: 分区表类型 ('gpt''msdos')。
:param size_gb: 分区大小 (GB)。
:param total_disk_mib: 磁盘总大小 (MiB)。
:param use_max_space: 是否使用最大可用空间。
:return: True 如果成功,否则 False。
"""
if not isinstance(disk_path, str) or not isinstance(partition_table_type, str):
logger.error(f"尝试创建分区时传入无效参数。磁盘路径: {disk_path}, 分区表类型: {partition_table_type}")
QMessageBox.critical(None, "错误", "无效的磁盘路径或分区表类型。")
return False
# 1. 检查磁盘是否有分区表
has_partition_table = False
success_check, stdout_check, stderr_check = self._execute_shell_command(
["parted", "-s", disk_path, "print"],
f"检查磁盘 {disk_path} 分区表失败",
suppress_critical_dialog_on_stderr_match=("无法辨识的磁盘卷标", "unrecognized disk label"),
root_privilege=True
)
if not success_check:
if any(s in stderr_check for s in ("无法辨识的磁盘卷标", "unrecognized disk label")):
logger.info(f"磁盘 {disk_path} 没有可识别的分区表。")
has_partition_table = False
else:
logger.error(f"检查磁盘 {disk_path} 分区表时发生非预期的错误: {stderr_check}")
return False
else:
if "Partition Table: unknown" not in stdout_check and "分区表unknown" not in stdout_check:
has_partition_table = True
else:
logger.info(f"parted print 报告磁盘 {disk_path} 的分区表为 'unknown'")
has_partition_table = False
actual_start_mib_for_parted = 0.0
# 2. 如果没有分区表,则创建分区表
if not has_partition_table:
reply = QMessageBox.question(None, "确认创建分区表",
f"磁盘 {disk_path} 没有分区表。您确定要创建 {partition_table_type} 分区表吗?"
f"此操作将擦除磁盘上的所有数据。",
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
if reply == QMessageBox.No:
logger.info(f"用户取消了在 {disk_path} 上创建分区表的操作。")
return False
logger.info(f"尝试在 {disk_path} 上创建 {partition_table_type} 分区表。")
success, _, stderr = self._execute_shell_command(
["parted", "-s", disk_path, "mklabel", partition_table_type],
f"创建 {partition_table_type} 分区表失败"
)
if not success:
return False
actual_start_mib_for_parted = 1.0
else:
# 如果有分区表,获取下一个可用分区的起始位置
start_mib_from_parted, _ = self.get_disk_free_space_info_mib(disk_path, total_disk_mib)
if start_mib_from_parted is None:
QMessageBox.critical(None, "错误", f"无法确定磁盘 {disk_path} 的分区起始位置或没有空闲空间。")
return False
if start_mib_from_parted < 1.0:
actual_start_mib_for_parted = 1.0
else:
actual_start_mib_for_parted = start_mib_from_parted
# 3. 确定分区结束位置
if use_max_space:
end_pos = "100%"
size_for_log = "最大可用空间"
else:
end_mib = actual_start_mib_for_parted + size_gb * 1024
if end_mib > total_disk_mib:
QMessageBox.warning(None, "警告", f"请求的分区大小 ({size_gb}GB) 超出了可用空间。将调整为最大可用空间。")
end_pos = "100%"
size_for_log = "最大可用空间"
else:
end_pos = f"{end_mib}MiB"
size_for_log = f"{size_gb}GB"
# 4. 创建分区
create_cmd = ["parted", "-s", disk_path, "mkpart", "primary", f"{actual_start_mib_for_parted}MiB", end_pos]
logger.info(f"尝试在 {disk_path} 上创建 {size_for_log} 的主分区。命令: {' '.join(create_cmd)}")
success, _, stderr = self._execute_shell_command(
create_cmd,
f"{disk_path} 上创建分区失败"
)
if success:
QMessageBox.information(None, "成功", f"{disk_path} 上成功创建了 {size_for_log} 的分区。")
return True
else:
return False
def delete_partition(self, device_path):
"""
删除指定分区。
:param device_path: 要删除的分区路径。
:return: True 如果成功,否则 False。
"""
reply = QMessageBox.question(None, "确认删除分区",
f"您确定要删除分区 {device_path} 吗?此操作将擦除分区上的所有数据!",
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
if reply == QMessageBox.No:
logger.info(f"用户取消了删除分区 {device_path} 的操作。")
return False
# 尝试卸载分区
self.unmount_partition(device_path, show_dialog_on_error=False)
# 从 fstab 中移除条目
self._remove_fstab_entry(device_path)
# 获取父磁盘和分区号
match = re.match(r'(/dev/[a-z]+)(\d+)', device_path)
if not match:
QMessageBox.critical(None, "错误", f"无法解析设备路径 {device_path}")
logger.error(f"无法解析设备路径 {device_path}")
return False
disk_path = match.group(1)
partition_number = match.group(2)
logger.info(f"尝试删除分区 {device_path} (磁盘: {disk_path}, 分区号: {partition_number})。")
success, _, stderr = self._execute_shell_command(
["parted", "-s", disk_path, "rm", partition_number],
f"删除分区 {device_path} 失败"
)
if success:
QMessageBox.information(None, "成功", f"分区 {device_path} 已成功删除。")
return True
else:
return False
def format_partition(self, device_path, fstype=None):
"""
启动一个 QInputDialog 让用户选择文件系统类型,然后将格式化操作提交到后台线程。
:param device_path: 要格式化的分区路径。
:param fstype: 文件系统类型 (例如 'ext4', 'xfs', 'fat32', 'ntfs')。如果为 None则弹出对话框让用户选择。
:return: True 如果成功启动后台任务,否则 False。
"""
if fstype is None:
items = ("ext4", "xfs", "fat32", "ntfs")
fstype, ok = QInputDialog.getItem(None, "选择文件系统", f"请选择要使用的文件系统类型 for {device_path}:", items, 0, False)
if not ok or not fstype:
logger.info("用户取消了文件系统选择。")
return False
reply = QMessageBox.question(None, "确认格式化",
f"您确定要格式化设备 {device_path}{fstype} 吗?此操作将擦除所有数据!",
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
if reply == QMessageBox.No:
logger.info(f"用户取消了格式化 {device_path} 的操作。")
return False
# 检查是否已有格式化任务正在进行
if device_path in self.active_format_workers:
QMessageBox.warning(None, "警告", f"设备 {device_path} 正在格式化中,请勿重复操作。")
return False
# 尝试卸载分区(静默,不弹对话框)
self.unmount_partition(device_path, show_dialog_on_error=False)
# 从 fstab 中移除条目 (因为格式化会改变 UUID)
self._remove_fstab_entry(device_path)
# 创建 QThread 和 FormatWorker 实例
thread = QThread()
worker = FormatWorker(device_path, fstype, self._execute_shell_command)
worker.moveToThread(thread)
# 连接信号和槽
thread.started.connect(worker.run)
worker.finished.connect(lambda s, dp, o, e: self._on_formatting_finished(s, dp, o, e))
worker.finished.connect(thread.quit)
worker.finished.connect(worker.deleteLater)
thread.finished.connect(thread.deleteLater)
self.active_format_workers[device_path] = thread
self.formatting_started.emit(device_path)
thread.start()
QMessageBox.information(None, "开始格式化", f"设备 {device_path} 正在后台格式化为 {fstype}。完成后将通知您。")
return True
def _on_formatting_finished(self, success, device_path, stdout, stderr):
"""
处理格式化工作线程完成后的结果。此槽函数在主线程中执行。
"""
if device_path in self.active_format_workers:
del self.active_format_workers[device_path]
if success:
QMessageBox.information(None, "格式化成功", f"设备 {device_path} 已成功格式化。")
else:
QMessageBox.critical(None, "格式化失败", f"格式化设备 {device_path} 失败。\n错误详情: {stderr}")
self.formatting_finished.emit(success, device_path, stdout, stderr)

163
form.ui Normal file
View File

@@ -0,0 +1,163 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>MainWindow</class>
<widget class="QMainWindow" name="MainWindow">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1000</width>
<height>700</height>
</rect>
</property>
<property name="windowTitle">
<string>Linux 存储管理工具</string>
</property>
<widget class="QWidget" name="centralwidget">
<layout class="QVBoxLayout" name="verticalLayout_2" stretch="0,4,1">
<item>
<widget class="QPushButton" name="refreshButton">
<property name="text">
<string>刷新数据</string>
</property>
</widget>
</item>
<item>
<widget class="QTabWidget" name="tabWidget">
<property name="currentIndex">
<number>0</number>
</property>
<widget class="QWidget" name="tab_block_devices">
<attribute name="title">
<string>块设备概览</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QTreeWidget" name="treeWidget_block_devices">
<column>
<property name="text">
<string>设备名</string>
</property>
</column>
<column>
<property name="text">
<string>类型</string>
</property>
</column>
<column>
<property name="text">
<string>大小</string>
</property>
</column>
<column>
<property name="text">
<string>挂载点</string>
</property>
</column>
<column>
<property name="text">
<string>文件系统</string>
</property>
</column>
<column>
<property name="text">
<string>只读</string>
</property>
</column>
<column>
<property name="text">
<string>UUID</string>
</property>
</column>
<column>
<property name="text">
<string>PARTUUID</string>
</property>
</column>
<column>
<property name="text">
<string>厂商</string>
</property>
</column>
<column>
<property name="text">
<string>型号</string>
</property>
</column>
<column>
<property name="text">
<string>序列号</string>
</property>
</column>
<column>
<property name="text">
<string>主次号</string>
</property>
</column>
<column>
<property name="text">
<string>父设备名</string>
</property>
</column>
</widget>
</item>
</layout>
</widget>
<widget class="QWidget" name="tab_raid">
<attribute name="title">
<string>RAID 管理</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_raid">
<item>
<widget class="QTreeWidget" name="treeWidget_raid">
<column>
<property name="text">
<string>1</string>
</property>
</column>
</widget>
</item>
</layout>
</widget>
<widget class="QWidget" name="tab_lvm">
<attribute name="title">
<string>LVM 管理</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_lvm">
<item>
<widget class="QTreeWidget" name="treeWidget_lvm">
<column>
<property name="text">
<string>1</string>
</property>
</column>
</widget>
</item>
</layout>
</widget>
</widget>
</item>
<item>
<widget class="QTextEdit" name="logOutputTextEdit">
<property name="readOnly">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</widget>
<widget class="QMenuBar" name="menubar">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1000</width>
<height>30</height>
</rect>
</property>
</widget>
<widget class="QStatusBar" name="statusbar"/>
</widget>
<resources/>
<connections/>
</ui>

54
logger_config.py Normal file
View File

@@ -0,0 +1,54 @@
# logger_config.py
import logging
from PySide6.QtWidgets import QTextEdit
from PySide6.QtCore import Signal, QObject
class QTextEditLogger(logging.Handler, QObject):
"""
自定义的 logging 处理器,将日志消息发送到 QTextEdit 控件。
"""
# 定义一个信号,用于在 GUI 线程中更新 QTextEdit
append_text = Signal(str)
def __init__(self, widget: QTextEdit):
super().__init__()
QObject.__init__(self) # 初始化 QObject 部分
self.widget = widget
self.widget.setReadOnly(True) # 确保日志区域是只读的
# 连接信号到 QTextEdit 的 append 方法
self.append_text.connect(self.widget.append)
def emit(self, record):
"""
处理日志记录,将其格式化并通过信号发送到 QTextEdit。
"""
msg = self.format(record)
self.append_text.emit(msg)
def setup_logging(text_edit_widget: QTextEdit):
"""
配置全局 logging使其输出到控制台和指定的 QTextEdit 控件。
"""
root_logger = logging.getLogger()
# 设置日志级别,可以根据需要调整 (DEBUG, INFO, WARNING, ERROR, CRITICAL)
root_logger.setLevel(logging.DEBUG)
# 清除现有的处理器,防止重复输出(如果多次调用此函数)
for handler in root_logger.handlers[:]:
root_logger.removeHandler(handler)
# 1. 控制台处理器 (可选,用于调试)
console_handler = logging.StreamHandler()
formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
console_handler.setFormatter(formatter)
root_logger.addHandler(console_handler)
# 2. QTextEdit 处理器
text_edit_handler = QTextEditLogger(text_edit_widget)
text_edit_handler.setFormatter(formatter)
root_logger.addHandler(text_edit_handler)
return root_logger
# 获取一个全局的 logger 实例,方便其他模块使用
logger = logging.getLogger(__name__)

362
lvm_operations.py Normal file
View File

@@ -0,0 +1,362 @@
# lvm_operations.py
import subprocess
import logging
from PySide6.QtWidgets import QMessageBox
logger = logging.getLogger(__name__)
class LvmOperations:
def __init__(self):
pass
def _execute_shell_command(self, command_list, error_message, root_privilege=True,
suppress_critical_dialog_on_stderr_match=None, input_data=None,
show_dialog=True, expected_non_zero_exit_codes=None):
"""
通用地运行一个 shell 命令,并处理错误。
:param command_list: 命令及其参数的列表。
:param error_message: 命令失败时显示给用户的错误消息。
:param root_privilege: 如果为 True则使用 sudo 执行命令。
:param suppress_critical_dialog_on_stderr_match: 如果 stderr 包含此字符串,则不显示关键错误对话框。
可以是字符串或字符串元组/列表。
:param input_data: 传递给命令stdin的数据 (str)。
:param show_dialog: 如果为 False则不显示关键错误对话框。
:param expected_non_zero_exit_codes: 预期为非零但仍视为成功的退出码列表。
:return: (True/False, stdout_str, stderr_str)
"""
if not all(isinstance(arg, str) for arg in command_list):
logger.error(f"命令列表包含非字符串元素: {command_list}")
if show_dialog:
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}")
# NEW LOGIC: Check if the exit code is expected as non-critical
if expected_non_zero_exit_codes and e.returncode in expected_non_zero_exit_codes:
logger.info(f"命令 '{full_cmd_str}' 以预期非零退出码 {e.returncode} 结束,仍视为成功。")
return True, e.stdout.strip(), stderr_output # Treat as success
should_suppress_dialog = False
if suppress_critical_dialog_on_stderr_match:
if isinstance(suppress_critical_dialog_on_stderr_match, str):
if suppress_critical_dialog_on_stderr_match in stderr_output:
should_suppress_dialog = True
elif isinstance(suppress_critical_dialog_on_stderr_match, (list, tuple)):
for pattern in suppress_critical_dialog_on_stderr_match:
if pattern in stderr_output:
should_suppress_dialog = True
break
if show_dialog and not should_suppress_dialog:
QMessageBox.critical(None, "错误", f"{error_message}\n错误详情: {stderr_output}")
return False, e.stdout.strip(), stderr_output
except FileNotFoundError:
if show_dialog:
QMessageBox.critical(None, "错误", f"命令 '{command_list[0]}' 未找到。请确保已安装相关工具。")
logger.error(f"命令 '{command_list[0]}' 未找到。")
return False, "", f"命令 '{command_list[0]}' 未找到。"
except Exception as e:
if show_dialog:
QMessageBox.critical(None, "错误", f"执行命令时发生未知错误: {e}")
logger.error(f"执行命令 {full_cmd_str} 时发生未知错误: {e}")
return False, "", str(e)
def create_pv(self, device_path):
"""
创建物理卷 (PV)。
:param device_path: 设备的路径,例如 /dev/sdb1 或 /dev/sdb。
:return: True 如果成功,否则 False。
"""
if not isinstance(device_path, str):
logger.error(f"尝试创建 PV 时传入非字符串设备路径: {device_path} (类型: {type(device_path)})")
QMessageBox.critical(None, "错误", "无效的设备路径。")
return False
reply = QMessageBox.question(None, "确认创建物理卷",
f"您确定要在设备 {device_path} 上创建物理卷吗?此操作将覆盖设备上的数据。",
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
if reply == QMessageBox.No:
logger.info(f"用户取消了在 {device_path} 上创建物理卷的操作。")
return False
logger.info(f"尝试在 {device_path} 上创建物理卷。")
success, _, stderr = self._execute_shell_command(
["pvcreate", "-y", device_path],
f"{device_path} 上创建物理卷失败",
suppress_critical_dialog_on_stderr_match="device is partitioned"
)
if success:
QMessageBox.information(None, "成功", f"物理卷已在 {device_path} 上成功创建。")
return True
else:
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):
"""
删除物理卷 (PV)。
:param device_path: 物理卷的路径,例如 /dev/sdb1。
:return: True 如果成功,否则 False。
"""
if not isinstance(device_path, str):
logger.error(f"尝试删除 PV 时传入非字符串设备路径: {device_path} (类型: {type(device_path)})")
QMessageBox.critical(None, "错误", "无效的设备路径。")
return False
reply = QMessageBox.question(None, "确认删除物理卷",
f"您确定要删除物理卷 {device_path} 吗?如果该物理卷属于某个卷组,请先将其从卷组中移除。",
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
if reply == QMessageBox.No:
logger.info(f"用户取消了删除物理卷 {device_path} 的操作。")
return False
logger.info(f"尝试删除物理卷: {device_path}")
success, _, stderr = self._execute_shell_command(
["pvremove", "-y", device_path],
f"删除物理卷 {device_path} 失败"
)
if success:
QMessageBox.information(None, "成功", f"物理卷 {device_path} 已成功删除。")
return True
else:
return False
def create_vg(self, vg_name, pv_paths):
"""
创建卷组 (VG)。
:param vg_name: 卷组的名称。
:param pv_paths: 组成卷组的物理卷路径列表。
:return: True 如果成功,否则 False。
"""
if not isinstance(vg_name, str) or not isinstance(pv_paths, list) or not all(isinstance(p, str) for p in pv_paths):
logger.error(f"尝试创建 VG 时传入无效参数。VG名称: {vg_name}, PV路径: {pv_paths}")
QMessageBox.critical(None, "错误", "无效的卷组名称或物理卷路径。")
return False
reply = QMessageBox.question(None, "确认创建卷组",
f"您确定要使用物理卷 {', '.join(pv_paths)} 创建卷组 {vg_name} 吗?",
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
if reply == QMessageBox.No:
logger.info(f"用户取消了创建卷组 {vg_name} 的操作。")
return False
logger.info(f"尝试使用 {', '.join(pv_paths)} 创建卷组 {vg_name}")
command = ["vgcreate", vg_name] + pv_paths
success, _, stderr = self._execute_shell_command(
command,
f"创建卷组 {vg_name} 失败"
)
if success:
QMessageBox.information(None, "成功", f"卷组 {vg_name} 已成功创建。")
return True
else:
return False
def delete_vg(self, vg_name):
"""
删除卷组 (VG)。
:param vg_name: 卷组的名称。
:return: True 如果成功,否则 False。
"""
if not isinstance(vg_name, str):
logger.error(f"尝试删除 VG 时传入非字符串 VG 名称: {vg_name} (类型: {type(vg_name)})")
QMessageBox.critical(None, "错误", "无效的卷组名称。")
return False
reply = QMessageBox.question(None, "确认删除卷组",
f"您确定要删除卷组 {vg_name} 吗?此操作将删除所有属于该卷组的逻辑卷!",
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
if reply == QMessageBox.No:
logger.info(f"用户取消了删除卷组 {vg_name} 的操作。")
return False
logger.info(f"尝试删除卷组: {vg_name}")
success, _, stderr = self._execute_shell_command(
["vgremove", "-y", "-f", vg_name],
f"删除卷组 {vg_name} 失败"
)
if success:
QMessageBox.information(None, "成功", f"卷组 {vg_name} 已成功删除。")
return True
else:
return False
def create_lv(self, lv_name, vg_name, size_gb, use_max_space=False):
"""
创建逻辑卷 (LV)。
:param lv_name: 逻辑卷的名称。
:param vg_name: 所属卷组的名称。
:param size_gb: 逻辑卷的大小GB。当 use_max_space 为 True 时,此参数被忽略。
:param use_max_space: 是否使用卷组的最大可用空间。
:return: True 如果成功,否则 False。
"""
if not isinstance(lv_name, str) or not isinstance(vg_name, str):
logger.error(f"尝试创建 LV 时传入无效参数。LV名称: {lv_name}, VG名称: {vg_name}")
QMessageBox.critical(None, "错误", "无效的逻辑卷名称或卷组名称。")
return False
if not use_max_space and (not isinstance(size_gb, (int, float)) or size_gb <= 0):
logger.error(f"尝试创建 LV 时传入无效大小。大小: {size_gb}")
QMessageBox.critical(None, "错误", "逻辑卷大小必须大于0。")
return False
confirm_message = f"您确定要在卷组 {vg_name} 中创建逻辑卷 {lv_name} 吗?"
if use_max_space:
confirm_message += "使用卷组所有可用空间。"
else:
confirm_message += f"大小为 {size_gb}GB。"
reply = QMessageBox.question(None, "确认创建逻辑卷",
confirm_message,
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
if reply == QMessageBox.No:
logger.info(f"用户取消了创建逻辑卷 {lv_name} 的操作。")
return False
if use_max_space:
create_cmd = ["lvcreate", "-y", "-l", "100%FREE", "-n", lv_name, vg_name]
logger.info(f"尝试在卷组 {vg_name} 中使用最大可用空间创建逻辑卷 {lv_name}")
else:
create_cmd = ["lvcreate", "-y", "-L", f"{size_gb}G", "-n", lv_name, vg_name]
logger.info(f"尝试在卷组 {vg_name} 中创建 {size_gb}GB 的逻辑卷 {lv_name}")
success, _, stderr = self._execute_shell_command(
create_cmd,
f"创建逻辑卷 {lv_name} 失败"
)
if success:
QMessageBox.information(None, "成功", f"逻辑卷 {lv_name} 已在卷组 {vg_name} 中成功创建。")
return True
else:
return False
def delete_lv(self, lv_name, vg_name):
"""
删除逻辑卷 (LV)。
:param lv_name: 逻辑卷的名称。
:param vg_name: 所属卷组的名称。
:return: True 如果成功,否则 False。
"""
if not isinstance(lv_name, str) or not isinstance(vg_name, str):
logger.error(f"尝试删除 LV 时传入非字符串 LV 名称或 VG 名称。LV名称: {lv_name}, VG名称: {vg_name}")
QMessageBox.critical(None, "错误", "无效的逻辑卷名称或卷组名称。")
return False
reply = QMessageBox.question(None, "确认删除逻辑卷",
f"您确定要删除逻辑卷 {vg_name}/{lv_name} 吗?此操作将擦除所有数据!",
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
if reply == QMessageBox.No:
logger.info(f"用户取消了删除逻辑卷 {vg_name}/{lv_name} 的操作。")
return False
logger.info(f"尝试删除逻辑卷: {vg_name}/{lv_name}")
success, _, stderr = self._execute_shell_command(
["lvremove", "-y", f"{vg_name}/{lv_name}"],
f"删除逻辑卷 {vg_name}/{lv_name} 失败"
)
if success:
QMessageBox.information(None, "成功", f"逻辑卷 {vg_name}/{lv_name} 已成功删除。")
return True
else:
return False
def activate_lv(self, lv_name, vg_name):
"""
激活逻辑卷。
:param lv_name: 逻辑卷的名称。
:param vg_name: 所属卷组的名称。
:return: True 如果成功,否则 False。
"""
if not isinstance(lv_name, str) or not isinstance(vg_name, str):
logger.error(f"尝试激活 LV 时传入非字符串 LV 名称或 VG 名称。LV名称: {lv_name}, VG名称: {vg_name}")
QMessageBox.critical(None, "错误", "无效的逻辑卷名称或卷组名称。")
return False
logger.info(f"尝试激活逻辑卷: {vg_name}/{lv_name}")
success, _, stderr = self._execute_shell_command(
["lvchange", "-ay", f"{vg_name}/{lv_name}"],
f"激活逻辑卷 {vg_name}/{lv_name} 失败"
)
if success:
QMessageBox.information(None, "成功", f"逻辑卷 {vg_name}/{lv_name} 已成功激活。")
return True
else:
return False
def deactivate_lv(self, lv_name, vg_name):
"""
停用逻辑卷。
:param lv_name: 逻辑卷的名称。
:param vg_name: 所属卷组的名称。
:return: True 如果成功,否则 False。
"""
if not isinstance(lv_name, str) or not isinstance(vg_name, str):
logger.error(f"尝试停用 LV 时传入非字符串 LV 名称或 VG 名称。LV名称: {lv_name}, VG名称: {vg_name}")
QMessageBox.critical(None, "错误", "无效的逻辑卷名称或卷组名称。")
return False
logger.info(f"尝试停用逻辑卷: {vg_name}/{lv_name}")
success, _, stderr = self._execute_shell_command(
["lvchange", "-an", f"{vg_name}/{lv_name}"],
f"停用逻辑卷 {vg_name}/{lv_name} 失败"
)
if success:
QMessageBox.information(None, "成功", f"逻辑卷 {vg_name}/{lv_name} 已成功停用。")
return True
else:
return False

901
mainwindow.py Normal file
View File

@@ -0,0 +1,901 @@
# mainwindow.py
import sys
import logging
import re
import os
from PySide6.QtWidgets import (QApplication, QMainWindow, QTreeWidgetItem,
QMessageBox, QHeaderView, QMenu, QInputDialog, QDialog)
from PySide6.QtCore import Qt, QPoint, QThread
# 导入自动生成的 UI 文件
from ui_form import Ui_MainWindow
# 导入我们自己编写的系统信息管理模块
from system_info import SystemInfoManager
# 导入日志配置
from logger_config import setup_logging, logger
# 导入磁盘操作模块
from disk_operations import DiskOperations
# 导入 RAID 操作模块
from raid_operations import RaidOperations
# 导入 LVM 操作模块
from lvm_operations import LvmOperations
# 导入新的 resolver 类
from occupation_resolver import OccupationResolver
# 导入自定义对话框
from dialogs import (CreatePartitionDialog, MountDialog, CreateRaidDialog,
CreatePvDialog, CreateVgDialog, CreateLvDialog)
class MainWindow(QMainWindow):
def __init__(self, parent=None):
super().__init__(parent)
self.ui = Ui_MainWindow()
self.ui.setupUi(self)
setup_logging(self.ui.logOutputTextEdit)
logger.info("应用程序启动。")
# 初始化管理器和操作类 (恢复原始的初始化顺序和依赖关系)
self.system_manager = SystemInfoManager()
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'):
self.ui.refreshButton.clicked.connect(self.refresh_all_info)
else:
logger.warning("Warning: refreshButton not found in UI. Please add it in form.ui and regenerate ui_form.py.")
# 启用 treeWidget 的自定义上下文菜单
self.ui.treeWidget_block_devices.setContextMenuPolicy(Qt.CustomContextMenu)
self.ui.treeWidget_block_devices.customContextMenuRequested.connect(self.show_block_device_context_menu)
self.ui.treeWidget_raid.setContextMenuPolicy(Qt.CustomContextMenu)
self.ui.treeWidget_raid.customContextMenuRequested.connect(self.show_raid_context_menu)
self.ui.treeWidget_lvm.setContextMenuPolicy(Qt.CustomContextMenu)
self.ui.treeWidget_lvm.customContextMenuRequested.connect(self.show_lvm_context_menu)
# 连接 DiskOperations 的格式化完成信号
self.disk_ops.formatting_finished.connect(self.on_disk_formatting_finished)
# 实例化 OccupationResolver
# 将 lvm_ops._execute_shell_command 和 system_manager.get_mountpoint_for_device 传递给它
# 确保 lvm_ops 和 system_manager 已经被正确初始化
self.occupation_resolver = OccupationResolver(
shell_executor_func=self.lvm_ops._execute_shell_command,
mount_info_getter_func=self.system_manager.get_mountpoint_for_device
)
# 初始化时刷新所有数据
self.refresh_all_info()
logger.info("所有设备信息已初始化加载。")
def refresh_all_info(self):
"""
刷新所有设备信息块设备、RAID和LVM。
"""
logger.info("开始刷新所有设备信息...")
self.refresh_block_devices_info()
self.refresh_raid_info()
self.refresh_lvm_info()
logger.info("所有设备信息刷新完成。")
# --- 块设备概览 Tab ---
def refresh_block_devices_info(self):
self.ui.treeWidget_block_devices.clear()
columns = [
("设备名", 'name'), ("类型", 'type'), ("大小", 'size'), ("挂载点", 'mountpoint'),
("文件系统", 'fstype'), ("只读", 'ro'), ("UUID", 'uuid'), ("PARTUUID", 'partuuid'),
("厂商", 'vendor'), ("型号", 'model'), ("序列号", 'serial'),
("主次号", 'maj:min'), ("父设备名", 'pkname'),
]
headers = [col[0] for col in columns]
self.field_keys = [col[1] for col in columns]
self.ui.treeWidget_block_devices.setColumnCount(len(headers))
self.ui.treeWidget_block_devices.setHeaderLabels(headers)
for i in range(len(headers)):
self.ui.treeWidget_block_devices.header().setSectionResizeMode(i, QHeaderView.ResizeToContents)
try:
devices = self.system_manager.get_block_devices()
for dev in devices:
self._add_device_to_tree(self.ui.treeWidget_block_devices, dev)
for i in range(len(headers)):
self.ui.treeWidget_block_devices.resizeColumnToContents(i)
logger.info("块设备信息刷新成功。")
except Exception as e:
QMessageBox.critical(self, "错误", f"刷新块设备信息失败: {e}")
logger.error(f"刷新块设备信息失败: {e}")
def _add_device_to_tree(self, parent_item, dev_data):
item = QTreeWidgetItem(parent_item)
for i, key in enumerate(self.field_keys):
value = dev_data.get(key)
if key == 'ro':
item.setText(i, "" if value else "")
elif value is None:
item.setText(i, "")
else:
item.setText(i, str(value))
item.setData(0, Qt.UserRole, dev_data)
if 'children' in dev_data:
for child in dev_data['children']:
self._add_device_to_tree(item, child)
item.setExpanded(True)
def show_block_device_context_menu(self, pos: QPoint):
item = self.ui.treeWidget_block_devices.itemAt(pos)
menu = QMenu(self)
create_menu = QMenu("创建...", self)
create_raid_action = create_menu.addAction("创建 RAID 阵列...")
create_raid_action.triggered.connect(self._handle_create_raid_array)
create_pv_action = create_menu.addAction("创建物理卷 (PV)...")
create_pv_action.triggered.connect(self._handle_create_pv)
menu.addMenu(create_menu)
menu.addSeparator()
if item:
dev_data = item.data(0, Qt.UserRole)
if not dev_data:
logger.warning(f"无法获取设备 {item.text(0)} 的详细数据。")
return
device_name = dev_data.get('name')
device_type = dev_data.get('type')
mount_point = dev_data.get('mountpoint')
device_path = dev_data.get('path')
if not device_path:
device_path = f"/dev/{device_name}"
if device_type == 'disk':
create_partition_action = menu.addAction(f"创建分区 {device_path}...")
create_partition_action.triggered.connect(lambda: self._handle_create_partition(device_path, dev_data))
wipe_action = menu.addAction(f"擦除分区表 {device_path}...")
wipe_action.triggered.connect(lambda: self._handle_wipe_partition_table(device_path))
menu.addSeparator()
if device_type == 'part':
if not mount_point or mount_point == '' or mount_point == 'N/A':
mount_action = menu.addAction(f"挂载 {device_path}...")
mount_action.triggered.connect(lambda: self._handle_mount(device_path))
elif mount_point != '[SWAP]':
unmount_action = menu.addAction(f"卸载 {device_path}")
unmount_action.triggered.connect(lambda: self._unmount_and_refresh(device_path))
menu.addSeparator()
delete_action = menu.addAction(f"删除分区 {device_path}")
delete_action.triggered.connect(lambda: self._handle_delete_partition(device_path))
format_action = menu.addAction(f"格式化分区 {device_path}...")
format_action.triggered.connect(lambda: self._handle_format_partition(device_path))
menu.addSeparator() # Add separator before new occupation options
# --- 新增:解除设备占用选项 ---
# This option is available for any disk or partition that might be occupied
if device_type in ['disk', 'part']:
resolve_occupation_action = menu.addAction(f"解除占用 {device_path}")
resolve_occupation_action.triggered.connect(lambda: self._handle_resolve_device_occupation(device_path))
# --- 新增:关闭交换分区选项 ---
# This option is only available for active swap partitions
if device_type == 'part' and mount_point == '[SWAP]':
deactivate_swap_action = menu.addAction(f"关闭交换分区 {device_path}")
deactivate_swap_action.triggered.connect(lambda: self._handle_deactivate_swap(device_path))
if menu.actions():
menu.exec(self.ui.treeWidget_block_devices.mapToGlobal(pos))
else:
logger.info("右键点击了空白区域或设备没有可用的操作。")
# --- 新增:处理解除设备占用 ---
def _handle_resolve_device_occupation(self, device_path: str) -> bool:
"""
处理设备占用问题,通过调用 OccupationResolver 进行全面检查和修复。
"""
success = self.occupation_resolver.resolve_occupation(device_path)
if success:
# 如果成功解除占用,刷新 UI 显示最新的设备状态
self.refresh_all_info()
return success
# --- 新增:处理关闭交换分区 ---
def _handle_deactivate_swap(self, device_path):
"""
处理关闭交换分区的操作。
"""
reply = QMessageBox.question(
None,
"确认关闭交换分区",
f"您确定要关闭交换分区 {device_path} 吗?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No
)
if reply == QMessageBox.No:
logger.info(f"用户取消了关闭交换分区 {device_path}")
return False
logger.info(f"尝试关闭交换分区 {device_path}")
success, _, stderr = self.lvm_ops._execute_shell_command(
["swapoff", device_path],
f"关闭交换分区 {device_path} 失败",
show_dialog=True
)
if success:
QMessageBox.information(None, "成功", f"交换分区 {device_path} 已成功关闭。")
self.refresh_all_info()
return True
else:
logger.error(f"关闭交换分区 {device_path} 失败: {stderr}")
return False
def _handle_wipe_partition_table(self, device_path):
"""
处理擦除物理盘分区表的操作。
"""
reply = QMessageBox.question(
self,
"确认擦除分区表",
f"您确定要擦除设备 {device_path} 上的所有分区表吗?\n"
f"**此操作将永久删除设备上的所有分区和数据,且不可恢复!**\n"
f"请谨慎操作!",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No
)
if reply == QMessageBox.No:
logger.info(f"用户取消了擦除 {device_path} 分区表的操作。")
return
logger.warning(f"尝试擦除 {device_path} 上的分区表。")
success, _, stderr = self.lvm_ops._execute_shell_command(
["parted", "-s", device_path, "mklabel", "gpt"],
f"擦除 {device_path} 上的分区表失败"
)
if success:
QMessageBox.information(self, "成功", f"设备 {device_path} 上的分区表已成功擦除。")
self.refresh_all_info()
else:
pass
def _handle_create_partition(self, disk_path, dev_data):
total_disk_mib = 0.0
total_size_str = dev_data.get('size')
logger.debug(f"尝试为磁盘 {disk_path} 创建分区。原始大小字符串: '{total_size_str}'")
if total_size_str:
match = re.match(r'(\d+(\.\d+)?)\s*([KMGT]?B?)', total_size_str, re.IGNORECASE)
if match:
value = float(match.group(1))
unit = match.group(3).upper() if match.group(3) else ''
if unit == 'KB' or unit == 'K': total_disk_mib = value / 1024
elif unit == 'MB' or unit == 'M': total_disk_mib = value
elif unit == 'GB' or unit == 'G': total_disk_mib = value * 1024
elif unit == 'TB' or unit == 'T': total_disk_mib = value * 1024 * 1024
elif unit == 'B':
total_disk_mib = value / (1024 * 1024)
else:
logger.warning(f"无法识别磁盘 {disk_path} 的大小单位: '{unit}' (原始: '{total_size_str}')")
total_disk_mib = 0.0
logger.debug(f"解析后的磁盘总大小 (MiB): {total_disk_mib}")
else:
logger.warning(f"无法解析磁盘 {disk_path} 的大小字符串 '{total_size_str}'。正则表达式不匹配。")
total_disk_mib = 0.0
else:
logger.warning(f"获取磁盘 {disk_path} 的大小字符串为空或None。")
total_disk_mib = 0.0
if total_disk_mib <= 0.0:
QMessageBox.critical(self, "错误", f"无法获取磁盘 {disk_path} 的有效总大小。")
return
start_position_mib = 0.0
max_available_mib = total_disk_mib
if dev_data.get('children'):
logger.debug(f"磁盘 {disk_path} 存在现有分区,尝试计算下一个分区起始位置。")
calculated_start_mib, largest_free_space_mib = self.disk_ops.get_disk_free_space_info_mib(disk_path, total_disk_mib)
if calculated_start_mib is None or largest_free_space_mib is None:
QMessageBox.critical(self, "错误", f"无法确定磁盘 {disk_path} 的分区起始位置。")
return
start_position_mib = calculated_start_mib
max_available_mib = largest_free_space_mib
if max_available_mib < 0:
max_available_mib = 0.0
else:
logger.debug(f"磁盘 {disk_path} 没有现有分区,假定从 0 MiB 开始,最大可用空间为整个磁盘。")
max_available_mib = max(0.0, total_disk_mib - 1.0)
start_position_mib = 1.0
logger.debug(f"磁盘 {disk_path} 没有现有分区,假定从 {start_position_mib} MiB 开始,最大可用空间为 {max_available_mib} MiB。")
dialog = CreatePartitionDialog(self, disk_path, total_disk_mib, max_available_mib)
if dialog.exec() == QDialog.Accepted:
info = dialog.get_partition_info()
if info:
if self.disk_ops.create_partition(
info['disk_path'],
info['partition_table_type'],
info['size_gb'],
info['total_disk_mib'],
info['use_max_space']
):
self.refresh_all_info()
def _handle_mount(self, device_path):
dialog = MountDialog(self, device_path)
if dialog.exec() == QDialog.Accepted:
info = dialog.get_mount_info()
if info:
if self.disk_ops.mount_partition(device_path, info['mount_point'], info['add_to_fstab']):
self.refresh_all_info()
def _unmount_and_refresh(self, device_path):
if self.disk_ops.unmount_partition(device_path, show_dialog_on_error=True):
self.refresh_all_info()
def _handle_delete_partition(self, device_path):
if self.disk_ops.delete_partition(device_path):
self.refresh_all_info()
def _handle_format_partition(self, device_path):
"""
调用 DiskOperations 的异步格式化方法。
"""
self.disk_ops.format_partition(device_path)
def on_disk_formatting_finished(self, success, device_path, stdout, stderr):
"""
接收 DiskOperations 发出的格式化完成信号,并刷新界面。
"""
logger.info(f"格式化完成信号接收: 设备 {device_path}, 成功: {success}")
self.refresh_all_info()
# --- RAID 管理 Tab ---
def refresh_raid_info(self):
self.ui.treeWidget_raid.clear()
raid_headers = [
"阵列设备", "级别", "状态", "大小", "活动设备", "失败设备", "备用设备",
"总设备数", "UUID", "名称", "Chunk Size", "挂载点"
]
self.ui.treeWidget_raid.setColumnCount(len(raid_headers))
self.ui.treeWidget_raid.setHeaderLabels(raid_headers)
for i in range(len(raid_headers)):
self.ui.treeWidget_raid.header().setSectionResizeMode(i, QHeaderView.ResizeToContents)
try:
raid_arrays = self.system_manager.get_mdadm_arrays()
if not raid_arrays:
item = QTreeWidgetItem(self.ui.treeWidget_raid)
item.setText(0, "未找到RAID阵列。")
logger.info("未找到RAID阵列。")
return
for array in raid_arrays:
array_item = QTreeWidgetItem(self.ui.treeWidget_raid)
array_path = array.get('device', 'N/A')
if not array_path or array_path == 'N/A':
logger.warning(f"RAID阵列 '{array.get('name', '未知')}' 的设备路径无效,跳过。")
continue
current_mount_point = ""
if array.get('state') != 'Stopped (Configured)':
current_mount_point = self.system_manager.get_mountpoint_for_device(array_path)
array_item.setText(0, array_path)
array_item.setText(1, array.get('level', 'N/A'))
array_item.setText(2, array.get('state', 'N/A'))
array_item.setText(3, array.get('array_size', 'N/A'))
array_item.setText(4, array.get('active_devices', 'N/A'))
array_item.setText(5, array.get('failed_devices', 'N/A'))
array_item.setText(6, array.get('spare_devices', 'N/A'))
array_item.setText(7, array.get('total_devices', 'N/A'))
array_item.setText(8, array.get('uuid', 'N/A'))
array_item.setText(9, array.get('name', 'N/A'))
array_item.setText(10, array.get('chunk_size', 'N/A'))
array_item.setText(11, current_mount_point if current_mount_point else "")
array_item.setExpanded(True)
array_item.setData(0, Qt.UserRole, array)
if array.get('state') != 'Stopped (Configured)':
for member in array.get('member_devices', []):
member_item = QTreeWidgetItem(array_item)
member_item.setText(0, f" {member.get('device_path', 'N/A')}")
member_item.setText(1, f"成员: {member.get('raid_device', 'N/A')}")
member_item.setText(2, member.get('state', 'N/A'))
for i in range(len(raid_headers)):
self.ui.treeWidget_raid.resizeColumnToContents(i)
logger.info("RAID阵列信息刷新成功。")
except Exception as e:
QMessageBox.critical(self, "错误", f"刷新RAID阵列信息失败: {e}")
logger.error(f"刷新RAID阵列信息失败: {e}")
def show_raid_context_menu(self, pos: QPoint):
item = self.ui.treeWidget_raid.itemAt(pos)
menu = QMenu(self)
create_raid_action = menu.addAction("创建 RAID 阵列...")
create_raid_action.triggered.connect(self._handle_create_raid_array)
menu.addSeparator()
if item and item.parent() is None:
array_data = item.data(0, Qt.UserRole)
if not array_data:
logger.warning(f"无法获取 RAID 阵列 {item.text(0)} 的详细数据。")
return
array_path = array_data.get('device')
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')
if not array_path or array_path == 'N/A':
logger.warning(f"RAID阵列 '{array_data.get('name', '未知')}' 的设备路径无效,无法显示操作。")
return
if array_state == 'Stopped (Configured)':
activate_action = menu.addAction(f"激活阵列 {array_path}")
activate_action.triggered.connect(lambda: self._handle_activate_raid_array(array_path))
delete_config_action = menu.addAction(f"删除配置文件条目 (UUID: {array_uuid})")
delete_config_action.triggered.connect(lambda: self._handle_delete_configured_raid_array(array_uuid))
else:
current_mount_point = self.system_manager.get_mountpoint_for_device(array_path)
if current_mount_point and current_mount_point != '[SWAP]' and current_mount_point != '':
unmount_action = menu.addAction(f"卸载 {array_path} ({current_mount_point})")
unmount_action.triggered.connect(lambda: self._unmount_and_refresh(array_path))
else:
mount_action = menu.addAction(f"挂载 {array_path}...")
mount_action.triggered.connect(lambda: self._handle_mount(array_path))
menu.addSeparator()
# --- 新增RAID 阵列解除占用选项 ---
resolve_occupation_action = menu.addAction(f"解除占用 {array_path}")
resolve_occupation_action.triggered.connect(lambda: self._handle_resolve_device_occupation(array_path))
menu.addSeparator() # 在解除占用后添加分隔符
stop_action = menu.addAction(f"停止阵列 {array_path}")
stop_action.triggered.connect(lambda: self._handle_stop_raid_array(array_path))
delete_action = menu.addAction(f"删除阵列 {array_path}")
delete_action.triggered.connect(lambda: self._handle_delete_active_raid_array(array_path, member_devices, array_uuid))
format_action = menu.addAction(f"格式化阵列 {array_path}...")
format_action.triggered.connect(lambda: self._handle_format_raid_array(array_path))
if menu.actions():
menu.exec(self.ui.treeWidget_raid.mapToGlobal(pos))
else:
logger.info("右键点击了空白区域或没有可用的RAID操作。")
def _handle_activate_raid_array(self, array_path):
"""
处理激活已停止的 RAID 阵列。
"""
item = self.ui.treeWidget_raid.currentItem()
if not item or item.parent() is not None:
logger.warning("未选择有效的 RAID 阵列进行激活。")
return
array_data = item.data(0, Qt.UserRole)
if not array_data or array_data.get('device') != array_path:
logger.error(f"获取 RAID 阵列 {array_path} 的数据失败或数据不匹配。")
return
array_uuid = array_data.get('uuid')
if not array_uuid or array_uuid == 'N/A':
QMessageBox.critical(None, "错误", f"无法激活 RAID 阵列 {array_path}:缺少 UUID 信息。")
logger.error(f"激活 RAID 阵列 {array_path} 失败:缺少 UUID。")
return
reply = QMessageBox.question(
self,
"确认激活 RAID 阵列",
f"您确定要激活 RAID 阵列 {array_path} (UUID: {array_uuid}) 吗?\n"
"这将尝试重新组装阵列。",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No
)
if reply == QMessageBox.No:
logger.info(f"用户取消了激活 RAID 阵列 {array_path} 的操作。")
return
if self.raid_ops.activate_raid_array(array_path, array_uuid):
self.refresh_all_info()
def _handle_create_raid_array(self):
available_devices = self.system_manager.get_unallocated_partitions()
if not available_devices:
QMessageBox.warning(self, "警告", "没有可用于创建 RAID 阵列的设备。请确保有未挂载、未被LVM或RAID使用的磁盘或分区。")
return
dialog = CreateRaidDialog(self, available_devices)
if dialog.exec() == QDialog.Accepted:
info = dialog.get_raid_info()
if info:
if self.raid_ops.create_raid_array(info['devices'], info['level'], info['chunk_size']):
self.refresh_all_info()
def _handle_stop_raid_array(self, array_path):
if self.raid_ops.stop_raid_array(array_path):
self.refresh_all_info()
def _handle_delete_active_raid_array(self, array_path, member_devices, uuid):
"""
处理删除一个活动的 RAID 阵列的操作。
"""
if self.raid_ops.delete_active_raid_array(array_path, member_devices, uuid):
self.refresh_all_info()
def _handle_delete_configured_raid_array(self, uuid):
"""
处理停止状态 RAID 阵列的配置文件条目。
"""
if self.raid_ops.delete_configured_raid_array(uuid):
self.refresh_all_info()
def _handle_format_raid_array(self, array_path):
"""
处理 RAID 阵列的格式化。
"""
self.disk_ops.format_partition(array_path)
# --- LVM 管理 Tab ---
def refresh_lvm_info(self):
self.ui.treeWidget_lvm.clear()
lvm_headers = ["名称", "大小", "属性", "UUID", "关联", "空闲/已用", "路径/格式", "挂载点"]
self.ui.treeWidget_lvm.setColumnCount(len(lvm_headers))
self.ui.treeWidget_lvm.setHeaderLabels(lvm_headers)
for i in range(len(lvm_headers)):
self.ui.treeWidget_lvm.header().setSectionResizeMode(i, QHeaderView.ResizeToContents)
try:
lvm_data = self.system_manager.get_lvm_info()
if not lvm_data.get('pvs') and not lvm_data.get('vgs') and not lvm_data.get('lvs'):
item = QTreeWidgetItem(self.ui.treeWidget_lvm)
item.setText(0, "未找到LVM信息。")
logger.info("未找到LVM信息。")
return
# 物理卷 (PVs)
pv_root_item = QTreeWidgetItem(self.ui.treeWidget_lvm)
pv_root_item.setText(0, "物理卷 (PVs)")
pv_root_item.setExpanded(True)
pv_root_item.setData(0, Qt.UserRole, {'type': 'pv_root'})
if lvm_data.get('pvs'):
for pv in lvm_data['pvs']:
pv_item = QTreeWidgetItem(pv_root_item)
pv_name = pv.get('pv_name', 'N/A')
if pv_name.startswith('/dev/'):
pv_path = pv_name
else:
pv_path = f"/dev/{pv_name}" if pv_name != 'N/A' else 'N/A'
pv_item.setText(0, pv_name)
pv_item.setText(1, pv.get('pv_size', 'N/A'))
pv_item.setText(2, pv.get('pv_attr', 'N/A'))
pv_item.setText(3, pv.get('pv_uuid', 'N/A'))
pv_item.setText(4, f"VG: {pv.get('vg_name', 'N/A')}")
pv_item.setText(5, f"空闲: {pv.get('pv_free', 'N/A')}")
pv_item.setText(6, pv.get('pv_fmt', 'N/A'))
pv_item.setText(7, "")
pv_data_for_context = pv.copy()
pv_data_for_context['pv_name'] = pv_path
pv_item.setData(0, Qt.UserRole, {'type': 'pv', 'data': pv_data_for_context})
else:
item = QTreeWidgetItem(pv_root_item)
item.setText(0, "未找到物理卷。")
# 卷组 (VGs)
vg_root_item = QTreeWidgetItem(self.ui.treeWidget_lvm)
vg_root_item.setText(0, "卷组 (VGs)")
vg_root_item.setExpanded(True)
vg_root_item.setData(0, Qt.UserRole, {'type': 'vg_root'})
if lvm_data.get('vgs'):
for vg in lvm_data['vgs']:
vg_item = QTreeWidgetItem(vg_root_item)
vg_name = vg.get('vg_name', 'N/A')
vg_item.setText(0, vg_name)
vg_item.setText(1, vg.get('vg_size', 'N/A'))
vg_item.setText(2, vg.get('vg_attr', 'N/A'))
vg_item.setText(3, vg.get('vg_uuid', 'N/A'))
vg_item.setText(4, f"PVs: {vg.get('pv_count', 'N/A')}, LVs: {vg.get('lv_count', 'N/A')}")
vg_item.setText(5, f"空闲: {vg.get('vg_free', 'N/A')}, 已分配: {vg.get('vg_alloc_percent', 'N/A')}%")
vg_item.setText(6, vg.get('vg_fmt', 'N/A'))
vg_item.setText(7, "")
vg_data_for_context = vg.copy()
vg_data_for_context['vg_name'] = vg_name
vg_item.setData(0, Qt.UserRole, {'type': 'vg', 'data': vg_data_for_context})
else:
item = QTreeWidgetItem(vg_root_item)
item.setText(0, "未找到卷组。")
# 逻辑卷 (LVs)
lv_root_item = QTreeWidgetItem(self.ui.treeWidget_lvm)
lv_root_item.setText(0, "逻辑卷 (LVs)")
lv_root_item.setExpanded(True)
lv_root_item.setData(0, Qt.UserRole, {'type': 'lv_root'})
if lvm_data.get('lvs'):
for lv in lvm_data['lvs']:
lv_item = QTreeWidgetItem(lv_root_item)
lv_name = lv.get('lv_name', 'N/A')
vg_name = lv.get('vg_name', 'N/A')
lv_attr = lv.get('lv_attr', '')
lv_path = lv.get('lv_path')
if not lv_path or lv_path == 'N/A':
if vg_name != 'N/A' and lv_name != 'N/A':
lv_path = f"/dev/{vg_name}/{lv_name}"
else:
lv_path = 'N/A'
current_mount_point = self.system_manager.get_mountpoint_for_device(lv_path)
lv_item.setText(0, lv_name)
lv_item.setText(1, lv.get('lv_size', 'N/A'))
lv_item.setText(2, lv_attr)
lv_item.setText(3, lv.get('lv_uuid', 'N/A'))
lv_item.setText(4, f"VG: {vg_name}, Origin: {lv.get('origin', 'N/A')}")
lv_item.setText(5, f"快照: {lv.get('snap_percent', 'N/A')}%")
lv_item.setText(6, lv_path)
lv_item.setText(7, current_mount_point if current_mount_point else "")
lv_data_for_context = lv.copy()
lv_data_for_context['lv_path'] = lv_path
lv_data_for_context['lv_name'] = lv_name
lv_data_for_context['vg_name'] = vg_name
lv_data_for_context['lv_attr'] = lv_attr
lv_item.setData(0, Qt.UserRole, {'type': 'lv', 'data': lv_data_for_context})
else:
item = QTreeWidgetItem(lv_root_item)
item.setText(0, "未找到逻辑卷。")
for i in range(len(lvm_headers)):
self.ui.treeWidget_lvm.resizeColumnToContents(i)
logger.info("LVM信息刷新成功。")
except Exception as e:
QMessageBox.critical(self, "错误", f"刷新LVM信息失败: {e}")
logger.error(f"刷新LVM信息失败: {e}")
def show_lvm_context_menu(self, pos: QPoint):
item = self.ui.treeWidget_lvm.itemAt(pos)
menu = QMenu(self)
create_menu = QMenu("创建...", self)
create_pv_action = create_menu.addAction("创建物理卷 (PV)...")
create_pv_action.triggered.connect(self._handle_create_pv)
create_vg_action = create_menu.addAction("创建卷组 (VG)...")
create_vg_action.triggered.connect(self._handle_create_vg)
create_lv_action = create_menu.addAction("创建逻辑卷 (LV)...")
create_lv_action.triggered.connect(self._handle_create_lv)
menu.addMenu(create_menu)
menu.addSeparator()
if item:
item_data = item.data(0, Qt.UserRole)
if not item_data:
logger.warning(f"无法获取 LVM 项 {item.text(0)} 的详细数据。")
return
item_type = item_data.get('type')
data = item_data.get('data', {})
if item_type == 'pv':
pv_name = data.get('pv_name')
if pv_name and pv_name != 'N/A':
delete_pv_action = menu.addAction(f"删除物理卷 {pv_name}")
delete_pv_action.triggered.connect(lambda: self._handle_delete_pv(pv_name))
elif item_type == 'vg':
vg_name = data.get('vg_name')
if vg_name and vg_name != 'N/A':
delete_vg_action = menu.addAction(f"删除卷组 {vg_name}")
delete_vg_action.triggered.connect(lambda: self._handle_delete_vg(vg_name))
elif item_type == 'lv':
lv_name = data.get('lv_name')
vg_name = data.get('vg_name')
lv_attr = data.get('lv_attr', '')
lv_path = data.get('lv_path')
if lv_name and vg_name and lv_path and lv_path != 'N/A':
if 'a' in lv_attr:
deactivate_lv_action = menu.addAction(f"停用逻辑卷 {lv_name}")
deactivate_lv_action.triggered.connect(lambda: self._handle_deactivate_lv(lv_name, vg_name))
else:
activate_lv_action = menu.addAction(f"激活逻辑卷 {lv_name}")
activate_lv_action.triggered.connect(lambda: self._handle_activate_lv(lv_name, vg_name))
current_mount_point = self.system_manager.get_mountpoint_for_device(lv_path)
if current_mount_point and current_mount_point != '[SWAP]' and current_mount_point != '':
unmount_lv_action = menu.addAction(f"卸载 {lv_name} ({current_mount_point})")
unmount_lv_action.triggered.connect(lambda: self._unmount_and_refresh(lv_path))
else:
mount_lv_action = menu.addAction(f"挂载 {lv_name}...")
mount_lv_action.triggered.connect(lambda: self._handle_mount(lv_path))
menu.addSeparator()
# --- 新增:逻辑卷解除占用选项 ---
resolve_occupation_action = menu.addAction(f"解除占用 {lv_path}")
resolve_occupation_action.triggered.connect(lambda: self._handle_resolve_device_occupation(lv_path))
menu.addSeparator() # 在解除占用后添加分隔符
delete_lv_action = menu.addAction(f"删除逻辑卷 {lv_name}")
delete_lv_action.triggered.connect(lambda: self._handle_delete_lv(lv_name, vg_name))
format_lv_action = menu.addAction(f"格式化逻辑卷 {lv_name}...")
format_lv_action.triggered.connect(lambda: self._handle_format_partition(lv_path))
else:
logger.warning(f"逻辑卷 '{lv_name}' (VG: {vg_name}) 的路径无效无法显示操作。Lv Path: {lv_path}")
if menu.actions():
menu.exec(self.ui.treeWidget_lvm.mapToGlobal(pos))
else:
logger.info("右键点击了空白区域或没有可用的LVM操作。")
def _handle_create_pv(self):
available_partitions = self.system_manager.get_unallocated_partitions()
if not available_partitions:
QMessageBox.warning(self, "警告", "没有可用于创建物理卷的未分配分区。")
return
dialog = CreatePvDialog(self, available_partitions)
if dialog.exec() == QDialog.Accepted:
info = dialog.get_pv_info()
if info:
if self.lvm_ops.create_pv(info['device_path']):
self.refresh_all_info()
def _handle_delete_pv(self, device_path):
if self.lvm_ops.delete_pv(device_path):
self.refresh_all_info()
def _handle_create_vg(self):
lvm_info = self.system_manager.get_lvm_info()
available_pvs = []
for pv in lvm_info.get('pvs', []):
pv_name = pv.get('pv_name')
if pv_name and pv_name != 'N/A' and not pv.get('vg_name'):
if pv_name.startswith('/dev/'):
available_pvs.append(pv_name)
else:
available_pvs.append(f"/dev/{pv_name}")
if not available_pvs:
QMessageBox.warning(self, "警告", "没有可用于创建卷组的物理卷。")
return
dialog = CreateVgDialog(self, available_pvs)
if dialog.exec() == QDialog.Accepted:
info = dialog.get_vg_info()
if info:
if self.lvm_ops.create_vg(info['vg_name'], info['pvs']):
self.refresh_all_info()
def _handle_create_lv(self):
lvm_info = self.system_manager.get_lvm_info()
available_vgs = []
vg_sizes = {}
for vg in lvm_info.get('vgs', []):
vg_name = vg.get('vg_name')
if vg_name and vg_name != 'N/A':
available_vgs.append(vg_name)
free_size_str = vg.get('vg_free', '0B').strip().lower()
current_vg_size_gb = 0.0
match = re.match(r'<?(\d+\.?\d*)\s*([gmktb])?', free_size_str)
if match:
value = float(match.group(1))
unit = match.group(2)
if unit == 'k':
current_vg_size_gb = value / (1024 * 1024)
elif unit == 'm':
current_vg_size_gb = value / 1024
elif unit == 'g' or unit is None:
current_vg_size_gb = value
elif unit == 't':
current_vg_size_gb = value * 1024
elif unit == 'b':
current_vg_size_gb = value / (1024 * 1024 * 1024)
else:
logger.warning(f"未知LVM单位: '{unit}' for '{free_size_str}'")
else:
logger.warning(f"无法解析LVM空闲大小字符串: '{free_size_str}'")
vg_sizes[vg_name] = current_vg_size_gb
if not available_vgs:
QMessageBox.warning(self, "警告", "没有可用于创建逻辑卷的卷组。")
return
dialog = CreateLvDialog(self, available_vgs, vg_sizes)
if dialog.exec() == QDialog.Accepted:
info = dialog.get_lv_info()
if info:
if self.lvm_ops.create_lv(info['lv_name'], info['vg_name'], info['size_gb'], info['use_max_space']):
self.refresh_all_info()
def _handle_delete_lv(self, lv_name, vg_name):
lv_path = f"/dev/{vg_name}/{lv_name}"
self.disk_ops.unmount_partition(lv_path, show_dialog_on_error=False)
self.disk_ops._remove_fstab_entry(lv_path)
if self.lvm_ops.delete_lv(lv_name, vg_name):
self.refresh_all_info()
def _handle_activate_lv(self, lv_name, vg_name):
if self.lvm_ops.activate_lv(lv_name, vg_name):
self.refresh_all_info()
def _handle_deactivate_lv(self, lv_name, vg_name):
if self.lvm_ops.deactivate_lv(lv_name, vg_name):
self.refresh_all_info()
def _handle_delete_vg(self, vg_name):
"""
处理删除卷组 (VG) 的操作。
"""
logger.info(f"尝试删除卷组 (VG) {vg_name}")
reply = QMessageBox.question(
self,
"确认删除卷组",
f"您确定要删除卷组 {vg_name} 吗?此操作将永久删除该卷组及其所有逻辑卷和数据!",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No
)
if reply == QMessageBox.No:
logger.info(f"用户取消了删除卷组 {vg_name} 的操作。")
return
lvm_info = self.system_manager.get_lvm_info()
vg_info = next((vg for vg in lvm_info.get('vgs', []) if vg.get('vg_name') == vg_name), None)
lv_count_raw = vg_info.get('lv_count', 0) if vg_info else 0
try:
lv_count = int(float(lv_count_raw))
except (ValueError, TypeError):
lv_count = 0
if lv_count > 0:
QMessageBox.critical(self, "删除失败", f"卷组 {vg_name} 中仍包含逻辑卷。请先删除所有逻辑卷。")
logger.error(f"尝试删除包含逻辑卷的卷组 {vg_name}。操作被阻止。")
return
success = self.lvm_ops.delete_vg(vg_name)
if success:
self.refresh_all_info()
else:
QMessageBox.critical(self, "删除卷组失败", f"删除卷组 {vg_name} 失败,请检查日志。")
if __name__ == "__main__":
app = QApplication(sys.argv)
widget = MainWindow()
widget.show()
sys.exit(app.exec())

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

5
pyproject.toml Normal file
View File

@@ -0,0 +1,5 @@
[project]
name = "PySide Widgets Project"
[tool.pyside6-project]
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"]

471
raid_operations.py Normal file
View File

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

1
requirements.txt Normal file
View File

@@ -0,0 +1 @@
PySide6

758
system_info.py Normal file
View File

@@ -0,0 +1,758 @@
# system_info.py
import subprocess
import json
import logging
import re
import os
logger = logging.getLogger(__name__)
class SystemInfoManager:
def __init__(self):
pass
def _run_command(self, command_list, root_privilege=False, check_output=True, input_data=None):
"""
通用地运行一个 shell 命令。
:param command_list: 命令及其参数的列表。
:param root_privilege: 如果为 True则使用 sudo 执行命令。
:param check_output: 如果为 True则捕获 stdout 和 stderr。如果为 False则不捕获用于需要交互或不关心输出的命令。
:param input_data: 传递给命令stdin的数据 (str)。
:return: (stdout_str, stderr_str)
:raises subprocess.CalledProcessError: 如果命令返回非零退出码。
"""
if root_privilege:
command_list = ["sudo"] + command_list
logger.debug(f"运行命令: {' '.join(command_list)}")
try:
result = subprocess.run(
command_list,
capture_output=check_output,
text=True,
check=True,
encoding='utf-8',
input=input_data
)
return result.stdout if check_output else "", result.stderr if check_output else ""
except subprocess.CalledProcessError as e:
logger.error(f"命令执行失败: {' '.join(command_list)}")
logger.error(f"退出码: {e.returncode}")
logger.error(f"标准输出: {e.stdout.strip()}")
logger.error(f"标准错误: {e.stderr.strip()}")
raise
except FileNotFoundError:
logger.error(f"命令 '{command_list[0]}' 未找到。请确保已安装相关工具。")
raise
except Exception as e:
logger.error(f"运行命令 {' '.join(command_list)} 时发生未知错误: {e}")
raise
def get_block_devices(self):
"""
使用 lsblk 获取块设备信息。
返回一个字典列表,每个字典代表一个设备。
"""
cmd = [
"lsblk", "-J", "-o",
"NAME,FSTYPE,SIZE,MOUNTPOINT,RO,TYPE,UUID,PARTUUID,VENDOR,MODEL,SERIAL,MAJ:MIN,PKNAME,PATH"
]
try:
stdout, _ = self._run_command(cmd)
data = json.loads(stdout)
devices = data.get('blockdevices', [])
def add_path_recursive(dev_list):
for dev in dev_list:
if 'PATH' not in dev and 'NAME' in dev:
dev['PATH'] = f"/dev/{dev['NAME']}"
# Rename PATH to path (lowercase) for consistency
if 'PATH' in dev:
dev['path'] = dev.pop('PATH')
if 'children' in dev:
add_path_recursive(dev['children'])
add_path_recursive(devices)
return devices
except subprocess.CalledProcessError as e:
logger.error(f"获取块设备信息失败: {e.stderr.strip()}")
return []
except json.JSONDecodeError as e:
logger.error(f"解析 lsblk JSON 输出失败: {e}")
return []
except Exception as e:
logger.error(f"获取块设备信息失败 (未知错误): {e}")
return []
def _find_device_by_path_recursive(self, dev_list, target_path):
"""
Helper to find device data by its path recursively.
Added type check for target_path.
"""
if not isinstance(target_path, str):
logger.warning(f"传入 _find_device_by_path_recursive 的 target_path 不是字符串: {target_path} (类型: {type(target_path)})")
return None
for dev in dev_list:
if dev.get('path') == target_path:
return dev
if 'children' in dev:
found = self._find_device_by_path_recursive(dev['children'], target_path)
if found:
return found
return None
def _find_device_by_maj_min_recursive(self, dev_list, target_maj_min):
"""
Helper to find device data by its major:minor number recursively.
"""
if not isinstance(target_maj_min, str):
logger.warning(f"传入 _find_device_by_maj_min_recursive 的 target_maj_min 不是字符串: {target_maj_min} (类型: {type(target_maj_min)})")
return None
for dev in dev_list:
if dev.get('maj:min') == target_maj_min:
return dev
if 'children' in dev:
found = self._find_device_by_maj_min_recursive(dev['children'], target_maj_min)
if found:
return found
return None
def get_mountpoint_for_device(self, device_path):
"""
根据设备路径获取其挂载点。
此方法现在可以正确处理 /dev/md/new_raid 这样的 RAID 阵列别名,以及 LVM 逻辑卷的符号链接。
"""
if not isinstance(device_path, str):
logger.warning(f"传入 get_mountpoint_for_device 的 device_path 不是字符串: {device_path} (类型: {type(device_path)})")
return None
original_device_path = device_path
logger.debug(f"get_mountpoint_for_device: 正在处理原始设备路径: {original_device_path}")
devices = self.get_block_devices()
logger.debug(f"get_mountpoint_for_device: 从 lsblk 获取到 {len(devices)} 个块设备信息。")
target_maj_min = None
current_search_path = original_device_path
# 1. 解析符号链接以获取实际路径,并尝试获取 maj:min
if original_device_path.startswith('/dev/') and os.path.exists(original_device_path):
try:
# 先尝试解析真实路径,因为 stat 可能对符号链接返回链接本身的 maj:min
resolved_path = os.path.realpath(original_device_path)
logger.debug(f"get_mountpoint_for_device: 原始设备路径 {original_device_path} 解析为 {resolved_path}")
current_search_path = resolved_path # 使用解析后的路径进行 stat 和后续查找
# 获取解析后路径的 maj:min
stat_output, _ = self._run_command(["stat", "-c", "%t:%T", resolved_path], check_output=True)
raw_maj_min = stat_output.strip() # e.g., "fc:0"
# MODIFIED: Convert hex major to decimal
if ':' in raw_maj_min:
major_hex, minor_dec = raw_maj_min.split(':')
try:
major_dec = str(int(major_hex, 16)) # Convert hex to decimal string
target_maj_min = f"{major_dec}:{minor_dec}" # e.g., "252:0"
logger.debug(f"get_mountpoint_for_device: 解析后设备 {resolved_path} 的 maj:min (hex) 为 {raw_maj_min},转换为 decimal 为 {target_maj_min}")
except ValueError:
logger.warning(f"get_mountpoint_for_device: 无法将 maj:min 的主要部分 '{major_hex}' 从十六进制转换为十进制。")
target_maj_min = None # Fallback if conversion fails
else:
logger.warning(f"get_mountpoint_for_device: stat 输出 '{raw_maj_min}' 格式不符合预期。")
target_maj_min = None
except subprocess.CalledProcessError as e:
logger.debug(f"get_mountpoint_for_device: 无法获取 {resolved_path} 的 maj:min (命令失败): {e.stderr.strip()}")
except Exception as e:
logger.debug(f"get_mountpoint_for_device: 无法获取 {resolved_path} 的 maj:min (未知错误): {e}")
elif original_device_path.startswith('/dev/') and not os.path.exists(original_device_path):
logger.warning(f"get_mountpoint_for_device: 设备路径 {original_device_path} 不存在,无法获取挂载点。")
return None
# 2. 尝试使用 (可能已解析的) current_search_path 在 lsblk 输出中查找
dev_info = self._find_device_by_path_recursive(devices, current_search_path)
logger.debug(f"get_mountpoint_for_device: _find_device_by_path_recursive 查找结果 (使用 {current_search_path}): {dev_info}")
if dev_info:
mountpoint = dev_info.get('mountpoint')
logger.debug(f"get_mountpoint_for_device: 直接从 lsblk 获取到 {original_device_path} (解析为 {current_search_path}) 的挂载点: {mountpoint}")
return mountpoint
# 3. 如果直接路径查找失败,并且我们有 maj:min尝试通过 maj:min 查找
if target_maj_min:
logger.debug(f"get_mountpoint_for_device: 直接路径查找失败,尝试通过 maj:min ({target_maj_min}) 查找。")
dev_info_by_maj_min = self._find_device_by_maj_min_recursive(devices, target_maj_min)
logger.debug(f"get_mountpoint_for_device: 通过 maj:min 查找结果: {dev_info_by_maj_min}")
if dev_info_by_maj_min:
mountpoint = dev_info_by_maj_min.get('mountpoint')
logger.debug(f"get_mountpoint_for_device: 通过 maj:min 找到 {original_device_path} 的挂载点: {mountpoint}")
return mountpoint
# 4. 如果仍然查找失败,并且是 RAID 阵列(例如 /dev/md/new_raid
if original_device_path.startswith('/dev/md'):
logger.debug(f"get_mountpoint_for_device: 正在处理 RAID 阵列 {original_device_path} 以获取挂载点...")
actual_md_device_path = self._get_actual_md_device_path(original_device_path)
logger.debug(f"get_mountpoint_for_device: RAID 阵列 {original_device_path} 的实际设备路径: {actual_md_device_path}")
if actual_md_device_path:
actual_dev_info = self._find_device_by_path_recursive(devices, actual_md_device_path)
logger.debug(f"get_mountpoint_for_device: 实际设备路径 {actual_md_device_path} 的 lsblk 详情: {actual_dev_info}")
if actual_dev_info:
mountpoint = actual_dev_info.get('mountpoint')
logger.debug(f"get_mountpoint_for_device: 在实际设备 {actual_md_device_path} 上找到了挂载点: {mountpoint}")
return mountpoint
logger.debug(f"get_mountpoint_for_device: 未能获取到 {original_device_path} 的挂载点。")
return None
def _parse_mdadm_detail_output(self, output, device_path):
"""
解析 mdadm --detail 命令的输出。
"""
array_info = {
'device': device_path, # Default to input path, will be updated by canonical_device_path
'level': 'N/A',
'state': 'N/A',
'array_size': 'N/A',
'active_devices': 'N/A',
'failed_devices': 'N/A',
'spare_devices': 'N/A',
'total_devices': 'N/A',
'uuid': 'N/A',
'name': os.path.basename(device_path), # Default name, will be overridden
'chunk_size': 'N/A',
'member_devices': []
}
# 首先,尝试从 mdadm --detail 输出的第一行获取规范的设备路径
device_path_header_match = re.match(r'^(?P<canonical_path>/dev/md\d+):', output)
if device_path_header_match:
array_info['device'] = device_path_header_match.group('canonical_path')
array_info['name'] = os.path.basename(array_info['device']) # 根据规范路径更新默认名称
# 逐行解析键值对
for line in output.splitlines():
line = line.strip()
if not line:
continue
# 尝试匹配 "Key : Value" 格式
kv_match = re.match(r'^(?P<key>[A-Za-z ]+)\s*:\s*(?P<value>.+)', line)
if kv_match:
key = kv_match.group('key').strip().lower().replace(' ', '_')
value = kv_match.group('value').strip()
if key == 'raid_level':
array_info['level'] = value
elif key == 'state':
array_info['state'] = value
elif key == 'array_size':
array_info['array_size'] = value
elif key == 'uuid':
array_info['uuid'] = value
elif key == 'raid_devices':
array_info['total_devices'] = value
elif key == 'active_devices':
array_info['active_devices'] = value
elif key == 'failed_devices':
array_info['failed_devices'] = value
elif key == 'spare_devices':
array_info['spare_devices'] = value
elif key == 'name':
# 只取名字的第一部分,忽略括号内的额外信息
array_info['name'] = value.split(' ')[0]
elif key == 'chunk_size':
array_info['chunk_size'] = value
# 成员设备解析 (独立于键值对,因为它有固定格式)
member_pattern = re.match(r'^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+([^\s]+(?:\s+[^\s]+)*)\s+(/dev/\S+)$', line)
if member_pattern:
array_info['member_devices'].append({
'number': member_pattern.group(1),
'major': member_pattern.group(2),
'minor': member_pattern.group(3),
'raid_device': member_pattern.group(4),
'state': member_pattern.group(5).strip(),
'device_path': member_pattern.group(6)
})
# 最终状态标准化
if array_info['state'] and 'active' in array_info['state'].lower():
array_info['state'] = array_info['state'].replace('active', 'Active')
elif array_info['state'] == 'clean':
array_info['state'] = 'Active, Clean'
elif array_info['state'] == 'N/A': # 如果未明确找到状态,则回退到推断
if "clean" in output.lower() and "active" in output.lower():
array_info['state'] = "Active, Clean"
elif "degraded" in output.lower():
array_info['state'] = "Degraded"
elif "inactive" in output.lower() or "stopped" in output.lower():
array_info['state'] = "Stopped"
return array_info
def get_mdadm_arrays(self):
"""
获取所有 RAID 阵列的信息,包括活动的和已停止的。
"""
all_arrays_info = {} # 使用 UUID 作为键,存储阵列的详细信息
# 1. 获取活动的 RAID 阵列信息 (通过 mdadm --detail --scan 和 /proc/mdstat)
active_md_devices = []
# 从 mdadm --detail --scan 获取
try:
stdout_scan, stderr_scan = self._run_command(["mdadm", "--detail", "--scan"], check_output=True)
for line in stdout_scan.splitlines():
if line.startswith("ARRAY"):
match = re.match(r'ARRAY\s+(\S+)(?:\s+\S+=\S+)*\s+UUID=([0-9a-f:]+)', line)
if match:
dev_path = match.group(1)
if dev_path not in active_md_devices:
active_md_devices.append(dev_path)
except subprocess.CalledProcessError as e:
if "No arrays found" not in e.stderr and "No arrays found" not in e.stdout:
logger.warning(f"执行 mdadm --detail --scan 失败: {e.stderr.strip()}")
except Exception as e:
logger.warning(f"处理 mdadm --detail --scan 输出时发生错误: {e}")
# 从 /proc/mdstat 获取 (补充可能未被 --scan 报告的活动阵列)
try:
with open("/proc/mdstat", "r") as f:
mdstat_content = f.read()
for line in mdstat_content.splitlines():
if line.startswith("md") and "active" in line:
device_name_match = re.match(r'^(md\d+)\s+:', line)
if device_name_match:
dev_path = f"/dev/{device_name_match.group(1)}"
if dev_path not in active_md_devices:
active_md_devices.append(dev_path)
except FileNotFoundError:
logger.debug("/proc/mdstat not found. Cannot check active arrays from /proc/mdstat.")
except Exception as e:
logger.error(f"Error reading /proc/mdstat: {e}")
# 现在,对每个找到的活动 MD 设备获取其详细信息
for device_path in active_md_devices:
try:
# 解析符号链接以获取规范路径,例如 /dev/md/0 -> /dev/md0
canonical_device_path = self._get_actual_md_device_path(device_path)
if not canonical_device_path:
canonical_device_path = device_path # 如果解析失败,使用原始路径作为回退
detail_stdout, detail_stderr = self._run_command(["mdadm", "--detail", canonical_device_path], check_output=True)
array_info = self._parse_mdadm_detail_output(detail_stdout, canonical_device_path)
if array_info and array_info.get('uuid') and array_info.get('device'):
# _parse_mdadm_detail_output 现在会更新 array_info['device'] 为规范路径
# 使用 UUID 作为键,如果同一个阵列有多个表示,只保留一个(通常是活动的)
all_arrays_info[array_info['uuid']] = array_info
else:
logger.warning(f"无法从 mdadm --detail {canonical_device_path} 输出中解析 RAID 阵列信息: {detail_stdout[:100]}...")
except subprocess.CalledProcessError as e:
logger.warning(f"获取 RAID 阵列 {device_path} 详细信息失败: {e.stderr.strip()}")
except Exception as e:
logger.warning(f"处理 RAID 阵列 {device_path} 详细信息时发生错误: {e}")
# 2. 从 /etc/mdadm.conf 获取配置的 RAID 阵列信息 (可能包含已停止的)
mdadm_conf_paths = ["/etc/mdadm/mdadm.conf", "/etc/mdadm.conf"]
found_conf = False
for mdadm_conf_path in mdadm_conf_paths:
if os.path.exists(mdadm_conf_path):
found_conf = True
try:
with open(mdadm_conf_path, 'r') as f:
for line in f:
line = line.strip()
if line.startswith("ARRAY"):
match_base = re.match(r'ARRAY\s+(\S+)\s*(.*)', line)
if match_base:
device_path = match_base.group(1)
rest_of_line = match_base.group(2)
uuid = 'N/A'
name = os.path.basename(device_path) # Default name
uuid_match_conf = re.search(r'UUID=([0-9a-f:]+)', rest_of_line)
if uuid_match_conf:
uuid = uuid_match_conf.group(1)
name_match_conf = re.search(r'NAME=(\S+)', rest_of_line)
if name_match_conf:
name = name_match_conf.group(1)
if uuid != 'N/A': # 只有成功提取到 UUID 才添加
# 只有当此 UUID 对应的阵列尚未被识别为活动状态时,才添加为停止状态
if uuid not in all_arrays_info:
all_arrays_info[uuid] = {
'device': device_path,
'uuid': uuid,
'name': name,
'level': 'Unknown',
'state': 'Stopped (Configured)',
'array_size': 'N/A',
'active_devices': 'N/A',
'failed_devices': 'N/A',
'spare_devices': 'N/A',
'total_devices': 'N/A',
'chunk_size': 'N/A',
'member_devices': []
}
else:
logger.warning(f"无法从 mdadm.conf 行 '{line}' 中提取 UUID。")
else:
logger.warning(f"无法解析 mdadm.conf 中的 ARRAY 行: '{line}'")
except Exception as e:
logger.warning(f"读取或解析 {mdadm_conf_path} 失败: {e}")
break # 找到并处理了一个配置文件就退出循环
if not found_conf:
logger.info(f"未找到 mdadm 配置文件。")
# 返回所有阵列的列表
# 排序:活动阵列在前,然后是停止的
sorted_arrays = sorted(all_arrays_info.values(), key=lambda x: (x.get('state') != 'Active', x.get('device')))
return sorted_arrays
def get_lvm_info(self):
"""
获取 LVM 物理卷 (PVs)、卷组 (VGs) 和逻辑卷 (LVs) 的信息。
"""
lvm_info = {'pvs': [], 'vgs': [], 'lvs': []}
# Get PVs
try:
stdout, stderr = self._run_command(["pvs", "--reportformat", "json"])
data = json.loads(stdout)
if 'report' in data and data['report']:
for pv_data in data['report'][0].get('pv', []):
lvm_info['pvs'].append({
'pv_name': pv_data.get('pv_name'),
'vg_name': pv_data.get('vg_name'),
'pv_uuid': pv_data.get('pv_uuid'),
'pv_size': pv_data.get('pv_size'),
'pv_free': pv_data.get('pv_free'),
'pv_attr': pv_data.get('pv_attr'),
'pv_fmt': pv_data.get('pv_fmt')
})
except subprocess.CalledProcessError as e:
if "No physical volume found" in e.stderr or "No physical volumes found" in e.stdout:
logger.info("未找到任何LVM物理卷。")
else:
logger.error(f"获取LVM物理卷信息失败: {e.stderr.strip()}")
except json.JSONDecodeError as e:
logger.error(f"解析LVM物理卷JSON输出失败: {e}")
except Exception as e:
logger.error(f"获取LVM物理卷信息失败 (未知错误): {e}")
# Get VGs
try:
stdout, stderr = self._run_command(["vgs", "--reportformat", "json"])
data = json.loads(stdout)
if 'report' in data and data['report']:
for vg_data in data['report'][0].get('vg', []):
lvm_info['vgs'].append({
'vg_name': vg_data.get('vg_name'),
'vg_uuid': vg_data.get('vg_uuid'),
'vg_size': vg_data.get('vg_size'),
'vg_free': vg_data.get('vg_free'),
'vg_attr': vg_data.get('vg_attr'),
'pv_count': vg_data.get('pv_count'),
'lv_count': vg_data.get('lv_count'),
'vg_alloc_percent': vg_data.get('vg_alloc_percent'),
'vg_fmt': vg_data.get('vg_fmt')
})
except subprocess.CalledProcessError as e:
if "No volume group found" in e.stderr or "No volume groups found" in e.stdout:
logger.info("未找到任何LVM卷组。")
else:
logger.error(f"获取LVM卷组信息失败: {e.stderr.strip()}")
except json.JSONDecodeError as e:
logger.error(f"解析LVM卷组JSON输出失败: {e}")
except Exception as e:
logger.error(f"获取LVM卷组信息失败 (未知错误): {e}")
# Get LVs (MODIFIED: added -o lv_path)
try:
# 明确请求 lv_path因为默认的 --reportformat json 不包含它
stdout, stderr = self._run_command(["lvs", "-o", "lv_name,vg_name,lv_uuid,lv_size,lv_attr,origin,snap_percent,lv_path", "--reportformat", "json"])
data = json.loads(stdout)
if 'report' in data and data['report']:
for lv_data in data['report'][0].get('lv', []):
lvm_info['lvs'].append({
'lv_name': lv_data.get('lv_name'),
'vg_name': lv_data.get('vg_name'),
'lv_uuid': lv_data.get('lv_uuid'),
'lv_size': lv_data.get('lv_size'),
'lv_attr': lv_data.get('lv_attr'),
'origin': lv_data.get('origin'),
'snap_percent': lv_data.get('snap_percent'),
'lv_path': lv_data.get('lv_path') # 现在应该能正确获取到路径了
})
except subprocess.CalledProcessError as e:
if "No logical volume found" in e.stderr or "No logical volumes found" in e.stdout:
logger.info("未找到任何LVM逻辑卷。")
else:
logger.error(f"获取LVM逻辑卷信息失败: {e.stderr.strip()}")
except json.JSONDecodeError as e:
logger.error(f"解析LVM逻辑卷JSON输出失败: {e}")
except Exception as e:
logger.error(f"获取LVM逻辑卷信息失败 (未知错误): {e}")
return lvm_info
def get_unallocated_partitions(self):
"""
获取所有可用于创建RAID阵列或LVM物理卷的设备。
这些设备包括:
1. 没有分区表的整个磁盘。
2. 未挂载的分区包括有文件系统但未挂载的如ext4/xfs等
3. 未被LVM或RAID使用的分区或磁盘。
返回一个设备路径列表,例如 ['/dev/sdb1', '/dev/sdc']。
"""
block_devices = self.get_block_devices()
candidates = []
def process_device(dev):
dev_path = dev.get('path')
dev_type = dev.get('type')
mountpoint = dev.get('mountpoint')
fstype = dev.get('fstype')
# 1. 检查是否已挂载 (排除SWAP因为SWAP可以被覆盖但如果活跃则不应动)
# 如果是活跃挂载点非SWAP则不是候选
if mountpoint and mountpoint != '[SWAP]':
# 如果设备已挂载,则它不是候选。
# 但仍需处理其子设备,因为父设备挂载不代表子设备不能用(虽然不常见)
# 或者子设备挂载不代表父设备不能用(例如使用整个磁盘)
if 'children' in dev:
for child in dev['children']:
process_device(child)
return
# 2. 检查是否是LVM物理卷或RAID成员
# 如果是LVM PV或RAID成员则它不是新RAID/PV的候选
if fstype in ['LVM2_member', 'linux_raid_member']:
if 'children' in dev: # 即使是LVM/RAID成员也可能存在子设备例如LVM上的分区虽然不常见
for child in dev['children']:
process_device(child)
return
# 3. 如果是整个磁盘且没有分区,则是一个候选
if dev_type == 'disk' and not dev.get('children'):
candidates.append(dev_path)
# 4. 如果是分区且通过了上述挂载和LVM/RAID检查则是一个候选
elif dev_type == 'part':
candidates.append(dev_path)
# 5. 递归处理子设备 (对于有分区的磁盘)
# 这一步放在最后,确保先对当前设备进行评估
if dev_type == 'disk' and dev.get('children'): # 只有当是磁盘且有子设备时才需要递归
for child in dev['children']:
process_device(child)
for dev in block_devices:
process_device(dev)
# 去重并排序
return sorted(list(set(candidates)))
def _get_actual_md_device_path(self, array_path):
"""
解析 mdadm 阵列名称(例如 /dev/md/new_raid
对应的实际内核设备路径(例如 /dev/md127
通过检查 /dev/md/ 目录下的符号链接来获取。
Added type check for array_path.
"""
if not isinstance(array_path, str):
logger.warning(f"传入 _get_actual_md_device_path 的 array_path 不是字符串: {array_path} (类型: {type(array_path)})")
return None
# 如果已经是规范的 /dev/mdX 路径,直接返回
if re.match(r'^/dev/md\d+$', array_path):
return array_path
if os.path.exists(array_path):
try:
# os.path.realpath 会解析符号链接,例如 /dev/md/new_raid -> /dev/md127
actual_path = os.path.realpath(array_path)
# 确保解析后仍然是 md 设备(以 /dev/md 开头)
if actual_path.startswith('/dev/md'):
logger.debug(f"已将 RAID 阵列 {array_path} 解析为实际设备路径: {actual_path}")
return actual_path
except Exception as e:
logger.warning(f"无法通过 os.path.realpath 获取 RAID 阵列 {array_path} 的实际设备路径: {e}")
logger.warning(f"RAID 阵列 {array_path} 不存在或无法解析其真实路径。")
return None
def get_device_details_by_path(self, device_path):
"""
根据设备路径获取设备的 UUID 和文件系统类型 (fstype)。
此方法现在可以正确处理 /dev/md/new_raid 这样的 RAID 阵列别名,以及 LVM 逻辑卷的符号链接。
Added type check for device_path.
:param device_path: 设备的路径,例如 '/dev/sdb1''/dev/md0''/dev/md/new_raid'
:return: 包含 'uuid''fstype' 的字典,如果未找到则返回 None。
"""
if not isinstance(device_path, str):
logger.warning(f"传入 get_device_details_by_path 的 device_path 不是字符串: {device_path} (类型: {type(device_path)})")
return None
original_device_path = device_path
logger.debug(f"get_device_details_by_path: 正在处理原始设备路径: {original_device_path}")
devices = self.get_block_devices() # 获取所有块设备信息
logger.debug(f"get_device_details_by_path: 从 lsblk 获取到 {len(devices)} 个块设备信息。")
target_maj_min = None
current_search_path = original_device_path
# 1. 解析符号链接以获取实际路径,并尝试获取 maj:min
if original_device_path.startswith('/dev/') and os.path.exists(original_device_path):
try:
resolved_path = os.path.realpath(original_device_path)
logger.debug(f"get_device_details_by_path: 原始设备路径 {original_device_path} 解析为 {resolved_path}")
current_search_path = resolved_path
stat_output, _ = self._run_command(["stat", "-c", "%t:%T", resolved_path], check_output=True)
raw_maj_min = stat_output.strip() # e.g., "fc:0"
# MODIFIED: Convert hex major to decimal
if ':' in raw_maj_min:
major_hex, minor_dec = raw_maj_min.split(':')
try:
major_dec = str(int(major_hex, 16)) # Convert hex to decimal string
target_maj_min = f"{major_dec}:{minor_dec}" # e.g., "252:0"
logger.debug(f"get_device_details_by_path: 解析后设备 {resolved_path} 的 maj:min (hex) 为 {raw_maj_min},转换为 decimal 为 {target_maj_min}")
except ValueError:
logger.warning(f"get_device_details_by_path: 无法将 maj:min 的主要部分 '{major_hex}' 从十六进制转换为十进制。")
target_maj_min = None
else:
logger.warning(f"get_device_details_by_path: stat 输出 '{raw_maj_min}' 格式不符合预期。")
target_maj_min = None
except subprocess.CalledProcessError as e:
logger.debug(f"get_device_details_by_path: 无法获取 {resolved_path} 的 maj:min (命令失败): {e.stderr.strip()}")
except Exception as e:
logger.debug(f"get_device_details_by_path: 无法获取 {resolved_path} 的 maj:min (未知错误): {e}")
elif original_device_path.startswith('/dev/') and not os.path.exists(original_device_path):
logger.warning(f"get_device_details_by_path: 设备路径 {original_device_path} 不存在,无法获取详情。")
return None
# 2. 首先,尝试使用 (可能已解析的) current_search_path 在 lsblk 输出中查找
lsblk_details = self._find_device_by_path_recursive(devices, current_search_path)
logger.debug(f"get_device_details_by_path: _find_device_by_path_recursive 查找结果 (使用 {current_search_path}, 直接查找): {lsblk_details}")
if lsblk_details and lsblk_details.get('fstype'):
logger.debug(f"get_device_details_by_path: 直接从 lsblk 获取到 {original_device_path} (解析为 {current_search_path}) 的详情: {lsblk_details}")
return {
'uuid': lsblk_details.get('uuid'),
'fstype': lsblk_details.get('fstype')
}
# 3. 如果直接路径查找失败,并且我们有 maj:min尝试通过 maj:min 查找
if target_maj_min:
logger.debug(f"get_device_details_by_path: 直接路径查找失败,尝试通过 maj:min ({target_maj_min}) 查找。")
dev_info_by_maj_min = self._find_device_by_maj_min_recursive(devices, target_maj_min)
logger.debug(f"get_device_details_by_path: 通过 maj:min 查找结果: {dev_info_by_maj_min}")
if dev_info_by_maj_min and dev_info_by_maj_min.get('fstype'):
logger.debug(f"get_device_details_by_path: 通过 maj:min 找到 {original_device_path} 的文件系统详情: {dev_info_by_maj_min}")
return {
'uuid': dev_info_by_maj_min.get('uuid'),
'fstype': dev_info_by_maj_min.get('fstype')
}
# 4. 如果仍然没有找到,并且是 RAID 阵列(例如 /dev/md/new_raid
if original_device_path.startswith('/dev/md'):
logger.debug(f"get_device_details_by_path: 正在处理 RAID 阵列 {original_device_path}...")
actual_md_device_path = self._get_actual_md_device_path(original_device_path)
logger.debug(f"get_device_details_by_path: RAID 阵列 {original_device_path} 的实际设备路径: {actual_md_device_path}")
if actual_md_device_path:
actual_device_lsblk_details = self._find_device_by_path_recursive(devices, actual_md_device_path)
logger.debug(f"get_device_details_by_path: 实际设备路径 {actual_md_device_path} 的 lsblk 详情: {actual_device_lsblk_details}")
if actual_device_lsblk_details and actual_device_lsblk_details.get('fstype'):
logger.debug(f"get_device_details_by_path: 在实际设备 {actual_md_device_path} 上找到了文件系统详情: {actual_device_lsblk_details}")
return {
'uuid': actual_device_lsblk_details.get('uuid'),
'fstype': actual_device_lsblk_details.get('fstype')
}
else:
logger.warning(f"get_device_details_by_path: RAID 阵列 {original_device_path} (实际设备 {actual_md_device_path}) 未找到文件系统类型。")
return None
else:
logger.warning(f"get_device_details_by_path: 无法确定 RAID 阵列 {original_device_path} 的实际内核设备路径。")
return None
# 5. 如果仍然没有找到,返回 None
logger.debug(f"get_device_details_by_path: 未能获取到 {original_device_path} 的任何详情。")
return None
def delete_raid_array_config(self, uuid):
"""
从 mdadm.conf 文件中删除指定 UUID 的 RAID 阵列配置条目。
:param uuid: 要删除的 RAID 阵列的 UUID。
:return: True 如果成功删除或未找到条目False 如果发生错误。
:raises Exception: 如果删除失败。
"""
logger.info(f"尝试从 mdadm.conf 中删除 UUID 为 {uuid} 的 RAID 阵列配置。")
mdadm_conf_paths = ["/etc/mdadm/mdadm.conf", "/etc/mdadm.conf"]
target_conf_path = None
# 找到存在的 mdadm.conf 文件
for path in mdadm_conf_paths:
if os.path.exists(path):
target_conf_path = path
break
if not target_conf_path:
logger.warning("未找到 mdadm 配置文件,无法删除配置条目。")
raise FileNotFoundError("mdadm 配置文件未找到。")
original_lines = []
try:
with open(target_conf_path, 'r') as f:
original_lines = f.readlines()
except Exception as e:
logger.error(f"读取 mdadm 配置文件 {target_conf_path} 失败: {e}")
raise Exception(f"读取 mdadm 配置文件失败: {e}")
new_lines = []
entry_found = False
for line in original_lines:
stripped_line = line.strip()
if stripped_line.startswith("ARRAY"):
# 尝试匹配 UUID
uuid_match = re.search(r'UUID=([0-9a-f:]+)', stripped_line)
if uuid_match and uuid_match.group(1) == uuid:
logger.debug(f"找到并跳过 UUID 为 {uuid} 的配置行: {stripped_line}")
entry_found = True
continue # 跳过此行,即删除
new_lines.append(line)
if not entry_found:
logger.warning(f"在 mdadm.conf 中未找到 UUID 为 {uuid} 的 RAID 阵列配置条目。")
# 即使未找到,也不视为错误,因为可能已经被手动删除或从未写入
return True
# 将修改后的内容写回文件 (需要 root 权限)
try:
# 使用临时文件进行原子写入,防止数据损坏
temp_file_path = f"{target_conf_path}.tmp"
with open(temp_file_path, 'w') as f:
f.writelines(new_lines)
# 替换原文件
self._run_command(["mv", temp_file_path, target_conf_path], root_privilege=True)
logger.info(f"成功从 {target_conf_path} 中删除 UUID 为 {uuid} 的 RAID 阵列配置。")
return True
except subprocess.CalledProcessError as e:
logger.error(f"删除 mdadm 配置条目失败 (mv 命令错误): {e.stderr.strip()}")
raise Exception(f"删除 mdadm 配置条目失败: {e.stderr.strip()}")
except Exception as e:
logger.error(f"写入 mdadm 配置文件 {target_conf_path} 失败: {e}")
raise Exception(f"写入 mdadm 配置文件失败: {e}")

122
ui_form.py Normal file
View File

@@ -0,0 +1,122 @@
# -*- coding: utf-8 -*-
################################################################################
## Form generated from reading UI file 'form.ui'
##
## Created by: Qt User Interface Compiler version 6.10.1
##
## WARNING! All changes made in this file will be lost when recompiling UI file!
################################################################################
from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale,
QMetaObject, QObject, QPoint, QRect,
QSize, QTime, QUrl, Qt)
from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor,
QFont, QFontDatabase, QGradient, QIcon,
QImage, QKeySequence, QLinearGradient, QPainter,
QPalette, QPixmap, QRadialGradient, QTransform)
from PySide6.QtWidgets import (QApplication, QHeaderView, QMainWindow, QMenuBar,
QPushButton, QSizePolicy, QStatusBar, QTabWidget,
QTextEdit, QTreeWidget, QTreeWidgetItem, QVBoxLayout,
QWidget)
class Ui_MainWindow(object):
def setupUi(self, MainWindow):
if not MainWindow.objectName():
MainWindow.setObjectName(u"MainWindow")
MainWindow.resize(1000, 700)
self.centralwidget = QWidget(MainWindow)
self.centralwidget.setObjectName(u"centralwidget")
self.verticalLayout_2 = QVBoxLayout(self.centralwidget)
self.verticalLayout_2.setObjectName(u"verticalLayout_2")
self.refreshButton = QPushButton(self.centralwidget)
self.refreshButton.setObjectName(u"refreshButton")
self.verticalLayout_2.addWidget(self.refreshButton)
self.tabWidget = QTabWidget(self.centralwidget)
self.tabWidget.setObjectName(u"tabWidget")
self.tab_block_devices = QWidget()
self.tab_block_devices.setObjectName(u"tab_block_devices")
self.verticalLayout = QVBoxLayout(self.tab_block_devices)
self.verticalLayout.setObjectName(u"verticalLayout")
self.treeWidget_block_devices = QTreeWidget(self.tab_block_devices)
self.treeWidget_block_devices.setObjectName(u"treeWidget_block_devices")
self.verticalLayout.addWidget(self.treeWidget_block_devices)
self.tabWidget.addTab(self.tab_block_devices, "")
self.tab_raid = QWidget()
self.tab_raid.setObjectName(u"tab_raid")
self.verticalLayout_raid = QVBoxLayout(self.tab_raid)
self.verticalLayout_raid.setObjectName(u"verticalLayout_raid")
self.treeWidget_raid = QTreeWidget(self.tab_raid)
self.treeWidget_raid.setObjectName(u"treeWidget_raid")
self.verticalLayout_raid.addWidget(self.treeWidget_raid)
self.tabWidget.addTab(self.tab_raid, "")
self.tab_lvm = QWidget()
self.tab_lvm.setObjectName(u"tab_lvm")
self.verticalLayout_lvm = QVBoxLayout(self.tab_lvm)
self.verticalLayout_lvm.setObjectName(u"verticalLayout_lvm")
self.treeWidget_lvm = QTreeWidget(self.tab_lvm)
self.treeWidget_lvm.setObjectName(u"treeWidget_lvm")
self.verticalLayout_lvm.addWidget(self.treeWidget_lvm)
self.tabWidget.addTab(self.tab_lvm, "")
self.verticalLayout_2.addWidget(self.tabWidget)
self.logOutputTextEdit = QTextEdit(self.centralwidget)
self.logOutputTextEdit.setObjectName(u"logOutputTextEdit")
self.logOutputTextEdit.setReadOnly(True)
self.verticalLayout_2.addWidget(self.logOutputTextEdit)
self.verticalLayout_2.setStretch(1, 4)
self.verticalLayout_2.setStretch(2, 1)
MainWindow.setCentralWidget(self.centralwidget)
self.menubar = QMenuBar(MainWindow)
self.menubar.setObjectName(u"menubar")
self.menubar.setGeometry(QRect(0, 0, 1000, 30))
MainWindow.setMenuBar(self.menubar)
self.statusbar = QStatusBar(MainWindow)
self.statusbar.setObjectName(u"statusbar")
MainWindow.setStatusBar(self.statusbar)
self.retranslateUi(MainWindow)
self.tabWidget.setCurrentIndex(0)
QMetaObject.connectSlotsByName(MainWindow)
# setupUi
def retranslateUi(self, MainWindow):
MainWindow.setWindowTitle(QCoreApplication.translate("MainWindow", u"Linux \u5b58\u50a8\u7ba1\u7406\u5de5\u5177", None))
self.refreshButton.setText(QCoreApplication.translate("MainWindow", u"\u5237\u65b0\u6570\u636e", None))
___qtreewidgetitem = self.treeWidget_block_devices.headerItem()
___qtreewidgetitem.setText(12, QCoreApplication.translate("MainWindow", u"\u7236\u8bbe\u5907\u540d", None));
___qtreewidgetitem.setText(11, QCoreApplication.translate("MainWindow", u"\u4e3b\u6b21\u53f7", None));
___qtreewidgetitem.setText(10, QCoreApplication.translate("MainWindow", u"\u5e8f\u5217\u53f7", None));
___qtreewidgetitem.setText(9, QCoreApplication.translate("MainWindow", u"\u578b\u53f7", None));
___qtreewidgetitem.setText(8, QCoreApplication.translate("MainWindow", u"\u5382\u5546", None));
___qtreewidgetitem.setText(7, QCoreApplication.translate("MainWindow", u"PARTUUID", None));
___qtreewidgetitem.setText(6, QCoreApplication.translate("MainWindow", u"UUID", None));
___qtreewidgetitem.setText(5, QCoreApplication.translate("MainWindow", u"\u53ea\u8bfb", None));
___qtreewidgetitem.setText(4, QCoreApplication.translate("MainWindow", u"\u6587\u4ef6\u7cfb\u7edf", None));
___qtreewidgetitem.setText(3, QCoreApplication.translate("MainWindow", u"\u6302\u8f7d\u70b9", None));
___qtreewidgetitem.setText(2, QCoreApplication.translate("MainWindow", u"\u5927\u5c0f", None));
___qtreewidgetitem.setText(1, QCoreApplication.translate("MainWindow", u"\u7c7b\u578b", None));
___qtreewidgetitem.setText(0, QCoreApplication.translate("MainWindow", u"\u8bbe\u5907\u540d", None));
self.tabWidget.setTabText(self.tabWidget.indexOf(self.tab_block_devices), QCoreApplication.translate("MainWindow", u"\u5757\u8bbe\u5907\u6982\u89c8", None))
___qtreewidgetitem1 = self.treeWidget_raid.headerItem()
___qtreewidgetitem1.setText(0, QCoreApplication.translate("MainWindow", u"1", None));
self.tabWidget.setTabText(self.tabWidget.indexOf(self.tab_raid), QCoreApplication.translate("MainWindow", u"RAID \u7ba1\u7406", None))
___qtreewidgetitem2 = self.treeWidget_lvm.headerItem()
___qtreewidgetitem2.setText(0, QCoreApplication.translate("MainWindow", u"1", None));
self.tabWidget.setTabText(self.tabWidget.indexOf(self.tab_lvm), QCoreApplication.translate("MainWindow", u"LVM \u7ba1\u7406", None))
# retranslateUi

68
up+.sh Normal file
View File

@@ -0,0 +1,68 @@
#!/bin/bash
#set -e
##################################################################################################################
# Author : Erik Dubois
# Website : https://www.erikdubois.be
# Website : https://www.alci.online
# Website : https://www.ariser.eu
# Website : https://www.arcolinux.info
# Website : https://www.arcolinux.com
# Website : https://www.arcolinuxd.com
# Website : https://www.arcolinuxb.com
# Website : https://www.arcolinuxiso.com
# Website : https://www.arcolinuxforum.com
##################################################################################################################
#
# DO NOT JUST RUN THIS. EXAMINE AND JUDGE. RUN AT YOUR OWN RISK.
#
##################################################################################################################
#tput setaf 0 = black
#tput setaf 1 = red
#tput setaf 2 = green
#tput setaf 3 = yellow
#tput setaf 4 = dark blue
#tput setaf 5 = purple
#tput setaf 6 = cyan
#tput setaf 7 = gray
#tput setaf 8 = light blue
##################################################################################################################
# reset - commit your changes or stash them before you merge
# git reset --hard - personal alias - grh
echo "Deleting the work folder if one exists"
[ -d work ] && rm -rf work
# checking if I have the latest files from github
echo "Checking for newer files online first"
git pull
# Below command will backup everything inside the project folder
git add --all .
# Give a comment to the commit if you want
echo "####################################"
echo "Write your commit comment!"
echo "####################################"
read input
# Committing to the local repository with a message containing the time details and commit text
git commit -m "$input"
# Push the local files to github
if grep -q main .git/config; then
echo "Using main"
git push -u origin main
fi
if grep -q master .git/config; then
echo "Using master"
git push -u origin master
fi
echo "################################################################"
echo "################### Git Push Done ######################"
echo "################################################################"