Files
Genarrative/server-rs/crates/server-manager-panel/src/app.rs
kdletters b54cbafc54 新增本地服务器管理面板
新增 egui 服务器管理面板并支持 SSH alias 多服务器巡检

接入硬件状态、服务状态、HTTP 探测和生产巡检状态展示

增加受控 systemd 启动关闭重启操作和中文字体注入

补充本地服务器面板技术方案与团队共享记忆
2026-06-11 22:33:05 +08:00

578 lines
19 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}