feat: 初始化项目 - 火山引擎服务器管理工具
This commit is contained in:
67
README.md
Normal file
67
README.md
Normal 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
|
||||
@@ -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<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` 是 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<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(®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<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", ×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<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: 首次运行验证通过"
|
||||
```
|
||||
@@ -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`
|
||||
Reference in New Issue
Block a user