From 8c53b9c0a05dcf903de9a5ee4aa5fc6645522350 Mon Sep 17 00:00:00 2001
From: zj <1052308357@qq.comm>
Date: Sat, 7 Feb 2026 14:58:56 +0800
Subject: [PATCH] first commit
---
.gitignore | 4 +
AGENTS.md | 275 +++++++++++++
Linux存儲管理器.spec | 38 ++
README.md | 5 +
build-app.sh | 3 +
dialogs.py | 486 ++++++++++++++++++++++
disk-manager.spec | 38 ++
disk_operations.py | 469 +++++++++++++++++++++
form.ui | 163 ++++++++
logger_config.py | 54 +++
lvm_operations.py | 362 +++++++++++++++++
mainwindow.py | 901 +++++++++++++++++++++++++++++++++++++++++
occupation_resolver.py | 317 +++++++++++++++
pyproject.toml | 5 +
raid_operations.py | 471 +++++++++++++++++++++
requirements.txt | 1 +
system_info.py | 758 ++++++++++++++++++++++++++++++++++
ui_form.py | 122 ++++++
up+.sh | 68 ++++
19 files changed, 4540 insertions(+)
create mode 100644 .gitignore
create mode 100644 AGENTS.md
create mode 100644 Linux存儲管理器.spec
create mode 100644 README.md
create mode 100644 build-app.sh
create mode 100644 dialogs.py
create mode 100644 disk-manager.spec
create mode 100644 disk_operations.py
create mode 100644 form.ui
create mode 100644 logger_config.py
create mode 100644 lvm_operations.py
create mode 100644 mainwindow.py
create mode 100644 occupation_resolver.py
create mode 100644 pyproject.toml
create mode 100644 raid_operations.py
create mode 100644 requirements.txt
create mode 100644 system_info.py
create mode 100644 ui_form.py
create mode 100644 up+.sh
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..30514e3
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+/dist/
+/build/
+/.qtcreator/
+/__pycache__/
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..75ba5a4
--- /dev/null
+++ b/AGENTS.md
@@ -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 交互处理可能需要调整
diff --git a/Linux存儲管理器.spec b/Linux存儲管理器.spec
new file mode 100644
index 0000000..74e5e3e
--- /dev/null
+++ b/Linux存儲管理器.spec
@@ -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,
+)
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..9494cd9
--- /dev/null
+++ b/README.md
@@ -0,0 +1,5 @@
+python pyqt
+### 实现功能
+1.磁盘管理
+2.逻辑卷管理
+3.软阵列管理
diff --git a/build-app.sh b/build-app.sh
new file mode 100644
index 0000000..b944ced
--- /dev/null
+++ b/build-app.sh
@@ -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
diff --git a/dialogs.py b/dialogs.py
new file mode 100644
index 0000000..321b0c0
--- /dev/null
+++ b/dialogs.py
@@ -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 # 返回此标志
+ }
diff --git a/disk-manager.spec b/disk-manager.spec
new file mode 100644
index 0000000..b74c1d5
--- /dev/null
+++ b/disk-manager.spec
@@ -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,
+)
diff --git a/disk_operations.py b/disk_operations.py
new file mode 100644
index 0000000..de18dd3
--- /dev/null
+++ b/disk_operations.py
@@ -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)
diff --git a/form.ui b/form.ui
new file mode 100644
index 0000000..7269374
--- /dev/null
+++ b/form.ui
@@ -0,0 +1,163 @@
+
+
+ MainWindow
+
+
+
+ 0
+ 0
+ 1000
+ 700
+
+
+
+ Linux 存储管理工具
+
+
+
+ -
+
+
+ 刷新数据
+
+
+
+ -
+
+
+ 0
+
+
+
+ 块设备概览
+
+
+
-
+
+
+
+ 设备名
+
+
+
+
+ 类型
+
+
+
+
+ 大小
+
+
+
+
+ 挂载点
+
+
+
+
+ 文件系统
+
+
+
+
+ 只读
+
+
+
+
+ UUID
+
+
+
+
+ PARTUUID
+
+
+
+
+ 厂商
+
+
+
+
+ 型号
+
+
+
+
+ 序列号
+
+
+
+
+ 主次号
+
+
+
+
+ 父设备名
+
+
+
+
+
+
+
+
+ RAID 管理
+
+
+ -
+
+
+
+ 1
+
+
+
+
+
+
+
+
+ LVM 管理
+
+
+ -
+
+
+
+ 1
+
+
+
+
+
+
+
+
+ -
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+
diff --git a/logger_config.py b/logger_config.py
new file mode 100644
index 0000000..0f06543
--- /dev/null
+++ b/logger_config.py
@@ -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__)
diff --git a/lvm_operations.py b/lvm_operations.py
new file mode 100644
index 0000000..abd27ed
--- /dev/null
+++ b/lvm_operations.py
@@ -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
diff --git a/mainwindow.py b/mainwindow.py
new file mode 100644
index 0000000..62f8343
--- /dev/null
+++ b/mainwindow.py
@@ -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())
diff --git a/occupation_resolver.py b/occupation_resolver.py
new file mode 100644
index 0000000..b2e348d
--- /dev/null
+++ b/occupation_resolver.py
@@ -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 下的挂载
+ # -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\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
+
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..7559d02
--- /dev/null
+++ b/pyproject.toml
@@ -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"]
diff --git a/raid_operations.py b/raid_operations.py
new file mode 100644
index 0000000..34146e9
--- /dev/null
+++ b/raid_operations.py
@@ -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 --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
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..7fa34f0
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1 @@
+PySide6
diff --git a/system_info.py b/system_info.py
new file mode 100644
index 0000000..275a604
--- /dev/null
+++ b/system_info.py
@@ -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/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[A-Za-z ]+)\s*:\s*(?P.+)', 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}")
+
diff --git a/ui_form.py b/ui_form.py
new file mode 100644
index 0000000..783dd25
--- /dev/null
+++ b/ui_form.py
@@ -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
+
diff --git a/up+.sh b/up+.sh
new file mode 100644
index 0000000..1a6d204
--- /dev/null
+++ b/up+.sh
@@ -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 "################################################################"