diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5e5d391 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..b854932 --- /dev/null +++ b/Cargo.toml @@ -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" \ No newline at end of file diff --git a/docs/superpowers/plans/2026-04-10-proxmox-vm-gui.md b/docs/superpowers/plans/2026-04-10-proxmox-vm-gui.md new file mode 100644 index 0000000..4207b8e --- /dev/null +++ b/docs/superpowers/plans/2026-04-10-proxmox-vm-gui.md @@ -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 { + pub data: Option, +} + +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 { + 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 = 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>>, + pub vm_id: u32, + pub node: String, + pub vm_status: String, + pub logs: Vec, + 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 { "● 已��接" } 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?** \ No newline at end of file diff --git a/src/api.rs b/src/api.rs new file mode 100644 index 0000000..711b9ec --- /dev/null +++ b/src/api.rs @@ -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 { + pub data: Option, +} + +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 { + 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 = 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 + } +} \ No newline at end of file diff --git a/src/gui.rs b/src/gui.rs new file mode 100644 index 0000000..676c354 --- /dev/null +++ b/src/gui.rs @@ -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>; + +pub struct AppState { + pub client: Arc>>, + pub vm_id: u32, + pub node: String, + pub vm_status: String, + pub log_buffer: Vec, + 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); + } + }); + }); + } +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..daccc05 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,8 @@ +#![windows_subsystem = "windows"] + +mod api; +mod gui; + +fn main() { + gui::gui_run(); +} \ No newline at end of file