From 1a3a4746a30e96fda0ef325cad24ddbfe0cfcbfa Mon Sep 17 00:00:00 2001 From: zj <1052308357@qq.comm> Date: Mon, 9 Feb 2026 03:14:35 +0800 Subject: [PATCH] change tk --- AGENTS.md | 326 ++++------ README_Tkinter.md | 169 +++++ dialogs_tkinter.py | 557 +++++++++++++++++ disk_operations_tkinter.py | 539 ++++++++++++++++ logger_config_tkinter.py | 84 +++ lvm_operations_tkinter.py | 291 +++++++++ main_tkinter.py | 43 ++ mainwindow_tkinter.py | 1053 ++++++++++++++++++++++++++++++++ occupation_resolver_tkinter.py | 179 ++++++ raid_operations_tkinter.py | 410 +++++++++++++ requirements.txt | 18 +- run_tkinter.sh | 41 ++ verify_tkinter.py | 106 ++++ 13 files changed, 3595 insertions(+), 221 deletions(-) create mode 100644 README_Tkinter.md create mode 100644 dialogs_tkinter.py create mode 100644 disk_operations_tkinter.py create mode 100644 logger_config_tkinter.py create mode 100644 lvm_operations_tkinter.py create mode 100644 main_tkinter.py create mode 100644 mainwindow_tkinter.py create mode 100644 occupation_resolver_tkinter.py create mode 100644 raid_operations_tkinter.py create mode 100644 run_tkinter.sh create mode 100644 verify_tkinter.py diff --git a/AGENTS.md b/AGENTS.md index 75ba5a4..766dbfc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,275 +1,161 @@ -# Linux 存储管理器 (Linux Storage Manager) - AI Agent Guide +# Linux 存储管理器 (Linux Storage Manager) ## 项目概述 -这是一个基于 PySide6 开发的 Linux 存储管理图形界面工具,提供磁盘管理、逻辑卷管理(LVM)和软阵列管理(RAID)三大核心功能。 +这是一个基于 PySide6 开发的 Linux 图形化存储管理工具,提供磁盘管理、逻辑卷管理(LVM)和软 RAID 管理功能。应用程序通过调用系统命令(如 `lsblk`, `parted`, `mdadm`, `lvm` 等)与 Linux 存储子系统交互。 -**主要功能模块:** -1. **磁盘管理** - 分区创建/删除、格式化(ext4/xfs/ntfs/fat32)、挂载/卸载、分区表擦除 -2. **LVM 管理** - 物理卷(PV)、卷组(VG)、逻辑卷(LV)的创建和管理 -3. **RAID 管理** - 软阵列创建、管理、停止/启动阵列 (支持 RAID0/1/5/6/10) +### 主要功能模块 + +1. **块设备管理** - 查看磁盘信息、创建分区、格式化文件系统(ext4/xfs/ntfs/fat32)、挂载/卸载设备 +2. **LVM 管理** - 物理卷(PV)、卷组(VG)、逻辑卷(LV)的创建、删除和扩容 +3. **RAID 管理** - 软 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 | +- **GUI 框架**: PySide6 (Qt for Python) +- **UI 设计**: Qt Designer (form.ui) +- **构建工具**: PyInstaller +- **依赖库**: + - PySide6 - GUI 框架 + - pexpect - 交互式命令执行(用于 RAID 创建时的密码输入) ## 项目结构 ``` . -├── 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 # 由 form.ui 自动生成的 UI 类 +├── form.ui # Qt Designer UI 定义文件(主界面布局) +├── dialogs.py # 自定义对话框(分区创建、挂载、RAID/LVM 创建等) +├── system_info.py # 系统信息获取模块(lsblk 解析等) +├── disk_operations.py # 磁盘操作(分区、格式化、挂载管理) +├── lvm_operations.py # LVM 操作(PV/VG/LV 管理) +├── raid_operations.py # RAID 操作(mdadm 命令封装) +├── occupation_resolver.py # 设备占用检测与解除(嵌套挂载、进程占用) +├── logger_config.py # 日志配置(输出到 QTextEdit 控件) +├── pyproject.toml # PySide6 项目配置文件 +├── disk-manager.spec # PyInstaller 打包配置 +└── build-app.sh # 构建脚本 ``` -## 代码组织 - -### 模块依赖关系 +## 模块依赖关系 ``` -mainwindow.py (入口) - ├── ui_form.py (自动生成的 UI) +mainwindow.py + ├── ui_form.py (UI 布局) ├── system_info.py (系统信息) + ├── logger_config.py (日志) ├── disk_operations.py (磁盘操作) - │ └── 依赖: system_info.py, lvm_operations.py - ├── lvm_operations.py (LVM 操作,提供 _execute_shell_command) + │ └── 依赖 system_info.py, lvm_operations.py ├── raid_operations.py (RAID 操作) - │ └── 依赖: system_info.py - ├── occupation_resolver.py (占用解除) - │ └── 依赖: lvm_operations.py, system_info.py - ├── dialogs.py (对话框) - └── logger_config.py (日志) + │ └── 依赖 system_info.py + ├── lvm_operations.py (LVM 操作) + ├── 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 | +### 文件命名 +- 模块文件使用下划线命名法:`disk_operations.py`, `lvm_operations.py` +- UI 相关文件:`form.ui`(设计源文件), `ui_form.py`(自动生成) -### 对话框类 (dialogs.py) +### 类命名规范 +- 主窗口类:`MainWindow` +- UI 类:`Ui_MainWindow`(Qt 自动生成) +- 操作类:`DiskOperations`, `LvmOperations`, `RaidOperations` +- 对话框类:`CreatePartitionDialog`, `MountDialog`, `CreateRaidDialog` +- 工具类:`SystemInfoManager`, `OccupationResolver` -- `CreatePartitionDialog` - 创建分区对话框 -- `MountDialog` - 挂载分区对话框 -- `CreateRaidDialog` - 创建 RAID 阵列对话框 -- `CreatePvDialog` - 创建物理卷对话框 -- `CreateVgDialog` - 创建卷组对话框 -- `CreateLvDialog` - 创建逻辑卷对话框 +### 命令执行模式 +所有系统命令执行都通过 `_execute_shell_command` 或 `_run_command` 方法封装,统一处理: +- sudo 权限提升 +- 错误处理和日志记录 +- QMessageBox 错误提示(可抑制) -## 构建和打包 +## 构建和运行 -### 依赖安装 +### 运行环境要求 +- Linux 操作系统(需要 root/sudo 权限执行存储管理命令) +- Python 3.x +- 系统依赖:`parted`, `mdadm`, `lvm2`, `dosfstools`, `e2fsprogs`, `xfsprogs`, `ntfs-3g` +### 安装依赖 ```bash -pip install PySide6 pyinstaller pexpect +pip install PySide6 pexpect ``` -### 开发运行 - +### 运行应用 ```bash -# 直接运行 python mainwindow.py - -# 注意: 部分功能需要 root 权限 -sudo python mainwindow.py ``` +注意:大部分功能需要 root 权限,建议以 sudo 运行或确保用户有 sudo 权限。 -### 生成可执行文件 - +### 打包可执行文件 ```bash -# 使用 PyInstaller (需要配置虚拟环境路径) -sudo /path/to/venv/bin/pyinstaller -F --name "disk-manager" mainwindow.py +# 使用 PyInstaller +sudo pyinstaller -F --name "disk-manager" mainwindow.py -# 或使用提供的打包脚本 (需修改路径) -bash build-app.sh +# 或使用提供的脚本 +sudo ./build-app.sh ``` -打包配置文件: -- `disk-manager.spec` - 英文名称配置 -- `Linux存儲管理器.spec` - 中文名称配置 +打包后的可执行文件位于 `dist/disk-manager`。 -## UI 设计规范 - -### 界面布局 (form.ui) - -主窗口包含: -1. **刷新按钮** - 刷新所有设备信息 -2. **标签页控件** - 三个标签页: - - 块设备概览 (treeWidget_block_devices) - - RAID 管理 (treeWidget_raid) - - LVM 管理 (treeWidget_lvm) -3. **日志输出区** (logOutputTextEdit) - 显示操作日志 - -### 修改 UI 的流程 +## 开发注意事项 +### 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` 是自动生成的,不要手动修改 +2. 使用 `pyside6-uic form.ui -o ui_form.py` 重新生成 Python UI 文件 +3. 或在 Qt Creator 中配置 PySide6 项目自动处理 -## 编码规范 +### 日志系统 +- 使用 `logger_config.py` 中配置的全局 logger +- 日志同时输出到控制台和 GUI 的 QTextEdit 控件 +- 日志级别:`logging.DEBUG`(开发时可调整) -### 代码风格 - -- 使用 4 空格缩进 -- 函数和变量使用小写加下划线命名 (snake_case) -- 类名使用大驼峰命名 (PascalCase) -- 注释和日志使用中文 +### 线程安全 +- 格式化操作使用 `FormatWorker` 在后台线程执行,避免阻塞 UI +- 通过 Qt Signal/Slot 机制与主线程通信 ### 错误处理 +- 所有 shell 命令执行都有统一的错误处理 +- 关键错误会弹出 QMessageBox 提示用户 +- 详细错误信息写入日志 -所有 shell 命令执行通过 `_execute_shell_command` 方法统一处理: -- 自动处理 sudo 权限 -- 统一的错误对话框显示 -- 详细的日志记录 +## 系统命令依赖 -示例: -```python -success, stdout, stderr = self.lvm_ops._execute_shell_command( - ["parted", "-s", device_path, "mklabel", "gpt"], - f"擦除 {device_path} 上的分区表失败" -) -``` +应用依赖以下 Linux 命令行工具: -### 日志记录 - -使用标准 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 创建) | +| 块设备信息 | `lsblk` | +| 分区操作 | `parted`, `partprobe` | +| 文件系统 | `mkfs.ext4`, `mkfs.xfs`, `mkfs.ntfs`, `mkfs.vfat` | +| 挂载管理 | `mount`, `umount`, `findmnt` | +| RAID 管理 | `mdadm` | +| LVM 管理 | `pvcreate`, `vgcreate`, `lvcreate`, `pvs`, `vgs`, `lvs` 等 | +| 进程查询 | `lsof`, `fuser` | -## 配置说明 +## 安全考虑 -### pyproject.toml +1. **权限要求**: 应用需要 root/sudo 权限执行存储管理操作 +2. **危险操作确认**: 格式化、删除分区/卷等操作都有确认对话框 +3. **设备占用检测**: `OccupationResolver` 模块检测并解除设备占用(卸载挂载点、停止进程)后才执行操作 +4. **fstab 备份**: 修改 `/etc/fstab` 前建议用户手动备份 -PySide6 项目配置文件,定义项目文件列表: -```toml -[tool.pyside6-project] -files = ["dialogs.py", "disk_operations.py", ...] -``` +## 已知限制 -### .gitignore +- 仅支持 Linux 系统 +- 需要图形桌面环境 +- 部分操作(如 RAID 创建)可能需要密码输入 +- 不支持 LVM/RAID 的降级恢复等高级场景 -排除目录: -- /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 交互处理可能需要调整 +1. 查看日志输出(GUI 底部 QTextEdit 或控制台) +2. 检查系统命令是否安装:`which parted mdadm lvm` +3. 手动测试命令:`sudo lsblk -J` +4. 检查权限:确保用户有 sudo 权限或已切换到 root diff --git a/README_Tkinter.md b/README_Tkinter.md new file mode 100644 index 0000000..0995dd6 --- /dev/null +++ b/README_Tkinter.md @@ -0,0 +1,169 @@ +# Linux 存储管理工具 - Tkinter 版本 + +这是一个使用 Tkinter 重写的 Linux 存储管理工具,适配低版本系统(如 CentOS 8)。 + +## 特性 + +- **无需 PySide6**: 使用 Python 内置的 Tkinter 模块,无需安装额外的 GUI 库 +- **兼容旧系统**: 支持 Python 3.6+,兼容 CentOS 8/RHEL 8 等系统 +- **完整功能**: 保留原版本的所有功能,包括: + - 块设备管理(查看、创建分区、格式化、挂载/卸载) + - RAID 管理(创建、停止、删除阵列) + - LVM 管理(PV/VG/LV 的创建和删除) + - 设备占用解除 + +## 系统要求 + +- Python 3.6 或更高版本 +- tkinter 模块(通常随 Python 一起安装) +- 以下系统工具(根据功能需求): + - `lsblk` - 块设备信息 + - `parted` - 分区管理 + - `mdadm` - RAID 管理 + - `lvm2` - LVM 管理 + +## 安装 + +### CentOS 8 / RHEL 8 + +```bash +# 安装 Python 3 和 tkinter +sudo yum install -y python3 python3-tkinter + +# 安装必要的存储管理工具 +sudo yum install -y parted mdadm lvm2 util-linux + +# 可选:安装 pexpect(用于交互式命令,推荐安装) +sudo pip3 install pexpect +``` + +### Ubuntu 20.04 / Debian 10 + +```bash +# 安装 Python 3 和 tkinter +sudo apt-get update +sudo apt-get install -y python3 python3-tk + +# 安装必要的存储管理工具 +sudo apt-get install -y parted mdadm lvm2 util-linux + +# 可选:安装 pexpect +sudo pip3 install pexpect +``` + +## 使用方法 + +### 方式一:使用启动脚本(推荐) + +```bash +chmod +x run_tkinter.sh +./run_tkinter.sh +``` + +### 方式二:直接运行 + +```bash +sudo python3 main_tkinter.py +``` + +### 方式三:创建桌面快捷方式 + +```bash +# 创建桌面文件 +cat > ~/.local/share/applications/linux-storage-manager.desktop << 'EOF' +[Desktop Entry] +Name=Linux 存储管理器 +Comment=Linux 存储管理工具 +Exec=python3 /path/to/diskmanager/main_tkinter.py +Type=Application +Terminal=false +Icon=drive-harddisk +Categories=System;Filesystem; +EOF +``` + +## 文件说明 + +| 文件 | 说明 | +|------|------| +| `main_tkinter.py` | 程序入口 | +| `mainwindow_tkinter.py` | 主窗口实现 | +| `dialogs_tkinter.py` | 对话框组件 | +| `logger_config_tkinter.py` | 日志配置(Tkinter 版本)| +| `disk_operations_tkinter.py` | 磁盘操作模块(Tkinter 版本)| +| `raid_operations_tkinter.py` | RAID 操作模块(Tkinter 版本)| +| `lvm_operations_tkinter.py` | LVM 操作模块(Tkinter 版本)| +| `occupation_resolver_tkinter.py` | 设备占用解除模块(Tkinter 版本)| +| `system_info.py` | 系统信息获取模块(无需修改)| +| `run_tkinter.sh` | 启动脚本 | +| `verify_tkinter.py` | 环境验证脚本 | + +## 与原版本的区别 + +1. **GUI 框架**: PySide6 → Tkinter +2. **线程机制**: QThread → threading.Thread +3. **信号/槽**: Qt Signal/Slot → Python 回调函数 + queue +4. **对话框**: QDialog → tkinter.Toplevel +5. **消息框**: QMessageBox → tkinter.messagebox +6. **输入框**: QInputDialog → tkinter.simpledialog + +## 注意事项 + +1. **权限**: 程序需要 root 权限才能执行存储管理操作 +2. **数据安全**: 删除、格式化等操作会永久删除数据,请谨慎操作 +3. **备份**: 执行重要操作前请备份重要数据 +4. **兼容性**: 虽然适配了旧系统,但建议使用前先在测试环境验证 + +## 故障排除 + +### 问题:无法启动,提示缺少 tkinter + +**解决**: +```bash +# CentOS/RHEL +sudo yum install python3-tkinter + +# Ubuntu/Debian +sudo apt-get install python3-tk +``` + +### 问题:部分功能无法使用 + +**解决**: 确保安装了相应的系统工具: +```bash +# CentOS/RHEL +sudo yum install parted mdadm lvm2 + +# Ubuntu/Debian +sudo apt-get install parted mdadm lvm2 +``` + +### 问题:界面显示异常 + +**解决**: 尝试设置环境变量: +```bash +export DISPLAY=:0 +python3 main_tkinter.py +``` + +### 问题:提示缺少 pexpect + +**解决**: pexpect 是可选依赖,用于处理交互式命令(如创建 RAID 时的确认提示)。 +```bash +sudo pip3 install pexpect +``` + +如果没有安装 pexpect,RAID 创建功能仍能工作,但可能无法处理交互式提示。 + +## 开发信息 + +- **目标 Python 版本**: 3.6+ +- **GUI 库**: Tkinter (内置) +- **线程库**: threading (内置) +- **依赖**: + - 必需: 无(纯 Python 标准库 + tkinter) + - 可选: pexpect(用于交互式命令) + +## 许可证 + +与原项目相同。 diff --git a/dialogs_tkinter.py b/dialogs_tkinter.py new file mode 100644 index 0000000..dee3b82 --- /dev/null +++ b/dialogs_tkinter.py @@ -0,0 +1,557 @@ +# dialogs_tkinter.py +import os +import tkinter as tk +from tkinter import ttk, messagebox, simpledialog +import logging + +logger = logging.getLogger(__name__) + + +class BaseDialog: + """对话框基类""" + + def __init__(self, parent, title, width=400, height=300): + self.parent = parent + self.result = None + + # 创建顶层窗口 + self.dialog = tk.Toplevel(parent) + self.dialog.title(title) + self.dialog.geometry(f"{width}x{height}") + self.dialog.transient(parent) # 设置父窗口 + self.dialog.grab_set() # 模态对话框 + + # 居中显示 + self._center_window() + + # 创建主框架 + self.main_frame = ttk.Frame(self.dialog, padding="10") + self.main_frame.pack(fill=tk.BOTH, expand=True) + + def _center_window(self): + """将窗口居中显示""" + self.dialog.update_idletasks() + width = self.dialog.winfo_width() + height = self.dialog.winfo_height() + x = (self.dialog.winfo_screenwidth() // 2) - (width // 2) + y = (self.dialog.winfo_screenheight() // 2) - (height // 2) + self.dialog.geometry(f"{width}x{height}+{x}+{y}") + + def _create_button_box(self): + """创建确定/取消按钮""" + btn_frame = ttk.Frame(self.main_frame) + btn_frame.pack(fill=tk.X, pady=(10, 0)) + + self.confirm_btn = ttk.Button(btn_frame, text="确定", command=self._on_confirm) + self.confirm_btn.pack(side=tk.RIGHT, padx=5) + + self.cancel_btn = ttk.Button(btn_frame, text="取消", command=self._on_cancel) + self.cancel_btn.pack(side=tk.RIGHT, padx=5) + + # 绑定回车键和 ESC 键 + self.dialog.bind('', lambda e: self._on_confirm()) + self.dialog.bind('', lambda e: self._on_cancel()) + + def _on_confirm(self): + """确定按钮回调 - 子类重写""" + pass + + def _on_cancel(self): + """取消按钮回调""" + self.dialog.destroy() + + def wait_for_result(self): + """等待对话框关闭并返回结果""" + self.dialog.wait_window() + return self.result + + +class CreatePartitionDialog(BaseDialog): + """创建分区对话框""" + + def __init__(self, parent, disk_path="", total_disk_mib=0.0, max_available_mib=0.0): + self.disk_path = disk_path + self.total_disk_mib = total_disk_mib + self.max_available_mib = max_available_mib + + super().__init__(parent, "创建分区", 450, 280) + + logger.debug(f"CreatePartitionDialog initialized for {disk_path}. Total MiB: {total_disk_mib}, Max Available MiB: {max_available_mib}") + + self._setup_ui() + self._initialize_state() + + def _setup_ui(self): + """设置 UI""" + # 创建内容框架 + content_frame = ttk.Frame(self.main_frame) + content_frame.pack(fill=tk.BOTH, expand=True) + + # 磁盘路径 + ttk.Label(content_frame, text="磁盘路径:").grid(row=0, column=0, sticky=tk.W, padx=5, pady=5) + ttk.Label(content_frame, text=self.disk_path).grid(row=0, column=1, sticky=tk.W, padx=5, pady=5) + + # 分区表类型 + ttk.Label(content_frame, text="分区表类型:").grid(row=1, column=0, sticky=tk.W, padx=5, pady=5) + self.part_type_var = tk.StringVar(value="gpt") + self.part_type_combo = ttk.Combobox(content_frame, textvariable=self.part_type_var, + values=["gpt", "msdos"], state="readonly", width=15) + self.part_type_combo.grid(row=1, column=1, sticky=tk.W, padx=5, pady=5) + + # 使用最大空间复选框 + self.use_max_var = tk.BooleanVar(value=True) + self.use_max_check = ttk.Checkbutton(content_frame, text="使用最大可用空间", + variable=self.use_max_var, + command=self._toggle_size_input) + self.use_max_check.grid(row=2, column=0, columnspan=2, sticky=tk.W, padx=5, pady=5) + + # 分区大小 + ttk.Label(content_frame, text="分区大小 (GB):").grid(row=3, column=0, sticky=tk.W, padx=5, pady=5) + + calculated_max_gb = self.max_available_mib / 1024.0 + self.max_gb = max(0.01, calculated_max_gb) + + self.size_var = tk.DoubleVar(value=self.max_gb) + self.size_spin = tk.Spinbox(content_frame, from_=0.01, to=self.max_gb, + increment=0.01, textvariable=self.size_var, width=12) + self.size_spin.grid(row=3, column=1, sticky=tk.W, padx=5, pady=5) + + logger.debug(f"Size spinbox max set to: {self.max_gb} GB (calculated from {calculated_max_gb} GB)") + + # 按钮 + self._create_button_box() + + def _initialize_state(self): + """初始化状态""" + self._toggle_size_input() + + def _toggle_size_input(self): + """切换大小输入框状态""" + is_checked = self.use_max_var.get() + logger.debug(f"[_toggle_size_input] Called. Checkbox isChecked(): {is_checked}") + + if is_checked: + logger.debug(f"[_toggle_size_input] Checkbox is CHECKED. Disabling spinbox and setting value to max.") + self.size_spin.configure(state=tk.DISABLED) + self.size_var.set(self.max_gb) + logger.debug(f"[_toggle_size_input] Spinbox value set to max: {self.max_gb} GB.") + else: + logger.debug(f"[_toggle_size_input] Checkbox is UNCHECKED. Enabling spinbox.") + self.size_spin.configure(state=tk.NORMAL) + current_val = self.size_var.get() + if current_val >= self.max_gb: + self.size_var.set(0.01) + logger.debug(f"[_toggle_size_input] Spinbox was at max, reset to min: 0.01 GB.") + else: + logger.debug(f"[_toggle_size_input] Spinbox was not at max, keeping current value: {current_val} GB.") + + def _on_confirm(self): + """确定按钮回调""" + size_gb = self.size_var.get() + use_max_space = self.use_max_var.get() + + if not use_max_space and size_gb <= 0: + messagebox.showwarning("输入错误", "分区大小必须大于0。") + return + + if not use_max_space and size_gb > self.max_gb: + messagebox.showwarning("输入错误", "分区大小不能超过最大可用空间。") + return + + self.result = { + 'disk_path': self.disk_path, + 'partition_table_type': self.part_type_var.get(), + 'size_gb': size_gb, + 'total_disk_mib': self.total_disk_mib, + 'use_max_space': use_max_space + } + self.dialog.destroy() + + def get_partition_info(self): + """获取分区信息""" + return self.result + + +class MountDialog(BaseDialog): + """挂载对话框""" + + def __init__(self, parent, device_path=""): + self.device_path = device_path + super().__init__(parent, "挂载分区", 400, 200) + self._setup_ui() + + def _setup_ui(self): + """设置 UI""" + # 创建内容框架 + content_frame = ttk.Frame(self.main_frame) + content_frame.pack(fill=tk.BOTH, expand=True) + + # 设备路径 + ttk.Label(content_frame, text="设备路径:").grid(row=0, column=0, sticky=tk.W, padx=5, pady=5) + ttk.Label(content_frame, text=self.device_path).grid(row=0, column=1, sticky=tk.W, padx=5, pady=5) + + # 挂载点 + ttk.Label(content_frame, text="挂载点:").grid(row=1, column=0, sticky=tk.W, padx=5, pady=5) + self.mount_point_var = tk.StringVar(value=f"/mnt/{os.path.basename(self.device_path)}") + self.mount_point_entry = ttk.Entry(content_frame, textvariable=self.mount_point_var, width=30) + self.mount_point_entry.grid(row=1, column=1, sticky=tk.W, padx=5, pady=5) + + # 开机自动挂载复选框 + self.add_to_fstab_var = tk.BooleanVar(value=False) + self.fstab_check = ttk.Checkbutton(content_frame, text="开机自动挂载 (添加到 /etc/fstab)", + variable=self.add_to_fstab_var) + self.fstab_check.grid(row=2, column=0, columnspan=2, sticky=tk.W, padx=5, pady=5) + + # 按钮 + self._create_button_box() + + def _on_confirm(self): + """确定按钮回调""" + mount_point = self.mount_point_var.get().strip() + + if not mount_point: + messagebox.showwarning("输入错误", "挂载点不能为空。") + return + + if not mount_point.startswith('/'): + messagebox.showwarning("输入错误", "挂载点必须是绝对路径 (以 '/' 开头)。") + return + + self.result = { + 'mount_point': mount_point, + 'add_to_fstab': self.add_to_fstab_var.get() + } + self.dialog.destroy() + + def get_mount_info(self): + """获取挂载信息""" + return self.result + + +class CreateRaidDialog(BaseDialog): + """创建 RAID 阵列对话框""" + + def __init__(self, parent, available_devices=None): + self.available_devices = available_devices if available_devices is not None else [] + self.selected_devices = [] + + super().__init__(parent, "创建 RAID 阵列", 450, 450) + self._setup_ui() + + def _setup_ui(self): + """设置 UI""" + # 创建内容框架 + content_frame = ttk.Frame(self.main_frame) + content_frame.pack(fill=tk.BOTH, expand=True) + + # RAID 级别 + ttk.Label(content_frame, text="RAID 级别:").grid(row=0, column=0, sticky=tk.W, padx=5, pady=5) + self.raid_level_var = tk.StringVar(value="raid0") + self.raid_level_combo = ttk.Combobox(content_frame, textvariable=self.raid_level_var, + values=["raid0", "raid1", "raid5", "raid6", "raid10"], + state="readonly", width=12) + self.raid_level_combo.grid(row=0, column=1, sticky=tk.W, padx=5, pady=5) + + # Chunk 大小 + ttk.Label(content_frame, text="Chunk 大小 (KB):").grid(row=1, column=0, sticky=tk.W, padx=5, pady=5) + self.chunk_size_var = tk.IntVar(value=512) + self.chunk_size_spin = tk.Spinbox(content_frame, from_=4, to=1024, increment=4, + textvariable=self.chunk_size_var, width=10) + self.chunk_size_spin.grid(row=1, column=1, sticky=tk.W, padx=5, pady=5) + + # 设备选择 + ttk.Label(content_frame, text="选择设备:").grid(row=2, column=0, sticky=tk.NW, padx=5, pady=5) + + # 创建设备选择框架 + device_frame = ttk.Frame(content_frame) + device_frame.grid(row=2, column=1, sticky=tk.W, padx=5, pady=5) + + # 设备复选框列表 + self.device_vars = {} + for i, dev in enumerate(self.available_devices): + var = tk.BooleanVar(value=False) + self.device_vars[dev] = var + cb = ttk.Checkbutton(device_frame, text=dev, variable=var) + cb.pack(anchor=tk.W) + + # 按钮 + self._create_button_box() + + def _on_confirm(self): + """确定按钮回调""" + # 收集选中的设备 + self.selected_devices = [dev for dev, var in self.device_vars.items() if var.get()] + + if not self.selected_devices: + messagebox.showwarning("输入错误", "请选择至少一个设备来创建 RAID 阵列。") + return + + raid_level = self.raid_level_var.get() + num_devices = len(self.selected_devices) + + # RAID 级别检查 + if raid_level == "raid0" and num_devices < 1: + messagebox.showwarning("输入错误", "RAID0 至少需要一个设备。") + return + elif raid_level == "raid1" and num_devices < 2: + messagebox.showwarning("输入错误", "RAID1 至少需要两个设备。") + return + elif raid_level == "raid5" and num_devices < 3: + messagebox.showwarning("输入错误", "RAID5 至少需要三个设备。") + return + elif raid_level == "raid6" and num_devices < 4: + messagebox.showwarning("输入错误", "RAID6 至少需要四个设备。") + return + elif raid_level == "raid10" and num_devices < 2: + messagebox.showwarning("输入错误", "RAID10 至少需要两个设备。") + return + + self.result = { + 'devices': self.selected_devices, + 'level': raid_level.replace('raid', ''), + 'chunk_size': self.chunk_size_var.get() + } + self.dialog.destroy() + + def get_raid_info(self): + """获取 RAID 信息""" + return self.result + + +class CreatePvDialog(BaseDialog): + """创建物理卷 (PV) 对话框""" + + def __init__(self, parent, available_partitions=None): + self.available_partitions = available_partitions if available_partitions is not None else [] + + super().__init__(parent, "创建物理卷 (PV)", 400, 200) + self._setup_ui() + + def _setup_ui(self): + """设置 UI""" + # 创建内容框架 + content_frame = ttk.Frame(self.main_frame) + content_frame.pack(fill=tk.BOTH, expand=True) + + # 设备选择 + ttk.Label(content_frame, text="选择设备:").grid(row=0, column=0, sticky=tk.W, padx=5, pady=5) + + self.device_var = tk.StringVar() + if self.available_partitions: + self.device_var.set(self.available_partitions[0]) + + self.device_combo = ttk.Combobox(content_frame, textvariable=self.device_var, + values=self.available_partitions, state="readonly", width=30) + self.device_combo.grid(row=0, column=1, sticky=tk.W, padx=5, pady=5) + + # 按钮 + self._create_button_box() + + def _on_confirm(self): + """确定按钮回调""" + device_path = self.device_var.get() + + if not device_path: + messagebox.showwarning("输入错误", "请选择一个设备来创建物理卷。") + return + + self.result = {'device_path': device_path} + self.dialog.destroy() + + def get_pv_info(self): + """获取 PV 信息""" + return self.result + + +class CreateVgDialog(BaseDialog): + """创建卷组 (VG) 对话框""" + + def __init__(self, parent, available_pvs=None): + self.available_pvs = available_pvs if available_pvs is not None else [] + self.selected_pvs = [] + + super().__init__(parent, "创建卷组 (VG)", 450, 350) + self._setup_ui() + + def _setup_ui(self): + """设置 UI""" + # 创建内容框架 + content_frame = ttk.Frame(self.main_frame) + content_frame.pack(fill=tk.BOTH, expand=True) + + # 卷组名称 + ttk.Label(content_frame, text="卷组名称:").grid(row=0, column=0, sticky=tk.W, padx=5, pady=5) + self.vg_name_var = tk.StringVar() + self.vg_name_entry = ttk.Entry(content_frame, textvariable=self.vg_name_var, width=25) + self.vg_name_entry.grid(row=0, column=1, sticky=tk.W, padx=5, pady=5) + + # 物理卷选择 + ttk.Label(content_frame, text="选择物理卷:").grid(row=1, column=0, sticky=tk.NW, padx=5, pady=5) + + pv_frame = ttk.Frame(content_frame) + pv_frame.grid(row=1, column=1, sticky=tk.W, padx=5, pady=5) + + self.pv_vars = {} + for pv in self.available_pvs: + var = tk.BooleanVar(value=False) + self.pv_vars[pv] = var + cb = ttk.Checkbutton(pv_frame, text=pv, variable=var) + cb.pack(anchor=tk.W) + + # 按钮 + self._create_button_box() + + def _on_confirm(self): + """确定按钮回调""" + vg_name = self.vg_name_var.get().strip() + self.selected_pvs = [pv for pv, var in self.pv_vars.items() if var.get()] + + if not vg_name: + messagebox.showwarning("输入错误", "卷组名称不能为空。") + return + + if not self.selected_pvs: + messagebox.showwarning("输入错误", "请选择至少一个物理卷来创建卷组。") + return + + self.result = { + 'vg_name': vg_name, + 'pvs': self.selected_pvs + } + self.dialog.destroy() + + def get_vg_info(self): + """获取 VG 信息""" + return self.result + + +class CreateLvDialog(BaseDialog): + """创建逻辑卷 (LV) 对话框""" + + def __init__(self, parent, available_vgs=None, vg_sizes=None): + self.available_vgs = available_vgs if available_vgs is not None else [] + self.vg_sizes = vg_sizes if vg_sizes is not None else {} + + super().__init__(parent, "创建逻辑卷", 450, 300) + + logger.debug(f"CreateLvDialog initialized. Available VGs: {self.available_vgs}, VG Sizes: {self.vg_sizes}") + + self._setup_ui() + self._initialize_state() + + def _setup_ui(self): + """设置 UI""" + # 逻辑卷名称 + # 创建内容框架 + content_frame = ttk.Frame(self.main_frame) + content_frame.pack(fill=tk.BOTH, expand=True) + + ttk.Label(content_frame, text="逻辑卷名称:").grid(row=0, column=0, sticky=tk.W, padx=5, pady=5) + self.lv_name_var = tk.StringVar() + self.lv_name_entry = ttk.Entry(content_frame, textvariable=self.lv_name_var, width=25) + self.lv_name_entry.grid(row=0, column=1, sticky=tk.W, padx=5, pady=5) + + # 卷组选择 + ttk.Label(content_frame, text="选择卷组:").grid(row=1, column=0, sticky=tk.W, padx=5, pady=5) + self.vg_var = tk.StringVar() + if self.available_vgs: + self.vg_var.set(self.available_vgs[0]) + self.vg_combo = ttk.Combobox(content_frame, textvariable=self.vg_var, + values=self.available_vgs, state="readonly", width=25) + self.vg_combo.grid(row=1, column=1, sticky=tk.W, padx=5, pady=5) + self.vg_combo.bind('<>', lambda e: self._update_size_options()) + + # 使用最大空间复选框 + self.use_max_var = tk.BooleanVar(value=True) + self.use_max_check = ttk.Checkbutton(content_frame, text="使用最大可用空间", + variable=self.use_max_var, + command=self._toggle_size_input) + self.use_max_check.grid(row=2, column=0, columnspan=2, sticky=tk.W, padx=5, pady=5) + + # 逻辑卷大小 + ttk.Label(content_frame, text="大小 (GB):").grid(row=3, column=0, sticky=tk.W, padx=5, pady=5) + self.size_var = tk.DoubleVar(value=1.0) + self.size_spin = tk.Spinbox(content_frame, from_=0.01, to=100000.0, increment=0.01, + textvariable=self.size_var, width=12) + self.size_spin.grid(row=3, column=1, sticky=tk.W, padx=5, pady=5) + + # 按钮 + self._create_button_box() + + def _initialize_state(self): + """初始化状态""" + self._update_size_options() + + def _update_size_options(self): + """根据选中的卷组更新大小选项""" + selected_vg = self.vg_var.get() + 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}") + + # 确保最大值至少大于等于最小值 + effective_max = max(0.01, max_size_gb) + self.size_spin.configure(to=effective_max) + self.max_gb = effective_max + + logger.debug(f"[_update_size_options] Spinbox max set to: {effective_max} GB.") + + # 应用复选框状态 + self._toggle_size_input() + logger.debug("[_update_size_options] Completed.") + + def _toggle_size_input(self): + """切换大小输入框状态""" + is_checked = self.use_max_var.get() + logger.debug(f"[_toggle_size_input] Called. Checkbox isChecked(): {is_checked}") + + if is_checked: + logger.debug(f"[_toggle_size_input] Checkbox is CHECKED. Disabling spinbox and setting value to max.") + self.size_spin.configure(state=tk.DISABLED) + self.size_var.set(self.max_gb) + logger.debug(f"[_toggle_size_input] Spinbox value set to max: {self.max_gb} GB.") + else: + logger.debug(f"[_toggle_size_input] Checkbox is UNCHECKED. Enabling spinbox.") + self.size_spin.configure(state=tk.NORMAL) + current_val = self.size_var.get() + if current_val >= self.max_gb: + self.size_var.set(0.01) + logger.debug(f"[_toggle_size_input] Spinbox was at max, reset to min: 0.01 GB.") + else: + logger.debug(f"[_toggle_size_input] Spinbox was not at max, keeping current value: {current_val} GB.") + logger.debug("[_toggle_size_input] Completed.") + + def _on_confirm(self): + """确定按钮回调""" + lv_name = self.lv_name_var.get().strip() + vg_name = self.vg_var.get() + size_gb = self.size_var.get() + use_max_space = self.use_max_var.get() + + if not lv_name: + messagebox.showwarning("输入错误", "逻辑卷名称不能为空。") + return + + if not vg_name: + messagebox.showwarning("输入错误", "请选择一个卷组。") + return + + if not use_max_space: + if size_gb <= 0: + messagebox.showwarning("输入错误", "逻辑卷大小必须大于0。") + return + if size_gb > self.max_gb: + messagebox.showwarning("输入错误", "逻辑卷大小不能超过卷组的可用空间。") + return + + self.result = { + 'lv_name': lv_name, + 'vg_name': vg_name, + 'size_gb': size_gb, + 'use_max_space': use_max_space + } + self.dialog.destroy() + + def get_lv_info(self): + """获取 LV 信息""" + return self.result diff --git a/disk_operations_tkinter.py b/disk_operations_tkinter.py new file mode 100644 index 0000000..5d9d941 --- /dev/null +++ b/disk_operations_tkinter.py @@ -0,0 +1,539 @@ +# disk_operations_tkinter.py +import subprocess +import logging +import re +import os +import threading +import queue +from tkinter import messagebox, simpledialog +from system_info import SystemInfoManager + +logger = logging.getLogger(__name__) + + +# --- 后台格式化工作线程 --- +class FormatWorker: + """后台格式化工作线程""" + + def __init__(self, device_path, fs_type, execute_shell_command_func, result_queue): + self.device_path = device_path + self.fs_type = fs_type + self._execute_shell_command_func = execute_shell_command_func + self.result_queue = result_queue + + def run(self): + """在单独线程中执行格式化命令。""" + logger.info(f"后台格式化开始: 设备 {self.device_path}, 文件系统 {self.fs_type}") + + # 发送开始信号 + self.result_queue.put(('started', self.device_path)) + + command_list = [] + if self.fs_type == "ext4": + command_list = ["mkfs.ext4", "-F", self.device_path] + elif self.fs_type == "xfs": + command_list = ["mkfs.xfs", "-f", self.device_path] + elif self.fs_type == "ntfs": + command_list = ["mkfs.ntfs", "-f", self.device_path] + elif self.fs_type == "fat32": + command_list = ["mkfs.vfat", "-F", "32", self.device_path] + else: + logger.error(f"不支持的文件系统类型: {self.fs_type}") + self.result_queue.put(('finished', False, self.device_path, "", f"不支持的文件系统类型: {self.fs_type}")) + return + + # 调用 shell 命令执行函数 + success, stdout, stderr = self._execute_shell_command_func( + command_list, + f"格式化设备 {self.device_path} 为 {self.fs_type} 失败", + root_privilege=True, + show_dialog=False + ) + + self.result_queue.put(('finished', success, self.device_path, stdout, stderr)) + logger.info(f"后台格式化完成: 设备 {self.device_path}, 成功: {success}") + + +class DiskOperations: + """磁盘操作类 - Tkinter 版本""" + + def __init__(self, system_manager: SystemInfoManager, lvm_ops): + self.system_manager = system_manager + self._lvm_ops = lvm_ops + self.active_format_workers = {} + self.result_queue = queue.Queue() + self.formatting_callback = None + self.root = None + + def set_formatting_callback(self, callback): + """设置格式化完成回调函数""" + self.formatting_callback = callback + + def start_queue_check(self, root): + """启动队列检查(应在主线程中调用)""" + self.root = root + self._check_queue() + + def _check_queue(self): + """定期检查队列""" + try: + while True: + msg = self.result_queue.get_nowait() + msg_type = msg[0] + + if msg_type == 'started': + device_path = msg[1] + logger.info(f"格式化开始: {device_path}") + + elif msg_type == 'finished': + _, success, device_path, stdout, stderr = msg + self._on_formatting_finished(success, device_path, stdout, stderr) + + except queue.Empty: + pass + + # 继续定时检查 + if self.root: + self.root.after(100, self._check_queue) + + 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): + """执行 shell 命令""" + 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""" + if not uuid or not mount_point or not fstype: + logger.error(f"无法将设备 {device_path} 添加到 fstab:缺少 UUID/挂载点/文件系统类型。") + messagebox.showwarning("警告", 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 中。跳过添加。") + messagebox.showinfo("信息", 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}。") + messagebox.showinfo("成功", f"设备 {device_path} 已成功添加到 /etc/fstab。") + return True + else: + return False + except Exception as e: + logger.error(f"处理 {fstab_path} 失败: {e}") + messagebox.showerror("错误", f"处理 {fstab_path} 失败: {e}") + return False + + def _remove_fstab_entry(self, device_path): + """从 /etc/fstab 中移除指定设备的条目""" + 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] + 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", + f"sed: {fstab_path}: 没有那个文件或目录", + "no changes were made" + ) + ) + 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" + )): + logger.info(f"fstab 中未找到 UUID={uuid} 的条目,无需删除。") + return True + return False + + def mount_partition(self, device_path, mount_point, add_to_fstab=False): + """挂载指定设备到指定挂载点""" + if not os.path.exists(mount_point): + try: + os.makedirs(mount_point) + logger.info(f"创建挂载点目录: {mount_point}") + except OSError as e: + messagebox.showerror("错误", 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: + messagebox.showinfo("成功", 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。") + messagebox.showwarning("警告", f"设备 {device_path} 已挂载,但无法获取文件系统类型或 UUID 以添加到 fstab。") + else: + logger.error(f"无法获取设备 {device_path} 的详细信息以添加到 fstab。") + messagebox.showwarning("警告", f"设备 {device_path} 已挂载,但无法获取详细信息以添加到 fstab。") + return True + else: + return False + + def unmount_partition(self, device_path, show_dialog_on_error=True): + """卸载指定设备""" + 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: + messagebox.showinfo("成功", 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): + """获取磁盘上最大的空闲空间块的起始位置和大小""" + 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 + + 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): + """在指定磁盘上创建分区""" + if not isinstance(disk_path, str) or not isinstance(partition_table_type, str): + logger.error(f"尝试创建分区时传入无效参数。磁盘路径: {disk_path}, 分区表类型: {partition_table_type}") + messagebox.showerror("错误", "无效的磁盘路径或分区表类型。") + 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: + if not messagebox.askyesno("确认创建分区表", + f"磁盘 {disk_path} 没有分区表。您确定要创建 {partition_table_type} 分区表吗?\n" + f"此操作将擦除磁盘上的所有数据。", + default=messagebox.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: + messagebox.showerror("错误", 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: + messagebox.showwarning("警告", 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: + messagebox.showinfo("成功", f"在 {disk_path} 上成功创建了 {size_for_log} 的分区。") + return True + else: + return False + + def delete_partition(self, device_path): + """删除指定分区""" + if not messagebox.askyesno("确认删除分区", + f"您确定要删除分区 {device_path} 吗?此操作将擦除分区上的所有数据!", + default=messagebox.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: + messagebox.showerror("错误", 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: + messagebox.showinfo("成功", f"分区 {device_path} 已成功删除。") + return True + else: + return False + + def format_partition(self, device_path, fstype=None): + """启动后台格式化线程""" + if fstype is None: + # 使用简单的输入对话框 + fstype = simpledialog.askstring("选择文件系统", + f"请选择要使用的文件系统类型 for {device_path}:\n\next4, xfs, fat32, ntfs", + initialvalue="ext4") + if not fstype: + logger.info("用户取消了文件系统选择。") + return False + + if fstype not in ["ext4", "xfs", "fat32", "ntfs"]: + messagebox.showerror("错误", f"不支持的文件系统类型: {fstype}") + return False + + if not messagebox.askyesno("确认格式化", + f"您确定要格式化设备 {device_path} 为 {fstype} 吗?此操作将擦除所有数据!", + default=messagebox.NO): + logger.info(f"用户取消了格式化 {device_path} 的操作。") + return False + + # 检查是否已有格式化任务正在进行 + if device_path in self.active_format_workers: + messagebox.showwarning("警告", f"设备 {device_path} 正在格式化中,请勿重复操作。") + return False + + # 尝试卸载分区 + self.unmount_partition(device_path, show_dialog_on_error=False) + # 从 fstab 中移除条目 + self._remove_fstab_entry(device_path) + + # 创建并启动后台线程 + worker = FormatWorker(device_path, fstype, self._execute_shell_command, self.result_queue) + thread = threading.Thread(target=worker.run, daemon=True) + self.active_format_workers[device_path] = thread + thread.start() + + messagebox.showinfo("开始格式化", 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: + messagebox.showinfo("格式化成功", f"设备 {device_path} 已成功格式化。") + else: + messagebox.showerror("格式化失败", f"格式化设备 {device_path} 失败。\n错误详情: {stderr}") + + # 调用回调函数通知主窗口 + if self.formatting_callback: + self.formatting_callback(success, device_path, stdout, stderr) + + + # === Loop 设备挂载/卸载功能 === + def mount_loop_device(self, device_path): + """挂载 ROM 设备为 loop 设备""" + # 获取默认挂载点 + default_mount = f"/mnt/{os.path.basename(device_path)}" + + mount_point = simpledialog.askstring( + "挂载 ROM 设备", + f"请输入挂载点路径 for {device_path}:", + initialvalue=default_mount + ) + + if not mount_point: + logger.info("用户取消了挂载操作。") + return False + + # 创建挂载点目录 + if not os.path.exists(mount_point): + try: + os.makedirs(mount_point) + logger.info(f"创建挂载点目录: {mount_point}") + except OSError as e: + messagebox.showerror("错误", f"创建挂载点目录 {mount_point} 失败: {e}") + return False + + # 使用 mount -o loop 挂载 + logger.info(f"尝试以 loop 方式挂载 ROM 设备 {device_path} 到 {mount_point}") + success, _, stderr = self._execute_shell_command( + ["mount", "-o", "loop,ro", device_path, mount_point], + f"挂载 ROM 设备 {device_path} 失败" + ) + + if success: + messagebox.showinfo("成功", f"ROM 设备 {device_path} 已成功挂载到 {mount_point}") + return True + else: + # 检查是否是因为设备不是有效的文件系统 + if "wrong fs type" in stderr.lower() or "bad option" in stderr.lower(): + messagebox.showerror("错误", + f"无法挂载 {device_path}\n" + f"可能的原因:\n" + f"1. 设备中没有可读取的介质(如光盘未插入)\n" + f"2. 设备不支持 loop 挂载\n\n" + f"错误详情: {stderr}") + return False + + def unmount_loop_device(self, device_path): + """卸载 loop 设备""" + logger.info(f"尝试卸载 loop 设备 {device_path}") + + # 首先尝试普通卸载 + success, _, stderr = self._execute_shell_command( + ["umount", device_path], + f"卸载设备 {device_path} 失败", + show_dialog=False + ) + + if success: + messagebox.showinfo("成功", f"设备 {device_path} 已成功卸载。") + return True + + # 检查是否是因为设备未挂载 + if "not mounted" in stderr.lower() or "未挂载" in stderr: + # 尝试查找关联的 loop 设备并卸载 + try: + result = subprocess.run( + ["losetup", "-a"], + capture_output=True, text=True, check=False + ) + if result.returncode == 0: + for line in result.stdout.splitlines(): + if device_path in line: + loop_dev = line.split(':')[0] + logger.info(f"找到关联的 loop 设备: {loop_dev}") + # 卸载 loop 设备 + success, _, _ = self._execute_shell_command( + ["losetup", "-d", loop_dev], + f"删除 loop 设备 {loop_dev} 失败", + show_dialog=False + ) + if success: + messagebox.showinfo("成功", f"Loop 设备 {loop_dev} 已卸载。") + return True + except Exception as e: + logger.error(f"查找 loop 设备时出错: {e}") + + messagebox.showerror("错误", f"卸载设备 {device_path} 失败:\n{stderr}") + return False diff --git a/logger_config_tkinter.py b/logger_config_tkinter.py new file mode 100644 index 0000000..5f3f657 --- /dev/null +++ b/logger_config_tkinter.py @@ -0,0 +1,84 @@ +# logger_config_tkinter.py +import logging +import tkinter as tk +from tkinter.scrolledtext import ScrolledText +import queue + + +class TkinterLogHandler(logging.Handler): + """ + 自定义的 logging 处理器,将日志消息发送到 Tkinter 文本控件。 + 使用队列确保线程安全。 + """ + + def __init__(self, text_widget: ScrolledText): + super().__init__() + self.text_widget = text_widget + self.queue = queue.Queue() + self._schedule_check() + + def _schedule_check(self): + """定期检查队列并更新 UI""" + self._check_queue() + # 每 100ms 检查一次队列 + self.text_widget.after(100, self._schedule_check) + + def _check_queue(self): + """处理队列中的日志消息""" + try: + while True: + msg = self.queue.get_nowait() + self._append_to_widget(msg) + except queue.Empty: + pass + + def _append_to_widget(self, msg): + """将消息追加到文本控件""" + try: + self.text_widget.configure(state=tk.NORMAL) + self.text_widget.insert(tk.END, msg + '\n') + self.text_widget.see(tk.END) # 滚动到底部 + self.text_widget.configure(state=tk.DISABLED) + except Exception as e: + # 如果控件已销毁,忽略错误 + pass + + def emit(self, record): + """ + 处理日志记录,将其格式化并放入队列。 + """ + try: + msg = self.format(record) + self.queue.put(msg) + except Exception: + self.handleError(record) + + +def setup_logging(text_widget: ScrolledText): + """ + 配置全局 logging,使其输出到控制台和指定的 Tkinter 文本控件。 + """ + root_logger = logging.getLogger() + # 设置日志级别 + 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. Tkinter 文本控件处理器 + text_handler = TkinterLogHandler(text_widget) + text_handler.setFormatter(formatter) + root_logger.addHandler(text_handler) + + return root_logger + + +# 获取一个全局的 logger 实例 +logger = logging.getLogger(__name__) diff --git a/lvm_operations_tkinter.py b/lvm_operations_tkinter.py new file mode 100644 index 0000000..56a7460 --- /dev/null +++ b/lvm_operations_tkinter.py @@ -0,0 +1,291 @@ +# lvm_operations_tkinter.py +import logging +import subprocess +from tkinter import messagebox +from system_info import SystemInfoManager + +logger = logging.getLogger(__name__) + + +class LvmOperations: + """LVM 操作类 - Tkinter 版本""" + + def __init__(self, system_manager: SystemInfoManager = None): + self.system_manager = system_manager + + 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): + """执行 shell 命令""" + full_cmd_str = ' '.join(command_list) + logger.info(f"执行命令: {full_cmd_str}") + + try: + if self.system_manager: + stdout, stderr = self.system_manager._run_command( + command_list, + root_privilege=root_privilege, + check_output=True, + input_data=input_data + ) + else: + result = subprocess.run( + ["sudo"] + command_list if root_privilege else command_list, + capture_output=True, text=True, check=True, + input=input_data + ) + stdout = result.stdout + stderr = result.stderr + + 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() if e.stderr else "" + logger.error(f"{error_message} 命令: {full_cmd_str}") + logger.error(f"退出码: {e.returncode}") + logger.error(f"标准输出: {e.stdout.strip() if e.stdout else ''}") + 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}' 匹配抑制条件,已抑制错误对话框。") + elif show_dialog: + messagebox.showerror("错误", f"{error_message}\n错误详情: {stderr_output}") + + return False, e.stdout if e.stdout else "", stderr_output + + except FileNotFoundError: + logger.error(f"命令 '{command_list[0]}' 未找到。请确保已安装相关工具。") + if show_dialog: + messagebox.showerror("错误", f"命令 '{command_list[0]}' 未找到。请确保已安装相关工具。") + return False, "", "Command not found." + + except Exception as e: + logger.error(f"执行命令时发生未知错误: {e}") + if show_dialog: + messagebox.showerror("错误", f"执行命令时发生未知错误: {e}") + return False, "", str(e) + + def create_pv(self, device_path): + """创建物理卷""" + if not device_path or not device_path.startswith('/dev/'): + messagebox.showerror("错误", "无效的设备路径。") + return False + + if not messagebox.askyesno("确认创建物理卷", + f"您确定要在 {device_path} 上创建物理卷吗?\n" + "此操作将擦除设备上的数据!", + default=messagebox.NO): + logger.info(f"用户取消了在 {device_path} 上创建物理卷的操作。") + return False + + logger.info(f"尝试在 {device_path} 上创建物理卷。") + success, stdout, stderr = self._execute_shell_command( + ["pvcreate", "-y", device_path], + f"在 {device_path} 上创建物理卷失败" + ) + + if success: + messagebox.showinfo("成功", f"物理卷已在 {device_path} 上成功创建。") + return True + else: + if "partition" in stderr.lower() or "分区" in stderr: + if messagebox.askyesno("警告", + f"设备 {device_path} 似乎已有分区。\n" + "是否继续擦除并创建物理卷?", + default=messagebox.NO): + success_wipe, _, _ = self._execute_shell_command( + ["wipefs", "-a", device_path], + f"擦除设备 {device_path} 失败" + ) + if success_wipe: + success_pv, _, stderr_pv = self._execute_shell_command( + ["pvcreate", "-y", device_path], + f"在 {device_path} 上创建物理卷失败" + ) + if success_pv: + messagebox.showinfo("成功", f"物理卷已在 {device_path} 上成功创建。") + return True + else: + messagebox.showerror("错误", f"在 {device_path} 上创建物理卷失败: {stderr_pv}") + else: + messagebox.showinfo("信息", f"未在已分区设备 {device_path} 上创建物理卷。") + return False + + def delete_pv(self, device_path): + """删除物理卷""" + if not device_path or not device_path.startswith('/dev/'): + messagebox.showerror("错误", "无效的设备路径。") + return False + + if not messagebox.askyesno("确认删除物理卷", + f"您确定要删除物理卷 {device_path} 吗?", + default=messagebox.NO): + logger.info(f"用户取消了删除物理卷 {device_path} 的操作。") + return False + + logger.info(f"尝试删除物理卷 {device_path}。") + success, stdout, stderr = self._execute_shell_command( + ["pvremove", "-y", device_path], + f"删除物理卷 {device_path} 失败" + ) + + if success: + messagebox.showinfo("成功", f"物理卷 {device_path} 已成功删除。") + return True + return False + + def create_vg(self, vg_name, pvs): + """创建卷组""" + if not vg_name or not pvs: + messagebox.showerror("错误", "无效的卷组名称或物理卷路径。") + return False + + if not messagebox.askyesno("确认创建卷组", + f"您确定要创建卷组 {vg_name} 吗?\n" + f"使用物理卷: {', '.join(pvs)}", + default=messagebox.NO): + logger.info(f"用户取消了创建卷组 {vg_name} 的操作。") + return False + + logger.info(f"尝试创建卷组 {vg_name},使用物理卷: {pvs}") + cmd = ["vgcreate", vg_name] + pvs + success, stdout, stderr = self._execute_shell_command( + cmd, + f"创建卷组 {vg_name} 失败" + ) + + if success: + messagebox.showinfo("成功", f"卷组 {vg_name} 已成功创建。") + return True + return False + + def delete_vg(self, vg_name): + """删除卷组""" + if not vg_name: + messagebox.showerror("错误", "无效的卷组名称。") + return False + + if not messagebox.askyesno("确认删除卷组", + f"您确定要删除卷组 {vg_name} 吗?\n" + "此操作将删除该卷组及其所有逻辑卷!", + default=messagebox.NO): + logger.info(f"用户取消了删除卷组 {vg_name} 的操作。") + return False + + logger.info(f"尝试删除卷组 {vg_name}。") + success, stdout, stderr = self._execute_shell_command( + ["vgremove", "-y", vg_name], + f"删除卷组 {vg_name} 失败" + ) + + if success: + messagebox.showinfo("成功", f"卷组 {vg_name} 已成功删除。") + return True + return False + + def create_lv(self, lv_name, vg_name, size_gb, use_max_space=False): + """创建逻辑卷""" + if not lv_name or not vg_name: + messagebox.showerror("错误", "无效的逻辑卷名称或卷组名称。") + return False + + if not use_max_space and size_gb <= 0: + messagebox.showerror("错误", "逻辑卷大小必须大于0。") + return False + + size_str = "100%FREE" if use_max_space else f"{size_gb}G" + + if not messagebox.askyesno("确认创建逻辑卷", + f"您确定要在卷组 {vg_name} 中创建逻辑卷 {lv_name} 吗?\n" + f"大小: {size_str}", + default=messagebox.NO): + logger.info(f"用户取消了创建逻辑卷 {lv_name} 的操作。") + return False + + logger.info(f"尝试在卷组 {vg_name} 中创建逻辑卷 {lv_name},大小: {size_str}") + cmd = ["lvcreate", "-y", "-n", lv_name, "-L", size_str, vg_name] if not use_max_space else \ + ["lvcreate", "-y", "-n", lv_name, "-l", "100%FREE", vg_name] + + success, stdout, stderr = self._execute_shell_command( + cmd, + f"创建逻辑卷 {lv_name} 失败" + ) + + if success: + messagebox.showinfo("成功", f"逻辑卷 {lv_name} 已在卷组 {vg_name} 中成功创建。") + return True + return False + + def delete_lv(self, lv_name, vg_name): + """删除逻辑卷""" + if not lv_name or not vg_name: + messagebox.showerror("错误", "无效的逻辑卷名称或卷组名称。") + return False + + if not messagebox.askyesno("确认删除逻辑卷", + f"您确定要删除逻辑卷 {vg_name}/{lv_name} 吗?\n" + "此操作将删除该逻辑卷及其所有数据!", + default=messagebox.NO): + logger.info(f"用户取消了删除逻辑卷 {lv_name} 的操作。") + return False + + logger.info(f"尝试删除逻辑卷 {vg_name}/{lv_name}。") + success, stdout, stderr = self._execute_shell_command( + ["lvremove", "-y", f"{vg_name}/{lv_name}"], + f"删除逻辑卷 {vg_name}/{lv_name} 失败" + ) + + if success: + messagebox.showinfo("成功", f"逻辑卷 {vg_name}/{lv_name} 已成功删除。") + return True + return False + + def activate_lv(self, lv_name, vg_name): + """激活逻辑卷""" + if not lv_name or not vg_name: + messagebox.showerror("错误", "无效的逻辑卷名称或卷组名称。") + return False + + logger.info(f"尝试激活逻辑卷 {vg_name}/{lv_name}。") + success, stdout, stderr = self._execute_shell_command( + ["lvchange", "-ay", f"{vg_name}/{lv_name}"], + f"激活逻辑卷 {vg_name}/{lv_name} 失败" + ) + + if success: + messagebox.showinfo("成功", f"逻辑卷 {vg_name}/{lv_name} 已成功激活。") + return True + return False + + def deactivate_lv(self, lv_name, vg_name): + """停用逻辑卷""" + if not lv_name or not vg_name: + messagebox.showerror("错误", "无效的逻辑卷名称或卷组名称。") + return False + + logger.info(f"尝试停用逻辑卷 {vg_name}/{lv_name}。") + success, stdout, stderr = self._execute_shell_command( + ["lvchange", "-an", f"{vg_name}/{lv_name}"], + f"停用逻辑卷 {vg_name}/{lv_name} 失败" + ) + + if success: + messagebox.showinfo("成功", f"逻辑卷 {vg_name}/{lv_name} 已成功停用。") + return True + return False diff --git a/main_tkinter.py b/main_tkinter.py new file mode 100644 index 0000000..3c2fd89 --- /dev/null +++ b/main_tkinter.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Linux 存储管理工具 - Tkinter 版本 +适配低版本系统(如 CentOS 8) +""" + +import tkinter as tk +from tkinter import ttk +import sys +import os + +# 将当前目录添加到路径 +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from mainwindow_tkinter import MainWindow + + +def main(): + """主程序入口""" + root = tk.Tk() + root.title("Linux 存储管理工具") + root.geometry("1200x800") + + # 设置最小窗口大小 + root.minsize(800, 600) + + # 尝试设置 DPI 感知(仅在 Windows 上有效) + try: + from ctypes import windll + windll.shcore.SetProcessDpiAwareness(1) + except Exception: + pass + + # 创建应用 + app = MainWindow(root) + + # 运行主循环 + root.mainloop() + + +if __name__ == "__main__": + main() diff --git a/mainwindow_tkinter.py b/mainwindow_tkinter.py new file mode 100644 index 0000000..fd0ef65 --- /dev/null +++ b/mainwindow_tkinter.py @@ -0,0 +1,1053 @@ +# mainwindow_tkinter.py +import sys +import logging +import re +import os +import tkinter as tk +from tkinter import ttk, messagebox, Menu +from tkinter.scrolledtext import ScrolledText + +# 导入系统信息管理模块 +from system_info import SystemInfoManager +# 导入日志配置 +from logger_config_tkinter import setup_logging, logger +# 导入磁盘操作模块 +from disk_operations_tkinter import DiskOperations +# 导入 RAID 操作模块 +from raid_operations_tkinter import RaidOperations +# 导入 LVM 操作模块 +from lvm_operations_tkinter import LvmOperations +# 导入新的 resolver 类 +from occupation_resolver_tkinter import OccupationResolver +# 导入自定义对话框 +from dialogs_tkinter import (CreatePartitionDialog, MountDialog, CreateRaidDialog, + CreatePvDialog, CreateVgDialog, CreateLvDialog) + + +class MainWindow: + """主窗口类 - Tkinter 版本""" + + def __init__(self, root): + self.root = root + self.root.title("Linux 存储管理工具") + self.root.geometry("1200x800") + + # 创建主框架 + self.main_frame = ttk.Frame(self.root, padding="5") + self.main_frame.pack(fill=tk.BOTH, expand=True) + + # 初始化管理器和操作类 + self.system_manager = SystemInfoManager() + self.lvm_ops = LvmOperations() + self.disk_ops = DiskOperations(self.system_manager, self.lvm_ops) + self.raid_ops = RaidOperations(self.system_manager) + + # 实例化 OccupationResolver + 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._current_context_menu = None + self._menu_close_binding = None + + # 创建界面 + self._create_widgets() + + # 设置日志 + setup_logging(self.log_text) + logger.info("应用程序启动。") + + # 连接磁盘操作的回调 + self.disk_ops.set_formatting_callback(self.on_disk_formatting_finished) + + # 启动队列检查 + self.disk_ops.start_queue_check(self.root) + + # 初始化时刷新所有数据 + self.refresh_all_info() + logger.info("所有设备信息已初始化加载。") + + def _create_widgets(self): + """创建界面元素""" + # 刷新按钮 + self.refresh_btn = ttk.Button(self.main_frame, text="刷新数据", command=self.refresh_all_info) + self.refresh_btn.pack(fill=tk.X, pady=(0, 5)) + + # Notebook (Tab 控件) + self.notebook = ttk.Notebook(self.main_frame) + self.notebook.pack(fill=tk.BOTH, expand=True) + + # === 块设备 Tab === + self.tab_block = ttk.Frame(self.notebook) + self.notebook.add(self.tab_block, text="块设备概览") + + # 块设备 Treeview + self._create_block_devices_tree() + + # === RAID 管理 Tab === + self.tab_raid = ttk.Frame(self.notebook) + self.notebook.add(self.tab_raid, text="RAID 管理") + + # RAID Treeview + self._create_raid_tree() + + # === LVM 管理 Tab === + self.tab_lvm = ttk.Frame(self.notebook) + self.notebook.add(self.tab_lvm, text="LVM 管理") + + # LVM Treeview + self._create_lvm_tree() + + # 日志区域 + log_frame = ttk.LabelFrame(self.main_frame, text="日志输出", padding="5") + log_frame.pack(fill=tk.X, pady=(5, 0)) + + self.log_text = ScrolledText(log_frame, height=8, state=tk.DISABLED) + self.log_text.pack(fill=tk.BOTH, expand=True) + + def _create_block_devices_tree(self): + """创建块设备 Treeview""" + # 创建框架 + tree_frame = ttk.Frame(self.tab_block) + tree_frame.pack(fill=tk.BOTH, expand=True) + + # 定义列 + self.block_columns = [ + ("设备名", 'name'), ("类型", 'type'), ("大小", 'size'), ("挂载点", 'mountpoint'), + ("文件系统", 'fstype'), ("只读", 'ro'), ("UUID", 'uuid'), ("PARTUUID", 'partuuid'), + ("厂商", 'vendor'), ("型号", 'model'), ("序列号", 'serial'), + ("主次号", 'maj:min'), ("父设备名", 'pkname'), + ] + + # 创建 Treeview + self.tree_block = ttk.Treeview(tree_frame, columns=[c[1] for c in self.block_columns], + show='tree headings') + + # 设置表头 + self.tree_block.heading('#0', text='设备结构') + self.tree_block.column('#0', width=150) + + for col_name, col_id in self.block_columns: + self.tree_block.heading(col_id, text=col_name) + self.tree_block.column(col_id, width=80, anchor=tk.W) + + # 滚动条 + vsb = ttk.Scrollbar(tree_frame, orient=tk.VERTICAL, command=self.tree_block.yview) + hsb = ttk.Scrollbar(tree_frame, orient=tk.HORIZONTAL, command=self.tree_block.xview) + self.tree_block.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set) + + # 布局 + self.tree_block.grid(row=0, column=0, sticky='nsew') + vsb.grid(row=0, column=1, sticky='ns') + hsb.grid(row=1, column=0, sticky='ew') + tree_frame.grid_rowconfigure(0, weight=1) + tree_frame.grid_columnconfigure(0, weight=1) + + # 右键菜单绑定 + self.tree_block.bind("", self._on_block_device_right_click) + + def _create_raid_tree(self): + """创建 RAID Treeview""" + tree_frame = ttk.Frame(self.tab_raid) + tree_frame.pack(fill=tk.BOTH, expand=True) + + # 定义列 + self.raid_columns = [ + ("阵列设备", 'device'), ("级别", 'level'), ("状态", 'state'), ("大小", 'array_size'), + ("活动设备", 'active_devices'), ("失败设备", 'failed_devices'), + ("备用设备", 'spare_devices'), ("总设备数", 'total_devices'), + ("UUID", 'uuid'), ("名称", 'name'), ("Chunk Size", 'chunk_size'), ("挂载点", 'mountpoint') + ] + + # 创建 Treeview + self.tree_raid = ttk.Treeview(tree_frame, columns=[c[1] for c in self.raid_columns], + show='tree headings') + + # 设置表头 + self.tree_raid.heading('#0', text='RAID 结构') + self.tree_raid.column('#0', width=150) + + for col_name, col_id in self.raid_columns: + self.tree_raid.heading(col_id, text=col_name) + self.tree_raid.column(col_id, width=80, anchor=tk.W) + + # 滚动条 + vsb = ttk.Scrollbar(tree_frame, orient=tk.VERTICAL, command=self.tree_raid.yview) + hsb = ttk.Scrollbar(tree_frame, orient=tk.HORIZONTAL, command=self.tree_raid.xview) + self.tree_raid.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set) + + # 布局 + self.tree_raid.grid(row=0, column=0, sticky='nsew') + vsb.grid(row=0, column=1, sticky='ns') + hsb.grid(row=1, column=0, sticky='ew') + tree_frame.grid_rowconfigure(0, weight=1) + tree_frame.grid_columnconfigure(0, weight=1) + + # 右键菜单绑定 + self.tree_raid.bind("", self._on_raid_right_click) + + def _create_lvm_tree(self): + """创建 LVM Treeview""" + tree_frame = ttk.Frame(self.tab_lvm) + tree_frame.pack(fill=tk.BOTH, expand=True) + + # 定义列 + self.lvm_columns = [ + ("名称", 'name'), ("大小", 'size'), ("属性", 'attr'), ("UUID", 'uuid'), + ("关联", 'relation'), ("空闲/已用", 'usage'), ("路径/格式", 'path_fmt'), ("挂载点", 'mountpoint') + ] + + # 创建 Treeview + self.tree_lvm = ttk.Treeview(tree_frame, columns=[c[1] for c in self.lvm_columns], + show='tree headings') + + # 设置表头 + self.tree_lvm.heading('#0', text='LVM 结构') + self.tree_lvm.column('#0', width=150) + + for col_name, col_id in self.lvm_columns: + self.tree_lvm.heading(col_id, text=col_name) + self.tree_lvm.column(col_id, width=80, anchor=tk.W) + + # 滚动条 + vsb = ttk.Scrollbar(tree_frame, orient=tk.VERTICAL, command=self.tree_lvm.yview) + hsb = ttk.Scrollbar(tree_frame, orient=tk.HORIZONTAL, command=self.tree_lvm.xview) + self.tree_lvm.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set) + + # 布局 + self.tree_lvm.grid(row=0, column=0, sticky='nsew') + vsb.grid(row=0, column=1, sticky='ns') + hsb.grid(row=1, column=0, sticky='ew') + tree_frame.grid_rowconfigure(0, weight=1) + tree_frame.grid_columnconfigure(0, weight=1) + + # 右键菜单绑定 + self.tree_lvm.bind("", self._on_lvm_right_click) + + def refresh_all_info(self): + """刷新所有设备信息""" + logger.info("开始刷新所有设备信息...") + self.refresh_block_devices_info() + self.refresh_raid_info() + self.refresh_lvm_info() + logger.info("所有设备信息刷新完成。") + + def refresh_block_devices_info(self): + """刷新块设备信息""" + # 清空树 + for item in self.tree_block.get_children(): + self.tree_block.delete(item) + + try: + devices = self.system_manager.get_block_devices() + for dev in devices: + self._add_device_to_tree(self.tree_block, '', dev) + + logger.info("块设备信息刷新成功。") + except Exception as e: + messagebox.showerror("错误", f"刷新块设备信息失败: {e}") + logger.error(f"刷新块设备信息失败: {e}") + + def _add_device_to_tree(self, tree, parent, dev_data): + """添加设备到树""" + # 获取显示值 + values = [] + for col_name, col_id in self.block_columns: + value = dev_data.get(col_id) + if col_id == 'ro': + value = "是" if value else "否" + elif value is None: + value = "" + values.append(value) + + # 插入节点 + item_id = tree.insert(parent, tk.END, text=dev_data.get('name', ''), values=values) + + # 保存完整数据 + tree.item(item_id, tags=(str(dev_data),)) + + # 递归添加子设备 + if 'children' in dev_data: + for child in dev_data['children']: + self._add_device_to_tree(tree, item_id, child) + tree.item(item_id, open=True) + + def _get_item_data(self, tree, item_id): + """获取树项关联的数据""" + tags = tree.item(item_id, 'tags') + if tags: + try: + # 解析字符串为字典 + return eval(tags[0]) + except: + pass + return None + + def _close_context_menu(self): + """关闭当前右键菜单""" + if self._current_context_menu is not None: + try: + self._current_context_menu.unpost() + except: + pass + self._current_context_menu = None + # 解绑全局点击事件 + if self._menu_close_binding is not None: + try: + self.root.unbind('', self._menu_close_binding) + self._menu_close_binding = None + except: + pass + + def _bind_menu_close(self): + """绑定点击事件来关闭菜单""" + if self._current_context_menu is not None: + self._menu_close_binding = self.root.bind('', self._on_click_outside_menu) + + def _on_click_outside_menu(self, event): + """点击菜单外部时关闭菜单""" + # tk.Menu 会拦截自己的点击事件,所以如果这里被调用,说明点击在菜单外部 + self._close_context_menu() + + def _show_context_menu(self, menu, x, y): + """显示右键菜单并记录引用""" + # 先关闭旧菜单 + self._close_context_menu() + # 记录新菜单 + self._current_context_menu = menu + # 显示菜单 + menu.post(x, y) + # 绑定全局点击事件来关闭菜单(延迟绑定避免立即触发) + self.root.after(100, self._bind_menu_close) + + # === 块设备右键菜单 === + def _on_block_device_right_click(self, event): + """块设备右键点击事件""" + # 先关闭任何已打开的菜单 + self._close_context_menu() + + item = self.tree_block.identify_row(event.y) + if item: + self.tree_block.selection_set(item) + else: + return + + # 创建菜单 + menu = tk.Menu(self.root, tearoff=0) + + # 创建子菜单 + create_menu = tk.Menu(menu, tearoff=0) + create_menu.add_command(label="创建 RAID 阵列...", command=self._handle_create_raid_array) + create_menu.add_command(label="创建物理卷 (PV)...", command=self._handle_create_pv) + menu.add_cascade(label="创建...", menu=create_menu) + menu.add_separator() + + if item: + dev_data = self._get_item_data(self.tree_block, item) + if dev_data: + device_name = dev_data.get('name') + device_type = dev_data.get('type') + mount_point = dev_data.get('mountpoint') + device_path = dev_data.get('path', f"/dev/{device_name}") + + # 磁盘操作 + if device_type == 'disk': + menu.add_command(label=f"创建分区 {device_path}...", + command=lambda: self._handle_create_partition(device_path, dev_data)) + menu.add_command(label=f"擦除分区表 {device_path}...", + command=lambda: self._handle_wipe_partition_table(device_path)) + menu.add_separator() + + # 分区操作 + if device_type == 'part': + if not mount_point or mount_point == '' or mount_point == 'N/A': + menu.add_command(label=f"挂载 {device_path}...", + command=lambda: self._handle_mount(device_path)) + elif mount_point != '[SWAP]': + menu.add_command(label=f"卸载 {device_path}", + command=lambda: self._unmount_and_refresh(device_path)) + menu.add_separator() + + menu.add_command(label=f"删除分区 {device_path}", + command=lambda: self._handle_delete_partition(device_path)) + menu.add_command(label=f"格式化分区 {device_path}...", + command=lambda: self._handle_format_partition(device_path)) + menu.add_separator() + + # Loop 设备操作 + if device_type == 'loop': + if not mount_point or mount_point == '' or mount_point == 'N/A': + menu.add_command(label=f"挂载 {device_path}...", + command=lambda: self._handle_mount(device_path)) + else: + menu.add_command(label=f"卸载 {device_path}", + command=lambda: self._unmount_and_refresh(device_path)) + menu.add_separator() + + # 解除占用 + if device_type in ['disk', 'part', 'loop']: + menu.add_command(label=f"解除占用 {device_path}", + command=lambda: self._handle_resolve_device_occupation(device_path)) + + # 关闭交换分区 + if device_type == 'part' and mount_point == '[SWAP]': + menu.add_command(label=f"关闭交换分区 {device_path}", + command=lambda: self._handle_deactivate_swap(device_path)) + + # ROM 设备操作 + if device_type == 'rom': + menu.add_separator() + if not mount_point or mount_point == '' or mount_point == 'N/A': + menu.add_command(label=f"挂载为 Loop {device_path}...", + command=lambda: self._handle_mount_loop(device_path)) + else: + menu.add_command(label=f"卸载 Loop {device_path}", + command=lambda: self._handle_unmount_loop(device_path)) + + if menu.index(tk.END) is not None: + self._show_context_menu(menu, event.x_root, event.y_root) + else: + menu.destroy() + logger.info("右键点击了空白区域或设备没有可用的操作。") + + # === RAID 右键菜单 === + def _on_raid_right_click(self, event): + """RAID 右键点击事件""" + # 先关闭任何已打开的菜单 + self._close_context_menu() + + item = self.tree_raid.identify_row(event.y) + if item: + self.tree_raid.selection_set(item) + + menu = tk.Menu(self.root, tearoff=0) + menu.add_command(label="创建 RAID 阵列...", command=self._handle_create_raid_array) + menu.add_separator() + + if item: + parent = self.tree_raid.parent(item) + if not parent: # 顶层项 + array_data = self._get_item_data(self.tree_raid, item) + if array_data: + 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 array_state == 'Stopped (Configured)': + menu.add_command(label=f"激活阵列 {array_path}", + command=lambda: self._handle_activate_raid_array(array_path)) + menu.add_command(label=f"删除配置文件条目 (UUID: {array_uuid})", + command=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 != '': + menu.add_command(label=f"卸载 {array_path} ({current_mount_point})", + command=lambda: self._unmount_and_refresh(array_path)) + else: + menu.add_command(label=f"挂载 {array_path}...", + command=lambda: self._handle_mount(array_path)) + menu.add_separator() + + menu.add_command(label=f"解除占用 {array_path}", + command=lambda: self._handle_resolve_device_occupation(array_path)) + menu.add_separator() + + menu.add_command(label=f"停止阵列 {array_path}", + command=lambda: self._handle_stop_raid_array(array_path)) + menu.add_command(label=f"删除阵列 {array_path}", + command=lambda: self._handle_delete_active_raid_array(array_path, member_devices, array_uuid)) + menu.add_command(label=f"格式化阵列 {array_path}...", + command=lambda: self._handle_format_raid_array(array_path)) + + if menu.index(tk.END) is not None: + self._show_context_menu(menu, event.x_root, event.y_root) + else: + menu.destroy() + + # === LVM 右键菜单 === + def _on_lvm_right_click(self, event): + """LVM 右键点击事件""" + # 先关闭任何已打开的菜单 + self._close_context_menu() + + item = self.tree_lvm.identify_row(event.y) + if item: + self.tree_lvm.selection_set(item) + + menu = tk.Menu(self.root, tearoff=0) + + create_menu = tk.Menu(menu, tearoff=0) + create_menu.add_command(label="创建物理卷 (PV)...", command=self._handle_create_pv) + create_menu.add_command(label="创建卷组 (VG)...", command=self._handle_create_vg) + create_menu.add_command(label="创建逻辑卷 (LV)...", command=self._handle_create_lv) + menu.add_cascade(label="创建...", menu=create_menu) + menu.add_separator() + + if item: + item_data = self._get_item_data(self.tree_lvm, item) + if item_data: + 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': + menu.add_command(label=f"删除物理卷 {pv_name}", + command=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': + menu.add_command(label=f"删除卷组 {vg_name}", + command=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: + menu.add_command(label=f"停用逻辑卷 {lv_name}", + command=lambda: self._handle_deactivate_lv(lv_name, vg_name)) + else: + menu.add_command(label=f"激活逻辑卷 {lv_name}", + command=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 != '': + menu.add_command(label=f"卸载 {lv_name} ({current_mount_point})", + command=lambda: self._unmount_and_refresh(lv_path)) + else: + menu.add_command(label=f"挂载 {lv_name}...", + command=lambda: self._handle_mount(lv_path)) + menu.add_separator() + + menu.add_command(label=f"解除占用 {lv_path}", + command=lambda: self._handle_resolve_device_occupation(lv_path)) + menu.add_separator() + + menu.add_command(label=f"删除逻辑卷 {lv_name}", + command=lambda: self._handle_delete_lv(lv_name, vg_name)) + menu.add_command(label=f"格式化逻辑卷 {lv_name}...", + command=lambda: self._handle_format_partition(lv_path)) + + if menu.index(tk.END) is not None: + self._show_context_menu(menu, event.x_root, event.y_root) + else: + menu.destroy() + + # === 块设备处理函数 === + def _handle_resolve_device_occupation(self, device_path: str) -> bool: + """处理设备占用问题""" + success = self.occupation_resolver.resolve_occupation(device_path) + if success: + self.refresh_all_info() + return success + + def _handle_deactivate_swap(self, device_path): + """处理关闭交换分区""" + if messagebox.askyesno("确认关闭交换分区", + f"您确定要关闭交换分区 {device_path} 吗?", + default=messagebox.NO): + logger.info(f"尝试关闭交换分区 {device_path}。") + success, _, stderr = self.lvm_ops._execute_shell_command( + ["swapoff", device_path], + f"关闭交换分区 {device_path} 失败", + show_dialog=True + ) + if success: + messagebox.showinfo("成功", f"交换分区 {device_path} 已成功关闭。") + self.refresh_all_info() + return True + else: + logger.error(f"关闭交换分区 {device_path} 失败: {stderr}") + else: + logger.info(f"用户取消了关闭交换分区 {device_path}。") + return False + + def _handle_mount_loop(self, device_path): + """处理挂载 ROM 设备为 loop""" + if self.disk_ops.mount_loop_device(device_path): + self.refresh_all_info() + + def _handle_unmount_loop(self, device_path): + """处理卸载 loop 设备""" + if self.disk_ops.unmount_loop_device(device_path): + self.refresh_all_info() + + def _handle_wipe_partition_table(self, device_path): + """处理擦除物理盘分区表""" + if messagebox.askyesno("确认擦除分区表", + f"您确定要擦除设备 {device_path} 上的所有分区表吗?\n" + f"**此操作将永久删除设备上的所有分区和数据,且不可恢复!**\n" + f"请谨慎操作!", + default=messagebox.NO): + logger.warning(f"尝试擦除 {device_path} 上的分区表。") + success, _, stderr = self.lvm_ops._execute_shell_command( + ["parted", "-s", device_path, "mklabel", "gpt"], + f"擦除 {device_path} 上的分区表失败" + ) + if success: + messagebox.showinfo("成功", f"设备 {device_path} 上的分区表已成功擦除。") + self.refresh_all_info() + + 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_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: + messagebox.showerror("错误", 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: + messagebox.showerror("错误", 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} 没有现有分区。") + max_available_mib = max(0.0, total_disk_mib - 1.0) + start_position_mib = 1.0 + + dialog = CreatePartitionDialog(self.root, disk_path, total_disk_mib, max_available_mib) + info = dialog.wait_for_result() + + 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.root, device_path) + info = dialog.wait_for_result() + + 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): + """处理格式化分区""" + self.disk_ops.format_partition(device_path) + + def on_disk_formatting_finished(self, success, device_path, stdout, stderr): + """接收格式化完成回调并刷新界面""" + logger.info(f"格式化完成信号接收: 设备 {device_path}, 成功: {success}") + self.refresh_all_info() + + # === RAID 处理函数 === + def refresh_raid_info(self): + """刷新 RAID 信息""" + for item in self.tree_raid.get_children(): + self.tree_raid.delete(item) + + try: + raid_arrays = self.system_manager.get_mdadm_arrays() + if not raid_arrays: + self.tree_raid.insert('', tk.END, text='未找到RAID阵列。', values=[''] * len(self.raid_columns)) + logger.info("未找到RAID阵列。") + return + + for array in raid_arrays: + array_path = array.get('device', 'N/A') + if not array_path or array_path == 'N/A': + continue + + current_mount_point = "" + if array.get('state') != 'Stopped (Configured)': + current_mount_point = self.system_manager.get_mountpoint_for_device(array_path) + + values = [ + array_path, + array.get('level', 'N/A'), + array.get('state', 'N/A'), + array.get('array_size', 'N/A'), + array.get('active_devices', 'N/A'), + array.get('failed_devices', 'N/A'), + array.get('spare_devices', 'N/A'), + array.get('total_devices', 'N/A'), + array.get('uuid', 'N/A'), + array.get('name', 'N/A'), + array.get('chunk_size', 'N/A'), + current_mount_point if current_mount_point else "" + ] + + item_id = self.tree_raid.insert('', tk.END, text=array_path, values=values) + self.tree_raid.item(item_id, tags=(str(array),)) + + if array.get('state') != 'Stopped (Configured)': + for member in array.get('member_devices', []): + member_values = [ + f" {member.get('device_path', 'N/A')}", + f"成员: {member.get('raid_device', 'N/A')}", + member.get('state', 'N/A') + ] + [''] * (len(self.raid_columns) - 3) + self.tree_raid.insert(item_id, tk.END, text='', values=member_values) + + self.tree_raid.item(item_id, open=True) + + logger.info("RAID阵列信息刷新成功。") + except Exception as e: + messagebox.showerror("错误", f"刷新RAID阵列信息失败: {e}") + logger.error(f"刷新RAID阵列信息失败: {e}") + + def _handle_activate_raid_array(self, array_path): + """处理激活 RAID 阵列""" + selected = self.tree_raid.selection() + if not selected: + return + + array_data = self._get_item_data(self.tree_raid, selected[0]) + if not array_data: + return + + array_uuid = array_data.get('uuid') + if not array_uuid or array_uuid == 'N/A': + messagebox.showerror("错误", f"无法激活 RAID 阵列 {array_path}:缺少 UUID 信息。") + return + + if messagebox.askyesno("确认激活 RAID 阵列", + f"您确定要激活 RAID 阵列 {array_path} (UUID: {array_uuid}) 吗?\n" + "这将尝试重新组装阵列。", + default=messagebox.NO): + if self.raid_ops.activate_raid_array(array_path, array_uuid): + self.refresh_all_info() + + def _handle_create_raid_array(self): + """处理创建 RAID 阵列""" + available_devices = self.system_manager.get_unallocated_partitions() + + if not available_devices: + messagebox.showwarning("警告", "没有可用于创建 RAID 阵列的设备。请确保有未挂载、未被LVM或RAID使用的磁盘或分区。") + return + + dialog = CreateRaidDialog(self.root, available_devices) + info = dialog.wait_for_result() + + 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): + """处理停止 RAID 阵列""" + 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 处理函数 === + def refresh_lvm_info(self): + """刷新 LVM 信息""" + for item in self.tree_lvm.get_children(): + self.tree_lvm.delete(item) + + 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'): + self.tree_lvm.insert('', tk.END, text='未找到LVM信息。', values=[''] * len(self.lvm_columns)) + logger.info("未找到LVM信息。") + return + + # 物理卷 (PVs) + pv_root = self.tree_lvm.insert('', tk.END, text="物理卷 (PVs)", values=[''] * len(self.lvm_columns)) + self.tree_lvm.item(pv_root, tags=(str({'type': 'pv_root'}),)) + + if lvm_data.get('pvs'): + for pv in lvm_data['pvs']: + pv_name = pv.get('pv_name', 'N/A') + pv_path = pv_name if pv_name.startswith('/dev/') else f"/dev/{pv_name}" + + values = [ + pv_name, + pv.get('pv_size', 'N/A'), + pv.get('pv_attr', 'N/A'), + pv.get('pv_uuid', 'N/A'), + f"VG: {pv.get('vg_name', 'N/A')}", + f"空闲: {pv.get('pv_free', 'N/A')}", + pv.get('pv_fmt', 'N/A'), + "" + ] + + item_id = self.tree_lvm.insert(pv_root, tk.END, text=pv_name, values=values) + pv_data = pv.copy() + pv_data['pv_name'] = pv_path + self.tree_lvm.item(item_id, tags=(str({'type': 'pv', 'data': pv_data}),)) + else: + self.tree_lvm.insert(pv_root, tk.END, text='未找到物理卷。') + + # 卷组 (VGs) + vg_root = self.tree_lvm.insert('', tk.END, text="卷组 (VGs)", values=[''] * len(self.lvm_columns)) + self.tree_lvm.item(vg_root, tags=(str({'type': 'vg_root'}),)) + + if lvm_data.get('vgs'): + for vg in lvm_data['vgs']: + vg_name = vg.get('vg_name', 'N/A') + + values = [ + vg_name, + vg.get('vg_size', 'N/A'), + vg.get('vg_attr', 'N/A'), + vg.get('vg_uuid', 'N/A'), + f"PVs: {vg.get('pv_count', 'N/A')}, LVs: {vg.get('lv_count', 'N/A')}", + f"空闲: {vg.get('vg_free', 'N/A')}, 已分配: {vg.get('vg_alloc_percent', 'N/A')}%", + vg.get('vg_fmt', 'N/A'), + "" + ] + + item_id = self.tree_lvm.insert(vg_root, tk.END, text=vg_name, values=values) + vg_data = vg.copy() + vg_data['vg_name'] = vg_name + self.tree_lvm.item(item_id, tags=(str({'type': 'vg', 'data': vg_data}),)) + else: + self.tree_lvm.insert(vg_root, tk.END, text='未找到卷组。') + + # 逻辑卷 (LVs) + lv_root = self.tree_lvm.insert('', tk.END, text="逻辑卷 (LVs)", values=[''] * len(self.lvm_columns)) + self.tree_lvm.item(lv_root, tags=(str({'type': 'lv_root'}),)) + + if lvm_data.get('lvs'): + for lv in lvm_data['lvs']: + 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': + lv_path = f"/dev/{vg_name}/{lv_name}" if vg_name != 'N/A' and lv_name != 'N/A' else 'N/A' + + current_mount_point = self.system_manager.get_mountpoint_for_device(lv_path) + + values = [ + lv_name, + lv.get('lv_size', 'N/A'), + lv_attr, + lv.get('lv_uuid', 'N/A'), + f"VG: {vg_name}, Origin: {lv.get('origin', 'N/A')}", + f"快照: {lv.get('snap_percent', 'N/A')}%", + lv_path, + current_mount_point if current_mount_point else "" + ] + + item_id = self.tree_lvm.insert(lv_root, tk.END, text=lv_name, values=values) + lv_data = lv.copy() + lv_data['lv_path'] = lv_path + lv_data['lv_name'] = lv_name + lv_data['vg_name'] = vg_name + lv_data['lv_attr'] = lv_attr + self.tree_lvm.item(item_id, tags=(str({'type': 'lv', 'data': lv_data}),)) + else: + self.tree_lvm.insert(lv_root, tk.END, text='未找到逻辑卷。') + + # 展开所有根节点 + self.tree_lvm.item(pv_root, open=True) + self.tree_lvm.item(vg_root, open=True) + self.tree_lvm.item(lv_root, open=True) + + logger.info("LVM信息刷新成功。") + except Exception as e: + messagebox.showerror("错误", f"刷新LVM信息失败: {e}") + logger.error(f"刷新LVM信息失败: {e}") + + def _handle_create_pv(self): + """处理创建物理卷""" + available_partitions = self.system_manager.get_unallocated_partitions() + if not available_partitions: + messagebox.showwarning("警告", "没有可用于创建物理卷的未分配分区。") + return + + dialog = CreatePvDialog(self.root, available_partitions) + info = dialog.wait_for_result() + + 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: + messagebox.showwarning("警告", "没有可用于创建卷组的物理卷。") + return + + dialog = CreateVgDialog(self.root, available_pvs) + info = dialog.wait_for_result() + + 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' 0: + messagebox.showerror("删除失败", f"卷组 {vg_name} 中仍包含逻辑卷。请先删除所有逻辑卷。") + logger.error(f"尝试删除包含逻辑卷的卷组 {vg_name}。操作被阻止。") + return + + success = self.lvm_ops.delete_vg(vg_name) + if success: + self.refresh_all_info() + else: + messagebox.showerror("删除卷组失败", f"删除卷组 {vg_name} 失败,请检查日志。") diff --git a/occupation_resolver_tkinter.py b/occupation_resolver_tkinter.py new file mode 100644 index 0000000..806145f --- /dev/null +++ b/occupation_resolver_tkinter.py @@ -0,0 +1,179 @@ +# occupation_resolver_tkinter.py +import logging +import subprocess +import os +from tkinter import messagebox + +logger = logging.getLogger(__name__) + + +class OccupationResolver: + """设备占用解除类 - Tkinter 版本""" + + def __init__(self, shell_executor_func, mount_info_getter_func): + self.shell_executor_func = shell_executor_func + self.mount_info_getter_func = mount_info_getter_func + + def resolve_occupation(self, device_path: str) -> bool: + """处理设备占用问题""" + logger.info(f"开始处理设备 {device_path} 的占用问题。") + + # 1. 获取挂载点 + mount_point = self.mount_info_getter_func(device_path) + + # 2. 如果设备已挂载,尝试卸载 + if mount_point: + logger.info(f"设备 {device_path} 已挂载在 {mount_point}。检查是否有嵌套挂载...") + + # 卸载嵌套挂载点 + if not self._unmount_nested_mounts(mount_point): + messagebox.showerror("错误", f"未能卸载设备 {device_path} 下的所有嵌套挂载点。") + # 嵌套挂载卸载失败,可能是被进程占用,继续检查进程 + else: + # 嵌套挂载卸载成功,尝试卸载设备本身 + if self._unmount_device(device_path): + messagebox.showinfo("成功", f"设备 {device_path} 及其所有挂载点已成功卸载。") + return True + # 设备卸载失败,可能是被进程占用,继续检查进程 + logger.warning(f"设备 {device_path} 卸载失败,可能存在进程占用。") + else: + logger.info(f"设备 {device_path} 当前未挂载。") + + # 3. 检查是否有进程占用设备(无论是否已挂载,都检查进程占用) + if self._is_device_busy(device_path): + return self._kill_processes_using_device(device_path) + + # 4. 如果之前有挂载点但卸载失败,且没有进程占用,说明是其他原因 + if mount_point: + messagebox.showwarning("警告", + f"设备 {device_path} 卸载失败,但未检测到进程占用。\n" + f"可能是其他原因导致,请检查日志。") + return False + + # 5. 无任何占用 + messagebox.showinfo("信息", f"设备 {device_path} 未被任何进程占用。") + return True + + def _unmount_nested_mounts(self, mount_point: str) -> bool: + """卸载指定挂载点下的所有嵌套挂载""" + logger.info(f"尝试卸载 {mount_point} 下的所有嵌套挂载点。") + + try: + with open('/proc/mounts', 'r') as f: + mounts = f.readlines() + + nested_mounts = [] + for line in mounts: + parts = line.split() + if len(parts) >= 2: + mp = parts[1] + if mp.startswith(mount_point + '/') or mp == mount_point: + nested_mounts.append(mp) + + # 按深度排序,先卸载深层挂载 + nested_mounts.sort(key=lambda x: x.count('/'), reverse=True) + + for nm in nested_mounts: + logger.info(f"尝试卸载嵌套挂载点: {nm}") + success, _, _ = self.shell_executor_func( + ["umount", nm], + f"卸载嵌套挂载点 {nm} 失败", + show_dialog=False + ) + if not success: + logger.warning(f"卸载嵌套挂载点 {nm} 失败,但将继续尝试其他挂载点。") + + return True + except Exception as e: + logger.error(f"卸载嵌套挂载点时发生错误: {e}") + return False + + def _unmount_device(self, device_path: str) -> bool: + """卸载设备""" + logger.info(f"尝试卸载设备 {device_path}。") + success, _, stderr = self.shell_executor_func( + ["umount", device_path], + f"卸载设备 {device_path} 失败", + show_dialog=False + ) + if success: + logger.info(f"设备 {device_path} 已成功卸载。") + return True + else: + # 检查是否是因为设备已经未挂载 + if "未挂载" in stderr or "not mounted" in stderr.lower(): + logger.info(f"设备 {device_path} 已经处于未挂载状态。") + return True + logger.error(f"卸载设备 {device_path} 失败: {stderr}") + return False + + def _is_device_busy(self, device_path: str) -> bool: + """检查设备是否被进程占用""" + logger.info(f"检查设备 {device_path} 是否被进程占用。") + + # 尝试使用 lsof + try: + result = subprocess.run( + ["sudo", "lsof", device_path], + capture_output=True, text=True, timeout=10 + ) + if result.returncode == 0 and result.stdout.strip(): + logger.info(f"设备 {device_path} 被以下进程占用:\n{result.stdout}") + return True + except Exception as e: + logger.debug(f"lsof 检查失败: {e}") + + # 尝试使用 fuser + try: + result = subprocess.run( + ["sudo", "fuser", device_path], + capture_output=True, text=True, timeout=10 + ) + if result.returncode == 0 and result.stdout.strip(): + logger.info(f"设备 {device_path} 被以下进程占用: {result.stdout.strip()}") + return True + except Exception as e: + logger.debug(f"fuser 检查失败: {e}") + + return False + + def _kill_processes_using_device(self, device_path: str) -> bool: + """终止占用设备的进程""" + logger.info(f"尝试终止占用设备 {device_path} 的进程。") + + if messagebox.askyesno("确认终止进程", + f"设备 {device_path} 当前被进程占用。\n" + "是否终止这些进程?", + default=messagebox.NO): + try: + # 使用 fuser -k 终止进程 + result = subprocess.run( + ["sudo", "fuser", "-k", device_path], + capture_output=True, text=True, timeout=10 + ) + + if result.returncode == 0 or result.returncode == 1: # 1 表示没有进程被终止 + messagebox.showinfo("成功", f"已尝试终止占用设备 {device_path} 的所有进程。") + + # 再次尝试卸载 + if self._unmount_device(device_path): + messagebox.showinfo("成功", f"设备 {device_path} 已成功卸载。") + return True + else: + # 设备可能已经没有挂载 + mount_point = self.mount_info_getter_func(device_path) + if not mount_point: + messagebox.showinfo("成功", f"设备 {device_path} 已成功卸载。") + return True + messagebox.showwarning("警告", f"进程已终止,但未能成功卸载设备 {device_path}。可能仍有其他问题。") + return False + else: + messagebox.showerror("错误", f"未能终止占用设备 {device_path} 的进程。") + return False + except Exception as e: + logger.error(f"终止进程时发生错误: {e}") + messagebox.showerror("错误", f"终止进程时发生错误: {e}") + return False + else: + messagebox.showinfo("信息", f"已取消终止占用设备 {device_path} 的进程。") + return False diff --git a/raid_operations_tkinter.py b/raid_operations_tkinter.py new file mode 100644 index 0000000..020907d --- /dev/null +++ b/raid_operations_tkinter.py @@ -0,0 +1,410 @@ +# raid_operations_tkinter.py +import logging +import subprocess +from tkinter import messagebox, simpledialog +from system_info import SystemInfoManager +import re +try: + import pexpect + PEXPECT_AVAILABLE = True +except ImportError: + PEXPECT_AVAILABLE = False + pexpect = None +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""" + 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: + messagebox.showerror("错误", f"{error_msg_prefix}\n详细信息: {stderr_output}") + + return False, e.stdout, stderr_output + except FileNotFoundError: + logger.error(f"命令 '{command_list[0]}' 未找到。请确保已安装相关工具。") + messagebox.showerror("错误", f"命令 '{command_list[0]}' 未找到。请确保已安装相关工具。") + return False, "", "Command not found." + except Exception as e: + logger.error(f"{error_msg_prefix} 命令: {full_cmd_str} 发生未知错误: {e}") + messagebox.showerror("错误", f"{error_msg_prefix}\n未知错误: {e}") + return False, "", str(e) + + def _execute_interactive_mdadm_command(self, command_list, error_msg_prefix): + """专门处理 mdadm --create 命令的交互式执行""" + if not PEXPECT_AVAILABLE: + logger.warning("pexpect 模块未安装,将尝试使用 --run 参数避免交互") + # 添加 --run 参数来避免交互式提示(自动回答 yes) + if "--create" in command_list: + # 在 --create 后面插入 --run + create_idx = command_list.index("--create") + command_list.insert(create_idx + 1, "--run") + logger.info("已添加 --run 参数以自动确认创建") + # 回退到普通命令执行 + return self._execute_shell_command(command_list, error_msg_prefix) + + 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 = simpledialog.askstring("Sudo 密码", "请输入您的 sudo 密码:", show='*') + if not password: + logger.error("sudo 密码未提供或取消。") + messagebox.showerror("错误", "sudo 密码未提供或取消。") + child.close(force=True) + return False, "", "Sudo password not provided." + child.sendline(password) + try: + auth_index = child.expect(['Authentication failure', pexpect.TIMEOUT], timeout=5) + if auth_index == 0: + logger.error("sudo 密码验证失败。") + messagebox.showerror("错误", "sudo 密码验证失败。请检查密码并重试。") + child.close(force=True) + return False, "", "Sudo authentication failed." + except pexpect.TIMEOUT: + logger.info("sudo 密码验证成功或无需密码。继续...") + except pexpect.TIMEOUT: + logger.info("未在 5 秒内收到密码提示,假定命令无需密码或已在缓存中。继续...") + + interaction_patterns = [ + r'Continue creating array.*\?', + r'partition table exists.*Continue', + r'write-indent bitmap.*enable it now.*\?', + r'bitmap.*\[y/N\]\?', + ] + + pattern_descriptions = { + 0: "mdadm 询问是否继续创建阵列(分区表将被清除)", + 1: "mdadm 提示分区表存在,询问是否继续", + 2: "mdadm 询问是否启用 write-intent bitmap", + 3: "mdadm 询问是否启用 bitmap", + } + + while True: + try: + index = child.expect(interaction_patterns + [pexpect.EOF, pexpect.TIMEOUT], timeout=300) + + if index < len(interaction_patterns): + # 匹配到交互式提示 + matched_text = child.after + logger.info(f"检测到交互式提示 (index={index}): {matched_text}") + + # 根据不同的提示给出不同的处理 + if index == 0 or index == 1: + # 继续创建阵列(分区表相关) + if messagebox.askyesno("mdadm 提示", + "设备上存在分区表,创建 RAID 将清除分区表。\n" + "是否继续创建 RAID 阵列?", + default=messagebox.YES): + child.sendline('y') + logger.info("用户确认继续创建 RAID 阵列。") + else: + child.sendline('n') + logger.info("用户选择不继续创建 RAID 阵列。中止操作。") + return False, "", "User aborted RAID creation." + elif index == 2 or index == 3: + # bitmap 相关提示 + if messagebox.askyesno("mdadm 提示", + "mdadm 建议启用 write-intent bitmap 以优化恢复速度。\n" + "是否启用 bitmap?", + default=messagebox.NO): + child.sendline('y') + logger.info("用户确认启用 bitmap。") + else: + child.sendline('n') + logger.info("用户选择不启用 bitmap,继续创建阵列。") + elif index == len(interaction_patterns): + # EOF + break + elif index == len(interaction_patterns) + 1: + # TIMEOUT + logger.warning(f"等待 mdadm 命令输出时超时。已收集输出:\n{''.join(stdout_buffer)}") + messagebox.showerror("错误", f"{error_msg_prefix}\n详细信息: mdadm 命令在交互完成后未正常退出,等待 EOF 超时。") + return False, "".join(stdout_buffer), "Timeout waiting for EOF." + + except pexpect.exceptions.EOF: + break + + final_output = child.before + stdout_buffer.append(final_output) + + child.close() + if child.exitstatus == 0: + logger.info(f"交互式命令成功完成: {full_cmd_str}") + return True, "".join(stdout_buffer), "" + else: + logger.error(f"交互式命令失败,退出码: {child.exitstatus}") + messagebox.showerror("错误", f"{error_msg_prefix}\n详细信息: 命令执行失败,退出码 {child.exitstatus}\n输出: {final_output}") + return False, "".join(stdout_buffer), f"Command failed with exit code {child.exitstatus}" + + except pexpect.exceptions.ExceptionPexpect as e: + logger.error(f"交互式命令执行过程中发生 pexpect 错误: {e}") + messagebox.showerror("错误", f"{error_msg_prefix}\n详细信息: pexpect 内部错误: {e}") + return False, "", str(e) + except Exception as e: + logger.error(f"交互式命令执行时发生未知错误: {e}") + messagebox.showerror("错误", f"{error_msg_prefix}\n未知错误: {e}") + return False, "", str(e) + + def create_raid_array(self, devices, level, chunk_size): + """创建 RAID 阵列""" + if not isinstance(devices, list): + logger.error(f"传入的设备列表类型不是 list: {type(devices)}") + messagebox.showerror("错误", f"不支持的 RAID 级别类型: {type(level)}") + return False + + if not devices: + messagebox.showerror("错误", "创建 RAID 阵列至少需要一个设备(RAID0)。") + return False + + num_devices = len(devices) + + if level == "1" and num_devices < 2: + messagebox.showerror("错误", "RAID1 至少需要两个设备。") + return False + elif level == "5" and num_devices < 3: + messagebox.showerror("错误", "RAID5 至少需要三个设备。") + return False + elif level == "6" and num_devices < 4: + messagebox.showerror("错误", "RAID6 至少需要四个设备。") + return False + elif level == "10" and num_devices < 2: + messagebox.showerror("错误", "RAID10 至少需要两个设备。") + return False + + if not messagebox.askyesno("确认创建 RAID 阵列", + f"您确定要创建 RAID {level} 阵列吗?\n" + f"这将使用设备: {', '.join(devices)}。\n" + f"此操作会擦除这些设备上的数据!", + default=messagebox.NO): + logger.info("用户取消了创建 RAID 阵列的操作。") + return False + + array_name = None + try: + mdadm_detail_result = self.system_manager._run_command(["mdadm", "--detail", "--scan"], root_privilege=True) + existing_arrays = re.findall(r'ARRAY\s+/dev/(md\d+)', mdadm_detail_result[0]) + if existing_arrays: + max_num = max([int(re.search(r'\d+', name).group()) for name in existing_arrays]) + array_name = f"/dev/md{max_num + 1}" + else: + array_name = "/dev/md0" + except Exception as e: + logger.warning(f"检测现有 RAID 阵列时出错: {e},将默认使用 /dev/md0。") + array_name = "/dev/md0" + + create_cmd = [ + "mdadm", "--create", array_name, "--level=" + level, + "--raid-devices=" + str(num_devices) + ] + if chunk_size: + create_cmd.append("--chunk=" + str(chunk_size)) + create_cmd.extend(devices) + + logger.info(f"尝试创建 RAID {level} 阵列 {array_name},使用设备: {devices}") + + success, stdout, stderr = self._execute_interactive_mdadm_command(create_cmd, f"创建 RAID {level} 阵列失败") + + if success: + if "already exists" in stderr.lower() or "already exists" in stdout.lower(): + messagebox.showerror("错误", f"创建 RAID 阵列失败:阵列名称 {array_name} 已被占用。请尝试停止或删除现有阵列。") + return False + + messagebox.showinfo("成功", f"成功创建 RAID {level} 阵列 {array_name}。") + + try: + mdadm_conf_path = "/etc/mdadm.conf" + success_scan, stdout_scan, stderr_scan = self._execute_shell_command( + ["mdadm", "--detail", "--scan"], + "生成 mdadm.conf 扫描信息失败", + suppress_critical_dialog_on_stderr_match="No arrays" + ) + + if success_scan: + success_mkdir, _, _ = self._execute_shell_command( + ["mkdir", "-p", "/etc"], + "创建 /etc 目录失败" + ) + if not success_mkdir: + messagebox.showerror("错误", f"无法创建 /etc 目录,更新 mdadm.conf 失败。\n详细信息: {stderr_mkdir}") + return False + + success_conf, _, stderr_conf = self._execute_shell_command( + ["sh", "-c", f"echo '{stdout_scan.strip()}' >> {mdadm_conf_path}"], + f"更新 {mdadm_conf_path} 失败" + ) + if success_conf: + logger.info(f"已将新的 RAID 阵列信息追加到 {mdadm_conf_path}。") + else: + logger.warning(f"无法追加到 {mdadm_conf_path}。这可能需要手动修复。错误: {stderr_conf}") + + else: + logger.warning("mdadm --detail --scan 失败,无法自动更新 mdadm.conf。") + + except Exception as e: + logger.error(f"更新 mdadm.conf 时发生错误: {e}") + + return True + return False + + def stop_raid_array(self, array_path): + """停止 RAID 阵列""" + if not messagebox.askyesno("确认停止 RAID 阵列", + f"您确定要停止 RAID 阵列 {array_path} 吗?\n" + f"这将卸载该阵列(如果已挂载)并将其置为停止状态。", + default=messagebox.NO): + logger.info(f"用户取消了停止 RAID 阵列 {array_path} 的操作。") + return False + + logger.info(f"尝试停止 RAID 阵列 {array_path}。") + + try: + stdout, stderr = self.system_manager._run_command( + ["mdadm", "--stop", array_path], + root_privilege=True, check_output=True + ) + messagebox.showinfo("成功", f"成功停止 RAID 阵列 {array_path}。") + return True + except subprocess.CalledProcessError as e: + error_msg = e.stderr.strip() if e.stderr else str(e) + logger.error(f"停止 RAID 阵列 {array_path} 失败: {error_msg}") + messagebox.showerror("错误", f"停止 RAID 阵列 {array_path} 失败。\n错误: {error_msg}") + return False + + def delete_active_raid_array(self, array_path, member_devices, array_uuid): + """删除活动的 RAID 阵列""" + if not messagebox.askyesno("确认删除 RAID 阵列", + f"您确定要删除 RAID 阵列 {array_path} 吗?\n" + f"此操作将停止阵列并清除所有成员设备的超级块!", + default=messagebox.NO): + logger.info(f"用户取消了删除 RAID 阵列 {array_path} 的操作。") + return False + + if not self.stop_raid_array(array_path): + messagebox.showerror("错误", f"删除 RAID 阵列 {array_path} 失败,因为无法停止阵列。") + return False + + all_wiped_successfully = True + if member_devices: + for dev in member_devices: + if dev: + logger.info(f"尝试清除设备 {dev} 上的 RAID 超级块。") + wipe_success, _, wipe_stderr = self._execute_shell_command( + ["mdadm", "--zero-superblock", dev], + f"清除设备 {dev} 的 RAID 超级块失败" + ) + if not wipe_success: + logger.error(f"清除设备 {dev} 的 RAID 超级块失败: {wipe_stderr}") + all_wiped_successfully = False + + try: + mdadm_conf_path = "/etc/mdadm.conf" + if array_uuid: + success_conf_rm, _, stderr_conf_rm = self._execute_shell_command( + ["sed", "-i", f"/{re.escape(array_uuid)}/d", mdadm_conf_path], + f"从 {mdadm_conf_path} 中删除 RAID 条目失败", + suppress_critical_dialog_on_stderr_match=("No such file", "没有那个文件") + ) + if not success_conf_rm: + logger.warning(f"无法从 {mdadm_conf_path} 中删除条目: {stderr_conf_rm}") + except Exception as e: + logger.error(f"更新 {mdadm_conf_path} 时发生错误: {e}") + + if all_wiped_successfully: + messagebox.showinfo("成功", f"成功删除 RAID 阵列 {array_path}。") + return True + else: + messagebox.showerror("错误", f"删除 RAID 阵列 {array_path} 完成,但部分成员设备超级块未能完全清除。") + return False + + def delete_configured_raid_array(self, uuid): + """删除停止状态 RAID 阵列的配置文件条目""" + if not messagebox.askyesno("确认删除 RAID 阵列配置", + f"您确定要删除 UUID 为 {uuid} 的 RAID 阵列配置吗?\n" + f"这将从 mdadm.conf 中删除该阵列的配置。", + default=messagebox.NO): + logger.info(f"用户取消了删除 RAID 阵列配置 (UUID: {uuid}) 的操作。") + return False + + mdadm_conf_path = "/etc/mdadm.conf" + success, _, stderr = self._execute_shell_command( + ["sed", "-i", f"/{re.escape(uuid)}/d", mdadm_conf_path], + f"从 {mdadm_conf_path} 中删除 RAID 条目失败" + ) + if success: + messagebox.showinfo("成功", f"成功删除 UUID 为 {uuid} 的 RAID 阵列配置。") + return True + else: + messagebox.showerror("错误", f"删除 RAID 阵列配置失败: {e}") + return False + + def activate_raid_array(self, array_path, array_uuid): + """激活已停止的 RAID 阵列""" + if not array_uuid or array_uuid == 'N/A': + messagebox.showerror("错误", f"无法激活 RAID 阵列 {array_path}:缺少 UUID 信息。") + return False + + if not messagebox.askyesno("确认激活 RAID 阵列", + f"您确定要激活 RAID 阵列 {array_path} (UUID: {array_uuid}) 吗?\n" + "这将尝试重新组装阵列。", + default=messagebox.NO): + logger.info(f"用户取消了激活 RAID 阵列 {array_path} 的操作。") + return False + + logger.info(f"尝试激活 RAID 阵列 {array_path} (UUID: {array_uuid})。") + success, stdout, stderr = self._execute_shell_command( + ["mdadm", "--assemble", "--uuid", array_uuid, array_path], + f"激活 RAID 阵列 {array_path} 失败" + ) + if success: + messagebox.showinfo("成功", f"成功激活 RAID 阵列 {array_path}。") + return True + return False diff --git a/requirements.txt b/requirements.txt index 7fa34f0..4a53aaa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,17 @@ -PySide6 +# Linux 存储管理工具 - Tkinter 版本 +# 核心依赖(Python 内置): +# - tkinter (GUI) +# - threading (后台任务) +# - queue (线程通信) + +# 可选依赖: +# pexpect - 用于交互式命令(如 RAID 创建时的密码输入) +# 安装: pip3 install pexpect + +# CentOS 8 安装命令: +# sudo yum install python3 python3-tkinter parted mdadm lvm2 +# sudo pip3 install pexpect + +# Ubuntu/Debian 安装命令: +# sudo apt-get install python3 python3-tk parted mdadm lvm2 +# sudo pip3 install pexpect diff --git a/run_tkinter.sh b/run_tkinter.sh new file mode 100644 index 0000000..fad6023 --- /dev/null +++ b/run_tkinter.sh @@ -0,0 +1,41 @@ +#!/bin/bash +# Linux 存储管理工具 - Tkinter 版本启动脚本 +# 适配低版本系统(如 CentOS 8) + +# 检查 Python 版本 +python3 --version > /dev/null 2>&1 +if [ $? -ne 0 ]; then + echo "错误: 未找到 Python3,请安装 Python 3.6 或更高版本。" + exit 1 +fi + +# 检查 tkinter +python3 -c "import tkinter" > /dev/null 2>&1 +if [ $? -ne 0 ]; then + echo "错误: Python 未安装 tkinter 模块。" + echo "请安装 tkinter:" + echo " CentOS/RHEL: sudo yum install python3-tkinter" + echo " Ubuntu/Debian: sudo apt-get install python3-tk" + exit 1 +fi + +# 检查必要的系统工具 +for tool in lsblk parted mdadm lvm; do + which $tool > /dev/null 2>&1 + if [ $? -ne 0 ]; then + echo "警告: 未找到工具 '$tool',某些功能可能无法使用。" + fi +done + +# 获取脚本所在目录 +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +# 以 root 权限运行(存储管理需要) +if [ "$EUID" -ne 0 ]; then + echo "注意: 存储管理工具需要 root 权限才能正常工作。" + echo "正在尝试使用 sudo 启动..." + sudo python3 "${SCRIPT_DIR}/main_tkinter.py" "$@" +else + python3 "${SCRIPT_DIR}/main_tkinter.py" "$@" +fi diff --git a/verify_tkinter.py b/verify_tkinter.py new file mode 100644 index 0000000..0a3cc2c --- /dev/null +++ b/verify_tkinter.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +""" +验证 Tkinter 版本的存储管理工具是否可以正确导入 +""" + +import sys +import os + +def check_python_version(): + """检查 Python 版本""" + print(f"Python 版本: {sys.version}") + if sys.version_info < (3, 6): + print("错误: 需要 Python 3.6 或更高版本") + return False + return True + +def check_tkinter(): + """检查 tkinter 是否可用""" + try: + import tkinter as tk + print(f"Tkinter 版本: {tk.Tcl().eval('info patch')}") + return True + except ImportError: + print("错误: 未找到 tkinter 模块") + print("请安装 tkinter:") + print(" CentOS/RHEL: sudo yum install python3-tkinter") + print(" Ubuntu/Debian: sudo apt-get install python3-tk") + return False + +def check_modules(): + """检查自定义模块""" + modules_to_check = [ + ('logger_config_tkinter', '日志配置'), + ('dialogs_tkinter', '对话框'), + ('disk_operations_tkinter', '磁盘操作'), + ('raid_operations_tkinter', 'RAID 操作'), + ('lvm_operations_tkinter', 'LVM 操作'), + ('occupation_resolver_tkinter', '设备占用解除'), + ('mainwindow_tkinter', '主窗口'), + ] + + all_ok = True + for module_name, desc in modules_to_check: + try: + __import__(module_name) + print(f"✓ {desc}模块 ({module_name}) 加载成功") + except Exception as e: + print(f"✗ {desc}模块 ({module_name}) 加载失败: {e}") + all_ok = False + + return all_ok + +def check_system_modules(): + """检查系统信息模块""" + try: + from system_info import SystemInfoManager + print("✓ 系统信息模块 (system_info) 加载成功") + return True + except Exception as e: + print(f"✗ 系统信息模块 (system_info) 加载失败: {e}") + return False + +def main(): + """主函数""" + print("=" * 60) + print("Linux 存储管理工具 - Tkinter 版本验证") + print("=" * 60) + print() + + checks = [ + ("Python 版本", check_python_version), + ("Tkinter 模块", check_tkinter), + ("系统模块", check_system_modules), + ("自定义模块", check_modules), + ] + + results = [] + for name, check_func in checks: + print(f"\n检查 {name}...") + print("-" * 40) + result = check_func() + results.append((name, result)) + + print() + print("=" * 60) + print("验证结果汇总") + print("=" * 60) + + all_passed = True + for name, result in results: + status = "通过" if result else "失败" + symbol = "✓" if result else "✗" + print(f"{symbol} {name}: {status}") + if not result: + all_passed = False + + print() + if all_passed: + print("✓ 所有检查通过!可以运行 main_tkinter.py 启动程序。") + return 0 + else: + print("✗ 部分检查失败,请根据上述提示修复问题。") + return 1 + +if __name__ == "__main__": + sys.exit(main())