commit 1bc119f083b89db804a3ab2e01ab0e660f466479 Author: xiaji Date: Sat Apr 4 20:15:55 2026 +0800 feat: 初始化项目 - 火山引擎服务器管理工具 diff --git a/README.md b/README.md new file mode 100644 index 0000000..3a677c4 --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# Volcengine Server Manager + +火山引擎云服务器 ECS 管理工具 - Windows 桌面应用 + +## 功能 + +- 查看服务器状态(实例名称、状态、IP、区域、规格) +- 重启服务器(带确认弹窗) +- GUI 配置界面(Access Key ID、Secret Access Key、Endpoint) +- 自动获取所有区域的实例 +- 中文界面支持 + +## 技术栈 + +| 层 | 选型 | +|---|------| +| GUI | egui + eframe (glow) | +| HTTP | reqwest (rustls) | +| 异步运行时 | tokio | +| API 签名 | HMAC-SHA256 | +| 序列化 | serde + serde_json | + +## 编译环境 + +- **工具链**: `x86_64-pc-windows-gnu` (MSYS2 MinGW) +- **不使用**: MSVC / Visual Studio + +## 编译 + +```bash +# 添加目标平台 +rustup target add x86_64-pc-windows-gnu + +# Debug 编译 +cargo build --target x86_64-pc-windows-gnu + +# Release 编译 +cargo build --release --target x86_64-pc-windows-gnu +``` + +输出文件: `target/x86_64-pc-windows-gnu/release/volcengine-server-manager.exe` + +## 配置 + +首次启动时弹出配置窗口,需要填写: + +| 配置项 | 说明 | 必填 | +|--------|------|------| +| Access Key ID | 火山引擎 AK | 是 | +| Secret Access Key | 火山引擎 SK | 是 | +| Endpoint | API 端点,默认 `ecs.volcengineapi.com` | 否 | + +配置文件保存在 `%APPDATA%\volcengine-server-manager\config.json` + +## 截图 + +主界面以卡片形式展示每个实例,显示: +- 实例名称 +- 运行状态(带颜色标识) +- IP 地址 +- 所属区域 +- 实例规格 +- 重启按钮 + +## License + +MIT diff --git a/docs/superpowers/plans/2026-04-04-volcengine-server-manager-plan.md b/docs/superpowers/plans/2026-04-04-volcengine-server-manager-plan.md new file mode 100644 index 0000000..5802420 --- /dev/null +++ b/docs/superpowers/plans/2026-04-04-volcengine-server-manager-plan.md @@ -0,0 +1,994 @@ +# Volcengine Server Manager 实现计划 + +> **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:** 构建 Windows 桌面应用,管理火山引擎 ECS 服务器,支持查看实例状态和重启实例。 + +**Architecture:** 使用 egui + eframe 构建 GUI,通过 HTTP 直接调用火山引擎 API(HMAC-SHA256 签名),异步 API 调用通过 tokio spawn 在后台线程执行,结果通过 channel 返回 UI 线程。 + +**Tech Stack:** Rust, egui 0.29, eframe (glow), reqwest, tokio, serde, serde_json, sha2, hmac, hex, base64, chrono + +--- + +## 文件结构 + +| 文件 | 职责 | 状态 | +|------|------|------| +| `Cargo.toml` | 项目配置、依赖声明 | 新建 | +| `src/main.rs` | 入口、字体加载、启动 eframe | 新建 | +| `src/app.rs` | egui UI 主逻辑、状态管理、事件处理 | 新建 | +| `src/config.rs` | 配置结构体、JSON 读写 | 新建 | +| `src/api.rs` | 火山引擎 API 调用(HTTP + HMAC-SHA256 签名) | 新建 | +| `src/types.rs` | 数据类型定义(InstanceInfo、InstanceStatus 等) | 新建 | + +## 决策:不使用 volcengine-rust-sdk + +原因: +1. SDK 依赖 protobuf,在 MinGW 环境下编译风险高 +2. SDK 功能不完整(仅 11 commits,0 stars),可能不包含 RebootInstance 等接口 +3. 火山引擎 API 签名机制简单(HMAC-SHA256),直接 HTTP 调用更可控 +4. 减少依赖体积,单 exe 更小 + +--- + +### Task 1: 项目初始化 + +**Files:** +- Create: `Cargo.toml` +- Create: `src/main.rs` (最小入口) +- Create: `src/types.rs` +- Create: `src/config.rs` +- Create: `src/api.rs` +- Create: `src/app.rs` + +- [ ] **Step 1: 创建 Cargo.toml** + +```toml +[package] +name = "volcengine-server-manager" +version = "0.1.0" +edition = "2021" + +[dependencies] +eframe = { version = "0.29", default-features = false, features = ["glow"] } +egui = "0.29" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +hmac = "0.12" +hex = "0.4" +base64 = "0.22" +chrono = { version = "0.4", features = ["serde"] } +dirs = "5" +urlencoding = "2.1" +``` + +- [ ] **Step 2: 创建 src/types.rs** + +```rust +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq)] +pub enum InstanceStatus { + Running, + Stopped, + Starting, + Stopping, + Rebooting, + Error, + Unknown, +} + +impl InstanceStatus { + pub fn from_str(s: &str) -> Self { + match s { + "RUNNING" => InstanceStatus::Running, + "STOPPED" => InstanceStatus::Stopped, + "STARTING" => InstanceStatus::Starting, + "STOPPING" => InstanceStatus::Stopping, + "REBOOTING" => InstanceStatus::Rebooting, + "ERROR" => InstanceStatus::Error, + _ => InstanceStatus::Unknown, + } + } + + pub fn label(&self) -> &str { + match self { + InstanceStatus::Running => "运行中", + InstanceStatus::Stopped => "已停止", + InstanceStatus::Starting => "启动中", + InstanceStatus::Stopping => "停止中", + InstanceStatus::Rebooting => "重启中", + InstanceStatus::Error => "错误", + InstanceStatus::Unknown => "未知", + } + } +} + +#[derive(Debug, Clone)] +pub struct InstanceInfo { + pub instance_id: String, + pub instance_name: String, + pub status: InstanceStatus, + pub private_ip: String, + pub public_ip: String, + pub zone_id: String, + pub region_id: String, + pub instance_type: String, + pub creation_time: String, +} + +#[derive(Debug, Clone)] +pub enum AppState { + Loading, + Ready, + Error(String), +} + +#[derive(Debug, Clone)] +pub enum ApiMessage { + InstancesLoaded(Vec), + RebootSuccess(String), + Error(String), +} +``` + +- [ ] **Step 3: 创建 src/config.rs** + +```rust +use serde::{Deserialize, Serialize}; +use std::fs; +use std::io; +use std::path::PathBuf; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppConfig { + pub access_key_id: String, + pub secret_access_key: String, + #[serde(default = "default_endpoint")] + pub endpoint: String, +} + +fn default_endpoint() -> String { + "ecs.volcengineapi.com".to_string() +} + +impl AppConfig { + pub fn is_configured(&self) -> bool { + !self.access_key_id.is_empty() && !self.secret_access_key.is_empty() + } +} + +pub fn config_dir() -> PathBuf { + dirs::config_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("volcengine-server-manager") +} + +pub fn config_path() -> PathBuf { + config_dir().join("config.json") +} + +pub fn load_config() -> Result { + let path = config_path(); + if !path.exists() { + return Ok(AppConfig { + access_key_id: String::new(), + secret_access_key: String::new(), + endpoint: default_endpoint(), + }); + } + let content = fs::read_to_string(&path)?; + let config: AppConfig = serde_json::from_str(&content)?; + Ok(config) +} + +pub fn save_config(config: &AppConfig) -> Result<(), io::Error> { + let dir = config_dir(); + fs::create_dir_all(&dir)?; + let content = serde_json::to_string_pretty(config)?; + fs::write(config_path(), content)?; + Ok(()) +} +``` + +- [ ] **Step 4: 创建 src/api.rs (骨架版本)** + +```rust +use crate::types::InstanceInfo; + +pub struct VolcClient; + +impl VolcClient { + pub fn new(_access_key_id: String, _secret_access_key: String, _endpoint: String) -> Self { + Self + } + + pub async fn list_instances(&self) -> Result, String> { + Err("API 未实现".to_string()) + } + + pub async fn reboot_instance(&self, _instance_id: &str) -> Result<(), String> { + Err("API 未实现".to_string()) + } +} +``` + +> 注意: Task 3 会替换此文件为完整的 API 实现。Task 1 的骨架版本仅用于验证编译通过。 + +- [ ] **Step 5: 创建 src/app.rs** + +```rust +use crate::api::VolcClient; +use crate::config::{load_config, save_config, AppConfig}; +use crate::types::{ApiMessage, AppState, InstanceInfo}; +use eframe::egui; +use std::sync::mpsc; +use std::thread; + +pub struct VolcManagerApp { + config: AppConfig, + instances: Vec, + app_state: AppState, + show_config_dialog: bool, + show_reboot_confirm: Option, + rebooting_instance: Option, + last_refresh: Option, + rx: Option>, + config_ak: String, + config_sk: String, + config_endpoint: String, + status_message: Option<(String, std::time::Instant)>, +} + +impl VolcManagerApp { + pub fn new(cc: &eframe::CreationContext<'_>) -> Self { + setup_fonts(&cc.egui_ctx); + let config = load_config().unwrap_or_else(|_| AppConfig { + access_key_id: String::new(), + secret_access_key: String::new(), + endpoint: "ecs.volcengineapi.com".to_string(), + }); + let show_config_dialog = !config.is_configured(); + Self { + config, + instances: Vec::new(), + app_state: AppState::Ready, + show_config_dialog, + show_reboot_confirm: None, + rebooting_instance: None, + last_refresh: None, + rx: None, + config_ak: String::new(), + config_sk: String::new(), + config_endpoint: "ecs.volcengineapi.com".to_string(), + status_message: None, + } + } + + fn refresh_instances(&mut self) { + if !self.config.is_configured() { + self.app_state = AppState::Error("请先配置 Access Key".to_string()); + return; + } + let (tx, rx) = mpsc::channel(); + self.rx = Some(rx); + self.app_state = AppState::Loading; + let config = self.config.clone(); + thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().unwrap(); + let result = rt.block_on(async { + let client = VolcClient::new( + config.access_key_id.clone(), + config.secret_access_key.clone(), + config.endpoint.clone(), + ); + match client.list_instances().await { + Ok(instances) => ApiMessage::InstancesLoaded(instances), + Err(e) => ApiMessage::Error(e), + } + }); + let _ = tx.send(result); + }); + } + + fn process_messages(&mut self) { + if let Some(rx) = &self.rx { + if let Ok(msg) = rx.try_recv() { + match msg { + ApiMessage::InstancesLoaded(instances) => { + self.instances = instances; + self.app_state = AppState::Ready; + self.last_refresh = Some( + chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(), + ); + } + ApiMessage::RebootSuccess(id) => { + self.status_message = Some(( + format!("实例 {} 重启命令已发送", id), + std::time::Instant::now(), + )); + self.rebooting_instance = None; + self.refresh_instances(); + } + ApiMessage::Error(e) => { + self.app_state = AppState::Error(e.clone()); + self.status_message = + Some((e, std::time::Instant::now())); + } + } + self.rx = None; + } + } + if let Some((_, time)) = &self.status_message { + if time.elapsed().as_secs() > 5 { + self.status_message = None; + } + } + } + + fn open_config_dialog(&mut self) { + self.config_ak = self.config.access_key_id.clone(); + self.config_sk = self.config.secret_access_key.clone(); + self.config_endpoint = self.config.endpoint.clone(); + self.show_config_dialog = true; + } + + fn save_config_dialog(&mut self) { + self.config.access_key_id = self.config_ak.clone(); + self.config.secret_access_key = self.config_sk.clone(); + self.config.endpoint = self.config_endpoint.clone(); + if let Err(e) = save_config(&self.config) { + self.app_state = AppState::Error(format!("保存配置失败: {}", e)); + } + self.show_config_dialog = false; + if self.config.is_configured() { + self.refresh_instances(); + } + } +} + +impl eframe::App for VolcManagerApp { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + self.process_messages(); + egui::TopBottomPanel::top("top_panel").show(ctx, |ui| { + ui.horizontal(|ui| { + ui.heading("火山引擎服务器管理"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("⚙ 配置").clicked() { + self.open_config_dialog(); + } + if ui.button("🔄 刷新").clicked() { + self.refresh_instances(); + } + }); + }); + }); + + egui::TopBottomPanel::bottom("status_bar").show(ctx, |ui| { + ui.horizontal(|ui| { + ui.label(format!("实例数: {}", self.instances.len())); + if let Some(time) = &self.last_refresh { + ui.label(format!("最后刷新: {}", time)); + } + if let Some((msg, _)) = &self.status_message { + ui.label(format!("| {}", msg)); + } + }); + }); + + egui::CentralPanel::default().show(ctx, |ui| { + match &self.app_state { + AppState::Loading => { + ui.centered_and_justified(|ui| { + ui.spinner(); + ui.label(" 加载中..."); + }); + } + AppState::Error(e) if self.instances.is_empty() => { + ui.centered_and_justified(|ui| { + ui.colored_label(egui::Color32::RED, e); + }); + } + _ => { + ui.horizontal_wrapped(|ui| { + for instance in &self.instances { + render_instance_card(ui, instance, |id| { + self.show_reboot_confirm = + Some(instance.clone()); + }); + } + }); + } + } + }); + + if self.show_config_dialog { + egui::Window::new("配置") + .collapsible(false) + .resizable(false) + .fixed_size([400.0, 250.0]) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.vertical(|ui| { + ui.label("Access Key ID:"); + ui.text_edit_singleline(&mut self.config_ak); + ui.add_space(8.0); + ui.label("Secret Access Key:"); + ui.text_edit_singleline(&mut self.config_sk); + ui.add_space(8.0); + ui.label("Endpoint:"); + ui.text_edit_singleline(&mut self.config_endpoint); + ui.add_space(16.0); + ui.horizontal(|ui| { + if ui.button("保存").clicked() { + self.save_config_dialog(); + } + if ui.button("取消").clicked() { + self.show_config_dialog = false; + } + }); + }); + }); + } + + if let Some(instance) = &self.show_reboot_confirm { + egui::Window::new("确认重启") + .collapsible(false) + .resizable(false) + .fixed_size([350.0, 150.0]) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.label(format!( + "确定要重启实例 \"{}\" 吗?", + instance.instance_name + )); + ui.label(format!("实例 ID: {}", instance.instance_id)); + ui.add_space(16.0); + ui.horizontal(|ui| { + if ui.button("确定").clicked() { + let config = self.config.clone(); + let instance_id = instance.instance_id.clone(); + let (tx, rx) = mpsc::channel(); + self.rx = Some(rx); + self.rebooting_instance = Some(instance_id.clone()); + thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().unwrap(); + let result = rt.block_on(async { + let client = VolcClient::new( + config.access_key_id.clone(), + config.secret_access_key.clone(), + config.endpoint.clone(), + ); + match client.reboot_instance(&instance_id).await { + Ok(()) => ApiMessage::RebootSuccess(instance_id), + Err(e) => ApiMessage::Error(e), + } + }); + let _ = tx.send(result); + }); + self.show_reboot_confirm = None; + } + if ui.button("取消").clicked() { + self.show_reboot_confirm = None; + } + }); + }); + } + + ctx.request_repaint(); + } +} + +fn render_instance_card( + ui: &mut egui::Ui, + instance: &InstanceInfo, + on_reboot: impl FnOnce(&str), +) { + let status_color = match instance.status { + crate::types::InstanceStatus::Running => egui::Color32::GREEN, + crate::types::InstanceStatus::Stopped => egui::Color32::GRAY, + crate::types::InstanceStatus::Starting => egui::Color32::BLUE, + crate::types::InstanceStatus::Stopping => egui::Color32::from_rgb(255, 165, 0), + crate::types::InstanceStatus::Rebooting => egui::Color32::from_rgb(255, 165, 0), + crate::types::InstanceStatus::Error => egui::Color32::RED, + crate::types::InstanceStatus::Unknown => egui::Color32::YELLOW, + }; + + egui::Frame::group(ui.style()) + .fill(egui::Color32::from_additive_luminance(10)) + .inner_margin(12.0) + .show(ui, |ui| { + ui.set_max_width(220.0); + ui.label(egui::RichText::new(&instance.instance_name).strong()); + ui.horizontal(|ui| { + ui.small("状态:"); + ui.colored_label(status_color, instance.status.label()); + }); + ui.small(format!("IP: {}", instance.public_ip)); + ui.small(format!("区域: {}", instance.region_id)); + ui.small(format!("规格: {}", instance.instance_type)); + ui.add_space(8.0); + if ui.button("🔄 重启").clicked() { + on_reboot(&instance.instance_id); + } + }); +} + +fn setup_fonts(ctx: &egui::Context) { + let mut fonts = egui::FontDefinitions::default(); + // 字体文件在 Task 2 中添加,这里先用默认字体 + // Task 2 完成后,取消下面注释并删除 fallback 逻辑 + // fonts.font_data.insert( + // "msyh".to_owned(), + // std::sync::Arc::new(egui::FontData::from_static(include_bytes!( + // "../fonts/msyh.ttc" + // ))), + // ); + // fonts + // .families + // .entry(egui::FontFamily::Proportional) + // .or_default() + // .insert(0, "msyh".to_owned()); + ctx.set_fonts(fonts); +} +``` + +- [ ] **Step 6: 创建 src/main.rs** + +```rust +#![windows_subsystem = "windows"] + +mod api; +mod app; +mod config; +mod types; + +use app::VolcManagerApp; + +fn main() { + let native_options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_inner_size([900.0, 600.0]) + .with_min_inner_size([600.0, 400.0]) + .with_title("火山引擎服务器管理"), + ..Default::default() + }; + + eframe::run_native( + "火山引擎服务器管理", + native_options, + Box::new(|cc| Ok(Box::new(VolcManagerApp::new(cc)))), + ) + .unwrap(); +} +``` + +- [ ] **Step 7: 创建 .gitignore** + +``` +/target +Cargo.lock +``` + +- [ ] **Step 8: 验证编译** + +运行: `cargo check --target x86_64-pc-windows-gnu` + +预期: 编译通过(可能有 unused warnings) + +注意: `api.rs` 中使用了 `urlencoding` crate,需要在 Cargo.toml 中添加: + +```toml +urlencoding = "2.1" +``` + +- [ ] **Step 9: 提交** + +```bash +git add . +git commit -m "feat: 初始化项目结构" +``` + +--- + +### Task 2: 添加中文字体文件 + +**Files:** +- Create: `fonts/msyh.ttc` + +- [ ] **Step 1: 复制微软雅黑字体文件** + +从 Windows 系统复制字体文件: +```bash +cp /c/Windows/Fonts/msyh.ttc fonts/msyh.ttc +``` + +如果 `msyh.ttc` 是 TTC(TrueType Collection)格式,egui 可能不支持。需要提取其中的 TTF 文件。备选方案:使用 `msyh.ttf` 或使用开源字体。 + +检查字体格式: +```bash +file fonts/msyh.ttc +``` + +如果是 TTC 格式,使用 `fonttools` 提取: +```bash +pip install fonttools +pyftsubset msyh.ttc --output-file=msyh.ttf +``` + +或者直接使用 Windows 自带的 `simsun.ttc`(宋体)或其他可用字体。 + +**备选方案**: 如果 TTC 不支持,下载开源中文字体 `NotoSansSC-Regular.ttf` 放到 `fonts/` 目录,并修改 `app.rs` 中的 `setup_fonts` 函数引用。 + +- [ ] **Step 2: 更新 app.rs 中的 setup_fonts 函数** + +将 `src/app.rs` 中的 `setup_fonts` 函数替换为: + +```rust +fn setup_fonts(ctx: &egui::Context) { + let mut fonts = egui::FontDefinitions::default(); + fonts.font_data.insert( + "msyh".to_owned(), + std::sync::Arc::new(egui::FontData::from_static(include_bytes!( + "../fonts/msyh.ttc" + ))), + ); + fonts + .families + .entry(egui::FontFamily::Proportional) + .or_default() + .insert(0, "msyh".to_owned()); + fonts + .families + .entry(egui::FontFamily::Monospace) + .or_default() + .push("msyh".to_owned()); + ctx.set_fonts(fonts); +} +``` + +- [ ] **Step 3: 提交** + +```bash +git add fonts/ .gitignore +git commit -m "feat: 添加中文字体支持" +``` + +--- + +### Task 3: 完善 API 签名和错误处理 + +**Files:** +- Modify: `src/api.rs` + +- [ ] **Step 1: 修正火山引擎签名算法** + +火山引擎使用 V4 签名算法,不是简单的 HMAC-SHA256。更新 `api.rs` 中的签名逻辑: + +```rust +use crate::types::{InstanceInfo, InstanceStatus}; +use base64::{engine::general_purpose::STANDARD, Engine}; +use chrono::Utc; +use hmac::{Hmac, Mac}; +use reqwest::Client; +use sha2::Sha256; +use std::collections::HashMap; + +type HmacSha256 = Hmac; + +pub struct VolcClient { + client: Client, + access_key_id: String, + secret_access_key: String, + endpoint: String, +} + +impl VolcClient { + pub fn new(access_key_id: String, secret_access_key: String, endpoint: String) -> Self { + Self { + client: Client::builder() + .danger_accept_invalid_certs(false) + .build() + .unwrap_or_default(), + access_key_id, + secret_access_key, + endpoint, + } + } + + pub async fn list_instances(&self) -> Result, String> { + let regions = self.get_regions().await?; + let mut all_instances = Vec::new(); + for region in regions { + match self.list_instances_by_region(®ion).await { + Ok(mut instances) => all_instances.append(&mut instances), + Err(e) => eprintln!("Failed to list instances in {}: {}", region, e), + } + } + Ok(all_instances) + } + + async fn get_regions(&self) -> Result, String> { + let mut params = HashMap::new(); + params.insert("Action", "DescribeRegions"); + params.insert("Version", "2020-04-01"); + + let resp = self.send_request(params).await?; + let regions = resp["Result"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|r| r["RegionId"].as_str().map(String::from)) + .collect() + }) + .or_else(|| { + resp["RegionInfos"].as_array().map(|arr| { + arr.iter() + .filter_map(|r| r["RegionId"].as_str().map(String::from)) + .collect() + }) + }) + .unwrap_or_else(|| { + vec![ + "cn-beijing".to_string(), + "cn-shanghai".to_string(), + "cn-guangzhou".to_string(), + "cn-chengdu".to_string(), + "ap-singapore-1".to_string(), + ] + }); + Ok(regions) + } + + async fn list_instances_by_region(&self, region: &str) -> Result, String> { + let mut params = HashMap::new(); + params.insert("Action", "DescribeInstances"); + params.insert("Version", "2020-04-01"); + params.insert("RegionId", region); + params.insert("MaxResults", "100"); + + let resp = self.send_request(params).await?; + let instances = resp["Result"]["Instances"] + .as_array() + .or_else(|| resp["Instances"].as_array()) + .map(|arr| { + arr.iter() + .filter_map(|inst| { + let status_str = inst["Status"].as_str().unwrap_or("UNKNOWN"); + let private_ip = inst["PrimaryIpAddress"] + .as_str() + .or_else(|| { + inst["NetworkInterfaces"] + .as_array() + .and_then(|nets| { + nets.first() + .and_then(|n| n["PrimaryIpAddress"].as_str()) + }) + }) + .unwrap_or("-") + .to_string(); + let public_ip = inst["EipAddress"]["IPAddress"] + .as_str() + .or_else(|| { + inst["PublicIpAddresses"] + .as_array() + .and_then(|ips| ips.first().and_then(|ip| ip.as_str())) + }) + .or_else(|| inst["EipAddress"].as_str()) + .unwrap_or("-") + .to_string(); + + Some(InstanceInfo { + instance_id: inst["InstanceId"] + .as_str() + .unwrap_or("") + .to_string(), + instance_name: inst["InstanceName"] + .as_str() + .unwrap_or( + inst["InstanceId"].as_str().unwrap_or(""), + ) + .to_string(), + status: InstanceStatus::from_str(status_str), + private_ip, + public_ip, + zone_id: inst["ZoneId"] + .as_str() + .unwrap_or("-") + .to_string(), + region_id: region.to_string(), + instance_type: inst["InstanceType"] + .as_str() + .unwrap_or("-") + .to_string(), + creation_time: inst["CreatedAt"] + .as_str() + .or_else(|| inst["CreationTime"].as_str()) + .unwrap_or("-") + .to_string(), + }) + }) + .collect() + }) + .unwrap_or_default(); + Ok(instances) + } + + pub async fn reboot_instance(&self, instance_id: &str) -> Result<(), String> { + let mut params = HashMap::new(); + params.insert("Action", "RebootInstance"); + params.insert("Version", "2020-04-01"); + params.insert("InstanceId", instance_id); + + let _resp = self.send_request(params).await?; + Ok(()) + } + + async fn send_request( + &self, + mut params: HashMap<&str, &str>, + ) -> Result { + params.insert("AccessKeyId", &self.access_key_id); + params.insert("SignatureVersion", "1.0"); + params.insert("SignatureMethod", "HMAC-SHA256"); + + let timestamp = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); + params.insert("Timestamp", ×tamp); + + let string_to_sign = Self::build_string_to_sign(¶ms); + let signature = self.sign(&string_to_sign); + params.insert("Signature", &signature); + + let url = format!("https://{}/", self.endpoint); + let resp = self + .client + .get(&url) + .query(¶ms) + .send() + .await + .map_err(|e| format!("网络请求失败: {}", e))?; + + let status = resp.status(); + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("解析响应失败: {}", e))?; + + if !status.is_success() { + let msg = body["ResponseMetadata"]["Error"]["Message"] + .as_str() + .unwrap_or("未知错误") + .to_string(); + return Err(msg); + } + + if let Some(error) = body["ResponseMetadata"]["Error"].as_object() { + if !error.is_empty() { + let msg = body["ResponseMetadata"]["Error"]["Message"] + .as_str() + .unwrap_or("API 错误") + .to_string(); + return Err(msg); + } + } + + Ok(body) + } + + fn build_string_to_sign(params: &HashMap<&str, &str>) -> String { + let mut keys: Vec<&str> = params.keys().copied().collect(); + keys.sort(); + let query: Vec = keys + .iter() + .map(|k| { + format!( + "{}={}", + url_encode(k), + url_encode(params.get(k).unwrap()) + ) + }) + .collect(); + format!("GET\n/\n{}\n", query.join("&")) + } + + fn sign(&self, string_to_sign: &str) -> String { + let mut mac = HmacSha256::new_from_slice(self.secret_access_key.as_bytes()) + .expect("HMAC can take key of any size"); + mac.update(string_to_sign.as_bytes()); + let result = mac.finalize(); + STANDARD.encode(result.into_bytes()) + } +} + +fn url_encode(s: &str) -> String { + urlencoding::encode(s).into_owned() +} +``` + +- [ ] **Step 2: 添加 urlencoding 依赖** + +在 `Cargo.toml` 中添加: +```toml +urlencoding = "2.1" +``` + +- [ ] **Step 3: 验证编译** + +运行: `cargo check --target x86_64-pc-windows-gnu` + +- [ ] **Step 4: 提交** + +```bash +git add src/api.rs Cargo.toml +git commit -m "feat: 完善 API 签名和错误处理" +``` + +--- + +### Task 4: Release 编译配置 + +**Files:** +- Modify: `Cargo.toml` + +- [ ] **Step 1: 添加 release profile 配置** + +在 `Cargo.toml` 末尾添加: + +```toml +[profile.release] +opt-level = 2 +strip = true +lto = true +codegen-units = 1 +``` + +- [ ] **Step 2: 执行 release 编译** + +运行: `cargo build --release --target x86_64-pc-windows-gnu` + +预期: 生成 `target/x86_64-pc-windows-gnu/release/volcengine-server-manager.exe` + +- [ ] **Step 3: 验证 exe 无控制台** + +运行: `file target/x86_64-pc-windows-gnu/release/volcengine-server-manager.exe` + +确认输出包含 `windows gui` 而非 `console` + +- [ ] **Step 4: 提交** + +```bash +git add Cargo.toml +git commit -m "chore: 添加 release 编译优化配置" +``` + +--- + +### Task 5: 首次运行测试 + +**Files:** +- 无修改 + +- [ ] **Step 1: 运行程序** + +```bash +./target/x86_64-pc-windows-gnu/release/volcengine-server-manager.exe +``` + +- [ ] **Step 2: 验证行为** + +1. 首次启动应弹出配置窗口 +2. 输入 AK/SK 后点击保存,应自动加载实例列表 +3. 实例以卡片形式展示,显示名称、状态、IP、区域、规格 +4. 点击"重启"按钮弹出确认窗口 +5. 确认重启后显示成功消息 +6. 状态栏显示实例数和最后刷新时间 + +- [ ] **Step 3: 提交** + +```bash +git add . +git commit -m "test: 首次运行验证通过" +``` diff --git a/docs/superpowers/specs/2026-04-04-volcengine-server-manager-design.md b/docs/superpowers/specs/2026-04-04-volcengine-server-manager-design.md new file mode 100644 index 0000000..535e937 --- /dev/null +++ b/docs/superpowers/specs/2026-04-04-volcengine-server-manager-design.md @@ -0,0 +1,251 @@ +# Volcengine Server Manager - 设计文档 + +**日期**: 2026-04-04 +**状态**: 待审核 + +## 概述 + +Windows 桌面应用,用于管理火山引擎云服务器 ECS。首期功能:查看服务器状态、重启服务器。 + +## 技术栈 + +| 层 | 选型 | 说明 | +|---|------|------| +| GUI | egui + eframe (glow backend) | 纯 Rust,无外部依赖,单 exe 友好 | +| 火山引擎 API | volcengine-rust-sdk (1.0.2) | crates.io 官方 SDK | +| HTTP | reqwest (SDK 自带) | SDK 内部使用 | +| 异步运行时 | tokio | API 异步调用 | +| 序列化 | serde + serde_json | 配置读写、API 响应解析 | +| 编译目标 | x86_64-pc-windows-gnu | MSYS2 MinGW 工具链 | + +## 配置 + +### 配置项 + +| 配置项 | 类型 | 默认值 | 必填 | 说明 | +|--------|------|--------|------|------| +| access_key_id | String | 空 | 是 | 火山引擎 Access Key ID | +| secret_access_key | String | 空 | 是 | 火山引擎 Secret Access Key | +| endpoint | String | `ecs.volcengineapi.com` | 否 | ECS API 端点 | + +### 存储方式 + +- 文件路径: `%APPDATA%\volcengine-server-manager\config.json` +- 格式: JSON +- 首次启动时检测配置是否存在,不存在则弹出配置弹窗 + +## 火山引擎 API 调用 + +### DescribeInstances(查询实例列表) + +- **目的**: 获取用户账号下所有 ECS 实例 +- **请求参数**: `MaxResults=100`,支持分页 +- **响应关键字段**: + - `InstanceId` - 实例 ID + - `InstanceName` - 实例名称 + - `Status` - 实例状态(Running, Stopped, etc.) + - `PrivateIpAddresses` - 私网 IP + - `PublicIpAddresses` - 公网 IP + - `ZoneId` - 可用区 + - `RegionId` - 地域 + - `InstanceType` - 实例规格 + - `CreationTime` - 创建时间 + +### RebootInstance(重启单台实例) + +- **目的**: 重启指定 ECS 实例 +- **请求参数**: `InstanceId` (字符串) +- **响应**: 操作结果 +- **注意**: 重启前需要用户确认弹窗 + +### 区域处理 + +- 不要求用户手动配置 Region +- 先调用 `DescribeRegions` 获取所有可用区域列表 +- 对每个区域并发调用 `DescribeInstances` 获取该区域实例 +- 如果 `DescribeRegions` 不可用,则使用常见区域列表硬编码:`cn-beijing`, `cn-shanghai`, `cn-guangzhou`, `cn-chengdu`, `ap-singapore-1` + +## 架构设计 + +### 模块划分 + +``` +src/ +├── main.rs # 入口:初始化 tokio 运行时,启动 eframe +├── app.rs # egui UI 主逻辑:渲染、事件处理、状态管理 +├── config.rs # 配置结构体、JSON 读写 +├── api.rs # 火山引擎 API 封装 +└── types.rs # 数据类型定义 +``` + +### 模块职责 + +#### `types.rs` +- `InstanceStatus` 枚举: Running, Stopped, Starting, Stopping, Rebooting, Error, Unknown +- `InstanceInfo` 结构体: 实例完整信息 +- `AppState` 枚举: Loading, Ready, Error(String) + +#### `config.rs` +- `AppConfig` 结构体: 配置项 +- `load_config()` -> `Result` +- `save_config(&AppConfig)` -> `Result<()>` +- 配置目录: `%APPDATA%\volcengine-server-manager\` + +#### `api.rs` +- `VolcClient` 结构体: 封装 SDK Session 或 HTTP 客户端 +- `new(access_key_id, secret_access_key, endpoint)` -> Self +- `list_instances()` -> `Result>` +- `reboot_instance(instance_id)` -> `Result<()>` +- **签名机制**: 火山引擎使用 HMAC-SHA256 签名,SDK 不支持时直接构造 HTTP 请求 + +#### `app.rs` +- `VolcManagerApp` 结构体: egui App 实现 +- 状态: instances (Vec), app_state, config, show_config_dialog, selected_instance, loading +- `update()`: UI 渲染主循环 +- 方法: `refresh_instances()`, `reboot_selected()`, `open_config_dialog()`, `save_config()` + +#### `main.rs` +- 初始化配置 +- 创建 tokio 运行时 +- 启动 eframe native window +- 设置 `windows_subsystem = "windows"` 去掉控制台 + +### 数据流 + +``` +用户操作 → egui 事件 → App 方法 → tokio spawn 后台任务 → API 调用 → 结果通过 channel 返回 → 更新 state → request_repaint() → UI 刷新 +``` + +### UI 布局 + +#### 主窗口 + +``` +┌──────────────────────────────────────────────────────┐ +│ 火山引擎服务器管理 [刷新] [⚙ 配置] │ +├──────────────────────────────────────────────────────┤ +│ │ +│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ +│ │ 实例名称 │ │ 实例名称 │ │ 实例名称 │ │ +│ │ 状态: 运行中 │ │ 状态: 已停止 │ │ 状态: 运行中 │ │ +│ │ IP: x.x.x.x │ │ IP: x.x.x.x │ │ IP: x.x.x.x │ │ +│ │ 区域: cn-beijing │ 区域: cn-shanghai │ 区域: cn-guangzhou│ +│ │ 规格: ecs.g3i.large │ 规格: ... │ 规格: ... │ │ +│ │ │ │ │ +│ │ [🔄 重启] │ [▶ 启动] │ [🔄 重启] │ +│ └────────────┘ └────────────┘ └────────────┘ │ +│ │ +│ ┌────────────┐ ┌────────────┐ │ +│ │ ... │ │ ... │ │ +│ └────────────┘ └────────────┘ │ +│ │ +├──────────────────────────────────────────────────────┤ +│ 实例数: 5 最后刷新: 2026-04-04 19:30 │ +└──────────────────────────────────────────────────────┘ +``` + +#### 配置弹窗(Modal) + +``` +┌────────────────────────────────────┐ +│ 配置 │ +├────────────────────────────────────┤ +│ Access Key ID: │ +│ [________________________________]│ +│ │ +│ Secret Access Key: │ +│ [________________________________]│ +│ │ +│ Endpoint: │ +│ [ecs.volcengineapi.com___________]│ +│ │ +│ [保存] [取消] │ +└────────────────────────────────────┘ +``` + +#### 重启确认弹窗 + +``` +┌────────────────────────────────────┐ +│ 确认重启 │ +├────────────────────────────────────┤ +│ 确定要重启实例 "xxx" 吗? │ +│ 实例 ID: i-xxxxxxxxxxxx │ +│ │ +│ [确定] [取消] │ +└────────────────────────────────────┘ +``` + +### 状态颜色映射 + +| 状态 | 颜色 | +|------|------| +| Running | 绿色 | +| Stopped | 灰色 | +| Starting | 蓝色 | +| Stopping | 橙色 | +| Rebooting | 橙色 | +| Error | 红色 | +| Unknown | 黄色 | + +### 错误处理 + +- API 调用失败:底部状态栏显示错误信息,3 秒后自动消失 +- 配置未设置:首次启动弹出配置弹窗,阻止主界面操作 +- 网络异常:显示 "网络连接失败,请检查网络设置" +- 认证失败:显示 "Access Key 或 Secret Key 无效" + +### 中文支持 + +- 加载 Windows 系统字体 `C:\Windows\Fonts\msyh.ttc`(微软雅黑) +- 通过 `egui::FontDefinitions` 注入为默认字体 +- 如果字体加载失败,回退到 egui 内置字体(英文字符) + +### 编译配置 + +#### Cargo.toml 关键设置 + +```toml +[package] +name = "volcengine-server-manager" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "volcengine-server-manager" +path = "src/main.rs" + +[profile.release] +opt-level = 2 +strip = true +lto = true + +# Windows 子系统设置(去掉控制台窗口) +[package.metadata.windows] +subsystem = "windows" +``` + +实际去掉控制台的方法:在 `main.rs` 中设置 `#![windows_subsystem = "windows"]` 属性。 + +### 依赖列表 + +```toml +[dependencies] +eframe = { version = "0.29", default-features = false, features = ["glow"] } +egui = "0.29" +volcengine-rust-sdk = "1.0.2" +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +dirs = "5" +``` + +### 编译命令 + +```bash +# 在 MSYS2 MinGW 环境下 +rustup target add x86_64-pc-windows-gnu +cargo build --release --target x86_64-pc-windows-gnu +``` + +输出文件: `target/x86_64-pc-windows-gnu/release/volcengine-server-manager.exe`