新增 egui 服务器管理面板并支持 SSH alias 多服务器巡检 接入硬件状态、服务状态、HTTP 探测和生产巡检状态展示 增加受控 systemd 启动关闭重启操作和中文字体注入 补充本地服务器面板技术方案与团队共享记忆
578 lines
19 KiB
Rust
578 lines
19 KiB
Rust
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)
|
||
}
|