This commit is contained in:
root
2026-02-10 02:54:13 +08:00
parent 4a59323398
commit 64bfd85368
22 changed files with 2572 additions and 422 deletions

View File

@@ -159,3 +159,79 @@ sudo ./build-app.sh
2. 检查系统命令是否安装:`which parted mdadm lvm` 2. 检查系统命令是否安装:`which parted mdadm lvm`
3. 手动测试命令:`sudo lsblk -J` 3. 手动测试命令:`sudo lsblk -J`
4. 检查权限:确保用户有 sudo 权限或已切换到 root 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 后端。

201
COMPAT_SUMMARY.md Normal file
View File

@@ -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 外观

215
README_COMPAT.md Normal file
View File

@@ -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 操作
- 其他所有原始文件
## 许可
与原项目相同。

216
build-compat.sh Executable file
View File

@@ -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

29
build-simple.sh Executable file
View File

@@ -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

292
dependency_checker.py Normal file
View File

@@ -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(" 所有必需依赖已安装")

View File

@@ -8,6 +8,237 @@ import logging
logger = logging.getLogger(__name__) 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(
"<Configure>",
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(
"<Configure>",
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: class EnhancedFormatDialog:
"""增强格式化对话框 - 显示详细警告信息""" """增强格式化对话框 - 显示详细警告信息"""
@@ -19,7 +250,7 @@ class EnhancedFormatDialog:
self.dialog = tk.Toplevel(parent) self.dialog = tk.Toplevel(parent)
self.dialog.title(f"格式化确认 - {device_path}") self.dialog.title(f"格式化确认 - {device_path}")
self.dialog.geometry("500x575") self.dialog.geometry("500x500")
self.dialog.minsize(480, 450) self.dialog.minsize(480, 450)
self.dialog.transient(parent) self.dialog.transient(parent)
self.dialog.grab_set() self.dialog.grab_set()
@@ -28,14 +259,17 @@ class EnhancedFormatDialog:
self._create_widgets() self._create_widgets()
def _center_window(self): def _center_window(self):
"""居中窗口"""
self.dialog.update_idletasks() self.dialog.update_idletasks()
width = self.dialog.winfo_width() width = 500
height = self.dialog.winfo_height() height = 500
x = (self.dialog.winfo_screenwidth() // 2) - (width // 2) x = (self.dialog.winfo_screenwidth() // 2) - (width // 2)
y = (self.dialog.winfo_screenheight() // 2) - (height // 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): def _create_widgets(self):
"""创建界面元素"""
# 主框架
main_frame = ttk.Frame(self.dialog, padding="15") main_frame = ttk.Frame(self.dialog, padding="15")
main_frame.pack(fill=tk.BOTH, expand=True) main_frame.pack(fill=tk.BOTH, expand=True)
@@ -43,58 +277,45 @@ class EnhancedFormatDialog:
warning_frame = ttk.Frame(main_frame) warning_frame = ttk.Frame(main_frame)
warning_frame.pack(fill=tk.X, pady=(0, 15)) warning_frame.pack(fill=tk.X, pady=(0, 15))
warning_label = ttk.Label( ttk.Label(warning_frame, text="⚠️ 警告",
warning_frame, font=('Arial', 16, 'bold'),
text="⚠ 警告:此操作将永久删除所有数据!", foreground='red').pack(anchor=tk.W)
font=("Arial", 12, "bold"),
foreground="red"
)
warning_label.pack()
# 设备信息区域 # 设备信息
info_frame = ttk.LabelFrame(main_frame, text="设备信息", padding="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, 15))
# 显示详细信息 ttk.Label(info_frame,
infos = [ text=f"设备路径: {self.device_path}",
("设备路径", self.device_path), font=('Arial', 10, 'bold')).pack(anchor=tk.W, pady=2)
("设备名称", self.device_info.get('name', 'N/A')), ttk.Label(info_frame,
("文件系统", self.device_info.get('fstype', 'N/A')), text=f"设备类型: {self.device_info.get('type', 'Unknown')}",
("大小", self.device_info.get('size', 'N/A')), font=('Arial', 9)).pack(anchor=tk.W, pady=2)
("UUID", self.device_info.get('uuid', 'N/A')),
("挂载点", self.device_info.get('mountpoint', '未挂载')),
]
for i, (label, value) in enumerate(infos): # 显示文件系统信息
ttk.Label(info_frame, text=f"{label}:", font=("Arial", 10, "bold")).grid( fstype = self.device_info.get('fstype', '')
row=i, column=0, sticky=tk.W, padx=5, pady=3 if fstype:
) ttk.Label(info_frame,
val_label = ttk.Label(info_frame, text=str(value)) text=f"当前文件系统: {fstype}",
val_label.grid(row=i, column=1, sticky=tk.W, padx=5, pady=3) font=('Arial', 9)).pack(anchor=tk.W, pady=2)
# 如果有挂载点,高亮显示 size = self.device_info.get('size', 'Unknown')
if label == "挂载点" and value and value != '未挂载': ttk.Label(info_frame,
val_label.configure(foreground="orange", font=("Arial", 9, "bold")) text=f"设备大小: {size}",
font=('Arial', 9)).pack(anchor=tk.W, pady=2)
# 数据风险提示 # 危险警告
risk_frame = ttk.LabelFrame(main_frame, text="数据风险提示", padding="10") danger_frame = ttk.Frame(main_frame)
risk_frame.pack(fill=tk.X, pady=(0, 15)) danger_frame.pack(fill=tk.X, pady=(0, 15))
risks = [] ttk.Label(danger_frame,
if self.device_info.get('fstype'): text="此操作将永久删除设备上的所有数据!",
risks.append(f"• 此分区包含 {self.device_info['fstype']} 文件系统") font=('Arial', 10, 'bold'),
if self.device_info.get('uuid'): foreground='red').pack(anchor=tk.W, pady=5)
risks.append(f"• 分区有 UUID: {self.device_info['uuid'][:20]}...")
if self.device_info.get('mountpoint'):
risks.append(f"• 分区当前挂载在: {self.device_info['mountpoint']}")
if not risks: ttk.Label(danger_frame,
risks.append("• 无法获取分区详细信息") text="• 所有数据将无法恢复\n• 请确保已备份重要数据",
foreground='red').pack(anchor=tk.W, pady=5)
for risk in risks:
ttk.Label(risk_frame, text=risk, foreground="red").pack(
anchor=tk.W, pady=2
)
# 文件系统选择 # 文件系统选择
fs_frame = ttk.LabelFrame(main_frame, text="选择新文件系统", padding="10") 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, ttk.Radiobutton(fs_frame, text=fs, variable=self.fs_var,
value=fs).pack(side=tk.LEFT, padx=10) 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 = ttk.Frame(main_frame)
btn_frame.pack(fill=tk.X, pady=10) btn_frame.pack(fill=tk.X, pady=10)
@@ -130,12 +338,6 @@ class EnhancedFormatDialog:
command=self._on_confirm).pack(side=tk.RIGHT, padx=5) command=self._on_confirm).pack(side=tk.RIGHT, padx=5)
def _on_confirm(self): 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.result = self.fs_var.get()
self.dialog.destroy() self.dialog.destroy()
@@ -158,9 +360,9 @@ class EnhancedRaidDeleteDialog:
self.result = False self.result = False
self.dialog = tk.Toplevel(parent) self.dialog = tk.Toplevel(parent)
self.dialog.title(f"删除 RAID 阵列确认 - {array_path}") self.dialog.title(f"删除 RAID 阵列 - {array_path}")
self.dialog.geometry("550x750") self.dialog.geometry("500x500")
self.dialog.minsize(520, 750) self.dialog.minsize(450, 400)
self.dialog.transient(parent) self.dialog.transient(parent)
self.dialog.grab_set() self.dialog.grab_set()
@@ -168,96 +370,62 @@ class EnhancedRaidDeleteDialog:
self._create_widgets() self._create_widgets()
def _center_window(self): def _center_window(self):
"""居中窗口"""
self.dialog.update_idletasks() self.dialog.update_idletasks()
width = self.dialog.winfo_width() width = 500
height = self.dialog.winfo_height() height = 500
x = (self.dialog.winfo_screenwidth() // 2) - (width // 2) x = (self.dialog.winfo_screenwidth() // 2) - (width // 2)
y = (self.dialog.winfo_screenheight() // 2) - (height // 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): def _create_widgets(self):
"""创建界面元素"""
# 主框架
main_frame = ttk.Frame(self.dialog, padding="15") main_frame = ttk.Frame(self.dialog, padding="15")
main_frame.pack(fill=tk.BOTH, expand=True) 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) danger_frame = ttk.Frame(main_frame)
warning_frame.pack(fill=tk.X, pady=(0, 15)) danger_frame.pack(fill=tk.X, pady=(0, 10))
warning_label = ttk.Label( ttk.Label(danger_frame,
warning_frame, text="⚠️ 删除操作将:",
text="⚠ 危险操作:此操作将永久删除 RAID 阵列!", font=('Arial', 10, 'bold'),
font=("Arial", 13, "bold"), foreground='red').pack(anchor=tk.W, pady=5)
foreground="red"
)
warning_label.pack()
# 阵列详情 warnings = [
array_frame = ttk.LabelFrame(main_frame, text="阵列详情", padding="10") "• 停止并删除 RAID 阵列",
array_frame.pack(fill=tk.X, pady=(0, 15)) "• 清除成员设备上的 RAID 超级块",
"• 从配置文件中移除阵列配置",
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')),
] ]
for warning in warnings:
for i, (label, value) in enumerate(details): ttk.Label(danger_frame, text=warning,
ttk.Label(array_frame, text=f"{label}:", foreground='red').pack(anchor=tk.W, pady=2)
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()
# 按钮 # 按钮
btn_frame = ttk.Frame(main_frame) btn_frame = ttk.Frame(main_frame)
@@ -266,23 +434,14 @@ class EnhancedRaidDeleteDialog:
ttk.Button(btn_frame, text="取消", ttk.Button(btn_frame, text="取消",
command=self._on_cancel).pack(side=tk.RIGHT, padx=5) command=self._on_cancel).pack(side=tk.RIGHT, padx=5)
ttk.Button(btn_frame, text="确认删除", ttk.Button(btn_frame, text="确认删除",
command=self._on_confirm, command=self._on_confirm).pack(side=tk.RIGHT, padx=5)
style="Danger.TButton").pack(side=tk.RIGHT, padx=5)
# 配置危险按钮样式
style = ttk.Style()
style.configure("Danger.TButton", foreground="red")
def _on_confirm(self): def _on_confirm(self):
if self.confirm_var.get() != "DELETE":
messagebox.showerror("错误",
"输入错误!请输入 'DELETE'(全大写)以确认删除操作。")
return
self.result = True self.result = True
self.dialog.destroy() self.dialog.destroy()
def _on_cancel(self): def _on_cancel(self):
self.result = False
self.dialog.destroy() self.dialog.destroy()
def wait_for_result(self): def wait_for_result(self):
@@ -291,7 +450,7 @@ class EnhancedRaidDeleteDialog:
class EnhancedPartitionDialog: class EnhancedPartitionDialog:
"""增强分区创建对话框 - 可视化滑块选择大小""" """增强分区创建对话框 - 智能分区建议"""
def __init__(self, parent, disk_path, total_disk_mib, max_available_mib): def __init__(self, parent, disk_path, total_disk_mib, max_available_mib):
self.parent = parent self.parent = parent
@@ -302,8 +461,8 @@ class EnhancedPartitionDialog:
self.dialog = tk.Toplevel(parent) self.dialog = tk.Toplevel(parent)
self.dialog.title(f"创建分区 - {disk_path}") self.dialog.title(f"创建分区 - {disk_path}")
self.dialog.geometry("550x575") self.dialog.geometry("550x550")
self.dialog.minsize(500, 575) self.dialog.minsize(500, 500)
self.dialog.transient(parent) self.dialog.transient(parent)
self.dialog.grab_set() self.dialog.grab_set()
@@ -311,90 +470,99 @@ class EnhancedPartitionDialog:
self._create_widgets() self._create_widgets()
def _center_window(self): def _center_window(self):
"""居中窗口"""
self.dialog.update_idletasks() self.dialog.update_idletasks()
width = self.dialog.winfo_width() width = 550
height = self.dialog.winfo_height() height = 550
x = (self.dialog.winfo_screenwidth() // 2) - (width // 2) x = (self.dialog.winfo_screenwidth() // 2) - (width // 2)
y = (self.dialog.winfo_screenheight() // 2) - (height // 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): def _create_widgets(self):
"""创建界面元素"""
# 主框架
main_frame = ttk.Frame(self.dialog, padding="15") main_frame = ttk.Frame(self.dialog, padding="15")
main_frame.pack(fill=tk.BOTH, expand=True) 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 = 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 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,
ttk.Label(info_frame, text=f"总容量: {total_gb:.2f} GB").pack(anchor=tk.W, pady=2) text=f"磁盘总大小: {total_gb:.2f} GB").pack(anchor=tk.W)
ttk.Label(info_frame, text=f"可用空间: {max_gb:.2f} GB", ttk.Label(info_frame,
foreground="green").pack(anchor=tk.W, pady=2) 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") part_type_frame = ttk.LabelFrame(main_frame, text="分区表类型", padding="10")
pt_frame.pack(fill=tk.X, pady=(0, 15)) part_type_frame.pack(fill=tk.X, pady=(0, 10))
self.part_type_var = tk.StringVar(value="gpt") self.part_type_var = tk.StringVar(value="gpt")
ttk.Radiobutton(pt_frame, text="GPT (推荐,支持大容量磁盘)", ttk.Radiobutton(part_type_frame, text="GPT (推荐,支持2TB以上磁盘)",
variable=self.part_type_var, value="gpt").pack(anchor=tk.W, pady=2) variable=self.part_type_var,
ttk.Radiobutton(pt_frame, text="MBR/MSDOS (兼容旧系统)", value="gpt").pack(anchor=tk.W, pady=2)
variable=self.part_type_var, value="msdos").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 = 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) self.use_max_var = tk.BooleanVar(value=False)
ttk.Checkbutton(size_frame, text="使用最大可用空间", ttk.Checkbutton(size_frame,
text="使用全部可用空间",
variable=self.use_max_var, variable=self.use_max_var,
command=self._toggle_size_input).pack(anchor=tk.W, pady=5) command=self._toggle_size_input).pack(anchor=tk.W, pady=5)
# 滑块和输入框容器 # 大小输入
self.size_control_frame = ttk.Frame(size_frame) size_input_frame = ttk.Frame(size_frame)
self.size_control_frame.pack(fill=tk.X, pady=5) size_input_frame.pack(fill=tk.X, pady=5)
# 滑块 ttk.Label(size_input_frame, text="大小 (GB):").pack(side=tk.LEFT)
self.size_var = tk.DoubleVar(value=max_gb)
self.size_slider = ttk.Scale( self.size_var = tk.DoubleVar(value=min(available_gb, 100))
self.size_control_frame, # 使用 tk.Spinbox 兼容旧版 tkinter (Python 3.6)
try:
self.size_spin = tk.Spinbox(size_input_frame,
from_=0.1, from_=0.1,
to=max_gb, to=available_gb,
orient=tk.HORIZONTAL,
variable=self.size_var,
command=self._on_slider_change
)
self.size_slider.pack(fill=tk.X, pady=5)
# 输入框和标签
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, textvariable=self.size_var,
width=10, width=10,
command=self._on_spin_change 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) 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_var = tk.StringVar(value="0%")
self.percent_label.pack(side=tk.LEFT, padx=10) 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() self._toggle_size_input()
@@ -405,90 +573,49 @@ class EnhancedPartitionDialog:
ttk.Button(btn_frame, text="取消", ttk.Button(btn_frame, text="取消",
command=self._on_cancel).pack(side=tk.RIGHT, padx=5) 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) command=self._on_confirm).pack(side=tk.RIGHT, padx=5)
def _create_space_visualizer(self, parent, max_gb): def _on_size_change(self, *args):
"""创建空间可视化条""" """大小输入变化时更新滑块"""
viz_frame = ttk.LabelFrame(parent, text="空间使用预览", padding="10") try:
viz_frame.pack(fill=tk.X, pady=(0, 15)) value = self.size_var.get()
self.size_slider.set(value)
# Canvas 用于绘制 self._update_percent(value)
self.viz_canvas = tk.Canvas(viz_frame, height=40, bg="white") except:
self.viz_canvas.pack(fill=tk.X, pady=5) pass
# 等待 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_slider_change(self, value): def _on_slider_change(self, value):
"""滑块变化回调""" """滑块变化时更新输入"""
try: try:
gb = float(value) self.size_var.set(float(value))
self._update_percent(gb) self._update_percent(float(value))
self._update_visualizer(gb, self.max_available_mib / 1024)
except: except:
pass pass
def _on_spin_change(self): def _update_percent(self, value):
"""输入框变化回调"""
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):
"""更新百分比显示""" """更新百分比显示"""
max_gb = self.max_available_mib / 1024 if self.max_available_mib > 0:
if max_gb > 0: percent = (value * 1024 / self.max_available_mib) * 100
percent = (gb / max_gb) * 100 self.percent_var.set(f"{percent:.1f}%")
self.percent_label.configure(text=f"({percent:.1f}%)")
def _toggle_size_input(self): def _toggle_size_input(self):
"""切换大小输入状态""" """切换大小输入状态"""
if self.use_max_var.get(): if self.use_max_var.get():
# ttk.Scale 在某些旧版本 Tkinter 中不支持 state 选项
try:
self.size_slider.configure(state=tk.DISABLED) self.size_slider.configure(state=tk.DISABLED)
except tk.TclError:
pass # 忽略不支持的选项
self.size_spin.configure(state=tk.DISABLED) self.size_spin.configure(state=tk.DISABLED)
max_gb = self.max_available_mib / 1024 max_gb = self.max_available_mib / 1024
self.size_var.set(max_gb) self.size_var.set(max_gb)
self._update_percent(max_gb) self._update_percent(max_gb)
self._update_visualizer(max_gb, max_gb)
else: else:
try:
self.size_slider.configure(state=tk.NORMAL) self.size_slider.configure(state=tk.NORMAL)
except tk.TclError:
pass # 忽略不支持的选项
self.size_spin.configure(state=tk.NORMAL) self.size_spin.configure(state=tk.NORMAL)
def _on_confirm(self): def _on_confirm(self):
@@ -508,8 +635,8 @@ class EnhancedPartitionDialog:
'disk_path': self.disk_path, 'disk_path': self.disk_path,
'partition_table_type': self.part_type_var.get(), 'partition_table_type': self.part_type_var.get(),
'size_gb': size_gb, '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() self.dialog.destroy()

View File

@@ -19,11 +19,13 @@ class SmartInfoDialog:
self.dialog.title(f"SMART 信息 - {device_path}") self.dialog.title(f"SMART 信息 - {device_path}")
self.dialog.geometry("700x600") self.dialog.geometry("700x600")
self.dialog.transient(parent) self.dialog.transient(parent)
self.dialog.grab_set()
self._center_window() self._center_window()
self._create_widgets() self._create_widgets()
# 窗口可见后再设置 grab_set
self.dialog.after(100, self.dialog.grab_set)
def _center_window(self): def _center_window(self):
"""居中显示""" """居中显示"""
self.dialog.update_idletasks() self.dialog.update_idletasks()
@@ -213,12 +215,14 @@ class SmartOverviewDialog:
self.dialog.title("SMART 健康监控 - 总览") self.dialog.title("SMART 健康监控 - 总览")
self.dialog.geometry("800x500") self.dialog.geometry("800x500")
self.dialog.transient(parent) self.dialog.transient(parent)
self.dialog.grab_set()
self._center_window() self._center_window()
self._create_widgets() self._create_widgets()
self._refresh_data() self._refresh_data()
# 窗口可见后再设置 grab_set
self.dialog.after(100, self.dialog.grab_set)
def _center_window(self): def _center_window(self):
"""居中显示""" """居中显示"""
self.dialog.update_idletasks() self.dialog.update_idletasks()

72
disk-manager-compat.spec Normal file
View File

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

View File

@@ -210,7 +210,7 @@ class DiskOperations:
else: else:
messagebox.showerror("错误", f"挂载设备 {device_path} 失败。\n\n" messagebox.showerror("错误", f"挂载设备 {device_path} 失败。\n\n"
f"普通挂载和 ntfs-3g 挂载均失败。\n" f"普通挂载和 ntfs-3g 挂载均失败。\n"
f"如果是 NTFS 文件系统损坏,请先使用文件系统工具中的 "修复文件系统 (ntfsfix)" 修复。") "如果是 NTFS 文件系统损坏,请先使用文件系统工具中的 '修复文件系统 (ntfsfix)' 修复。")
return False return False
else: else:
messagebox.showerror("错误", f"挂载设备 {device_path} 失败: {stderr}") messagebox.showerror("错误", f"挂载设备 {device_path} 失败: {stderr}")
@@ -308,11 +308,11 @@ class DiskOperations:
success_check, stdout_check, stderr_check = self._execute_shell_command( success_check, stdout_check, stderr_check = self._execute_shell_command(
["parted", "-s", disk_path, "print"], ["parted", "-s", disk_path, "print"],
f"检查磁盘 {disk_path} 分区表失败", f"检查磁盘 {disk_path} 分区表失败",
suppress_critical_dialog_on_stderr_match=("无法辨识的磁盘卷标", "unrecognized disk label"), suppress_critical_dialog_on_stderr_match=("无法辨识的磁盘卷标", "unrecognised disk label"),
root_privilege=True root_privilege=True
) )
if not success_check: 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} 没有可识别的分区表。") logger.info(f"磁盘 {disk_path} 没有可识别的分区表。")
has_partition_table = False has_partition_table = False
else: else:
@@ -538,7 +538,7 @@ class DiskOperations:
try: try:
result = subprocess.run( result = subprocess.run(
["losetup", "-a"], ["losetup", "-a"],
capture_output=True, text=True, check=False stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding='utf-8', check=False
) )
if result.returncode == 0: if result.returncode == 0:
for line in result.stdout.splitlines(): for line in result.stdout.splitlines():

View File

@@ -1,25 +1,29 @@
# -*- mode: python ; coding: utf-8 -*- # -*- mode: python ; coding: utf-8 -*-
a = Analysis( block_cipher = None
['mainwindow_tkinter.py'],
a = Analysis(['main.py'],
pathex=[], pathex=[],
binaries=[('/usr/lib/python3.14/lib-dynload/_tkinter.cpython-314-x86_64-linux-gnu.so', '.')], binaries=[],
datas=[('/usr/lib', 'tcl'), ('/usr/lib/tcl8.6', 'tk'), ('/usr/lib64/tcl8.6', 'tcltk'), ('/usr/lib/tcl8.6', 'tcltk')], datas=[],
hiddenimports=['tkinter', 'tkinter.ttk', 'tkinter.scrolledtext', 'tkinter.messagebox', 'tkinter.filedialog', 'tkinter.simpledialog', '_tkinter'], hiddenimports=[],
hookspath=[], hookspath=[],
hooksconfig={}, hooksconfig={},
runtime_hooks=[], runtime_hooks=[],
excludes=[], excludes=[],
noarchive=False, win_no_prefer_redirects=False,
optimize=0, win_private_assemblies=False,
) cipher=block_cipher,
pyz = PYZ(a.pure) noarchive=False)
pyz = PYZ(a.pure, a.zipped_data,
cipher=block_cipher)
exe = EXE( exe = EXE(pyz,
pyz,
a.scripts, a.scripts,
a.binaries, a.binaries,
a.zipfiles,
a.datas, a.datas,
[], [],
name='linux-storage-manager', name='linux-storage-manager',
@@ -31,8 +35,6 @@ exe = EXE(
runtime_tmpdir=None, runtime_tmpdir=None,
console=True, console=True,
disable_windowed_traceback=False, disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None, target_arch=None,
codesign_identity=None, codesign_identity=None,
entitlements_file=None, entitlements_file=None )
)

View File

@@ -39,11 +39,12 @@ class LvmOperations:
try: try:
result = subprocess.run( result = subprocess.run(
command_list, command_list,
capture_output=True, stdout=subprocess.PIPE,
text=True, stderr=subprocess.PIPE,
check=True,
encoding='utf-8', 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}") logger.info(f"命令成功: {full_cmd_str}")
return True, result.stdout.strip(), result.stderr.strip() return True, result.stdout.strip(), result.stderr.strip()

View File

@@ -31,8 +31,9 @@ class LvmOperations:
else: else:
result = subprocess.run( result = subprocess.run(
["sudo"] + command_list if root_privilege else command_list, ["sudo"] + command_list if root_privilege else command_list,
capture_output=True, text=True, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding='utf-8', check=True,
input=input_data input=input_data,
stdin=subprocess.PIPE if input_data else None
) )
stdout = result.stdout stdout = result.stdout
stderr = result.stderr stderr = result.stderr

131
main.py Executable file
View File

@@ -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()

View File

@@ -26,7 +26,8 @@ from dialogs_tkinter import (CreatePartitionDialog, MountDialog, CreateRaidDialo
from dialogs_smart import SmartInfoDialog, SmartOverviewDialog from dialogs_smart import SmartInfoDialog, SmartOverviewDialog
from smart_monitor import SmartMonitor from smart_monitor import SmartMonitor
# 导入增强对话框 # 导入增强对话框
from dialogs_enhanced import EnhancedFormatDialog, EnhancedRaidDeleteDialog, EnhancedPartitionDialog from dialogs_enhanced import (EnhancedFormatDialog, EnhancedRaidDeleteDialog,
EnhancedPartitionDialog, DependencyCheckDialog)
class MainWindow: class MainWindow:
@@ -138,6 +139,8 @@ class MainWindow:
# 帮助菜单 # 帮助菜单
help_menu = tk.Menu(menubar, tearoff=0) help_menu = tk.Menu(menubar, tearoff=0)
menubar.add_cascade(label="帮助", menu=help_menu) 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) help_menu.add_command(label="关于", command=self._show_about)
def _show_about(self): def _show_about(self):
@@ -155,6 +158,10 @@ class MainWindow:
"使用 Python 3.6+ 和 Tkinter 构建" "使用 Python 3.6+ 和 Tkinter 构建"
) )
def _show_dependency_check(self):
"""显示依赖检查对话框"""
DependencyCheckDialog(self.root)
def _create_block_devices_tree(self): def _create_block_devices_tree(self):
"""创建块设备 Treeview""" """创建块设备 Treeview"""
# 创建框架 # 创建框架
@@ -407,6 +414,48 @@ class MainWindow:
menu.add_command(label=f"擦除分区表 {device_path}...", menu.add_command(label=f"擦除分区表 {device_path}...",
command=lambda: self._handle_wipe_partition_table(device_path)) command=lambda: self._handle_wipe_partition_table(device_path))
menu.add_separator() 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 信息 # SMART 信息
menu.add_command(label=f"查看 SMART 信息 {device_path}", menu.add_command(label=f"查看 SMART 信息 {device_path}",
command=lambda: self._handle_show_smart(device_path)) command=lambda: self._handle_show_smart(device_path))
@@ -1092,8 +1141,17 @@ class MainWindow:
self.refresh_all_info() self.refresh_all_info()
def _handle_format_raid_array(self, array_path): def _handle_format_raid_array(self, array_path):
"""处理格式化 RAID 阵列""" """处理格式化 RAID 阵列 - 使用增强型对话框"""
self.disk_ops.format_partition(array_path) # 获取设备信息
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 处理函数 === # === LVM 处理函数 ===
def refresh_lvm_info(self): def refresh_lvm_info(self):

View File

@@ -115,7 +115,7 @@ class OccupationResolver:
try: try:
result = subprocess.run( result = subprocess.run(
["sudo", "lsof", device_path], ["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(): if result.returncode == 0 and result.stdout.strip():
logger.info(f"设备 {device_path} 被以下进程占用:\n{result.stdout}") logger.info(f"设备 {device_path} 被以下进程占用:\n{result.stdout}")
@@ -127,7 +127,7 @@ class OccupationResolver:
try: try:
result = subprocess.run( result = subprocess.run(
["sudo", "fuser", device_path], ["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(): if result.returncode == 0 and result.stdout.strip():
logger.info(f"设备 {device_path} 被以下进程占用: {result.stdout.strip()}") logger.info(f"设备 {device_path} 被以下进程占用: {result.stdout.strip()}")
@@ -149,7 +149,7 @@ class OccupationResolver:
# 使用 fuser -k 终止进程 # 使用 fuser -k 终止进程
result = subprocess.run( result = subprocess.run(
["sudo", "fuser", "-k", device_path], ["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 表示没有进程被终止 if result.returncode == 0 or result.returncode == 1: # 1 表示没有进程被终止

View File

@@ -203,8 +203,9 @@ class PartitionTableBackup:
# 使用 sfdisk 备份分区表 # 使用 sfdisk 备份分区表
result = subprocess.run( result = subprocess.run(
["sudo", "sfdisk", "-d", device_path], ["sudo", "sfdisk", "-d", device_path],
capture_output=True, stdout=subprocess.PIPE,
text=True, stderr=subprocess.PIPE,
encoding='utf-8',
check=True, check=True,
timeout=30 timeout=30
) )
@@ -236,8 +237,10 @@ class PartitionTableBackup:
result = subprocess.run( result = subprocess.run(
["sudo", "sfdisk", device_path], ["sudo", "sfdisk", device_path],
input=partition_data, input=partition_data,
capture_output=True, stdout=subprocess.PIPE,
text=True, stderr=subprocess.PIPE,
stdin=subprocess.PIPE,
encoding='utf-8',
check=True, check=True,
timeout=30 timeout=30
) )

337
platform_compat.py Executable file
View File

@@ -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}")

19
run.sh Executable file
View File

@@ -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

View File

@@ -5,10 +5,38 @@ import subprocess
import logging import logging
import re import re
import json import json
import sys
from typing import Dict, List, Optional, Tuple from typing import Dict, List, Optional, Tuple
from dataclasses import dataclass
from enum import Enum 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__) logger = logging.getLogger(__name__)
@@ -77,8 +105,9 @@ class SmartMonitor:
try: try:
result = subprocess.run( result = subprocess.run(
["which", "smartctl"], ["which", "smartctl"],
capture_output=True, stdout=subprocess.PIPE,
text=True, stderr=subprocess.PIPE,
encoding='utf-8',
check=False check=False
) )
return result.returncode == 0 return result.returncode == 0
@@ -99,8 +128,9 @@ class SmartMonitor:
# 获取基本信息 # 获取基本信息
result = subprocess.run( result = subprocess.run(
["sudo", "smartctl", "-a", device_path], ["sudo", "smartctl", "-a", device_path],
capture_output=True, stdout=subprocess.PIPE,
text=True, stderr=subprocess.PIPE,
encoding='utf-8',
check=False, check=False,
timeout=30 timeout=30
) )
@@ -232,8 +262,9 @@ class SmartMonitor:
# 获取所有块设备 # 获取所有块设备
result = subprocess.run( result = subprocess.run(
["lsblk", "-d", "-n", "-o", "NAME,TYPE,ROTA"], ["lsblk", "-d", "-n", "-o", "NAME,TYPE,ROTA"],
capture_output=True, stdout=subprocess.PIPE,
text=True, stderr=subprocess.PIPE,
encoding='utf-8',
check=True check=True
) )
@@ -270,7 +301,7 @@ class SmartMonitor:
score = 70 score = 70
# 根据错误数量调整 # 根据错误数量调整
error_count = len(smart_info.errors) error_count = len(smart_info.errors) if smart_info.errors else 0
score -= error_count * 10 score -= error_count * 10
# 确保分数在合理范围内 # 确保分数在合理范围内

364
system_compat.py Executable file
View File

@@ -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

View File

@@ -4,6 +4,7 @@ import json
import logging import logging
import re import re
import os import os
from system_compat import get_system_compat
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -26,20 +27,26 @@ class SystemInfoManager:
logger.debug(f"运行命令: {' '.join(command_list)}") logger.debug(f"运行命令: {' '.join(command_list)}")
try: try:
# Python 3.6 兼容: 使用 stdout/stderr 参数代替 capture_output
# Python 3.6 兼容: 不使用 text 参数,手动解码
result = subprocess.run( result = subprocess.run(
command_list, command_list,
capture_output=check_output, stdout=subprocess.PIPE if check_output else None,
text=True, stderr=subprocess.PIPE if check_output else None,
stdin=subprocess.PIPE if input_data else None,
check=True, check=True,
encoding='utf-8', encoding='utf-8' if check_output else None,
input=input_data 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: except subprocess.CalledProcessError as e:
logger.error(f"命令执行失败: {' '.join(command_list)}") logger.error(f"命令执行失败: {' '.join(command_list)}")
logger.error(f"退出码: {e.returncode}") logger.error(f"退出码: {e.returncode}")
logger.error(f"标准输出: {e.stdout.strip()}") logger.error(f"标准输出: {e.stdout.strip() if e.stdout else ''}")
logger.error(f"标准错误: {e.stderr.strip()}") logger.error(f"标准错误: {e.stderr.strip() if e.stderr else ''}")
raise raise
except FileNotFoundError: except FileNotFoundError:
logger.error(f"命令 '{command_list[0]}' 未找到。请确保已安装相关工具。") logger.error(f"命令 '{command_list[0]}' 未找到。请确保已安装相关工具。")
@@ -52,33 +59,32 @@ class SystemInfoManager:
""" """
使用 lsblk 获取块设备信息。 使用 lsblk 获取块设备信息。
返回一个字典列表,每个字典代表一个设备。 返回一个字典列表,每个字典代表一个设备。
自动适配不同系统的 lsblk 版本。
""" """
cmd = [
"lsblk", "-J", "-o",
"NAME,FSTYPE,SIZE,MOUNTPOINT,RO,TYPE,UUID,PARTUUID,VENDOR,MODEL,SERIAL,MAJ:MIN,PKNAME,PATH"
]
try: try:
stdout, _ = self._run_command(cmd) # 使用兼容性模块获取适当的命令
data = json.loads(stdout) compat = get_system_compat()
devices = data.get('blockdevices', []) cmd, need_manual_parse = compat.get_lsblk_command()
def add_path_recursive(dev_list): stdout, _ = self._run_command(cmd)
devices = compat.parse_lsblk_output(stdout, need_manual_parse)
# 统一处理 path 字段
def normalize_path_recursive(dev_list):
for dev in dev_list: for dev in dev_list:
if 'PATH' not in dev and 'NAME' in dev: if 'path' not in dev and 'name' in dev:
dev['PATH'] = f"/dev/{dev['NAME']}" dev['path'] = f"/dev/{dev['name']}"
# Rename PATH to path (lowercase) for consistency
if 'PATH' in dev: if 'PATH' in dev:
dev['path'] = dev.pop('PATH') dev['path'] = dev.pop('PATH')
if 'children' in dev: if 'children' in dev:
add_path_recursive(dev['children']) normalize_path_recursive(dev['children'])
add_path_recursive(devices)
normalize_path_recursive(devices)
return devices return devices
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
logger.error(f"获取块设备信息失败: {e.stderr.strip()}") logger.error(f"获取块设备信息失败: {e.stderr.strip()}")
return [] return []
except json.JSONDecodeError as e:
logger.error(f"解析 lsblk JSON 输出失败: {e}")
return []
except Exception as e: except Exception as e:
logger.error(f"获取块设备信息失败 (未知错误): {e}") logger.error(f"获取块设备信息失败 (未知错误): {e}")
return [] return []
@@ -419,85 +425,50 @@ class SystemInfoManager:
def get_lvm_info(self): def get_lvm_info(self):
""" """
获取 LVM 物理卷 (PVs)、卷组 (VGs) 和逻辑卷 (LVs) 的信息。 获取 LVM 物理卷 (PVs)、卷组 (VGs) 和逻辑卷 (LVs) 的信息。
自动适配不同系统的 LVM 版本。
""" """
lvm_info = {'pvs': [], 'vgs': [], 'lvs': []} lvm_info = {'pvs': [], 'vgs': [], 'lvs': []}
# 使用兼容性模块获取适当的命令
compat = get_system_compat()
commands = compat.get_lvm_commands()
# Get PVs # Get PVs
try: try:
stdout, stderr = self._run_command(["pvs", "--reportformat", "json"]) cmd, need_manual_parse = commands['pvs']
data = json.loads(stdout) stdout, stderr = self._run_command(cmd)
if 'report' in data and data['report']: lvm_info['pvs'] = compat.parse_pvs_output(stdout, need_manual_parse)
for pv_data in data['report'][0].get('pv', []):
lvm_info['pvs'].append({
'pv_name': pv_data.get('pv_name'),
'vg_name': pv_data.get('vg_name'),
'pv_uuid': pv_data.get('pv_uuid'),
'pv_size': pv_data.get('pv_size'),
'pv_free': pv_data.get('pv_free'),
'pv_attr': pv_data.get('pv_attr'),
'pv_fmt': pv_data.get('pv_fmt')
})
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
if "No physical volume found" in e.stderr or "No physical volumes found" in e.stdout: if "No physical volume found" in e.stderr or "No physical volumes found" in e.stdout:
logger.info("未找到任何LVM物理卷。") logger.info("未找到任何LVM物理卷。")
else: else:
logger.error(f"获取LVM物理卷信息失败: {e.stderr.strip()}") logger.error(f"获取LVM物理卷信息失败: {e.stderr.strip()}")
except json.JSONDecodeError as e:
logger.error(f"解析LVM物理卷JSON输出失败: {e}")
except Exception as e: except Exception as e:
logger.error(f"获取LVM物理卷信息失败 (未知错误): {e}") logger.error(f"获取LVM物理卷信息失败 (未知错误): {e}")
# Get VGs # Get VGs
try: try:
stdout, stderr = self._run_command(["vgs", "--reportformat", "json"]) cmd, need_manual_parse = commands['vgs']
data = json.loads(stdout) stdout, stderr = self._run_command(cmd)
if 'report' in data and data['report']: lvm_info['vgs'] = compat.parse_vgs_output(stdout, need_manual_parse)
for vg_data in data['report'][0].get('vg', []):
lvm_info['vgs'].append({
'vg_name': vg_data.get('vg_name'),
'vg_uuid': vg_data.get('vg_uuid'),
'vg_size': vg_data.get('vg_size'),
'vg_free': vg_data.get('vg_free'),
'vg_attr': vg_data.get('vg_attr'),
'pv_count': vg_data.get('pv_count'),
'lv_count': vg_data.get('lv_count'),
'vg_alloc_percent': vg_data.get('vg_alloc_percent'),
'vg_fmt': vg_data.get('vg_fmt')
})
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
if "No volume group found" in e.stderr or "No volume groups found" in e.stdout: if "No volume group found" in e.stderr or "No volume groups found" in e.stdout:
logger.info("未找到任何LVM卷组。") logger.info("未找到任何LVM卷组。")
else: else:
logger.error(f"获取LVM卷组信息失败: {e.stderr.strip()}") logger.error(f"获取LVM卷组信息失败: {e.stderr.strip()}")
except json.JSONDecodeError as e:
logger.error(f"解析LVM卷组JSON输出失败: {e}")
except Exception as e: except Exception as e:
logger.error(f"获取LVM卷组信息失败 (未知错误): {e}") logger.error(f"获取LVM卷组信息失败 (未知错误): {e}")
# Get LVs (MODIFIED: added -o lv_path) # Get LVs
try: try:
# 明确请求 lv_path因为默认的 --reportformat json 不包含它 cmd, need_manual_parse = commands['lvs']
stdout, stderr = self._run_command(["lvs", "-o", "lv_name,vg_name,lv_uuid,lv_size,lv_attr,origin,snap_percent,lv_path", "--reportformat", "json"]) stdout, stderr = self._run_command(cmd)
data = json.loads(stdout) lvm_info['lvs'] = compat.parse_lvs_output(stdout, need_manual_parse)
if 'report' in data and data['report']:
for lv_data in data['report'][0].get('lv', []):
lvm_info['lvs'].append({
'lv_name': lv_data.get('lv_name'),
'vg_name': lv_data.get('vg_name'),
'lv_uuid': lv_data.get('lv_uuid'),
'lv_size': lv_data.get('lv_size'),
'lv_attr': lv_data.get('lv_attr'),
'origin': lv_data.get('origin'),
'snap_percent': lv_data.get('snap_percent'),
'lv_path': lv_data.get('lv_path') # 现在应该能正确获取到路径了
})
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
if "No logical volume found" in e.stderr or "No logical volumes found" in e.stdout: if "No logical volume found" in e.stderr or "No logical volumes found" in e.stdout:
logger.info("未找到任何LVM逻辑卷。") logger.info("未找到任何LVM逻辑卷。")
else: else:
logger.error(f"获取LVM逻辑卷信息失败: {e.stderr.strip()}") logger.error(f"获取LVM逻辑卷信息失败: {e.stderr.strip()}")
except json.JSONDecodeError as e:
logger.error(f"解析LVM逻辑卷JSON输出失败: {e}")
except Exception as e: except Exception as e:
logger.error(f"获取LVM逻辑卷信息失败 (未知错误): {e}") logger.error(f"获取LVM逻辑卷信息失败 (未知错误): {e}")