feat: 初始化项目 - 火山引擎服务器管理工具

This commit is contained in:
xiaji
2026-04-04 20:15:55 +08:00
commit 1bc119f083
3 changed files with 1312 additions and 0 deletions

67
README.md Normal file
View File

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

View File

@@ -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 直接调用火山引擎 APIHMAC-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 commits0 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<InstanceInfo>),
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<AppConfig, io::Error> {
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<Vec<InstanceInfo>, 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<InstanceInfo>,
app_state: AppState,
show_config_dialog: bool,
show_reboot_confirm: Option<InstanceInfo>,
rebooting_instance: Option<String>,
last_refresh: Option<String>,
rx: Option<mpsc::Receiver<ApiMessage>>,
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` 是 TTCTrueType 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<Sha256>;
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<Vec<InstanceInfo>, String> {
let regions = self.get_regions().await?;
let mut all_instances = Vec::new();
for region in regions {
match self.list_instances_by_region(&region).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<Vec<String>, 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<Vec<InstanceInfo>, 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<serde_json::Value, String> {
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", &timestamp);
let string_to_sign = Self::build_string_to_sign(&params);
let signature = self.sign(&string_to_sign);
params.insert("Signature", &signature);
let url = format!("https://{}/", self.endpoint);
let resp = self
.client
.get(&url)
.query(&params)
.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<String> = 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: 首次运行验证通过"
```

View File

@@ -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<AppConfig>`
- `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<Vec<InstanceInfo>>`
- `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`