新增本地服务器管理面板
新增 egui 服务器管理面板并支持 SSH alias 多服务器巡检 接入硬件状态、服务状态、HTTP 探测和生产巡检状态展示 增加受控 systemd 启动关闭重启操作和中文字体注入 补充本地服务器面板技术方案与团队共享记忆
This commit is contained in:
577
server-rs/crates/server-manager-panel/src/app.rs
Normal file
577
server-rs/crates/server-manager-panel/src/app.rs
Normal file
@@ -0,0 +1,577 @@
|
||||
use eframe::egui;
|
||||
|
||||
use crate::health::{
|
||||
DiskSnapshot, HealthLevel, MemorySnapshot, ProbeSnapshot, ServerHealthReport, ServiceSnapshot,
|
||||
};
|
||||
use crate::remote::{
|
||||
RemoteEvent, RemoteReceiver, RemoteSender, ServiceAction, channel, spawn_health_check,
|
||||
spawn_service_action,
|
||||
};
|
||||
use crate::ssh_config::{SshAlias, discover_ssh_aliases};
|
||||
|
||||
const DEFAULT_MANAGED_SERVICES: &[&str] = &[
|
||||
"genarrative-api.service",
|
||||
"spacetimedb.service",
|
||||
"nginx.service",
|
||||
"genarrative-health-patrol.timer",
|
||||
"genarrative-database-backup.timer",
|
||||
];
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ServerManagerApp {
|
||||
servers: Vec<ServerState>,
|
||||
selected_alias: Option<String>,
|
||||
sidebar_collapsed: bool,
|
||||
tx: RemoteSender,
|
||||
rx: RemoteReceiver,
|
||||
pending_confirmation: Option<ServiceConfirmation>,
|
||||
custom_service_name: String,
|
||||
}
|
||||
|
||||
impl Default for ServerManagerApp {
|
||||
fn default() -> Self {
|
||||
let (tx, rx) = channel();
|
||||
let aliases = discover_ssh_aliases();
|
||||
let selected_alias = aliases.first().map(|alias| alias.name.clone());
|
||||
Self {
|
||||
servers: aliases.into_iter().map(ServerState::new).collect(),
|
||||
selected_alias,
|
||||
sidebar_collapsed: false,
|
||||
tx,
|
||||
rx,
|
||||
pending_confirmation: None,
|
||||
custom_service_name: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for ServerManagerApp {
|
||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.drain_remote_events(ctx);
|
||||
self.render_confirm_dialog(ctx);
|
||||
|
||||
egui::TopBottomPanel::top("top_bar").show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
if ui
|
||||
.button(if self.sidebar_collapsed {
|
||||
"展开侧栏"
|
||||
} else {
|
||||
"收起侧栏"
|
||||
})
|
||||
.clicked()
|
||||
{
|
||||
self.sidebar_collapsed = !self.sidebar_collapsed;
|
||||
}
|
||||
if ui.button("重新读取 SSH alias").clicked() {
|
||||
self.reload_aliases();
|
||||
}
|
||||
if let Some(alias) = self.selected_alias.clone() {
|
||||
if ui.button("刷新当前服务器").clicked() {
|
||||
self.refresh_server(&alias);
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
ui.label("本地 SSH alias 管理");
|
||||
});
|
||||
});
|
||||
|
||||
if !self.sidebar_collapsed {
|
||||
egui::SidePanel::left("server_sidebar")
|
||||
.resizable(true)
|
||||
.default_width(260.0)
|
||||
.width_range(180.0..=360.0)
|
||||
.show(ctx, |ui| self.render_sidebar(ui));
|
||||
}
|
||||
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
if self.servers.is_empty() {
|
||||
self.render_empty_state(ui);
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(alias) = self.selected_alias.clone() else {
|
||||
self.render_empty_state(ui);
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some(index) = self.server_index(&alias) {
|
||||
self.render_server_detail(ui, index);
|
||||
} else {
|
||||
ui.label("请选择服务器");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl ServerManagerApp {
|
||||
fn drain_remote_events(&mut self, ctx: &egui::Context) {
|
||||
while let Ok(event) = self.rx.try_recv() {
|
||||
match event {
|
||||
RemoteEvent::Health { alias, result } => {
|
||||
if let Some(server) = self.server_mut(&alias) {
|
||||
server.loading = false;
|
||||
match result {
|
||||
Ok(report) => {
|
||||
server.error = None;
|
||||
server.report = Some(report);
|
||||
}
|
||||
Err(error) => {
|
||||
server.error = Some(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
RemoteEvent::ServiceAction {
|
||||
alias,
|
||||
service,
|
||||
action,
|
||||
result,
|
||||
} => {
|
||||
if let Some(server) = self.server_mut(&alias) {
|
||||
server.action_in_progress = None;
|
||||
server.action_log = Some(format!(
|
||||
"{} {}: {}\n{}{}",
|
||||
action.label(),
|
||||
service,
|
||||
result.summary,
|
||||
result.stdout,
|
||||
result.stderr
|
||||
));
|
||||
server.loading = true;
|
||||
spawn_health_check(alias, self.tx.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
fn render_sidebar(&mut self, ui: &mut egui::Ui) {
|
||||
ui.heading("服务器");
|
||||
ui.add_space(8.0);
|
||||
let mut refresh_alias: Option<String> = None;
|
||||
|
||||
for server in &mut self.servers {
|
||||
let selected = self.selected_alias.as_deref() == Some(server.alias.name.as_str());
|
||||
let response = ui.selectable_label(selected, server_label(server));
|
||||
if response.clicked() {
|
||||
self.selected_alias = Some(server.alias.name.clone());
|
||||
}
|
||||
response.on_hover_text(server.alias.source.display().to_string());
|
||||
ui.horizontal(|ui| {
|
||||
let status = server.status();
|
||||
ui.colored_label(level_color(status), status.label());
|
||||
if server.loading {
|
||||
ui.spinner();
|
||||
}
|
||||
if ui.small_button("刷新").clicked() {
|
||||
refresh_alias = Some(server.alias.name.clone());
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
|
||||
if let Some(alias) = refresh_alias {
|
||||
self.refresh_server(&alias);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_empty_state(&mut self, ui: &mut egui::Ui) {
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.heading("未发现 SSH alias");
|
||||
ui.label("请在 ~/.ssh/config 中配置 Host alias 后重新读取。");
|
||||
if ui.button("重新读取").clicked() {
|
||||
self.reload_aliases();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn render_server_detail(&mut self, ui: &mut egui::Ui, index: usize) {
|
||||
let alias = self.servers[index].alias.name.clone();
|
||||
let status = self.servers[index].status();
|
||||
let loading = self.servers[index].loading;
|
||||
let report = self.servers[index].report.clone();
|
||||
let error = self.servers[index].error.clone();
|
||||
let action_log = self.servers[index].action_log.clone();
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.heading(&alias);
|
||||
ui.colored_label(level_color(status), status.label());
|
||||
if loading {
|
||||
ui.spinner();
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
|
||||
if let Some(error) = error {
|
||||
ui.colored_label(warning_color(), format!("SSH 巡检失败:{error}"));
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
|
||||
if let Some(report) = report {
|
||||
self.render_report(ui, &alias, &report);
|
||||
} else {
|
||||
ui.label("尚未执行巡检。");
|
||||
}
|
||||
|
||||
ui.add_space(12.0);
|
||||
self.render_service_controls(ui, &alias, index);
|
||||
|
||||
if let Some(log) = action_log {
|
||||
ui.add_space(12.0);
|
||||
egui::CollapsingHeader::new("最近一次服务操作输出")
|
||||
.default_open(true)
|
||||
.show(ui, |ui| {
|
||||
ui.add(
|
||||
egui::TextEdit::multiline(&mut log.clone())
|
||||
.font(egui::TextStyle::Monospace)
|
||||
.desired_rows(8)
|
||||
.interactive(false),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn render_report(&self, ui: &mut egui::Ui, alias: &str, report: &ServerHealthReport) {
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
info_chip(ui, "主机", value_or_dash(&report.host.hostname));
|
||||
info_chip(ui, "内核", value_or_dash(&report.host.kernel));
|
||||
info_chip(ui, "运行时间", value_or_dash(&report.host.uptime));
|
||||
info_chip(ui, "检查时间", value_or_dash(&report.checked_at));
|
||||
});
|
||||
|
||||
ui.add_space(10.0);
|
||||
egui::CollapsingHeader::new("硬件状态")
|
||||
.default_open(true)
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
info_chip(ui, "CPU", value_or_dash(&report.hardware.cpu_model));
|
||||
info_chip(ui, "核心", value_or_dash(&report.hardware.cpu_cores));
|
||||
info_chip(ui, "负载", value_or_dash(&report.hardware.load_average));
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
memory_row(ui, "内存", &report.hardware.memory);
|
||||
memory_row(ui, "Swap", &report.hardware.swap);
|
||||
ui.add_space(6.0);
|
||||
for disk in &report.hardware.disks {
|
||||
disk_row(ui, disk);
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
for sensor in &report.hardware.sensors {
|
||||
ui.label(sensor);
|
||||
}
|
||||
});
|
||||
|
||||
egui::CollapsingHeader::new("服务状态")
|
||||
.default_open(true)
|
||||
.show(ui, |ui| {
|
||||
egui::Grid::new(format!("{alias}_services"))
|
||||
.striped(true)
|
||||
.show(ui, |ui| {
|
||||
ui.strong("服务");
|
||||
ui.strong("状态");
|
||||
ui.strong("子状态");
|
||||
ui.strong("Unit");
|
||||
ui.end_row();
|
||||
for service in &report.services {
|
||||
service_row(ui, service);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
egui::CollapsingHeader::new("HTTP 探测")
|
||||
.default_open(true)
|
||||
.show(ui, |ui| {
|
||||
egui::Grid::new(format!("{alias}_probes"))
|
||||
.striped(true)
|
||||
.show(ui, |ui| {
|
||||
ui.strong("探测");
|
||||
ui.strong("状态码");
|
||||
ui.strong("耗时");
|
||||
ui.strong("目标");
|
||||
ui.end_row();
|
||||
for probe in &report.probes {
|
||||
probe_row(ui, probe);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if let Some(patrol) = &report.health_patrol {
|
||||
egui::CollapsingHeader::new("生产健康巡检")
|
||||
.default_open(true)
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.colored_label(level_color(patrol.level), &patrol.status);
|
||||
ui.label(value_or_dash(&patrol.checked_at));
|
||||
ui.label(value_or_dash(&patrol.summary));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
egui::CollapsingHeader::new("原始巡检输出").show(ui, |ui| {
|
||||
ui.add(
|
||||
egui::TextEdit::multiline(&mut report.raw_output.clone())
|
||||
.font(egui::TextStyle::Monospace)
|
||||
.desired_rows(12)
|
||||
.interactive(false),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn render_service_controls(&mut self, ui: &mut egui::Ui, alias: &str, index: usize) {
|
||||
ui.heading("服务控制");
|
||||
ui.add_space(4.0);
|
||||
|
||||
let action_in_progress = self.servers[index].action_in_progress.clone();
|
||||
for service in DEFAULT_MANAGED_SERVICES {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(*service);
|
||||
for action in [
|
||||
ServiceAction::Start,
|
||||
ServiceAction::Stop,
|
||||
ServiceAction::Restart,
|
||||
] {
|
||||
let disabled = action_in_progress.is_some();
|
||||
if ui
|
||||
.add_enabled(!disabled, egui::Button::new(action.label()))
|
||||
.clicked()
|
||||
{
|
||||
self.pending_confirmation = Some(ServiceConfirmation {
|
||||
alias: alias.to_owned(),
|
||||
service: (*service).to_owned(),
|
||||
action,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("其他 unit");
|
||||
ui.text_edit_singleline(&mut self.custom_service_name);
|
||||
if ui.button("启动").clicked() {
|
||||
self.confirm_custom_service(alias, ServiceAction::Start);
|
||||
}
|
||||
if ui.button("关闭").clicked() {
|
||||
self.confirm_custom_service(alias, ServiceAction::Stop);
|
||||
}
|
||||
if ui.button("重启").clicked() {
|
||||
self.confirm_custom_service(alias, ServiceAction::Restart);
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(action) = action_in_progress {
|
||||
ui.label(format!("正在执行:{action}"));
|
||||
}
|
||||
}
|
||||
|
||||
fn render_confirm_dialog(&mut self, ctx: &egui::Context) {
|
||||
let Some(confirmation) = self.pending_confirmation.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
egui::Window::new("确认服务操作")
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.show(ctx, |ui| {
|
||||
ui.label(format!(
|
||||
"确认在 {} 上{} {}?",
|
||||
confirmation.alias,
|
||||
confirmation.action.label(),
|
||||
confirmation.service
|
||||
));
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("确认").clicked() {
|
||||
self.execute_service_action(&confirmation);
|
||||
self.pending_confirmation = None;
|
||||
}
|
||||
if ui.button("取消").clicked() {
|
||||
self.pending_confirmation = None;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn reload_aliases(&mut self) {
|
||||
let aliases = discover_ssh_aliases();
|
||||
self.servers = aliases.into_iter().map(ServerState::new).collect();
|
||||
self.selected_alias = self.servers.first().map(|server| server.alias.name.clone());
|
||||
}
|
||||
|
||||
fn refresh_server(&mut self, alias: &str) {
|
||||
if let Some(server) = self.server_mut(alias) {
|
||||
server.loading = true;
|
||||
server.error = None;
|
||||
}
|
||||
spawn_health_check(alias.to_owned(), self.tx.clone());
|
||||
}
|
||||
|
||||
fn confirm_custom_service(&mut self, alias: &str, action: ServiceAction) {
|
||||
let service = self.custom_service_name.trim();
|
||||
if service.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.pending_confirmation = Some(ServiceConfirmation {
|
||||
alias: alias.to_owned(),
|
||||
service: service.to_owned(),
|
||||
action,
|
||||
});
|
||||
}
|
||||
|
||||
fn execute_service_action(&mut self, confirmation: &ServiceConfirmation) {
|
||||
if let Some(server) = self.server_mut(&confirmation.alias) {
|
||||
server.action_in_progress = Some(format!(
|
||||
"{} {}",
|
||||
confirmation.action.label(),
|
||||
confirmation.service
|
||||
));
|
||||
server.action_log = None;
|
||||
}
|
||||
spawn_service_action(
|
||||
confirmation.alias.clone(),
|
||||
confirmation.service.clone(),
|
||||
confirmation.action,
|
||||
self.tx.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
fn server_index(&self, alias: &str) -> Option<usize> {
|
||||
self.servers
|
||||
.iter()
|
||||
.position(|server| server.alias.name == alias)
|
||||
}
|
||||
|
||||
fn server_mut(&mut self, alias: &str) -> Option<&mut ServerState> {
|
||||
self.servers
|
||||
.iter_mut()
|
||||
.find(|server| server.alias.name == alias)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ServiceConfirmation {
|
||||
alias: String,
|
||||
service: String,
|
||||
action: ServiceAction,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ServerState {
|
||||
alias: SshAlias,
|
||||
report: Option<ServerHealthReport>,
|
||||
loading: bool,
|
||||
error: Option<String>,
|
||||
action_in_progress: Option<String>,
|
||||
action_log: Option<String>,
|
||||
}
|
||||
|
||||
impl ServerState {
|
||||
fn new(alias: SshAlias) -> Self {
|
||||
Self {
|
||||
alias,
|
||||
report: None,
|
||||
loading: false,
|
||||
error: None,
|
||||
action_in_progress: None,
|
||||
action_log: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn status(&self) -> HealthLevel {
|
||||
if self.error.is_some() {
|
||||
HealthLevel::Critical
|
||||
} else {
|
||||
self.report
|
||||
.as_ref()
|
||||
.map(|report| report.status)
|
||||
.unwrap_or(HealthLevel::Unknown)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn server_label(server: &ServerState) -> String {
|
||||
let prefix = match server.status() {
|
||||
HealthLevel::Ok => "[OK]",
|
||||
HealthLevel::Warning => "[!]",
|
||||
HealthLevel::Critical => "[X]",
|
||||
HealthLevel::Unknown => "[?]",
|
||||
};
|
||||
format!("{prefix} {}", server.alias.name)
|
||||
}
|
||||
|
||||
fn service_row(ui: &mut egui::Ui, service: &ServiceSnapshot) {
|
||||
ui.label(&service.name);
|
||||
ui.colored_label(level_color(service.level), &service.active);
|
||||
ui.label(&service.sub);
|
||||
ui.label(&service.unit_file);
|
||||
ui.end_row();
|
||||
}
|
||||
|
||||
fn probe_row(ui: &mut egui::Ui, probe: &ProbeSnapshot) {
|
||||
ui.label(&probe.name);
|
||||
ui.colored_label(level_color(probe.level), &probe.http_code);
|
||||
ui.label(
|
||||
probe
|
||||
.elapsed_ms
|
||||
.map(|elapsed| format!("{elapsed}ms"))
|
||||
.unwrap_or_else(|| "-".to_owned()),
|
||||
);
|
||||
ui.label(&probe.target);
|
||||
ui.end_row();
|
||||
}
|
||||
|
||||
fn memory_row(ui: &mut egui::Ui, label: &str, memory: &MemorySnapshot) {
|
||||
let percent = memory.used_percent.unwrap_or_default();
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(label);
|
||||
ui.add(egui::ProgressBar::new(f32::from(percent) / 100.0).text(format!("{percent}%")));
|
||||
ui.label(format!(
|
||||
"已用 {} / 总计 {},可用 {}",
|
||||
value_or_dash(&memory.used),
|
||||
value_or_dash(&memory.total),
|
||||
value_or_dash(&memory.available)
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
fn disk_row(ui: &mut egui::Ui, disk: &DiskSnapshot) {
|
||||
let percent = disk.used_percent.unwrap_or_default();
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(&disk.mount);
|
||||
ui.add(egui::ProgressBar::new(f32::from(percent) / 100.0).text(format!("{percent}%")));
|
||||
ui.label(format!(
|
||||
"{} 已用 {} / {},可用 {}",
|
||||
disk.filesystem, disk.used, disk.size, disk.available
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
fn info_chip(ui: &mut egui::Ui, label: &str, value: &str) {
|
||||
ui.group(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.small(label);
|
||||
ui.label(value);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn value_or_dash(value: &str) -> &str {
|
||||
if value.trim().is_empty() { "-" } else { value }
|
||||
}
|
||||
|
||||
fn level_color(level: HealthLevel) -> egui::Color32 {
|
||||
match level {
|
||||
HealthLevel::Ok => egui::Color32::from_rgb(38, 166, 91),
|
||||
HealthLevel::Warning => egui::Color32::from_rgb(214, 137, 16),
|
||||
HealthLevel::Critical => egui::Color32::from_rgb(205, 66, 70),
|
||||
HealthLevel::Unknown => egui::Color32::from_rgb(120, 126, 136),
|
||||
}
|
||||
}
|
||||
|
||||
fn warning_color() -> egui::Color32 {
|
||||
egui::Color32::from_rgb(205, 66, 70)
|
||||
}
|
||||
Reference in New Issue
Block a user