feat: 实现火山引擎服务器管理 GUI 应用
This commit is contained in:
3557
Cargo.lock
generated
Normal file
3557
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
Cargo.toml
Normal file
25
Cargo.toml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
[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"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = 2
|
||||||
|
strip = true
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
BIN
fonts/msyh.ttc
Normal file
BIN
fonts/msyh.ttc
Normal file
Binary file not shown.
237
src/api.rs
Normal file
237
src/api.rs
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
317
src/app.rs
Normal file
317
src/app.rs
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
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 self.show_reboot_confirm.is_some() {
|
||||||
|
let instance = self.show_reboot_confirm.clone().unwrap();
|
||||||
|
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();
|
||||||
|
fonts.font_data.insert(
|
||||||
|
"msyh".to_owned(),
|
||||||
|
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);
|
||||||
|
}
|
||||||
62
src/config.rs
Normal file
62
src/config.rs
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
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 content = content.trim();
|
||||||
|
if content.is_empty() {
|
||||||
|
return Ok(AppConfig {
|
||||||
|
access_key_id: String::new(),
|
||||||
|
secret_access_key: String::new(),
|
||||||
|
endpoint: default_endpoint(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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(())
|
||||||
|
}
|
||||||
25
src/main.rs
Normal file
25
src/main.rs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
#![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();
|
||||||
|
}
|
||||||
63
src/types.rs
Normal file
63
src/types.rs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
#[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),
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user