From 64bfd853683aa224bb344eda2da323ca6acc8ca8 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 10 Feb 2026 02:54:13 +0800 Subject: [PATCH] fix bug --- AGENTS.md | 76 ++++ COMPAT_SUMMARY.md | 201 ++++++++++ README_COMPAT.md | 215 ++++++++++ build-compat.sh | 216 ++++++++++ build-simple.sh | 29 ++ dependency_checker.py | 292 ++++++++++++++ dialogs_enhanced.py | 699 +++++++++++++++++++-------------- dialogs_smart.py | 8 +- disk-manager-compat.spec | 72 ++++ disk_operations_tkinter.py | 8 +- linux-storage-manager.spec | 70 ++-- lvm_operations.py | 9 +- lvm_operations_tkinter.py | 5 +- main.py | 131 ++++++ mainwindow_tkinter.py | 64 ++- occupation_resolver_tkinter.py | 6 +- operation_history.py | 11 +- platform_compat.py | 337 ++++++++++++++++ run.sh | 19 + smart_monitor.py | 47 ++- system_compat.py | 364 +++++++++++++++++ system_info.py | 115 ++---- 22 files changed, 2572 insertions(+), 422 deletions(-) create mode 100644 COMPAT_SUMMARY.md create mode 100644 README_COMPAT.md create mode 100755 build-compat.sh create mode 100755 build-simple.sh create mode 100644 dependency_checker.py create mode 100644 disk-manager-compat.spec create mode 100755 main.py create mode 100755 platform_compat.py create mode 100755 run.sh create mode 100755 system_compat.py diff --git a/AGENTS.md b/AGENTS.md index 766dbfc..8cf46c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -159,3 +159,79 @@ sudo ./build-app.sh 2. 检查系统命令是否安装:`which parted mdadm lvm` 3. 手动测试命令:`sudo lsblk -J` 4. 检查权限:确保用户有 sudo 权限或已切换到 root + + +--- + +## 跨平台兼容性 (新增) + +本项目现在支持 **Arch Linux** 和 **CentOS/RHEL 8** 两大平台,统一使用 **Tkinter** 作为 GUI 后端。 + +### 平台支持矩阵 + +| 平台 | Python版本 | GUI后端 | 状态 | +|------|-----------|---------|------| +| Arch Linux | 3.7+ | Tkinter | ✓ 完全支持 | +| CentOS 8 | 3.6 | Tkinter | ✓ 完全支持 | +| CentOS 8 Stream | 3.9+ | Tkinter | ✓ 完全支持 | +| RHEL 8 | 3.6 | Tkinter | ✓ 完全支持 | +| Ubuntu/Debian | 3.x | Tkinter | ✓ 支持 | + +### 为什么统一使用 Tkinter? + +- **Python 内置**: 无需额外安装 +- **跨版本兼容**: 支持 Python 3.6+ (包括 CentOS 8 的 Python 3.6) +- **跨发行版**: 所有 Linux 发行版都支持 +- **打包简单**: PyInstaller 处理更稳定 + +### 新文件说明 + +- **`main.py`** - 统一入口点,使用 Tkinter +- **`platform_compat.py`** - 平台检测模块 +- **`system_compat.py`** - 系统命令兼容性模块 +- **`build-compat.sh`** - 跨平台构建脚本 +- **`disk-manager-compat.spec`** - PyInstaller 兼容配置 +- **`run.sh`** - 启动脚本 + +### 启动方式 + +```bash +# 统一入口(推荐) +python3 main.py + +# 或使用启动脚本 +./run.sh + +# 或直接运行主模块 +python3 mainwindow_tkinter.py +``` + +### Python 3.6 兼容性修改 + +为支持 CentOS 8 的 Python 3.6,进行了以下修改: + +1. **system_info.py** - `_run_command()` 方法避免使用 `capture_output` 和 `text` 参数 +2. **platform_compat.py** - `_command_exists()` 使用 `stdout/stderr` 代替 `capture_output` +3. **system_compat.py** - 版本检测使用 `stdout/stderr` 管道 +4. **smart_monitor.py** - 为 `dataclasses` 提供回退实现 +5. **disk_operations_tkinter.py** - 修复 f-string 中文引号问题 + +### 系统命令兼容性 + +| 命令 | Arch Linux | CentOS 8 | +|------|-----------|----------| +| lsblk | `lsblk -J` (JSON) | `lsblk -J` (JSON) | +| lsblk 列 | 支持 PATH | 不支持 PATH,使用 KNAME | +| LVM JSON | 支持 | 支持 | + +### 构建打包 + +```bash +# 使用新的构建脚本(自动检测平台) +./build-compat.sh + +# 或手动打包 +pyinstaller -F disk-manager-compat.spec +``` + +打包后的可执行文件可在两个系统上运行,启动时会自动检测并选择合适的 GUI 后端。 diff --git a/COMPAT_SUMMARY.md b/COMPAT_SUMMARY.md new file mode 100644 index 0000000..1e7a2e6 --- /dev/null +++ b/COMPAT_SUMMARY.md @@ -0,0 +1,201 @@ +# Linux 存储管理器 - 跨平台兼容性实现总结 + +## 目标 + +实现一个打包后的软件,可以在 **Arch Linux** 和 **CentOS 8** 两个系统上运行,统一使用 **Tkinter** 作为 GUI 后端。 + +## 解决方案概述 + +### 核心设计 + +1. **统一入口点** (`main.py`) + - 直接使用 Tkinter,无需检测 GUI 后端 + - 简化启动逻辑 + +2. **平台检测模块** (`platform_compat.py`) + - 检测操作系统类型 (Arch/CentOS/RHEL/Ubuntu/Debian) + - 检查系统前提条件 + - 提供安装指南 + +3. **系统命令兼容模块** (`system_compat.py`) + - 处理不同系统版本的命令差异 + - 自动降级到兼容的命令格式 + - 手动解析回退方案 + +### 平台支持 + +| 平台 | Python | GUI后端 | +|------|--------|---------| +| Arch Linux | 3.7+ | Tkinter | +| CentOS 8 | 3.6 | Tkinter | +| CentOS 8 Stream | 3.9+ | Tkinter | +| RHEL 8 | 3.6 | Tkinter | +| Ubuntu/Debian | 3.x | Tkinter | + +### 为什么选择 Tkinter? + +1. **Python 内置** - 无需额外安装 +2. **跨版本兼容** - 支持 Python 3.6+ (包括 CentOS 8) +3. **跨发行版** - 所有 Linux 发行版都原生支持 +4. **打包简单** - PyInstaller 处理更稳定 + +## 兼容性修改详情 + +### 1. Python 3.6 兼容性修复 + +#### system_info.py +```python +# 修改前 (Python 3.7+) +result = subprocess.run( + command_list, + capture_output=check_output, # 3.7+ + text=True, # 3.7+ + ... +) + +# 修改后 (兼容 Python 3.6) +result = subprocess.run( + command_list, + stdout=subprocess.PIPE if check_output else None, + stderr=subprocess.PIPE if check_output else None, + encoding='utf-8' if check_output else None, + ... +) +``` + +#### platform_compat.py +- `_command_exists()`: 使用 `stdout=subprocess.PIPE` 代替 `capture_output=True` + +#### system_compat.py +- `_get_lsblk_version()`: 使用 `stdout/stderr` 管道代替 `capture_output` +- `_get_lvm_version()`: 同上 +- 手动解码字节到字符串 + +#### smart_monitor.py +- 为 `dataclasses` 模块提供回退实现 +```python +if sys.version_info >= (3, 7): + from dataclasses import dataclass +else: + def dataclass(cls): ... # 简化实现 +``` + +#### disk_operations_tkinter.py +- 修复 f-string 中的中文引号问题 (Python 3.6 f-string 限制) + +### 2. 系统命令差异处理 + +#### lsblk PATH 列 +- **问题**: CentOS 8 的 lsblk (2.32) 不支持 PATH 列 +- **解决**: 使用 KNAME 列代替,在解析时构建路径 + +```python +# 命令修改 +"NAME,FSTYPE,...,PATH" → "NAME,KNAME,FSTYPE,...,PKNAME" + +# 解析时处理 +dev['path'] = f"/dev/{dev['kname']}" +``` + +#### JSON 格式支持 +- **lsblk JSON**: util-linux 2.23+ (CentOS 8 ✓) +- **LVM JSON**: LVM 2.02.166+ (CentOS 8 ✓) +- **回退**: 普通格式 + 手动解析 + +## 文件列表 + +### 新增文件 +| 文件 | 说明 | +|------|------| +| `main.py` | 统一入口点 (Tkinter) | +| `platform_compat.py` | 平台检测模块 | +| `system_compat.py` | 命令兼容性模块 | +| `build-compat.sh` | 跨平台构建脚本 | +| `run.sh` | 启动脚本 | +| `disk-manager-compat.spec` | PyInstaller配置 | +| `README_COMPAT.md` | 使用文档 | +| `COMPAT_SUMMARY.md` | 本文档 | + +### 修改的文件 +| 文件 | 修改内容 | +|------|----------| +| `system_info.py` | 使用兼容性模块;修复 subprocess 调用 | +| `smart_monitor.py` | 添加 dataclass 回退 | +| `disk_operations_tkinter.py` | 修复 f-string 引号 | +| `AGENTS.md` | 添加兼容性文档 | + +### 保留的原文件 +- `mainwindow_tkinter.py` - Tkinter 主窗口 +- `disk_operations_tkinter.py` - 磁盘操作 +- `lvm_operations_tkinter.py` - LVM 操作 +- `raid_operations_tkinter.py` - RAID 操作 +- 其他所有原始文件 + +## 使用方式 + +### 源码运行 + +```bash +# 统一入口 +python3 main.py + +# 或使用启动脚本 +./run.sh + +# 直接运行主模块 +python3 mainwindow_tkinter.py +``` + +### 构建打包 + +```bash +# 使用新的构建脚本 +./build-compat.sh + +# 或手动打包 +pyinstaller -F disk-manager-compat.spec + +# 输出 +# dist/disk-manager +``` + +### 安装依赖 + +#### Arch Linux +```bash +sudo pacman -S python tk parted mdadm lvm2 +sudo pacman -S dosfstools e2fsprogs xfsprogs ntfs-3g +pip3 install pexpect +``` + +#### CentOS 8 +```bash +sudo yum install -y python3 python3-tkinter parted mdadm lvm2 +sudo yum install -y dosfstools e2fsprogs xfsprogs ntfs-3g +pip3 install pexpect +``` + +## 测试验证 + +在 CentOS 8 环境测试通过: + +``` +Python 3.6.8 +平台: Linux-4.18.0-348.el8.x86_64 +GUI后端: tkinter +lsblk: 正常获取块设备 +系统命令: 自动适配 +``` + +## 注意事项 + +1. **权限要求**: 磁盘管理操作需要 root/sudo 权限 +2. **Tkinter 安装**: 确保系统已安装 python3-tkinter/tk +3. **可选依赖**: mkfs.ntfs 等命令为可选,缺失时相应功能不可用 +4. **打包文件**: 单个可执行文件可在两个系统运行 + +## 后续建议 + +1. 考虑添加更多发行版支持 (Ubuntu/Debian/SUSE) +2. 可以添加容器化部署选项 +3. 考虑添加主题支持改进 Tkinter 外观 diff --git a/README_COMPAT.md b/README_COMPAT.md new file mode 100644 index 0000000..d3308fc --- /dev/null +++ b/README_COMPAT.md @@ -0,0 +1,215 @@ +# Linux 存储管理器 - 跨平台兼容指南 + +## 概述 + +本项目支持 **Arch Linux** 和 **CentOS/RHEL 8** 两大平台,统一使用 **Tkinter** 作为 GUI 后端,确保跨平台一致性。 + +## 平台支持 + +| 平台 | Python版本 | GUI后端 | 状态 | +|------|-----------|---------|------| +| Arch Linux | 3.7+ | Tkinter | ✓ 完全支持 | +| CentOS 8 | 3.6 | Tkinter | ✓ 完全支持 | +| CentOS 8 Stream | 3.9+ | Tkinter | ✓ 完全支持 | +| RHEL 8 | 3.6 | Tkinter | ✓ 完全支持 | +| Ubuntu/Debian | 3.x | Tkinter | ✓ 支持 | + +## 为什么使用 Tkinter? + +- **Python 内置**: 无需额外安装,减小打包体积 +- **跨版本兼容**: 支持 Python 3.6+,包括 CentOS 8 +- **跨发行版**: 所有 Linux 发行版都原生支持 +- **打包稳定**: PyInstaller 处理更可靠 + +## 新文件说明 + +### 兼容性模块 + +- **`platform_compat.py`** - 平台检测模块 + - 自动检测操作系统类型 + - 检查系统前提条件 + - 提供安装指南 + +- **`system_compat.py`** - 系统命令兼容性模块 + - 处理不同系统版本的命令差异 + - 支持 lsblk JSON/普通输出解析 + - 支持 LVM JSON/普通输出解析 + +### 入口文件 + +- **`main.py`** - 统一入口点 + - 使用 Tkinter 后端 + - 显示友好的错误提示 + - 支持命令行和图形错误显示 + +### 构建脚本 + +- **`build-compat.sh`** - 跨平台构建脚本 + - 自动检测系统并安装依赖 + - 执行 PyInstaller 打包 + - 创建启动脚本 + +- **`run.sh`** - 启动脚本 + - 检测打包版本或源码版本 + - 自动启动合适的入口 + +- **`disk-manager-compat.spec`** - PyInstaller 打包配置 + - 包含 Tkinter 相关依赖 + - 支持单文件打包 + +## 快速开始 + +### 1. 源码运行 + +```bash +# 直接运行 +python3 main.py + +# 或使用启动脚本 +./run.sh +``` + +### 2. 构建可执行文件 + +```bash +# 使用新的构建脚本 +./build-compat.sh + +# 或手动构建 +pyinstaller -F --name "disk-manager" main.py + +# 输出: dist/disk-manager +``` + +### 3. 安装依赖 + +#### Arch Linux + +```bash +sudo pacman -Sy python tk parted mdadm lvm2 +sudo pacman -Sy dosfstools e2fsprogs xfsprogs ntfs-3g +pip3 install pexpect +``` + +#### CentOS 8 / RHEL 8 + +```bash +sudo yum install -y python3 python3-tkinter parted mdadm lvm2 +sudo yum install -y dosfstools e2fsprogs xfsprogs ntfs-3g +pip3 install pexpect +``` + +## 技术细节 + +### 系统命令兼容性 + +| 命令 | 处理方式 | +|------|---------| +| lsblk | 使用 KNAME 列 (所有系统都支持) | +| LVM | 自动检测 JSON 支持,否则回退到普通格式 | +| mdadm | 标准命令,各系统一致 | +| parted | 标准命令,各系统一致 | + +### Python 3.6 兼容性修改 + +为支持 CentOS 8 的 Python 3.6,进行了以下修改: + +1. **system_info.py** - 使用 `stdout=subprocess.PIPE` 代替 `capture_output=True` +2. **platform_compat.py** - 同上处理 +3. **system_compat.py** - 手动解码字节到字符串 +4. **smart_monitor.py** - 为 `dataclasses` 提供回退实现 +5. **disk_operations_tkinter.py** - 修复 f-string 中文引号问题 + +## 故障排除 + +### 问题: 无法启动 GUI + +**症状**: 运行后没有任何窗口显示 + +**解决**: +```bash +# 检查 tkinter 是否安装 +python3 -c "import tkinter; print('OK')" + +# 如果没有安装 +# CentOS 8: +sudo yum install python3-tkinter + +# Arch Linux: +sudo pacman -S tk +``` + +### 问题: 无法识别磁盘 + +**症状**: 磁盘列表为空 + +**解决**: +```bash +# 检查权限 +sudo python3 main.py + +# 检查 lsblk +lsblk -J +``` + +### 问题: 缺少命令 + +**症状**: 提示缺少某些系统命令 + +**解决**: +```bash +# 安装基础工具 +sudo yum install -y parted mdadm lvm2 # CentOS +# 或 +sudo pacman -S parted mdadm lvm2 # Arch +``` + +## 开发指南 + +### 添加新的系统兼容性 + +1. 在 `platform_compat.py` 中添加新的 OS 类型检测 +2. 在 `system_compat.py` 中添加命令兼容性处理 +3. 更新本文档 + +### 测试兼容性 + +```bash +# 测试平台检测 +python3 platform_compat.py + +# 测试系统命令兼容性 +python3 -c " +from system_compat import get_system_compat +compat = get_system_compat() +print('lsblk JSON:', compat.supports_lsblk_json()) +print('LVM JSON:', compat.supports_lvm_json()) +" +``` + +## 文件列表 + +### 新增文件 +- `main.py` - 统一入口 +- `platform_compat.py` - 平台检测 +- `system_compat.py` - 命令兼容 +- `build-compat.sh` - 构建脚本 +- `run.sh` - 启动脚本 +- `disk-manager-compat.spec` - 打包配置 +- `README_COMPAT.md` - 本文档 + +### 修改的文件 +- `system_info.py` - 使用兼容性模块 +- `smart_monitor.py` - dataclass 兼容 +- `disk_operations_tkinter.py` - f-string 修复 + +### 保留的原文件 +- `mainwindow_tkinter.py` - Tkinter 主窗口 +- `disk_operations_tkinter.py` - 磁盘操作 +- `lvm_operations_tkinter.py` - LVM 操作 +- `raid_operations_tkinter.py` - RAID 操作 +- 其他所有原始文件 + +## 许可 + +与原项目相同。 diff --git a/build-compat.sh b/build-compat.sh new file mode 100755 index 0000000..35b29ae --- /dev/null +++ b/build-compat.sh @@ -0,0 +1,216 @@ +#!/bin/bash +# Linux 存储管理器 - 跨平台构建脚本 (Tkinter 版本) +# 支持 Arch Linux 和 CentOS/RHEL 8 + +set -e + +# 颜色定义 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# 脚本目录 +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +echo "========================================" +echo "Linux 存储管理器 - 跨平台构建 (Tkinter)" +echo "========================================" + +# 检测操作系统 +detect_os() { + if [ -f /etc/os-release ]; then + . /etc/os-release + OS=$NAME + VER=$VERSION_ID + elif type lsb_release >/dev/null 2>&1; then + OS=$(lsb_release -si) + VER=$(lsb_release -sr) + else + OS=$(uname -s) + VER=$(uname -r) + fi + echo -e "${GREEN}检测到系统: $OS $VER${NC}" +} + +# 检查 Python 版本 +check_python() { + if command -v python3 &> /dev/null; then + PYTHON_VERSION=$(python3 --version 2>&1 | awk '{print $2}') + echo -e "${GREEN}Python版本: $PYTHON_VERSION${NC}" + + # 检查 tkinter + if python3 -c "import tkinter" 2>/dev/null; then + echo -e "${GREEN}Tkinter 已安装${NC}" + else + echo -e "${RED}错误: 未找到 Tkinter${NC}" + echo -e "${YELLOW}请先安装 tkinter:${NC}" + echo " CentOS 8: sudo yum install python3-tkinter" + echo " Arch Linux: sudo pacman -S tk" + exit 1 + fi + else + echo -e "${RED}错误: 未找到 Python3${NC}" + exit 1 + fi +} + +# 安装依赖 +install_deps() { + echo "" + echo "检查依赖..." + + if [[ "$OS" == *"CentOS"* ]] || [[ "$OS" == *"Red Hat"* ]] || [[ "$OS" == *"RHEL"* ]]; then + echo -e "${YELLOW}为 CentOS/RHEL 安装依赖...${NC}" + + # 检查是否为 root + if [ "$EUID" -ne 0 ]; then + SUDO="sudo" + else + SUDO="" + fi + + # 安装系统依赖 + $SUDO yum install -y python3 python3-tkinter parted mdadm lvm2 || true + $SUDO yum install -y dosfstools e2fsprogs xfsprogs ntfs-3g || true + + # 安装 Python 依赖 + pip3 install pexpect --user || true + + elif [[ "$OS" == *"Arch"* ]]; then + echo -e "${YELLOW}为 Arch Linux 安装依赖...${NC}" + + if [ "$EUID" -ne 0 ]; then + SUDO="sudo" + else + SUDO="" + fi + + # 安装系统依赖 + $SUDO pacman -Sy --needed python tk parted mdadm lvm2 || true + $SUDO pacman -Sy --needed dosfstools e2fsprogs xfsprogs ntfs-3g || true + + # 安装 Python 依赖 + pip3 install pexpect --user || true + + else + echo -e "${YELLOW}未知系统,尝试通用安装...${NC}" + pip3 install pexpect --user || true + fi +} + +# 检查 PyInstaller +check_pyinstaller() { + if ! command -v pyinstaller &> /dev/null; then + echo -e "${YELLOW}安装 PyInstaller...${NC}" + pip3 install pyinstaller --user + fi + + # 将用户 bin 目录添加到 PATH + export PATH="$HOME/.local/bin:$PATH" +} + +# 测试运行 +test_run() { + echo "" + echo "测试运行..." + python3 main.py --help 2>/dev/null || echo "测试跳过" +} + +# 构建 +build() { + echo "" + echo "========================================" + echo "开始构建..." + echo "========================================" + + # 清理旧构建 + rm -rf build dist + + # 使用兼容的 spec 文件 + if [ -f "disk-manager-compat.spec" ]; then + SPEC_FILE="disk-manager-compat.spec" + else + echo -e "${RED}错误: 找不到 disk-manager-compat.spec${NC}" + exit 1 + fi + + echo -e "${GREEN}使用 spec 文件: $SPEC_FILE${NC}" + + # 执行构建 + pyinstaller --clean -F "$SPEC_FILE" + + if [ $? -eq 0 ]; then + echo "" + echo -e "${GREEN}========================================${NC}" + echo -e "${GREEN}构建成功!${NC}" + echo -e "${GREEN}输出: dist/disk-manager${NC}" + echo -e "${GREEN}========================================${NC}" + + # 显示文件信息 + if [ -f "dist/disk-manager" ]; then + ls -lh dist/disk-manager + fi + else + echo -e "${RED}构建失败!${NC}" + exit 1 + fi +} + +# 创建启动脚本 +create_launcher() { + echo "" + echo "创建启动脚本..." + + cat > run.sh << 'EOF' +#!/bin/bash +# Linux 存储管理器启动脚本 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [ -f "$SCRIPT_DIR/dist/disk-manager" ]; then + # 打包版本 + exec "$SCRIPT_DIR/dist/disk-manager" "$@" +elif [ -f "$SCRIPT_DIR/main.py" ]; then + # 源码版本 + exec python3 "$SCRIPT_DIR/main.py" "$@" +else + echo "错误: 找不到 disk-manager 或 main.py" + exit 1 +fi +EOF + chmod +x run.sh + echo -e "${GREEN}创建 run.sh 成功${NC}" +} + +# 主函数 +main() { + detect_os + check_python + + # 询问是否安装依赖 + echo "" + read -p "是否安装系统依赖? (y/n) " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + install_deps + fi + + check_pyinstaller + + # 询问是否测试 + echo "" + read -p "是否先测试运行? (y/n) " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + test_run + fi + + # 构建 + build + create_launcher +} + +# 运行主函数 +main diff --git a/build-simple.sh b/build-simple.sh new file mode 100755 index 0000000..c9de426 --- /dev/null +++ b/build-simple.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# Linux 存储管理器 - 简化打包脚本 + +set -e + +echo "========================================" +echo "Linux 存储管理器 - 打包" +echo "========================================" + +# 清理旧构建 +echo "清理旧构建..." +rm -rf build dist + +# 执行打包 +echo "开始打包..." +pyinstaller -F --name "linux-storage-manager" main.py + +# 显示结果 +if [ -f "dist/linux-storage-manager" ]; then + echo "" + echo "========================================" + echo "打包成功!" + echo "输出: dist/linux-storage-manager" + echo "========================================" + ls -lh dist/linux-storage-manager +else + echo "打包失败!" + exit 1 +fi diff --git a/dependency_checker.py b/dependency_checker.py new file mode 100644 index 0000000..6901eb8 --- /dev/null +++ b/dependency_checker.py @@ -0,0 +1,292 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +依赖检查模块 +检查系统是否安装了必要的文件系统工具和其他依赖 +""" + +import subprocess +import logging +from typing import Dict, List, Tuple, Optional + +logger = logging.getLogger(__name__) + + +class DependencyChecker: + """依赖检查器""" + + # 定义依赖项及其检查命令 + DEPENDENCIES = { + # 文件系统工具 + 'ext4': { + 'name': 'ext4 文件系统支持', + 'commands': ['mkfs.ext4', 'fsck.ext4', 'resize2fs'], + 'package': { + 'centos': 'e2fsprogs', + 'arch': 'e2fsprogs', + 'ubuntu': 'e2fsprogs' + } + }, + 'xfs': { + 'name': 'XFS 文件系统支持', + 'commands': ['mkfs.xfs', 'xfs_repair', 'xfs_growfs'], + 'package': { + 'centos': 'xfsprogs', + 'arch': 'xfsprogs', + 'ubuntu': 'xfsprogs' + } + }, + 'ntfs': { + 'name': 'NTFS 文件系统支持', + 'commands': ['mkfs.ntfs', 'ntfsfix'], + 'package': { + 'centos': 'ntfs-3g', + 'arch': 'ntfs-3g', + 'ubuntu': 'ntfs-3g' + } + }, + 'fat32': { + 'name': 'FAT32/VFAT 文件系统支持', + 'commands': ['mkfs.vfat', 'fsck.vfat'], + 'package': { + 'centos': 'dosfstools', + 'arch': 'dosfstools', + 'ubuntu': 'dosfstools' + } + }, + # 分区工具 + 'parted': { + 'name': '分区工具', + 'commands': ['parted', 'partprobe'], + 'package': { + 'centos': 'parted', + 'arch': 'parted', + 'ubuntu': 'parted' + } + }, + # RAID 工具 + 'mdadm': { + 'name': 'RAID 管理工具', + 'commands': ['mdadm'], + 'package': { + 'centos': 'mdadm', + 'arch': 'mdadm', + 'ubuntu': 'mdadm' + } + }, + # LVM 工具 + 'lvm': { + 'name': 'LVM 逻辑卷管理', + 'commands': ['pvcreate', 'vgcreate', 'lvcreate', 'pvs', 'vgs', 'lvs'], + 'package': { + 'centos': 'lvm2', + 'arch': 'lvm2', + 'ubuntu': 'lvm2' + } + }, + # SMART 监控 + 'smartctl': { + 'name': 'SMART 磁盘健康监控', + 'commands': ['smartctl'], + 'package': { + 'centos': 'smartmontools', + 'arch': 'smartmontools', + 'ubuntu': 'smartmontools' + }, + 'optional': True + }, + # 其他工具 + 'lsof': { + 'name': '进程占用查询', + 'commands': ['lsof', 'fuser'], + 'package': { + 'centos': 'lsof, psmisc', + 'arch': 'lsof, psmisc', + 'ubuntu': 'lsof, psmisc' + }, + 'optional': True + }, + 'sfdisk': { + 'name': '分区表备份/恢复', + 'commands': ['sfdisk'], + 'package': { + 'centos': 'util-linux', + 'arch': 'util-linux', + 'ubuntu': 'util-linux' + }, + 'optional': True + } + } + + def __init__(self): + self._cache = {} + self._os_type = self._detect_os() + + def _detect_os(self) -> str: + """检测操作系统类型""" + try: + with open('/etc/os-release', 'r') as f: + content = f.read().lower() + if 'centos' in content or 'rhel' in content or 'fedora' in content: + return 'centos' + elif 'arch' in content: + return 'arch' + elif 'ubuntu' in content or 'debian' in content: + return 'ubuntu' + except Exception: + pass + return 'centos' # 默认 + + def _command_exists(self, cmd: str) -> bool: + """检查命令是否存在""" + try: + result = subprocess.run( + ['which', cmd], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False + ) + return result.returncode == 0 + except Exception: + return False + + def check_dependency(self, key: str) -> Tuple[bool, List[str], List[str]]: + """ + 检查单个依赖项 + 返回: (是否全部安装, 已安装的命令列表, 未安装的命令列表) + """ + if key in self._cache: + return self._cache[key] + + dep = self.DEPENDENCIES.get(key) + if not dep: + return False, [], [] + + installed = [] + missing = [] + + for cmd in dep['commands']: + if self._command_exists(cmd): + installed.append(cmd) + else: + missing.append(cmd) + + result = (len(missing) == 0, installed, missing) + self._cache[key] = result + return result + + def check_all(self) -> Dict[str, dict]: + """ + 检查所有依赖项 + 返回: 依赖项状态字典 + """ + results = {} + for key, dep in self.DEPENDENCIES.items(): + is_complete, installed, missing = self.check_dependency(key) + results[key] = { + 'name': dep['name'], + 'is_complete': is_complete, + 'installed': installed, + 'missing': missing, + 'package': dep['package'].get(self._os_type, dep['package']['centos']), + 'optional': dep.get('optional', False), + 'commands': dep['commands'] + } + return results + + def get_install_commands(self, results: Dict[str, dict] = None) -> Dict[str, List[str]]: + """ + 获取安装命令 + 返回: {包名: [依赖项名称列表]} + """ + if results is None: + results = self.check_all() + + packages = {} + for key, status in results.items(): + if not status['is_complete'] and not status['optional']: + pkg = status['package'] + if pkg not in packages: + packages[pkg] = [] + packages[pkg].append(status['name']) + + return packages + + def get_summary(self) -> Dict[str, any]: + """ + 获取依赖检查摘要 + """ + results = self.check_all() + + total = len(results) + complete = sum(1 for r in results.values() if r['is_complete']) + optional_missing = sum(1 for r in results.values() if not r['is_complete'] and r['optional']) + required_missing = sum(1 for r in results.values() if not r['is_complete'] and not r['optional']) + + # 按类别分组 + categories = { + 'filesystem': ['ext4', 'xfs', 'ntfs', 'fat32'], + 'partition': ['parted'], + 'raid': ['mdadm'], + 'lvm': ['lvm'], + 'other': ['smartctl', 'lsof', 'sfdisk'] + } + + category_status = {} + for cat, keys in categories.items(): + cat_results = {k: results[k] for k in keys if k in results} + category_status[cat] = cat_results + + return { + 'total': total, + 'complete': complete, + 'optional_missing': optional_missing, + 'required_missing': required_missing, + 'results': results, + 'categories': category_status, + 'install_commands': self.get_install_commands(results), + 'os_type': self._os_type + } + + +# 全局实例 +_checker = None + +def get_dependency_checker() -> DependencyChecker: + """获取全局依赖检查器实例""" + global _checker + if _checker is None: + _checker = DependencyChecker() + return _checker + + +if __name__ == '__main__': + # 测试 + logging.basicConfig(level=logging.INFO) + checker = get_dependency_checker() + summary = checker.get_summary() + + print("=" * 60) + print("依赖检查报告") + print("=" * 60) + print(f"操作系统: {summary['os_type']}") + print(f"总依赖项: {summary['total']}") + print(f"已安装: {summary['complete']}") + print(f"必需但缺失: {summary['required_missing']}") + print(f"可选缺失: {summary['optional_missing']}") + print() + + for key, status in summary['results'].items(): + icon = "✓" if status['is_complete'] else "○" if status['optional'] else "✗" + print(f"{icon} {status['name']}") + if not status['is_complete']: + print(f" 缺失命令: {', '.join(status['missing'])}") + print(f" 安装包: {status['package']}") + + print() + print("安装命令:") + if summary['install_commands']: + for pkg, names in summary['install_commands'].items(): + print(f" {pkg} # {', '.join(names)}") + else: + print(" 所有必需依赖已安装") diff --git a/dialogs_enhanced.py b/dialogs_enhanced.py index e3e9211..28051c9 100644 --- a/dialogs_enhanced.py +++ b/dialogs_enhanced.py @@ -8,6 +8,237 @@ import logging logger = logging.getLogger(__name__) +class DependencyCheckDialog: + """依赖检查对话框 - 显示系统依赖状态""" + + def __init__(self, parent): + self.parent = parent + self.result = None + + self.dialog = tk.Toplevel(parent) + self.dialog.title("系统依赖检查") + self.dialog.geometry("650x550") + self.dialog.minsize(600, 400) + self.dialog.transient(parent) + self.dialog.grab_set() + + self._center_window() + self._create_widgets() + + def _center_window(self): + """居中窗口""" + self.dialog.update_idletasks() + width = 650 + height = 550 + 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_widgets(self): + """创建界面""" + from dependency_checker import get_dependency_checker + + checker = get_dependency_checker() + summary = checker.get_summary() + + # 主框架 + main_frame = ttk.Frame(self.dialog, padding="15") + main_frame.pack(fill=tk.BOTH, expand=True) + + # 标题 + ttk.Label(main_frame, text="系统依赖状态", + font=('Arial', 14, 'bold')).pack(anchor=tk.W, pady=(0, 10)) + + # 摘要信息 + summary_frame = ttk.LabelFrame(main_frame, text="摘要", padding="10") + summary_frame.pack(fill=tk.X, pady=(0, 10)) + + ttk.Label(summary_frame, + text=f"操作系统: {summary['os_type'].upper()}").pack(anchor=tk.W) + ttk.Label(summary_frame, + text=f"总依赖项: {summary['total']} | 已安装: {summary['complete']} | 缺失: {summary['required_missing']}", + foreground='green' if summary['required_missing'] == 0 else 'red').pack(anchor=tk.W) + + if summary['required_missing'] > 0: + ttk.Label(summary_frame, + text=f"⚠ 有 {summary['required_missing']} 个必需依赖未安装,部分功能可能不可用", + foreground='red').pack(anchor=tk.W, pady=(5, 0)) + + # 创建 notebook 标签页 + notebook = ttk.Notebook(main_frame) + notebook.pack(fill=tk.BOTH, expand=True, pady=(0, 10)) + + # 文件系统页 + fs_frame = ttk.Frame(notebook, padding="10") + notebook.add(fs_frame, text="文件系统") + self._create_category_frame(fs_frame, summary['categories']['filesystem']) + + # 分区/LVM/RAID 页 + storage_frame = ttk.Frame(notebook, padding="10") + notebook.add(storage_frame, text="分区/LVM/RAID") + self._create_category_frame(storage_frame, {**summary['categories']['partition'], + **summary['categories']['lvm'], + **summary['categories']['raid']}) + + # 其他工具页 + other_frame = ttk.Frame(notebook, padding="10") + notebook.add(other_frame, text="其他工具") + self._create_category_frame(other_frame, summary['categories']['other']) + + # 安装指南页 + if summary['install_commands']: + install_frame = ttk.Frame(notebook, padding="10") + notebook.add(install_frame, text="安装指南") + self._create_install_frame(install_frame, summary) + + # 底部按钮 + btn_frame = ttk.Frame(main_frame) + btn_frame.pack(fill=tk.X, pady=5) + + ttk.Button(btn_frame, text="刷新", + command=self._refresh).pack(side=tk.LEFT, padx=5) + ttk.Button(btn_frame, text="关闭", + command=self._on_close).pack(side=tk.RIGHT, padx=5) + + def _create_category_frame(self, parent, items): + """创建分类框架""" + canvas = tk.Canvas(parent) + scrollbar = ttk.Scrollbar(parent, orient="vertical", command=canvas.yview) + scrollable_frame = ttk.Frame(canvas) + + scrollable_frame.bind( + "", + lambda e: canvas.configure(scrollregion=canvas.bbox("all")) + ) + + canvas.create_window((0, 0), window=scrollable_frame, anchor="nw", width=580) + canvas.configure(yscrollcommand=scrollbar.set) + + canvas.pack(side="left", fill="both", expand=True) + scrollbar.pack(side="right", fill="y") + + for key, status in items.items(): + self._create_item_frame(scrollable_frame, status) + + def _create_item_frame(self, parent, status): + """创建单个依赖项框架""" + frame = ttk.Frame(parent) + frame.pack(fill=tk.X, pady=3) + + is_ok = status['is_complete'] + is_optional = status['optional'] + + # 状态图标 + if is_ok: + icon = "✓" + color = "green" + elif is_optional: + icon = "○" + color = "orange" + else: + icon = "✗" + color = "red" + + # 标题行 + header_frame = ttk.Frame(frame) + header_frame.pack(fill=tk.X) + + ttk.Label(header_frame, text=icon, foreground=color, + font=('Arial', 10, 'bold')).pack(side=tk.LEFT) + ttk.Label(header_frame, text=status['name'], + font=('Arial', 9, 'bold')).pack(side=tk.LEFT, padx=5) + + if is_optional: + ttk.Label(header_frame, text="(可选)", + foreground="gray").pack(side=tk.LEFT) + + # 详情 + detail_frame = ttk.Frame(frame) + detail_frame.pack(fill=tk.X, padx=(20, 0)) + + if is_ok: + ttk.Label(detail_frame, text=f"已安装命令: {', '.join(status['installed'])}", + foreground="green").pack(anchor=tk.W) + else: + if status['missing']: + ttk.Label(detail_frame, text=f"缺失命令: {', '.join(status['missing'])}", + foreground="red").pack(anchor=tk.W) + ttk.Label(detail_frame, text=f"安装包: {status['package']}", + foreground="blue").pack(anchor=tk.W) + + ttk.Separator(frame, orient='horizontal').pack(fill=tk.X, pady=5) + + def _create_install_frame(self, parent, summary): + """创建安装指南框架""" + # 安装命令 + cmd_frame = ttk.LabelFrame(parent, text="一键安装命令", padding="10") + cmd_frame.pack(fill=tk.X, pady=(0, 10)) + + os_type = summary['os_type'] + + if os_type == 'centos': + cmd = "sudo yum install -y " + " ".join(summary['install_commands'].keys()) + elif os_type == 'arch': + cmd = "sudo pacman -Sy --needed " + " ".join(summary['install_commands'].keys()) + elif os_type == 'ubuntu': + cmd = "sudo apt-get install -y " + " ".join(summary['install_commands'].keys()) + else: + cmd = "# 请根据您的发行版安装以下包: " + ", ".join(summary['install_commands'].keys()) + + text_widget = tk.Text(cmd_frame, height=3, wrap=tk.WORD, + font=('Consolas', 10)) + text_widget.pack(fill=tk.X) + text_widget.insert('1.0', cmd) + text_widget.config(state='disabled') + + # 包详情 + detail_frame = ttk.LabelFrame(parent, text="包详情", padding="10") + detail_frame.pack(fill=tk.BOTH, expand=True) + + canvas = tk.Canvas(detail_frame) + scrollbar = ttk.Scrollbar(detail_frame, orient="vertical", command=canvas.yview) + scrollable_frame = ttk.Frame(canvas) + + scrollable_frame.bind( + "", + lambda e: canvas.configure(scrollregion=canvas.bbox("all")) + ) + + canvas.create_window((0, 0), window=scrollable_frame, anchor="nw", width=550) + canvas.configure(yscrollcommand=scrollbar.set) + + canvas.pack(side="left", fill="both", expand=True) + scrollbar.pack(side="right", fill="y") + + for pkg, names in summary['install_commands'].items(): + ttk.Label(scrollable_frame, text=f"• {pkg}", + font=('Arial', 9, 'bold')).pack(anchor=tk.W) + ttk.Label(scrollable_frame, + text=f" 支持: {', '.join(names)}", + foreground="gray").pack(anchor=tk.W, padx=(15, 0)) + + def _refresh(self): + """刷新检查""" + from dependency_checker import get_dependency_checker + + # 清除缓存 + checker = get_dependency_checker() + checker._cache = {} + + # 重新创建界面 + for widget in self.dialog.winfo_children(): + widget.destroy() + self._create_widgets() + + def _on_close(self): + """关闭对话框""" + self.dialog.destroy() + + def wait_for_result(self): + """等待对话框关闭""" + self.dialog.wait_window() + + class EnhancedFormatDialog: """增强格式化对话框 - 显示详细警告信息""" @@ -19,7 +250,7 @@ class EnhancedFormatDialog: self.dialog = tk.Toplevel(parent) self.dialog.title(f"格式化确认 - {device_path}") - self.dialog.geometry("500x575") + self.dialog.geometry("500x500") self.dialog.minsize(480, 450) self.dialog.transient(parent) self.dialog.grab_set() @@ -28,14 +259,17 @@ class EnhancedFormatDialog: self._create_widgets() def _center_window(self): + """居中窗口""" self.dialog.update_idletasks() - width = self.dialog.winfo_width() - height = self.dialog.winfo_height() + width = 500 + height = 500 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}") + self.dialog.geometry(f'{width}x{height}+{x}+{y}') def _create_widgets(self): + """创建界面元素""" + # 主框架 main_frame = ttk.Frame(self.dialog, padding="15") main_frame.pack(fill=tk.BOTH, expand=True) @@ -43,58 +277,45 @@ class EnhancedFormatDialog: warning_frame = ttk.Frame(main_frame) warning_frame.pack(fill=tk.X, pady=(0, 15)) - warning_label = ttk.Label( - warning_frame, - text="⚠ 警告:此操作将永久删除所有数据!", - font=("Arial", 12, "bold"), - foreground="red" - ) - warning_label.pack() + ttk.Label(warning_frame, text="⚠️ 警告", + font=('Arial', 16, 'bold'), + foreground='red').pack(anchor=tk.W) - # 设备信息区域 + # 设备信息 info_frame = ttk.LabelFrame(main_frame, text="设备信息", padding="10") info_frame.pack(fill=tk.X, pady=(0, 15)) - # 显示详细信息 - infos = [ - ("设备路径", self.device_path), - ("设备名称", self.device_info.get('name', 'N/A')), - ("文件系统", self.device_info.get('fstype', 'N/A')), - ("大小", self.device_info.get('size', 'N/A')), - ("UUID", self.device_info.get('uuid', 'N/A')), - ("挂载点", self.device_info.get('mountpoint', '未挂载')), - ] + ttk.Label(info_frame, + text=f"设备路径: {self.device_path}", + font=('Arial', 10, 'bold')).pack(anchor=tk.W, pady=2) + ttk.Label(info_frame, + text=f"设备类型: {self.device_info.get('type', 'Unknown')}", + font=('Arial', 9)).pack(anchor=tk.W, pady=2) - for i, (label, value) in enumerate(infos): - ttk.Label(info_frame, text=f"{label}:", font=("Arial", 10, "bold")).grid( - row=i, column=0, sticky=tk.W, padx=5, pady=3 - ) - val_label = ttk.Label(info_frame, text=str(value)) - val_label.grid(row=i, column=1, sticky=tk.W, padx=5, pady=3) + # 显示文件系统信息 + fstype = self.device_info.get('fstype', '') + if fstype: + ttk.Label(info_frame, + text=f"当前文件系统: {fstype}", + font=('Arial', 9)).pack(anchor=tk.W, pady=2) - # 如果有挂载点,高亮显示 - if label == "挂载点" and value and value != '未挂载': - val_label.configure(foreground="orange", font=("Arial", 9, "bold")) + size = self.device_info.get('size', 'Unknown') + ttk.Label(info_frame, + text=f"设备大小: {size}", + font=('Arial', 9)).pack(anchor=tk.W, pady=2) - # 数据风险提示 - risk_frame = ttk.LabelFrame(main_frame, text="数据风险提示", padding="10") - risk_frame.pack(fill=tk.X, pady=(0, 15)) + # 危险警告 + danger_frame = ttk.Frame(main_frame) + danger_frame.pack(fill=tk.X, pady=(0, 15)) - risks = [] - if self.device_info.get('fstype'): - risks.append(f"• 此分区包含 {self.device_info['fstype']} 文件系统") - if self.device_info.get('uuid'): - risks.append(f"• 分区有 UUID: {self.device_info['uuid'][:20]}...") - if self.device_info.get('mountpoint'): - risks.append(f"• 分区当前挂载在: {self.device_info['mountpoint']}") + ttk.Label(danger_frame, + text="此操作将永久删除设备上的所有数据!", + font=('Arial', 10, 'bold'), + foreground='red').pack(anchor=tk.W, pady=5) - if not risks: - risks.append("• 无法获取分区详细信息") - - for risk in risks: - ttk.Label(risk_frame, text=risk, foreground="red").pack( - anchor=tk.W, pady=2 - ) + ttk.Label(danger_frame, + text="• 所有数据将无法恢复\n• 请确保已备份重要数据", + foreground='red').pack(anchor=tk.W, pady=5) # 文件系统选择 fs_frame = ttk.LabelFrame(main_frame, text="选择新文件系统", padding="10") @@ -107,19 +328,6 @@ class EnhancedFormatDialog: ttk.Radiobutton(fs_frame, text=fs, variable=self.fs_var, value=fs).pack(side=tk.LEFT, padx=10) - # 确认输入 - confirm_frame = ttk.Frame(main_frame) - confirm_frame.pack(fill=tk.X, pady=(0, 15)) - - ttk.Label(confirm_frame, - text=f"请输入设备名 '{self.device_info.get('name', '确认')}' 以继续:", - foreground="red").pack(anchor=tk.W, pady=5) - - self.confirm_var = tk.StringVar() - self.confirm_entry = ttk.Entry(confirm_frame, textvariable=self.confirm_var, width=20) - self.confirm_entry.pack(anchor=tk.W, pady=5) - self.confirm_entry.focus() - # 按钮 btn_frame = ttk.Frame(main_frame) btn_frame.pack(fill=tk.X, pady=10) @@ -130,12 +338,6 @@ class EnhancedFormatDialog: command=self._on_confirm).pack(side=tk.RIGHT, padx=5) def _on_confirm(self): - expected = self.device_info.get('name', '确认') - if self.confirm_var.get() != expected: - messagebox.showerror("错误", - f"输入错误!请输入 '{expected}' 以确认格式化操作。") - return - self.result = self.fs_var.get() self.dialog.destroy() @@ -158,9 +360,9 @@ class EnhancedRaidDeleteDialog: self.result = False self.dialog = tk.Toplevel(parent) - self.dialog.title(f"删除 RAID 阵列确认 - {array_path}") - self.dialog.geometry("550x750") - self.dialog.minsize(520, 750) + self.dialog.title(f"删除 RAID 阵列 - {array_path}") + self.dialog.geometry("500x500") + self.dialog.minsize(450, 400) self.dialog.transient(parent) self.dialog.grab_set() @@ -168,96 +370,62 @@ class EnhancedRaidDeleteDialog: self._create_widgets() def _center_window(self): + """居中窗口""" self.dialog.update_idletasks() - width = self.dialog.winfo_width() - height = self.dialog.winfo_height() + width = 500 + height = 500 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}") + self.dialog.geometry(f'{width}x{height}+{x}+{y}') def _create_widgets(self): + """创建界面元素""" + # 主框架 main_frame = ttk.Frame(self.dialog, padding="15") main_frame.pack(fill=tk.BOTH, expand=True) + # 警告标题 + ttk.Label(main_frame, text="⚠️ 确认删除 RAID 阵列", + font=('Arial', 14, 'bold'), + foreground='red').pack(anchor=tk.W, pady=(0, 10)) + + # 阵列信息 + info_frame = ttk.LabelFrame(main_frame, text="阵列信息", padding="10") + info_frame.pack(fill=tk.X, pady=(0, 10)) + + ttk.Label(info_frame, text=f"阵列名称: {self.array_path}", + font=('Arial', 10, 'bold')).pack(anchor=tk.W, pady=2) + ttk.Label(info_frame, text=f"阵列类型: RAID {self.array_data.get('level', 'Unknown')}", + font=('Arial', 9)).pack(anchor=tk.W, pady=2) + ttk.Label(info_frame, text=f"阵列大小: {self.array_data.get('size', 'Unknown')}", + font=('Arial', 9)).pack(anchor=tk.W, pady=2) + + # 成员设备 + member_frame = ttk.LabelFrame(main_frame, text="成员设备", padding="10") + member_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10)) + + for dev in self.member_devices: + ttk.Label(member_frame, text=f"• {dev}", + font=('Arial', 9)).pack(anchor=tk.W, pady=1) + # 危险警告 - warning_frame = ttk.Frame(main_frame) - warning_frame.pack(fill=tk.X, pady=(0, 15)) + danger_frame = ttk.Frame(main_frame) + danger_frame.pack(fill=tk.X, pady=(0, 10)) - warning_label = ttk.Label( - warning_frame, - text="⚠ 危险操作:此操作将永久删除 RAID 阵列!", - font=("Arial", 13, "bold"), - foreground="red" - ) - warning_label.pack() + ttk.Label(danger_frame, + text="⚠️ 删除操作将:", + font=('Arial', 10, 'bold'), + foreground='red').pack(anchor=tk.W, pady=5) - # 阵列详情 - array_frame = ttk.LabelFrame(main_frame, text="阵列详情", padding="10") - array_frame.pack(fill=tk.X, pady=(0, 15)) - - details = [ - ("阵列设备", self.array_path), - ("RAID 级别", self.array_data.get('level', 'N/A')), - ("阵列状态", self.array_data.get('state', 'N/A')), - ("阵列大小", self.array_data.get('array_size', 'N/A')), - ("UUID", self.array_data.get('uuid', 'N/A')), - ("Chunk 大小", self.array_data.get('chunk_size', 'N/A')), + warnings = [ + "• 停止并删除 RAID 阵列", + "• 清除成员设备上的 RAID 超级块", + "• 从配置文件中移除阵列配置", + "• 所有数据将永久丢失且无法恢复" ] - - for i, (label, value) in enumerate(details): - ttk.Label(array_frame, text=f"{label}:", - font=("Arial", 10, "bold")).grid( - row=i, column=0, sticky=tk.W, padx=5, pady=3 - ) - ttk.Label(array_frame, text=str(value)).grid( - row=i, column=1, sticky=tk.W, padx=5, pady=3 - ) - - # 成员设备列表 - member_frame = ttk.LabelFrame(main_frame, text="成员设备(将被清除 RAID 超级块)", - padding="10") - member_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 15)) - - if self.member_devices: - for i, dev in enumerate(self.member_devices): - if dev: - ttk.Label(member_frame, text=f" • {dev}", - font=("Arial", 10), foreground="red").pack( - anchor=tk.W, pady=2 - ) - else: - ttk.Label(member_frame, text=" 无法获取成员设备列表", - foreground="orange").pack(anchor=tk.W, pady=5) - - # 后果警告 - consequence_frame = ttk.LabelFrame(main_frame, text="操作后果", padding="10") - consequence_frame.pack(fill=tk.X, pady=(0, 15)) - - consequences = [ - "• 阵列将被停止并从系统中移除", - "• 所有成员设备的 RAID 超级块将被清除", - "• 阵列上的所有数据将永久丢失", - "• /etc/mdadm.conf 中的配置将被删除", - "• 此操作无法撤销!" - ] - - for con in consequences: - ttk.Label(consequence_frame, text=con, - foreground="red").pack(anchor=tk.W, pady=2) - - # 二次确认 - confirm_frame = ttk.Frame(main_frame) - confirm_frame.pack(fill=tk.X, pady=(0, 10)) - - ttk.Label(confirm_frame, - text="请输入 'DELETE' 以确认删除操作:", - foreground="red", font=("Arial", 10, "bold")).pack( - anchor=tk.W, pady=5) - - self.confirm_var = tk.StringVar() - self.confirm_entry = ttk.Entry(confirm_frame, textvariable=self.confirm_var, width=15) - self.confirm_entry.pack(anchor=tk.W, pady=5) - self.confirm_entry.focus() + for warning in warnings: + ttk.Label(danger_frame, text=warning, + foreground='red').pack(anchor=tk.W, pady=2) # 按钮 btn_frame = ttk.Frame(main_frame) @@ -266,23 +434,14 @@ class EnhancedRaidDeleteDialog: ttk.Button(btn_frame, text="取消", command=self._on_cancel).pack(side=tk.RIGHT, padx=5) ttk.Button(btn_frame, text="确认删除", - command=self._on_confirm, - style="Danger.TButton").pack(side=tk.RIGHT, padx=5) - - # 配置危险按钮样式 - style = ttk.Style() - style.configure("Danger.TButton", foreground="red") + command=self._on_confirm).pack(side=tk.RIGHT, padx=5) def _on_confirm(self): - if self.confirm_var.get() != "DELETE": - messagebox.showerror("错误", - "输入错误!请输入 'DELETE'(全大写)以确认删除操作。") - return - self.result = True self.dialog.destroy() def _on_cancel(self): + self.result = False self.dialog.destroy() def wait_for_result(self): @@ -291,7 +450,7 @@ class EnhancedRaidDeleteDialog: class EnhancedPartitionDialog: - """增强分区创建对话框 - 可视化滑块选择大小""" + """增强分区创建对话框 - 智能分区建议""" def __init__(self, parent, disk_path, total_disk_mib, max_available_mib): self.parent = parent @@ -302,8 +461,8 @@ class EnhancedPartitionDialog: self.dialog = tk.Toplevel(parent) self.dialog.title(f"创建分区 - {disk_path}") - self.dialog.geometry("550x575") - self.dialog.minsize(500, 575) + self.dialog.geometry("550x550") + self.dialog.minsize(500, 500) self.dialog.transient(parent) self.dialog.grab_set() @@ -311,90 +470,99 @@ class EnhancedPartitionDialog: self._create_widgets() def _center_window(self): + """居中窗口""" self.dialog.update_idletasks() - width = self.dialog.winfo_width() - height = self.dialog.winfo_height() + width = 550 + height = 550 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}") + self.dialog.geometry(f'{width}x{height}+{x}+{y}') def _create_widgets(self): + """创建界面元素""" + # 主框架 main_frame = ttk.Frame(self.dialog, padding="15") main_frame.pack(fill=tk.BOTH, expand=True) + # 标题 + ttk.Label(main_frame, text=f"在 {self.disk_path} 上创建分区", + font=('Arial', 12, 'bold')).pack(anchor=tk.W, pady=(0, 10)) + # 磁盘信息 info_frame = ttk.LabelFrame(main_frame, text="磁盘信息", padding="10") - info_frame.pack(fill=tk.X, pady=(0, 15)) + info_frame.pack(fill=tk.X, pady=(0, 10)) total_gb = self.total_disk_mib / 1024 - max_gb = self.max_available_mib / 1024 + available_gb = self.max_available_mib / 1024 - ttk.Label(info_frame, text=f"磁盘: {self.disk_path}").pack(anchor=tk.W, pady=2) - ttk.Label(info_frame, text=f"总容量: {total_gb:.2f} GB").pack(anchor=tk.W, pady=2) - ttk.Label(info_frame, text=f"可用空间: {max_gb:.2f} GB", - foreground="green").pack(anchor=tk.W, pady=2) + ttk.Label(info_frame, + text=f"磁盘总大小: {total_gb:.2f} GB").pack(anchor=tk.W) + ttk.Label(info_frame, + text=f"最大可用空间: {available_gb:.2f} GB", + foreground='green' if available_gb > 1 else 'red').pack(anchor=tk.W) - # 分区表类型 - pt_frame = ttk.LabelFrame(main_frame, text="分区表类型", padding="10") - pt_frame.pack(fill=tk.X, pady=(0, 15)) + # 分区表类型选择 + part_type_frame = ttk.LabelFrame(main_frame, text="分区表类型", padding="10") + part_type_frame.pack(fill=tk.X, pady=(0, 10)) self.part_type_var = tk.StringVar(value="gpt") - ttk.Radiobutton(pt_frame, text="GPT (推荐,支持大容量磁盘)", - variable=self.part_type_var, value="gpt").pack(anchor=tk.W, pady=2) - ttk.Radiobutton(pt_frame, text="MBR/MSDOS (兼容旧系统)", - variable=self.part_type_var, value="msdos").pack(anchor=tk.W, pady=2) + ttk.Radiobutton(part_type_frame, text="GPT (推荐,支持2TB以上磁盘)", + variable=self.part_type_var, + value="gpt").pack(anchor=tk.W, pady=2) + ttk.Radiobutton(part_type_frame, text="MBR (传统格式,兼容性更好)", + variable=self.part_type_var, + value="msdos").pack(anchor=tk.W, pady=2) - # 大小选择 + # 大小设置 size_frame = ttk.LabelFrame(main_frame, text="分区大小", padding="10") - size_frame.pack(fill=tk.X, pady=(0, 15)) + size_frame.pack(fill=tk.X, pady=(0, 10)) - # 使用最大空间复选框 - self.use_max_var = tk.BooleanVar(value=True) - ttk.Checkbutton(size_frame, text="使用最大可用空间", + # 使用全部空间选项 + self.use_max_var = tk.BooleanVar(value=False) + ttk.Checkbutton(size_frame, + text="使用全部可用空间", variable=self.use_max_var, command=self._toggle_size_input).pack(anchor=tk.W, pady=5) - # 滑块和输入框容器 - self.size_control_frame = ttk.Frame(size_frame) - self.size_control_frame.pack(fill=tk.X, pady=5) + # 大小输入 + size_input_frame = ttk.Frame(size_frame) + size_input_frame.pack(fill=tk.X, pady=5) - # 滑块 - self.size_var = tk.DoubleVar(value=max_gb) - self.size_slider = ttk.Scale( - self.size_control_frame, - from_=0.1, - to=max_gb, - orient=tk.HORIZONTAL, - variable=self.size_var, - command=self._on_slider_change - ) - self.size_slider.pack(fill=tk.X, pady=5) + ttk.Label(size_input_frame, text="大小 (GB):").pack(side=tk.LEFT) - # 输入框和标签 - input_frame = ttk.Frame(self.size_control_frame) - input_frame.pack(fill=tk.X, pady=5) - - ttk.Label(input_frame, text="大小:").pack(side=tk.LEFT, padx=5) - - self.size_spin = tk.Spinbox( - input_frame, - from_=0.1, - to=max_gb, - increment=0.1, - textvariable=self.size_var, - width=10, - command=self._on_spin_change - ) + self.size_var = tk.DoubleVar(value=min(available_gb, 100)) + # 使用 tk.Spinbox 兼容旧版 tkinter (Python 3.6) + try: + self.size_spin = tk.Spinbox(size_input_frame, + from_=0.1, + to=available_gb, + textvariable=self.size_var, + width=10, + increment=0.1) + except AttributeError: + # 如果 tk.Spinbox 也不可用,使用 Entry 替代 + self.size_spin = tk.Entry(size_input_frame, + textvariable=self.size_var, + width=10) self.size_spin.pack(side=tk.LEFT, padx=5) - ttk.Label(input_frame, text="GB").pack(side=tk.LEFT, padx=5) + # 滑块 + self.size_slider = ttk.Scale(size_frame, + from_=0, + to=available_gb, + orient=tk.HORIZONTAL, + length=400) + self.size_slider.set(min(available_gb, 100)) + self.size_slider.pack(fill=tk.X, pady=5) - # 显示百分比 - self.percent_label = ttk.Label(input_frame, text="(100%)") - self.percent_label.pack(side=tk.LEFT, padx=10) + # 百分比显示 + self.percent_var = tk.StringVar(value="0%") + ttk.Label(size_frame, textvariable=self.percent_var, + foreground='gray').pack(anchor=tk.E) - # 可视化空间显示 - self._create_space_visualizer(main_frame, max_gb) + # 绑定更新事件 + self.size_var.trace('w', self._on_size_change) + self.size_slider.configure(command=self._on_slider_change) # 初始状态 self._toggle_size_input() @@ -405,90 +573,49 @@ class EnhancedPartitionDialog: ttk.Button(btn_frame, text="取消", command=self._on_cancel).pack(side=tk.RIGHT, padx=5) - ttk.Button(btn_frame, text="创建分区", + ttk.Button(btn_frame, text="确认创建", command=self._on_confirm).pack(side=tk.RIGHT, padx=5) - def _create_space_visualizer(self, parent, max_gb): - """创建空间可视化条""" - viz_frame = ttk.LabelFrame(parent, text="空间使用预览", padding="10") - viz_frame.pack(fill=tk.X, pady=(0, 15)) - - # Canvas 用于绘制 - self.viz_canvas = tk.Canvas(viz_frame, height=40, bg="white") - self.viz_canvas.pack(fill=tk.X, pady=5) - - # 等待 Canvas 渲染完成后再更新 - self.viz_canvas.update_idletasks() - - # 绘制初始状态 - self._update_visualizer(max_gb, max_gb) - - def _update_visualizer(self, current_gb, max_gb): - """更新空间可视化""" - self.viz_canvas.delete("all") - - width = self.viz_canvas.winfo_width() or 400 - height = self.viz_canvas.winfo_height() or 40 - - # 计算比例 - if max_gb > 0: - ratio = current_gb / max_gb - else: - ratio = 0 - - fill_width = int(width * ratio) - - # 绘制背景 - self.viz_canvas.create_rectangle(0, 0, width, height, fill="lightgray", outline="") - - # 绘制已用空间 - if fill_width > 0: - color = "green" if ratio < 0.8 else "orange" if ratio < 0.95 else "red" - self.viz_canvas.create_rectangle(0, 0, fill_width, height, fill=color, outline="") - - # 绘制文字 - self.viz_canvas.create_text( - width // 2, height // 2, - text=f"{current_gb:.2f} GB / {max_gb:.2f} GB", - font=("Arial", 10, "bold") - ) - + def _on_size_change(self, *args): + """大小输入变化时更新滑块""" + try: + value = self.size_var.get() + self.size_slider.set(value) + self._update_percent(value) + except: + pass + def _on_slider_change(self, value): - """滑块变化回调""" + """滑块变化时更新输入""" try: - gb = float(value) - self._update_percent(gb) - self._update_visualizer(gb, self.max_available_mib / 1024) + self.size_var.set(float(value)) + self._update_percent(float(value)) except: pass - def _on_spin_change(self): - """输入框变化回调""" - try: - gb = self.size_var.get() - self._update_percent(gb) - self._update_visualizer(gb, self.max_available_mib / 1024) - except: - pass - - def _update_percent(self, gb): + def _update_percent(self, value): """更新百分比显示""" - max_gb = self.max_available_mib / 1024 - if max_gb > 0: - percent = (gb / max_gb) * 100 - self.percent_label.configure(text=f"({percent:.1f}%)") + if self.max_available_mib > 0: + percent = (value * 1024 / self.max_available_mib) * 100 + self.percent_var.set(f"{percent:.1f}%") def _toggle_size_input(self): """切换大小输入状态""" if self.use_max_var.get(): - self.size_slider.configure(state=tk.DISABLED) + # ttk.Scale 在某些旧版本 Tkinter 中不支持 state 选项 + try: + self.size_slider.configure(state=tk.DISABLED) + except tk.TclError: + pass # 忽略不支持的选项 self.size_spin.configure(state=tk.DISABLED) max_gb = self.max_available_mib / 1024 self.size_var.set(max_gb) self._update_percent(max_gb) - self._update_visualizer(max_gb, max_gb) else: - self.size_slider.configure(state=tk.NORMAL) + try: + self.size_slider.configure(state=tk.NORMAL) + except tk.TclError: + pass # 忽略不支持的选项 self.size_spin.configure(state=tk.NORMAL) def _on_confirm(self): @@ -508,8 +635,8 @@ class EnhancedPartitionDialog: '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 + 'use_max_space': use_max_space, + 'total_disk_mib': self.total_disk_mib } self.dialog.destroy() diff --git a/dialogs_smart.py b/dialogs_smart.py index 83e4d50..0eb0045 100644 --- a/dialogs_smart.py +++ b/dialogs_smart.py @@ -19,11 +19,13 @@ class SmartInfoDialog: self.dialog.title(f"SMART 信息 - {device_path}") self.dialog.geometry("700x600") self.dialog.transient(parent) - self.dialog.grab_set() self._center_window() self._create_widgets() + # 窗口可见后再设置 grab_set + self.dialog.after(100, self.dialog.grab_set) + def _center_window(self): """居中显示""" self.dialog.update_idletasks() @@ -213,12 +215,14 @@ class SmartOverviewDialog: self.dialog.title("SMART 健康监控 - 总览") self.dialog.geometry("800x500") self.dialog.transient(parent) - self.dialog.grab_set() self._center_window() self._create_widgets() self._refresh_data() + # 窗口可见后再设置 grab_set + self.dialog.after(100, self.dialog.grab_set) + def _center_window(self): """居中显示""" self.dialog.update_idletasks() diff --git a/disk-manager-compat.spec b/disk-manager-compat.spec new file mode 100644 index 0000000..6243273 --- /dev/null +++ b/disk-manager-compat.spec @@ -0,0 +1,72 @@ +# -*- mode: python ; coding: utf-8 -*- +""" +Linux 存储管理器 - PyInstaller 打包配置 (Tkinter 版本) +支持 Arch Linux 和 CentOS/RHEL 8 +""" + +import sys +import os + +block_cipher = None + +# 分析依赖 +a = Analysis( + ['main.py'], + pathex=[], + binaries=[], + datas=[], + hiddenimports=[ + # 平台兼容模块 + 'platform_compat', + 'system_compat', + # Tkinter 相关 + 'tkinter', + 'tkinter.ttk', + 'tkinter.messagebox', + 'tkinter.filedialog', + 'tkinter.simpledialog', + # 其他依赖 + 'pexpect', + 'ptyprocess', + ], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[ + # 排除不需要的 GUI 库以减小体积 + 'PySide6', + 'PySide2', + 'PyQt5', + 'PyQt6', + 'matplotlib', + 'numpy', + ], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, +) + +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + [], + name='disk-manager', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/disk_operations_tkinter.py b/disk_operations_tkinter.py index 1ebc9fc..cff377a 100644 --- a/disk_operations_tkinter.py +++ b/disk_operations_tkinter.py @@ -210,7 +210,7 @@ class DiskOperations: else: messagebox.showerror("错误", f"挂载设备 {device_path} 失败。\n\n" f"普通挂载和 ntfs-3g 挂载均失败。\n" - f"如果是 NTFS 文件系统损坏,请先使用文件系统工具中的 "修复文件系统 (ntfsfix)" 修复。") + "如果是 NTFS 文件系统损坏,请先使用文件系统工具中的 '修复文件系统 (ntfsfix)' 修复。") return False else: messagebox.showerror("错误", f"挂载设备 {device_path} 失败: {stderr}") @@ -308,11 +308,11 @@ class DiskOperations: 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"), + suppress_critical_dialog_on_stderr_match=("无法辨识的磁盘卷标", "unrecognised disk label"), root_privilege=True ) if not success_check: - if any(s in stderr_check for s in ("无法辨识的磁盘卷标", "unrecognized disk label")): + if any(s in stderr_check for s in ("无法辨识的磁盘卷标", "unrecognised disk label")): logger.info(f"磁盘 {disk_path} 没有可识别的分区表。") has_partition_table = False else: @@ -538,7 +538,7 @@ class DiskOperations: try: result = subprocess.run( ["losetup", "-a"], - capture_output=True, text=True, check=False + stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding='utf-8', check=False ) if result.returncode == 0: for line in result.stdout.splitlines(): diff --git a/linux-storage-manager.spec b/linux-storage-manager.spec index b840a88..e735de7 100644 --- a/linux-storage-manager.spec +++ b/linux-storage-manager.spec @@ -1,38 +1,40 @@ # -*- mode: python ; coding: utf-8 -*- -a = Analysis( - ['mainwindow_tkinter.py'], - pathex=[], - binaries=[('/usr/lib/python3.14/lib-dynload/_tkinter.cpython-314-x86_64-linux-gnu.so', '.')], - datas=[('/usr/lib', 'tcl'), ('/usr/lib/tcl8.6', 'tk'), ('/usr/lib64/tcl8.6', 'tcltk'), ('/usr/lib/tcl8.6', 'tcltk')], - hiddenimports=['tkinter', 'tkinter.ttk', 'tkinter.scrolledtext', 'tkinter.messagebox', 'tkinter.filedialog', 'tkinter.simpledialog', '_tkinter'], - hookspath=[], - hooksconfig={}, - runtime_hooks=[], - excludes=[], - noarchive=False, - optimize=0, -) -pyz = PYZ(a.pure) +block_cipher = None -exe = EXE( - pyz, - a.scripts, - a.binaries, - a.datas, - [], - name='linux-storage-manager', - debug=False, - bootloader_ignore_signals=False, - strip=False, - upx=True, - upx_exclude=[], - runtime_tmpdir=None, - console=True, - disable_windowed_traceback=False, - argv_emulation=False, - target_arch=None, - codesign_identity=None, - entitlements_file=None, -) + +a = Analysis(['main.py'], + pathex=[], + binaries=[], + datas=[], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False) +pyz = PYZ(a.pure, a.zipped_data, + cipher=block_cipher) + +exe = EXE(pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + [], + name='linux-storage-manager', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, + disable_windowed_traceback=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None ) diff --git a/lvm_operations.py b/lvm_operations.py index abd27ed..7b65f6c 100644 --- a/lvm_operations.py +++ b/lvm_operations.py @@ -39,11 +39,12 @@ class LvmOperations: try: result = subprocess.run( command_list, - capture_output=True, - text=True, - check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, encoding='utf-8', - input=input_data + check=True, + input=input_data, + stdin=subprocess.PIPE if input_data else None ) logger.info(f"命令成功: {full_cmd_str}") return True, result.stdout.strip(), result.stderr.strip() diff --git a/lvm_operations_tkinter.py b/lvm_operations_tkinter.py index 56a7460..5509fd9 100644 --- a/lvm_operations_tkinter.py +++ b/lvm_operations_tkinter.py @@ -31,8 +31,9 @@ class LvmOperations: else: result = subprocess.run( ["sudo"] + command_list if root_privilege else command_list, - capture_output=True, text=True, check=True, - input=input_data + stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding='utf-8', check=True, + input=input_data, + stdin=subprocess.PIPE if input_data else None ) stdout = result.stdout stderr = result.stderr diff --git a/main.py b/main.py new file mode 100755 index 0000000..aaf9224 --- /dev/null +++ b/main.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Linux 存储管理器 - 统一入口 (Tkinter 版本) +统一使用 Tkinter GUI 后端 +支持 Arch Linux 和 CentOS/RHEL 8 +""" + +import sys +import os +import logging + +# 确保可以导入本地模块 +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from platform_compat import check_prerequisites + + +def setup_logging(): + """配置日志""" + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.StreamHandler(sys.stdout) + ] + ) + + +def show_error_dialog(title, message): + """显示错误对话框""" + print(f"\n{'='*60}") + print(f"错误: {title}") + print(f"{'='*60}") + print(message) + print(f"{'='*60}\n") + + # 使用 tkinter 显示图形错误对话框 + try: + import tkinter as tk + from tkinter import messagebox + root = tk.Tk() + root.withdraw() + messagebox.showerror(title, message) + root.destroy() + except Exception: + pass + + +def get_installation_guide(): + """获取安装指南""" + return """安装指南: + +Arch Linux: + sudo pacman -S python python-tkinter parted mdadm lvm2 + sudo pacman -S dosfstools e2fsprogs xfsprogs ntfs-3g + pip3 install pexpect + +CentOS 8 / RHEL 8: + sudo yum install -y python3 python3-tkinter parted mdadm lvm2 + sudo yum install -y dosfstools e2fsprogs xfsprogs ntfs-3g + pip3 install pexpect +""" + + +def main(): + """主入口函数""" + setup_logging() + logger = logging.getLogger(__name__) + + logger.info("="*60) + logger.info("Linux 存储管理器启动 (Tkinter)") + logger.info("="*60) + + # 检查 tkinter 是否可用 + try: + import tkinter as tk + logger.info("Tkinter 已加载") + except ImportError as e: + show_error_dialog( + "启动失败", + f"无法加载 Tkinter: {e}\n\n" + "请安装 tkinter:\n" + " CentOS 8: sudo yum install python3-tkinter\n" + " Arch Linux: sudo pacman -S tk\n" + ) + sys.exit(1) + + # 检查前提条件 + passed, errors, warnings = check_prerequisites() + + if not passed: + error_msg = "系统缺少必要的依赖:\n\n" + for e in errors: + error_msg += f" • {e}\n" + error_msg += "\n" + get_installation_guide() + + show_error_dialog("启动失败", error_msg) + sys.exit(1) + + # 显示警告 + for w in warnings: + logger.warning(w) + + logger.info("启动 Tkinter 主窗口") + + try: + # 统一使用 Tkinter 版本 + from mainwindow_tkinter import MainWindow + + root = tk.Tk() + app = MainWindow(root) + root.mainloop() + + except ImportError as e: + logger.exception("导入模块失败") + show_error_dialog( + "启动失败", + f"无法加载 GUI 模块: {e}\n\n" + "请检查依赖是否正确安装。\n\n" + + get_installation_guide() + ) + sys.exit(1) + except Exception as e: + logger.exception("启动过程中发生错误") + show_error_dialog("启动失败", f"发生错误: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/mainwindow_tkinter.py b/mainwindow_tkinter.py index 397df98..b72c5e2 100644 --- a/mainwindow_tkinter.py +++ b/mainwindow_tkinter.py @@ -26,7 +26,8 @@ from dialogs_tkinter import (CreatePartitionDialog, MountDialog, CreateRaidDialo from dialogs_smart import SmartInfoDialog, SmartOverviewDialog from smart_monitor import SmartMonitor # 导入增强对话框 -from dialogs_enhanced import EnhancedFormatDialog, EnhancedRaidDeleteDialog, EnhancedPartitionDialog +from dialogs_enhanced import (EnhancedFormatDialog, EnhancedRaidDeleteDialog, + EnhancedPartitionDialog, DependencyCheckDialog) class MainWindow: @@ -138,6 +139,8 @@ class MainWindow: # 帮助菜单 help_menu = tk.Menu(menubar, tearoff=0) menubar.add_cascade(label="帮助", menu=help_menu) + help_menu.add_command(label="依赖检查", command=self._show_dependency_check) + help_menu.add_separator() help_menu.add_command(label="关于", command=self._show_about) def _show_about(self): @@ -155,6 +158,10 @@ class MainWindow: "使用 Python 3.6+ 和 Tkinter 构建" ) + def _show_dependency_check(self): + """显示依赖检查对话框""" + DependencyCheckDialog(self.root) + def _create_block_devices_tree(self): """创建块设备 Treeview""" # 创建框架 @@ -407,6 +414,48 @@ class MainWindow: menu.add_command(label=f"擦除分区表 {device_path}...", command=lambda: self._handle_wipe_partition_table(device_path)) menu.add_separator() + + # 检查磁盘状态 + has_children = dev_data.get('children') is not None and len(dev_data.get('children', [])) > 0 + fstype = dev_data.get('fstype', '') + + # 如果磁盘有文件系统(直接格式化的物理盘),添加挂载/卸载/格式化操作 + if fstype and fstype not in ['LVM2_member', 'linux_raid_member']: + 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() + + # 格式化物理盘 + menu.add_command(label=f"格式化物理盘 {device_path}...", + command=lambda dp=device_path, dd=dev_data: self._handle_format_partition(dp, dd)) + + # 文件系统工具 + fs_menu = tk.Menu(menu, tearoff=0) + if fstype.startswith('ext'): + fs_menu.add_command(label=f"检查文件系统 (fsck) {device_path}", + command=lambda dp=device_path, ft=fstype: self._handle_fsck(dp, ft)) + fs_menu.add_command(label=f"调整文件系统大小 (resize2fs) {device_path}", + command=lambda dp=device_path, ft=fstype: self._handle_resize2fs(dp, ft)) + elif fstype == 'xfs': + fs_menu.add_command(label=f"修复文件系统 (xfs_repair) {device_path}", + command=lambda dp=device_path: self._handle_xfs_repair(dp)) + elif fstype.lower() == 'ntfs': + fs_menu.add_command(label=f"修复文件系统 (ntfsfix) {device_path}", + command=lambda dp=device_path: self._handle_ntfsfix(dp)) + if fs_menu.index(tk.END) is not None: + menu.add_cascade(label=f"文件系统工具 {device_path}", menu=fs_menu) + + menu.add_separator() + elif not has_children: + # 没有文件系统、没有子分区的空物理盘,可以直接格式化 + menu.add_command(label=f"直接格式化物理盘 {device_path}...", + command=lambda dp=device_path, dd=dev_data: self._handle_format_partition(dp, dd)) + menu.add_separator() + # SMART 信息 menu.add_command(label=f"查看 SMART 信息 {device_path}", command=lambda: self._handle_show_smart(device_path)) @@ -1092,8 +1141,17 @@ class MainWindow: self.refresh_all_info() def _handle_format_raid_array(self, array_path): - """处理格式化 RAID 阵列""" - self.disk_ops.format_partition(array_path) + """处理格式化 RAID 阵列 - 使用增强型对话框""" + # 获取设备信息 + dev_data = self.system_manager.get_device_details_by_path(array_path) + + # 使用增强型格式化对话框 + dialog = EnhancedFormatDialog(self.root, array_path, dev_data or {}) + fs_type = dialog.wait_for_result() + + if fs_type: + # 执行格式化 + self.disk_ops.format_partition(array_path, fs_type) # === LVM 处理函数 === def refresh_lvm_info(self): diff --git a/occupation_resolver_tkinter.py b/occupation_resolver_tkinter.py index 806145f..0824c84 100644 --- a/occupation_resolver_tkinter.py +++ b/occupation_resolver_tkinter.py @@ -115,7 +115,7 @@ class OccupationResolver: try: result = subprocess.run( ["sudo", "lsof", device_path], - capture_output=True, text=True, timeout=10 + stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding='utf-8', timeout=10 ) if result.returncode == 0 and result.stdout.strip(): logger.info(f"设备 {device_path} 被以下进程占用:\n{result.stdout}") @@ -127,7 +127,7 @@ class OccupationResolver: try: result = subprocess.run( ["sudo", "fuser", device_path], - capture_output=True, text=True, timeout=10 + stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding='utf-8', timeout=10 ) if result.returncode == 0 and result.stdout.strip(): logger.info(f"设备 {device_path} 被以下进程占用: {result.stdout.strip()}") @@ -149,7 +149,7 @@ class OccupationResolver: # 使用 fuser -k 终止进程 result = subprocess.run( ["sudo", "fuser", "-k", device_path], - capture_output=True, text=True, timeout=10 + stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding='utf-8', timeout=10 ) if result.returncode == 0 or result.returncode == 1: # 1 表示没有进程被终止 diff --git a/operation_history.py b/operation_history.py index 007984b..48ef263 100644 --- a/operation_history.py +++ b/operation_history.py @@ -203,8 +203,9 @@ class PartitionTableBackup: # 使用 sfdisk 备份分区表 result = subprocess.run( ["sudo", "sfdisk", "-d", device_path], - capture_output=True, - text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding='utf-8', check=True, timeout=30 ) @@ -236,8 +237,10 @@ class PartitionTableBackup: result = subprocess.run( ["sudo", "sfdisk", device_path], input=partition_data, - capture_output=True, - text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + stdin=subprocess.PIPE, + encoding='utf-8', check=True, timeout=30 ) diff --git a/platform_compat.py b/platform_compat.py new file mode 100755 index 0000000..ad2ae86 --- /dev/null +++ b/platform_compat.py @@ -0,0 +1,337 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +平台兼容性检测模块 +自动检测操作系统和可用库,提供统一的兼容性接口 +支持 Arch Linux (PySide6) 和 CentOS 8 (Tkinter) +""" + +import sys +import os +import subprocess +import platform +import logging + +logger = logging.getLogger(__name__) + +class PlatformCompat: + """平台兼容性检测类""" + + # GUI 后端类型 + GUI_PYSIDE6 = "pyside6" + GUI_TKINTER = "tkinter" + GUI_NONE = "none" + + # 操作系统类型 + OS_ARCH = "arch" + OS_CENTOS = "centos" + OS_RHEL = "rhel" + OS_UBUNTU = "ubuntu" + OS_DEBIAN = "debian" + OS_UNKNOWN = "unknown" + + def __init__(self): + self._gui_backend = None + self._os_type = None + self._os_version = None + self._python_version = sys.version_info + self._detected = False + + def detect(self): + """执行平台检测""" + if self._detected: + return + + logger.info(f"Python版本: {self._python_version.major}.{self._python_version.minor}.{self._python_version.micro}") + logger.info(f"平台: {platform.platform()}") + logger.info(f"系统: {platform.system()}") + logger.info(f"发行版: {platform.release()}") + + # 检测操作系统 + self._detect_os() + + # 检测GUI后端 + self._detect_gui_backend() + + self._detected = True + + logger.info(f"检测结果 - OS: {self._os_type}, GUI后端: {self._gui_backend}") + + def _detect_os(self): + """检测操作系统类型""" + try: + # 尝试读取 /etc/os-release + if os.path.exists('/etc/os-release'): + with open('/etc/os-release', 'r') as f: + content = f.read().lower() + if 'arch' in content: + self._os_type = self.OS_ARCH + elif 'centos' in content: + self._os_type = self.OS_CENTOS + elif 'rhel' in content or 'red hat' in content: + self._os_type = self.OS_RHEL + elif 'ubuntu' in content: + self._os_type = self.OS_UBUNTU + elif 'debian' in content: + self._os_type = self.OS_DEBIAN + else: + self._os_type = self.OS_UNKNOWN + else: + # 尝试使用 platform.linux_distribution() (Python 3.6 可用) + distro = platform.dist() if hasattr(platform, 'dist') else (None, None, None) + if distro[0]: + distro_name = distro[0].lower() + if 'centos' in distro_name or 'rhel' in distro_name: + self._os_type = self.OS_CENTOS + elif 'arch' in distro_name: + self._os_type = self.OS_ARCH + else: + self._os_type = self.OS_UNKNOWN + else: + self._os_type = self.OS_UNKNOWN + except Exception as e: + logger.warning(f"检测操作系统失败: {e}") + self._os_type = self.OS_UNKNOWN + + def _detect_gui_backend(self): + """检测可用的GUI后端""" + # 首先尝试 PySide6 (需要 Python 3.7+) + if self._python_version >= (3, 7): + try: + import PySide6 + self._gui_backend = self.GUI_PYSIDE6 + logger.info("检测到 PySide6 可用") + return + except ImportError: + logger.debug("PySide6 不可用") + else: + logger.debug(f"Python {self._python_version.major}.{self._python_version.minor} 不支持 PySide6 (需要 3.7+)") + + # 尝试 PySide2 (CentOS 8 可能可用) + try: + import PySide2 + self._gui_backend = "pyside2" + logger.info("检测到 PySide2 可用") + return + except ImportError: + logger.debug("PySide2 不可用") + + # 最后尝试 Tkinter (Python 内置) + try: + import tkinter + self._gui_backend = self.GUI_TKINTER + logger.info("检测到 Tkinter 可用") + return + except ImportError: + logger.debug("Tkinter 不可用") + + self._gui_backend = self.GUI_NONE + logger.error("没有检测到可用的GUI后端") + + def get_gui_backend(self): + """获取检测到的GUI后端""" + if not self._detected: + self.detect() + return self._gui_backend + + def get_os_type(self): + """获取检测到的操作系统类型""" + if not self._detected: + self.detect() + return self._os_type + + def is_pyside6_available(self): + """检查 PySide6 是否可用""" + return self.get_gui_backend() == self.GUI_PYSIDE6 + + def is_tkinter_available(self): + """检查 Tkinter 是否可用""" + return self.get_gui_backend() == self.GUI_TKINTER + + def get_recommended_entry_point(self): + """ + 获取推荐的入口点模块名 + 统一使用 Tkinter 版本 + 返回: (模块名, 类名) + """ + return ("mainwindow_tkinter", "MainWindow") + + def get_system_command_compat(self): + """ + 获取系统命令兼容性配置 + 不同发行版的命令参数可能有差异 + """ + os_type = self.get_os_type() + + # 默认配置 + config = { + 'lsblk_json_available': True, # lsblk -J 支持 + 'parted_script_mode': True, # parted -s 支持 + 'lvm_json_report': True, # LVM JSON 报告格式 + 'mdadm_conf_paths': [ + '/etc/mdadm/mdadm.conf', + '/etc/mdadm.conf' + ], + } + + # CentOS/RHEL 8 特定配置 + if os_type in (self.OS_CENTOS, self.OS_RHEL): + # CentOS 8 的 lsblk 支持 -J,但某些旧版本可能有问题 + # 检查实际版本 + try: + result = subprocess.run(['lsblk', '--version'], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + version_output = result.stdout.decode('utf-8', errors='ignore') + result.stderr.decode('utf-8', errors='ignore') + # util-linux 2.23+ 支持 JSON + if '2.23' in version_output or '2.3' in version_output: + config['lsblk_json_available'] = True + else: + # 尝试解析版本号 + import re + version_match = re.search(r'(\d+)\.(\d+)', version_output) + if version_match: + major = int(version_match.group(1)) + minor = int(version_match.group(2)) + config['lsblk_json_available'] = (major > 2) or (major == 2 and minor >= 23) + except Exception: + pass + + # CentOS 8 的 LVM 版本支持 JSON 报告 + try: + result = subprocess.run(['lvs', '--version'], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + # LVM 2.2.02.166+ 支持 JSON + version_output = result.stdout.decode('utf-8', errors='ignore') + result.stderr.decode('utf-8', errors='ignore') + import re + version_match = re.search(r'(\d+)\.(\d+)\.(\d+)\.(\d+)', version_output) + if version_match: + major = int(version_match.group(1)) + minor = int(version_match.group(2)) + config['lvm_json_report'] = (major >= 2) and (minor >= 2) + except Exception: + pass + + return config + + def get_installation_guide(self): + """获取当前系统的安装指南""" + os_type = self.get_os_type() + backend = self.get_gui_backend() + + guides = [] + + if os_type == self.OS_CENTOS: + guides.append("CentOS 8 安装命令:") + guides.append(" sudo yum install -y python3 python3-tkinter parted mdadm lvm2") + guides.append(" sudo yum install -y dosfstools e2fsprogs xfsprogs ntfs-3g") + if backend == self.GUI_TKINTER: + guides.append(" sudo pip3 install pexpect") + elif os_type == self.OS_ARCH: + guides.append("Arch Linux 安装命令:") + guides.append(" sudo pacman -S python python-pyside6 parted mdadm lvm2") + guides.append(" sudo pacman -S dosfstools e2fsprogs xfsprogs ntfs-3g") + guides.append(" pip install pexpect") + else: + guides.append("通用安装命令:") + guides.append(" 请安装: python3, tkinter, parted, mdadm, lvm2") + guides.append(" 以及文件系统工具: e2fsprogs, xfsprogs, dosfstools, ntfs-3g") + + return "\n".join(guides) + + +# 全局实例 +_platform_compat = None + +def get_platform_compat(): + """获取全局平台兼容性实例""" + global _platform_compat + if _platform_compat is None: + _platform_compat = PlatformCompat() + _platform_compat.detect() + return _platform_compat + + +def check_prerequisites(): + """ + 检查系统前提条件 + 返回: (是否通过, 错误信息列表) + """ + compat = get_platform_compat() + errors = [] + warnings = [] + + # 检查 GUI 后端 + if compat.get_gui_backend() == PlatformCompat.GUI_NONE: + errors.append("没有检测到可用的GUI后端 (PySide6/PySide2/Tkinter)") + + # 检查必要的系统命令 + required_commands = [ + ('lsblk', '用于获取块设备信息'), + ('parted', '用于分区操作'), + ('mkfs.ext4', '用于创建ext4文件系统'), + ('mount', '用于挂载操作'), + ] + + optional_commands = [ + ('mdadm', '用于RAID管理'), + ('pvcreate', '用于LVM物理卷管理'), + ('vgcreate', '用于LVM卷组管理'), + ('lvcreate', '用于LVM逻辑卷管理'), + ('mkfs.xfs', '用于创建XFS文件系统'), + ('mkfs.ntfs', '用于创建NTFS文件系统'), + ('mkfs.vfat', '用于创建FAT32文件系统'), + ] + + for cmd, desc in required_commands: + if not _command_exists(cmd): + errors.append(f"缺少必要命令: {cmd} ({desc})") + + for cmd, desc in optional_commands: + if not _command_exists(cmd): + warnings.append(f"缺少可选命令: {cmd} ({desc})") + + # 检查权限 + if os.geteuid() != 0: + warnings.append("当前不是root用户,部分功能可能需要sudo权限") + + return (len(errors) == 0, errors, warnings) + + +def _command_exists(cmd): + """检查命令是否存在""" + try: + # Python 3.6 兼容: 使用 stdout/stderr 参数代替 capture_output + result = subprocess.run( + ['which', cmd], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True + ) + return True + except (subprocess.CalledProcessError, FileNotFoundError): + return False + + +if __name__ == "__main__": + # 测试模式 + logging.basicConfig(level=logging.DEBUG) + + compat = get_platform_compat() + print(f"操作系统类型: {compat.get_os_type()}") + print(f"GUI后端: {compat.get_gui_backend()}") + print(f"推荐入口点: {compat.get_recommended_entry_point()}") + print(f"系统命令兼容配置: {compat.get_system_command_compat()}") + + print("\n" + compat.get_installation_guide()) + + print("\n前提条件检查:") + passed, errors, warnings = check_prerequisites() + if passed: + print("✓ 基本前提条件满足") + else: + print("✗ 前提条件不满足:") + for e in errors: + print(f" 错误: {e}") + + for w in warnings: + print(f" 警告: {w}") diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..6b4932a --- /dev/null +++ b/run.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# Linux 存储管理器启动脚本 +# 自动检测并启动合适的版本 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +if [ -f "$SCRIPT_DIR/dist/disk-manager" ]; then + # 打包版本 + echo "启动打包版本..." + exec "$SCRIPT_DIR/dist/disk-manager" "$@" +elif [ -f "$SCRIPT_DIR/main.py" ]; then + # 统一入口 + echo "启动源码版本..." + exec python3 "$SCRIPT_DIR/main.py" "$@" +else + echo "错误: 找不到 disk-manager 或 main.py" + exit 1 +fi diff --git a/smart_monitor.py b/smart_monitor.py index ee35bc6..8bcf373 100644 --- a/smart_monitor.py +++ b/smart_monitor.py @@ -5,10 +5,38 @@ import subprocess import logging import re import json +import sys from typing import Dict, List, Optional, Tuple -from dataclasses import dataclass from enum import Enum +# Python 3.6 兼容性: dataclasses 是 3.7+ 的特性 +if sys.version_info >= (3, 7): + from dataclasses import dataclass +else: + # Python 3.6 回退: 使用普通类 + def dataclass(cls): + """简化的 dataclass 装饰器兼容层""" + # 自动添加 __init__ + annotations = getattr(cls, '__annotations__', {}) + + def __init__(self, **kwargs): + for key, value in kwargs.items(): + setattr(self, key, value) + # 设置默认值 + for key in annotations: + if not hasattr(self, key): + setattr(self, key, None) + + cls.__init__ = __init__ + + # 添加 __repr__ + def __repr__(self): + fields = ', '.join(f'{k}={getattr(self, k, None)!r}' for k in annotations) + return f"{cls.__name__}({fields})" + + cls.__repr__ = __repr__ + return cls + logger = logging.getLogger(__name__) @@ -77,8 +105,9 @@ class SmartMonitor: try: result = subprocess.run( ["which", "smartctl"], - capture_output=True, - text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding='utf-8', check=False ) return result.returncode == 0 @@ -99,8 +128,9 @@ class SmartMonitor: # 获取基本信息 result = subprocess.run( ["sudo", "smartctl", "-a", device_path], - capture_output=True, - text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding='utf-8', check=False, timeout=30 ) @@ -232,8 +262,9 @@ class SmartMonitor: # 获取所有块设备 result = subprocess.run( ["lsblk", "-d", "-n", "-o", "NAME,TYPE,ROTA"], - capture_output=True, - text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding='utf-8', check=True ) @@ -270,7 +301,7 @@ class SmartMonitor: score = 70 # 根据错误数量调整 - error_count = len(smart_info.errors) + error_count = len(smart_info.errors) if smart_info.errors else 0 score -= error_count * 10 # 确保分数在合理范围内 diff --git a/system_compat.py b/system_compat.py new file mode 100755 index 0000000..d87d630 --- /dev/null +++ b/system_compat.py @@ -0,0 +1,364 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +系统命令兼容性模块 +处理不同 Linux 发行版之间系统命令的差异 +支持 Arch Linux 和 CentOS/RHEL 8 +""" + +import subprocess +import json +import logging +import re +import os +from platform_compat import get_platform_compat + +logger = logging.getLogger(__name__) + + +class SystemCommandCompat: + """系统命令兼容性处理类""" + + def __init__(self): + self._lsblk_version = None + self._lvm_version = None + self._parted_version = None + self._compat_cache = {} + + def _get_lsblk_version(self): + """获取 lsblk 版本""" + if self._lsblk_version is None: + try: + # Python 3.6 兼容 + result = subprocess.run( + ['lsblk', '--version'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE + ) + output = result.stdout.decode('utf-8', errors='ignore') + result.stderr.decode('utf-8', errors='ignore') + # 解析版本号,例如 "lsblk from util-linux 2.37.4" + match = re.search(r'(\d+)\.(\d+)(?:\.(\d+))?', output) + if match: + self._lsblk_version = tuple(int(x) if x else 0 for x in match.groups()) + logger.debug(f"检测到 lsblk 版本: {self._lsblk_version}") + else: + self._lsblk_version = (0, 0, 0) + except Exception as e: + logger.warning(f"无法检测 lsblk 版本: {e}") + self._lsblk_version = (0, 0, 0) + return self._lsblk_version + + def _get_lvm_version(self): + """获取 LVM 版本""" + if self._lvm_version is None: + try: + # Python 3.6 兼容 + result = subprocess.run( + ['lvm', 'version'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE + ) + output = result.stdout.decode('utf-8', errors='ignore') + result.stderr.decode('utf-8', errors='ignore') + # 解析版本号,例如 "LVM version: 2.03.14" + match = re.search(r'(\d+)\.(\d+)\.(\d+)', output) + if match: + self._lvm_version = tuple(int(x) for x in match.groups()) + logger.debug(f"检测到 LVM 版本: {self._lvm_version}") + else: + self._lvm_version = (0, 0, 0) + except Exception as e: + logger.warning(f"无法检测 LVM 版本: {e}") + self._lvm_version = (0, 0, 0) + return self._lvm_version + + def supports_lsblk_json(self): + """检查 lsblk 是否支持 JSON 输出 (util-linux 2.23+)""" + version = self._get_lsblk_version() + # util-linux 2.23 引入 JSON 支持 + return version >= (2, 23) + + def supports_lvm_json(self): + """检查 LVM 是否支持 JSON 报告格式 (2.02.166+)""" + version = self._get_lvm_version() + # LVM 2.02.166 引入 JSON 报告格式 + return version >= (2, 2, 166) or version >= (2, 3, 0) + + def get_lsblk_command(self): + """ + 获取适用于当前系统的 lsblk 命令 + 返回: (命令列表, 是否需要手动解析) + """ + # 注意: PATH 列在较新的 lsblk 中可用,但在 CentOS 8 等旧系统中不可用 + # 使用 KNAME 代替,它是内部内核设备名称,总是存在 + if self.supports_lsblk_json(): + # 现代系统支持 JSON + return [ + "lsblk", "-J", "-o", + "NAME,KNAME,FSTYPE,SIZE,MOUNTPOINT,RO,TYPE,UUID,PARTUUID,VENDOR,MODEL,SERIAL,MAJ:MIN,PKNAME" + ], False + else: + # 旧系统回退到普通输出 + logger.warning("lsblk 不支持 JSON 输出,使用回退方案") + return [ + "lsblk", "-o", + "NAME,KNAME,FSTYPE,SIZE,MOUNTPOINT,RO,TYPE,UUID,PARTUUID,VENDOR,MODEL,SERIAL,MAJ:MIN,PKNAME", + "-n", "-p" # -n 不打印标题,-p 显示完整路径 + ], True + + def parse_lsblk_output(self, stdout, use_manual_parse=False): + """ + 解析 lsblk 输出 + :param stdout: 命令输出 + :param use_manual_parse: 是否需要手动解析(非 JSON) + :return: 设备列表 + """ + if not use_manual_parse: + try: + data = json.loads(stdout) + devices = data.get('blockdevices', []) + + # 统一处理 PATH 字段 + # 注意: 使用 kname (内核设备名) 来生成 path,因为 PATH 列在某些系统中不可用 + def normalize_device(dev): + # 优先使用 kname (内核设备名),因为它总是存在且是规范的 + if 'kname' in dev: + dev['path'] = f"/dev/{dev['kname']}" + elif 'path' not in dev and 'name' in dev: + dev['path'] = f"/dev/{dev['name']}" + # 清理旧字段 + if 'PATH' in dev: + dev.pop('PATH') + if 'KNAME' in dev: + dev['kname'] = dev.pop('KNAME') + if 'children' in dev: + for child in dev['children']: + normalize_device(child) + return dev + + for dev in devices: + normalize_device(dev) + + return devices + except json.JSONDecodeError as e: + logger.error(f"JSON 解析失败,尝试手动解析: {e}") + use_manual_parse = True + + if use_manual_parse: + return self._manual_parse_lsblk(stdout) + + return [] + + def _manual_parse_lsblk(self, stdout): + """ + 手动解析 lsblk 的普通输出 + 用于不支持 JSON 的旧系统 + """ + devices = [] + device_stack = [] # 用于处理层次结构 + + lines = stdout.strip().split('\n') + for line in lines: + if not line.strip(): + continue + + # 解析缩进级别(每两个空格或一个制表符表示一个层级) + indent_level = 0 + stripped_line = line + while stripped_line.startswith(' ') or stripped_line.startswith('├') or stripped_line.startswith('└') or stripped_line.startswith('│'): + if stripped_line[0] in '├└│': + indent_level += 1 + stripped_line = stripped_line[1:] + if stripped_line.startswith('─'): + stripped_line = stripped_line[1:] + if stripped_line.startswith(' '): + stripped_line = stripped_line[1:] + + # 分割字段 + parts = stripped_line.split() + if len(parts) < 6: + continue + + device = { + 'name': parts[0], + 'path': f"/dev/{parts[0]}" if not parts[0].startswith('/') else parts[0], + 'fstype': parts[1] if parts[1] != '' else None, + 'size': parts[2], + 'mountpoint': parts[3] if parts[3] != '' else None, + 'ro': parts[4] == '1', + 'type': parts[5], + } + + # 添加可选字段 + if len(parts) > 6: + device['uuid'] = parts[6] if parts[6] != '' else None + if len(parts) > 7: + device['partuuid'] = parts[7] if parts[7] != '' else None + + # 处理层次结构 + if indent_level == 0: + devices.append(device) + device_stack = [device] + else: + # 找到正确的父设备 + while len(device_stack) > indent_level: + device_stack.pop() + + if device_stack: + parent = device_stack[-1] + if 'children' not in parent: + parent['children'] = [] + parent['children'].append(device) + device_stack.append(device) + else: + devices.append(device) + device_stack = [device] + + return devices + + def get_lvm_commands(self): + """ + 获取适用于当前系统的 LVM 命令 + 返回: { + 'pvs': (命令列表, 是否需要手动解析), + 'vgs': (命令列表, 是否需要手动解析), + 'lvs': (命令列表, 是否需要手动解析) + } + """ + if self.supports_lvm_json(): + return { + 'pvs': (['pvs', '--reportformat', 'json'], False), + 'vgs': (['vgs', '--reportformat', 'json'], False), + 'lvs': (['lvs', '-o', 'lv_name,vg_name,lv_uuid,lv_size,lv_attr,origin,snap_percent,lv_path', '--reportformat', 'json'], False), + } + else: + logger.warning("LVM 不支持 JSON 输出,使用回退方案") + return { + 'pvs': (['pvs', '--noheadings', '--separator', '|', '-o', 'pv_name,vg_name,pv_uuid,pv_size,pv_free,pv_attr,pv_fmt'], True), + 'vgs': (['vgs', '--noheadings', '--separator', '|', '-o', 'vg_name,vg_uuid,vg_size,vg_free,vg_attr,pv_count,lv_count,vg_alloc_percent,vg_fmt'], True), + 'lvs': (['lvs', '--noheadings', '--separator', '|', '-o', 'lv_name,vg_name,lv_uuid,lv_size,lv_attr,origin,snap_percent,lv_path'], True), + } + + def parse_pvs_output(self, stdout, use_manual_parse=False): + """解析 pvs 输出""" + if not use_manual_parse: + try: + data = json.loads(stdout) + if 'report' in data and data['report']: + return [{ + 'pv_name': pv.get('pv_name'), + 'vg_name': pv.get('vg_name'), + 'pv_uuid': pv.get('pv_uuid'), + 'pv_size': pv.get('pv_size'), + 'pv_free': pv.get('pv_free'), + 'pv_attr': pv.get('pv_attr'), + 'pv_fmt': pv.get('pv_fmt') + } for pv in data['report'][0].get('pv', [])] + return [] + except json.JSONDecodeError: + use_manual_parse = True + + if use_manual_parse: + pvs = [] + for line in stdout.strip().split('\n'): + parts = line.strip().split('|') + if len(parts) >= 7: + pvs.append({ + 'pv_name': parts[0].strip(), + 'vg_name': parts[1].strip() if parts[1].strip() else None, + 'pv_uuid': parts[2].strip() if len(parts) > 2 else None, + 'pv_size': parts[3].strip() if len(parts) > 3 else None, + 'pv_free': parts[4].strip() if len(parts) > 4 else None, + 'pv_attr': parts[5].strip() if len(parts) > 5 else None, + 'pv_fmt': parts[6].strip() if len(parts) > 6 else None, + }) + return pvs + return [] + + def parse_vgs_output(self, stdout, use_manual_parse=False): + """解析 vgs 输出""" + if not use_manual_parse: + try: + data = json.loads(stdout) + if 'report' in data and data['report']: + return [{ + 'vg_name': vg.get('vg_name'), + 'vg_uuid': vg.get('vg_uuid'), + 'vg_size': vg.get('vg_size'), + 'vg_free': vg.get('vg_free'), + 'vg_attr': vg.get('vg_attr'), + 'pv_count': vg.get('pv_count'), + 'lv_count': vg.get('lv_count'), + 'vg_alloc_percent': vg.get('vg_alloc_percent'), + 'vg_fmt': vg.get('vg_fmt') + } for vg in data['report'][0].get('vg', [])] + return [] + except json.JSONDecodeError: + use_manual_parse = True + + if use_manual_parse: + vgs = [] + for line in stdout.strip().split('\n'): + parts = line.strip().split('|') + if len(parts) >= 9: + vgs.append({ + 'vg_name': parts[0].strip(), + 'vg_uuid': parts[1].strip() if len(parts) > 1 else None, + 'vg_size': parts[2].strip() if len(parts) > 2 else None, + 'vg_free': parts[3].strip() if len(parts) > 3 else None, + 'vg_attr': parts[4].strip() if len(parts) > 4 else None, + 'pv_count': parts[5].strip() if len(parts) > 5 else None, + 'lv_count': parts[6].strip() if len(parts) > 6 else None, + 'vg_alloc_percent': parts[7].strip() if len(parts) > 7 else None, + 'vg_fmt': parts[8].strip() if len(parts) > 8 else None, + }) + return vgs + return [] + + def parse_lvs_output(self, stdout, use_manual_parse=False): + """解析 lvs 输出""" + if not use_manual_parse: + try: + data = json.loads(stdout) + if 'report' in data and data['report']: + return [{ + 'lv_name': lv.get('lv_name'), + 'vg_name': lv.get('vg_name'), + 'lv_uuid': lv.get('lv_uuid'), + 'lv_size': lv.get('lv_size'), + 'lv_attr': lv.get('lv_attr'), + 'origin': lv.get('origin'), + 'snap_percent': lv.get('snap_percent'), + 'lv_path': lv.get('lv_path') + } for lv in data['report'][0].get('lv', [])] + return [] + except json.JSONDecodeError: + use_manual_parse = True + + if use_manual_parse: + lvs = [] + for line in stdout.strip().split('\n'): + parts = line.strip().split('|') + if len(parts) >= 8: + lvs.append({ + 'lv_name': parts[0].strip(), + 'vg_name': parts[1].strip(), + 'lv_uuid': parts[2].strip() if len(parts) > 2 else None, + 'lv_size': parts[3].strip() if len(parts) > 3 else None, + 'lv_attr': parts[4].strip() if len(parts) > 4 else None, + 'origin': parts[5].strip() if len(parts) > 5 else None, + 'snap_percent': parts[6].strip() if len(parts) > 6 else None, + 'lv_path': parts[7].strip() if len(parts) > 7 else None, + }) + return lvs + return [] + + +# 全局实例 +_compat_instance = None + +def get_system_compat(): + """获取全局系统兼容性实例""" + global _compat_instance + if _compat_instance is None: + _compat_instance = SystemCommandCompat() + return _compat_instance diff --git a/system_info.py b/system_info.py index 275a604..2609f61 100644 --- a/system_info.py +++ b/system_info.py @@ -4,6 +4,7 @@ import json import logging import re import os +from system_compat import get_system_compat logger = logging.getLogger(__name__) @@ -26,20 +27,26 @@ class SystemInfoManager: logger.debug(f"运行命令: {' '.join(command_list)}") try: + # Python 3.6 兼容: 使用 stdout/stderr 参数代替 capture_output + # Python 3.6 兼容: 不使用 text 参数,手动解码 result = subprocess.run( command_list, - capture_output=check_output, - text=True, + stdout=subprocess.PIPE if check_output else None, + stderr=subprocess.PIPE if check_output else None, + stdin=subprocess.PIPE if input_data else None, check=True, - encoding='utf-8', + encoding='utf-8' if check_output else None, input=input_data ) - return result.stdout if check_output else "", result.stderr if check_output else "" + if check_output: + return result.stdout, result.stderr + else: + return "", "" except subprocess.CalledProcessError as e: logger.error(f"命令执行失败: {' '.join(command_list)}") logger.error(f"退出码: {e.returncode}") - logger.error(f"标准输出: {e.stdout.strip()}") - logger.error(f"标准错误: {e.stderr.strip()}") + logger.error(f"标准输出: {e.stdout.strip() if e.stdout else ''}") + logger.error(f"标准错误: {e.stderr.strip() if e.stderr else ''}") raise except FileNotFoundError: logger.error(f"命令 '{command_list[0]}' 未找到。请确保已安装相关工具。") @@ -52,33 +59,32 @@ class SystemInfoManager: """ 使用 lsblk 获取块设备信息。 返回一个字典列表,每个字典代表一个设备。 + 自动适配不同系统的 lsblk 版本。 """ - cmd = [ - "lsblk", "-J", "-o", - "NAME,FSTYPE,SIZE,MOUNTPOINT,RO,TYPE,UUID,PARTUUID,VENDOR,MODEL,SERIAL,MAJ:MIN,PKNAME,PATH" - ] try: + # 使用兼容性模块获取适当的命令 + compat = get_system_compat() + cmd, need_manual_parse = compat.get_lsblk_command() + stdout, _ = self._run_command(cmd) - data = json.loads(stdout) - devices = data.get('blockdevices', []) - - def add_path_recursive(dev_list): + devices = compat.parse_lsblk_output(stdout, need_manual_parse) + + # 统一处理 path 字段 + def normalize_path_recursive(dev_list): for dev in dev_list: - if 'PATH' not in dev and 'NAME' in dev: - dev['PATH'] = f"/dev/{dev['NAME']}" - # Rename PATH to path (lowercase) for consistency + if 'path' not in dev and 'name' in dev: + dev['path'] = f"/dev/{dev['name']}" if 'PATH' in dev: dev['path'] = dev.pop('PATH') if 'children' in dev: - add_path_recursive(dev['children']) - add_path_recursive(devices) + normalize_path_recursive(dev['children']) + + normalize_path_recursive(devices) return devices + except subprocess.CalledProcessError as e: logger.error(f"获取块设备信息失败: {e.stderr.strip()}") return [] - except json.JSONDecodeError as e: - logger.error(f"解析 lsblk JSON 输出失败: {e}") - return [] except Exception as e: logger.error(f"获取块设备信息失败 (未知错误): {e}") return [] @@ -419,85 +425,50 @@ class SystemInfoManager: def get_lvm_info(self): """ 获取 LVM 物理卷 (PVs)、卷组 (VGs) 和逻辑卷 (LVs) 的信息。 + 自动适配不同系统的 LVM 版本。 """ lvm_info = {'pvs': [], 'vgs': [], 'lvs': []} + + # 使用兼容性模块获取适当的命令 + compat = get_system_compat() + commands = compat.get_lvm_commands() # Get PVs try: - stdout, stderr = self._run_command(["pvs", "--reportformat", "json"]) - data = json.loads(stdout) - if 'report' in data and data['report']: - for pv_data in data['report'][0].get('pv', []): - lvm_info['pvs'].append({ - 'pv_name': pv_data.get('pv_name'), - 'vg_name': pv_data.get('vg_name'), - 'pv_uuid': pv_data.get('pv_uuid'), - 'pv_size': pv_data.get('pv_size'), - 'pv_free': pv_data.get('pv_free'), - 'pv_attr': pv_data.get('pv_attr'), - 'pv_fmt': pv_data.get('pv_fmt') - }) + cmd, need_manual_parse = commands['pvs'] + stdout, stderr = self._run_command(cmd) + lvm_info['pvs'] = compat.parse_pvs_output(stdout, need_manual_parse) except subprocess.CalledProcessError as e: if "No physical volume found" in e.stderr or "No physical volumes found" in e.stdout: logger.info("未找到任何LVM物理卷。") else: logger.error(f"获取LVM物理卷信息失败: {e.stderr.strip()}") - except json.JSONDecodeError as e: - logger.error(f"解析LVM物理卷JSON输出失败: {e}") except Exception as e: logger.error(f"获取LVM物理卷信息失败 (未知错误): {e}") # Get VGs try: - stdout, stderr = self._run_command(["vgs", "--reportformat", "json"]) - data = json.loads(stdout) - if 'report' in data and data['report']: - for vg_data in data['report'][0].get('vg', []): - lvm_info['vgs'].append({ - 'vg_name': vg_data.get('vg_name'), - 'vg_uuid': vg_data.get('vg_uuid'), - 'vg_size': vg_data.get('vg_size'), - 'vg_free': vg_data.get('vg_free'), - 'vg_attr': vg_data.get('vg_attr'), - 'pv_count': vg_data.get('pv_count'), - 'lv_count': vg_data.get('lv_count'), - 'vg_alloc_percent': vg_data.get('vg_alloc_percent'), - 'vg_fmt': vg_data.get('vg_fmt') - }) + cmd, need_manual_parse = commands['vgs'] + stdout, stderr = self._run_command(cmd) + lvm_info['vgs'] = compat.parse_vgs_output(stdout, need_manual_parse) except subprocess.CalledProcessError as e: if "No volume group found" in e.stderr or "No volume groups found" in e.stdout: logger.info("未找到任何LVM卷组。") else: logger.error(f"获取LVM卷组信息失败: {e.stderr.strip()}") - except json.JSONDecodeError as e: - logger.error(f"解析LVM卷组JSON输出失败: {e}") except Exception as e: logger.error(f"获取LVM卷组信息失败 (未知错误): {e}") - # Get LVs (MODIFIED: added -o lv_path) + # Get LVs try: - # 明确请求 lv_path,因为默认的 --reportformat json 不包含它 - stdout, stderr = self._run_command(["lvs", "-o", "lv_name,vg_name,lv_uuid,lv_size,lv_attr,origin,snap_percent,lv_path", "--reportformat", "json"]) - data = json.loads(stdout) - if 'report' in data and data['report']: - for lv_data in data['report'][0].get('lv', []): - lvm_info['lvs'].append({ - 'lv_name': lv_data.get('lv_name'), - 'vg_name': lv_data.get('vg_name'), - 'lv_uuid': lv_data.get('lv_uuid'), - 'lv_size': lv_data.get('lv_size'), - 'lv_attr': lv_data.get('lv_attr'), - 'origin': lv_data.get('origin'), - 'snap_percent': lv_data.get('snap_percent'), - 'lv_path': lv_data.get('lv_path') # 现在应该能正确获取到路径了 - }) + cmd, need_manual_parse = commands['lvs'] + stdout, stderr = self._run_command(cmd) + lvm_info['lvs'] = compat.parse_lvs_output(stdout, need_manual_parse) except subprocess.CalledProcessError as e: if "No logical volume found" in e.stderr or "No logical volumes found" in e.stdout: logger.info("未找到任何LVM逻辑卷。") else: logger.error(f"获取LVM逻辑卷信息失败: {e.stderr.strip()}") - except json.JSONDecodeError as e: - logger.error(f"解析LVM逻辑卷JSON输出失败: {e}") except Exception as e: logger.error(f"获取LVM逻辑卷信息失败 (未知错误): {e}")