feat: add Proxmox VM GUI controller in Rust

This commit is contained in:
xiaji
2026-04-10 22:44:00 +08:00
parent a022e9d7ce
commit 7f6c8bff83
6 changed files with 791 additions and 0 deletions

23
.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
# Build output
target/
*.exe
# Dependencies
Cargo.lock
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Local config (sensitive)
.env
*.local
# Logs
*.log

24
Cargo.toml Normal file
View File

@@ -0,0 +1,24 @@
[package]
name = "proxmox-vm-gui"
version = "0.1.0"
edition = "2021"
[dependencies]
egui = "0.29"
eframe = "0.29"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
dotenv = "0.15"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
chrono = "0.4"
[profile.release]
opt-level = 3
lto = true
codegen-units = 1
strip = true
# MinGW target config
[target.x86_64-pc-windows-gnu]
linker = "x86_64-w64-mingw32-gcc"

View File

@@ -0,0 +1,422 @@
# Proxmox VM GUI 控制工具实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 将 Python Proxmox 控制器改写为 Rust GUI 应用,使用 egui 框架,通过 Proxmox API 控制 Windows VM
**Architecture:** 单窗口 GUI 应用,顶部显示状态和控制按钮,底部显示操作日志
**Tech Stack:**
- GUI: egui 0.29
- HTTP: reqwest (rust-tls)
- 配置: dotenv + serde
---
### Task 1: 创建 Rust 项目结构
**Files:**
- Create: `Cargo.toml`
- Create: `src/main.rs`
- Create: `.env.example`
- [ ] **Step 1: 创建 Cargo.toml**
```toml
[package]
name = "proxmox-vm-gui"
version = "0.1.0"
edition = "2021"
[dependencies]
egui = "0.29"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
dotenv = "0.15"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
chrono = "0.4"
[profile.release]
opt-level = 3
lto = true
codegen-units = 1
[target.x86_64-pc-windows-gnu]
linker = "x86_64-w64-mingw32-gcc"
```
- [ ] **Step 2: 创建 src/main.rs 骨架**
```rust
#![windows_subsystem = "windows"]
fn main() {
println!("Hello");
}
```
- [ ] **Step 3: 创建 .env.example**
```
PROXMOX_HOST=proxmox.example.com
PROXMOX_USER=root@pam
PROXMOX_TOKEN=your-api-token-here
VM_ID=100
```
- [ ] **Step 4: 验证编译**
Run: `cargo build --target x86_64-pc-windows-gnu`
Expected: 编译成功生成 exe
---
### Task 2: 实现 Proxmox API 客户端
**Files:**
- Create: `src/api.rs`
- Modify: `src/main.rs`
Proxmox API 基础结构,用于所有 API 调用
- [ ] **Step 1: 创建 src/api.rs**
```rust
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::RwLock;
#[derive(Debug, Clone)]
pub struct ProxmoxClient {
client: reqwest::Client,
base_url: String,
token_id: String,
token_secret: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct VmStatus {
pub status: String,
pub uptime: u64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ApiResponse<T> {
pub data: Option<T>,
}
impl ProxmoxClient {
pub fn new(host: &str, token_id: &str, token_secret: &str) -> Self {
let base_url = format!("https://{}:8006/api2/json", host);
Self {
client: reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.build()
.unwrap(),
base_url,
token_id: token_id.to_string(),
token_secret: token_secret.to_string(),
}
}
fn auth_header(&self) -> String {
format!("PVEAPIToken={}={}", self.token_id, self.token_secret)
}
pub async fn get_vm_status(&self, node: &str, vm_id: u32) -> Result<String, String> {
let url = format!("{}/nodes/{}/qemu/{}/status/current", self.base_url, node, vm_id);
let resp = self.client
.get(&url)
.header("Authorization", self.auth_header())
.send()
.await
.map_err(|e| e.to_string())?;
let data: ApiResponse<VmStatus> = resp.json().await.map_err(|e| e.to_string())?;
Ok(data.data.map(|d| d.status).unwrap_or_else(|| "unknown".to_string()))
}
pub async fn start_vm(&self, node: &str, vm_id: u32) -> Result<(), String> {
let url = format!("{}/nodes/{}/qemu/{}/status/start", self.base_url, node, vm_id);
self.client
.post(&url)
.header("Authorization", self.auth_header())
.send()
.await
.map_err(|e| e.to_string())?;
Ok(())
}
pub async fn stop_vm(&self, node: &str, vm_id: u32) -> Result<(), String> {
let url = format!("{}/nodes/{}/qemu/{}/status/stop", self.base_url, node, vm_id);
self.client
.post(&url)
.header("Authorization", self.auth_header())
.send()
.await
.map_err(|e| e.to_string())?;
Ok(())
}
pub async fn reboot_vm(&self, node: &str, vm_id: u32) -> Result<(), String> {
self.stop_vm(node, vm_id).await?;
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
self.start_vm(node, vm_id).await
}
}
```
- [ ] **Step 2: 修改 main.rs 添加基础结构**
导入 api 模块,添加空 main 函数
```rust
mod api;
use api::ProxmoxClient;
fn main() {
println!("Proxmox VM GUI");
}
```
- [ ] **Step 3: 测试 API 编译**
Run: `cargo build --target x86_64-pc-windows-gnu`
Expected: 编译成功
---
### Task 3: 实现 egui GUI 界面
**Files:**
- Create: `src/gui.rs`
- Modify: `src/main.rs`
核心 GUI 界面:状态显示、控制按钮、日志区域
- [ ] **Step 1: 创建 src/gui.rs**
```rust
use egui::*;
use crate::api::ProxmoxClient;
use std::sync::Arc;
use tokio::sync::RwLock;
pub struct AppState {
pub client: Arc<RwLock<Option<ProxmoxClient>>>,
pub vm_id: u32,
pub node: String,
pub vm_status: String,
pub logs: Vec<String>,
pub is_connected: bool,
}
impl AppState {
pub fn new() -> Self {
Self {
client: Arc::new(RwLock::new(None)),
vm_id: 100,
node: "proxmox".to_string(),
vm_status: "未知".to_string(),
logs: vec!["程序启动".to_string()],
is_connected: false,
}
}
pub fn add_log(&mut self, msg: &str) {
let time = chrono::Local::now().format("%H:%M:%S").to_string();
self.logs.push(format!("{} {}", time, msg));
if self.logs.len() > 100 {
self.logs.remove(0);
}
}
}
pub fn gui_run() {
let options = NativeOptions {
default_viewport: egui::ViewportBuilder::default()
.with_inner_size([500.0, 400.0])
.with_min_inner_size([400.0, 300.0])
.with_title("Proxmox VM 控制器"),
..Default::default()
};
run_native("Proxmox VM 控制器", options, |ctx| {
set_font(ctx);
App::new().clone().run(ctx, &mut AppState::new());
}).unwrap();
}
fn set_font(ctx: &egui::Context) {
let mut style = (*ctx.style()).clone();
style.text_styles = [
(TextStyle::Heading, FontId::new(18.0, "Microsoft YaHei")),
(TextStyle::Body, FontId::new(14.0, "Microsoft YaHei")),
(TextStyle::Button, FontId::new(14.0, "Microsoft YaHei")),
].into();
ctx.set_style(style);
}
struct App;
impl App {
fn new() -> Self {
Self
}
fn run(self, ctx: &egui::Context, state: &mut AppState) {
egui::CentralPanel::default().show(ctx, |ui| {
self.ui_content(ui, state);
});
}
fn ui_content(&self, ui: &mut Ui, state: &mut AppState) {
ui.heading("Proxmox VM 控制器");
ui.separator();
ui.horizontal(|ui| {
ui.label("连接状态: ");
ui.label(if state.is_connected { "● 已<><E5B7B2>" } else { "○ 未连接" });
});
ui.horizontal(|ui| {
ui.label("VM ID: ");
ui.add(egui::DragValue::new(&mut state.vm_id).clamp_range(100..=999));
});
ui.horizontal(|ui| {
ui.label("VM 状态: ");
ui.label(&state.vm_status);
});
ui.separator();
ui.horizontal(|ui| {
if ui.button("启动").clicked() {
state.add_log("点击了启动按钮");
}
if ui.button("停止").clicked() {
state.add_log("点击了停止按钮");
}
if ui.button("刷新").clicked() {
state.add_log("点击了刷新按钮");
}
});
ui.separator();
ui.label("日志:");
egui::ScrollArea::vertical().stick_to_bottom(true).show(ui, |ui| {
for log in &state.logs {
ui.label(log);
}
});
}
}
```
- [ ] **Step 2: 修改 main.rs**
```rust
#![windows_subsystem = "windows"]
mod api;
mod gui;
fn main() {
gui::gui_run();
}
```
- [ ] **Step 3: 测试 GUI 编译**
Run: `cargo build --target x86_64-pc-windows-gnu`
Expected: 编译成功
---
### Task 4: 集成配置加载
**Files:**
- Modify: `src/gui.rs`
- Create: `.env`
从 .env 文件加载配置
- [ ] **Step 1: 创建 .env 文件**
```
PROXMOX_HOST=proxmox.example.com
PROXMOX_USER=root@pam
PROXMOX_TOKEN=your-token
VM_ID=100
NODE=proxmox
```
- [ ] **Step 2: 修改gui.rs 添加配置加载**
添加启动时加载配置的代码
- [ ] **Step 3: 验证配置加载**
编译测试
---
### Task 5: 连接 API 控制逻辑
**Files:**
- Modify: `src/gui.rs`
将按钮与 API 调用关联
- [ ] **Step 1: 添加异步任务处理**
在按钮点击时调用 API
- [ ] **Step 2: 添加 VM 状态刷新**
刷新按钮获取最新状态
- [ ] **Step 3: 测试完整流程**
编译运行测试
---
### Task 6: 编译发布版本
**Files:**
- Modify: `Cargo.toml`
静态链接配置
- [ ] **Step 1: 更新 Cargo.toml 添加静态链接**
```toml
[dependencies]
rustls = "0.23"
flate2 = "1.0"
[target.x86_64-pc-windows-gnu]
rustflags = "-C link-args=-static"
```
- [ ] **Step 2: 编译发布版本**
Run: `cargo build --release --target x86_64-pc-windows-gnu`
- [ ] **Step 3: 验证单个 exe**
检查是否生成单个 exe 文件
---
**Plan complete and saved to `docs/superpowers/plans/2026-04-10-proxmox-vm-gui.md`. Two execution options:**
**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration
**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints
**Which approach?**

80
src/api.rs Normal file
View File

@@ -0,0 +1,80 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone)]
pub struct ProxmoxClient {
client: reqwest::Client,
base_url: String,
token_id: String,
token_secret: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct VmStatus {
pub status: String,
pub uptime: u64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ApiResponse<T> {
pub data: Option<T>,
}
impl ProxmoxClient {
pub fn new(host: &str, token_id: &str, token_secret: &str) -> Self {
let base_url = format!("https://{}:8006/api2/json", host);
Self {
client: reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.build()
.unwrap(),
base_url,
token_id: token_id.to_string(),
token_secret: token_secret.to_string(),
}
}
fn auth_header(&self) -> String {
format!("PVEAPIToken={}={}", self.token_id, self.token_secret)
}
pub async fn get_vm_status(&self, node: &str, vm_id: u32) -> Result<String, String> {
let url = format!("{}/nodes/{}/qemu/{}/status/current", self.base_url, node, vm_id);
let resp = self.client
.get(&url)
.header("Authorization", self.auth_header())
.send()
.await
.map_err(|e| e.to_string())?;
let data: ApiResponse<VmStatus> = resp.json().await.map_err(|e| e.to_string())?;
Ok(data.data.map(|d| d.status).unwrap_or_else(|| "unknown".to_string()))
}
pub async fn start_vm(&self, node: &str, vm_id: u32) -> Result<(), String> {
let url = format!("{}/nodes/{}/qemu/{}/status/start", self.base_url, node, vm_id);
self.client
.post(&url)
.header("Authorization", self.auth_header())
.send()
.await
.map_err(|e| e.to_string())?;
Ok(())
}
pub async fn stop_vm(&self, node: &str, vm_id: u32) -> Result<(), String> {
let url = format!("{}/nodes/{}/qemu/{}/status/stop", self.base_url, node, vm_id);
self.client
.post(&url)
.header("Authorization", self.auth_header())
.send()
.await
.map_err(|e| e.to_string())?;
Ok(())
}
pub async fn reboot_vm(&self, node: &str, vm_id: u32) -> Result<(), String> {
self.stop_vm(node, vm_id).await?;
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
self.start_vm(node, vm_id).await
}
}

234
src/gui.rs Normal file
View File

@@ -0,0 +1,234 @@
use eframe::{NativeOptions, run_native};
use egui::*;
use crate::api::ProxmoxClient;
use std::sync::{Arc, RwLock, Mutex};
use dotenv::dotenv;
use std::env;
use std::thread;
pub type SharedState = Arc<RwLock<AppState>>;
pub struct AppState {
pub client: Arc<Mutex<Option<ProxmoxClient>>>,
pub vm_id: u32,
pub node: String,
pub vm_status: String,
pub log_buffer: Vec<String>,
pub is_connected: bool,
}
impl AppState {
pub fn new() -> Self {
dotenv().ok();
let host = env::var("PROXMOX_HOST").unwrap_or_default();
let token = env::var("PROXMOX_TOKEN").unwrap_or_default();
let vm_id: u32 = env::var("VM_ID").unwrap_or_default().parse().unwrap_or(100);
let node = env::var("NODE").unwrap_or_else(|_| "proxmox".to_string());
let client = if !host.is_empty() && !token.is_empty() {
let (token_id, token_secret) = if let Some(pos) = token.find('=') {
(token[..pos].to_string(), token[pos+1..].to_string())
} else {
(token.clone(), token.clone())
};
let c = ProxmoxClient::new(&host, &token_id, &token_secret);
Arc::new(Mutex::new(Some(c)))
} else {
Arc::new(Mutex::new(None))
};
let is_connected = !host.is_empty() && !token.is_empty();
Self {
client,
vm_id,
node,
vm_status: "未知".to_string(),
log_buffer: vec!["程序启动".to_string()],
is_connected,
}
}
pub fn add_log(&mut self, msg: &str) {
let time = chrono::Local::now().format("%H:%M:%S").to_string();
self.log_buffer.push(format!("{} {}", time, msg));
if self.log_buffer.len() > 100 {
self.log_buffer.remove(0);
}
}
}
pub fn gui_run() {
let options = NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([500.0, 450.0])
.with_min_inner_size([400.0, 300.0])
.with_title("Proxmox VM 控制器"),
..Default::default()
};
run_native("Proxmox VM 控制器", options, Box::new(|cc| {
set_font(&cc.egui_ctx);
Ok(Box::new(App::new()))
})).unwrap();
}
fn set_font(ctx: &egui::Context) {
let mut style = (*ctx.style()).clone();
style.text_styles = [
(TextStyle::Heading, FontId::new(18.0, FontFamily::Proportional)),
(TextStyle::Body, FontId::new(14.0, FontFamily::Proportional)),
(TextStyle::Button, FontId::new(14.0, FontFamily::Proportional)),
].into();
ctx.set_style(style);
}
struct App {
state: SharedState,
}
impl App {
fn new() -> Self {
Self {
state: Arc::new(RwLock::new(AppState::new())),
}
}
}
impl eframe::App for App {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
let state = self.state.clone();
let ctx_clone = ctx.clone();
egui::CentralPanel::default().show(ctx, |ui| {
ui.heading("Proxmox VM 控制器");
ui.separator();
let mut st = state.write().unwrap();
ui.horizontal(|ui| {
ui.label("连接状态: ");
ui.label(if st.is_connected { "● 已连接" } else { "○ 未连接" });
});
ui.horizontal(|ui| {
ui.label("VM ID: ");
ui.add(egui::DragValue::new(&mut st.vm_id).range(100..=999));
});
ui.horizontal(|ui| {
ui.label("节点: ");
ui.text_edit_singleline(&mut st.node);
});
ui.horizontal(|ui| {
ui.label("VM 状态: ");
ui.label(&st.vm_status);
});
ui.separator();
let vm_id_start = st.vm_id;
let node_start = st.node.clone();
let client_start = st.client.clone();
let vm_id_stop = st.vm_id;
let node_stop = st.node.clone();
let client_stop = st.client.clone();
let vm_id_refresh = st.vm_id;
let node_refresh = st.node.clone();
let client_refresh = st.client.clone();
ui.horizontal(|ui| {
if ui.button("启动").clicked() {
let client = client_start.clone();
let node = node_start.clone();
let vm_id = vm_id_start;
let state = state.clone();
let ctx = ctx_clone.clone();
st.add_log("正在启动 VM...");
ctx.request_repaint();
thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(async {
let client = client.lock().unwrap();
if let Some(c) = client.as_ref() {
c.start_vm(&node, vm_id).await
} else {
Err("未连接".to_string())
}
});
let msg = match result {
Ok(_) => "启动命令已发送".to_string(),
Err(e) => format!("启动失败: {}", e),
};
state.write().unwrap().add_log(&msg);
});
}
if ui.button("停止").clicked() {
let client = client_stop.clone();
let node = node_stop.clone();
let vm_id = vm_id_stop;
let state = state.clone();
let ctx = ctx_clone.clone();
st.add_log("正在停止 VM...");
ctx.request_repaint();
thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(async {
let client = client.lock().unwrap();
if let Some(c) = client.as_ref() {
c.stop_vm(&node, vm_id).await
} else {
Err("未连接".to_string())
}
});
let msg = match result {
Ok(_) => "停止命令已发送".to_string(),
Err(e) => format!("停止失败: {}", e),
};
state.write().unwrap().add_log(&msg);
});
}
if ui.button("刷新").clicked() {
let client = client_refresh.clone();
let node = node_refresh.clone();
let vm_id = vm_id_refresh;
let state = state.clone();
let ctx = ctx_clone.clone();
st.add_log("正在获取状态...");
ctx.request_repaint();
thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(async {
let client = client.lock().unwrap();
if let Some(c) = client.as_ref() {
c.get_vm_status(&node, vm_id).await
} else {
Err("未连接".to_string())
}
});
let msg = match result {
Ok(status) => format!("VM状态: {}", status),
Err(e) => format!("获取状态失败: {}", e),
};
state.write().unwrap().add_log(&msg);
});
}
});
ui.separator();
ui.label("日志:");
egui::ScrollArea::vertical().stick_to_bottom(true).show(ui, |ui| {
for log in &st.log_buffer {
ui.label(log);
}
});
});
}
}

8
src/main.rs Normal file
View File

@@ -0,0 +1,8 @@
#![windows_subsystem = "windows"]
mod api;
mod gui;
fn main() {
gui::gui_run();
}