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, selected_alias: Option, sidebar_collapsed: bool, tx: RemoteSender, rx: RemoteReceiver, pending_confirmation: Option, 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 = 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 { 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, loading: bool, error: Option, action_in_progress: Option, action_log: Option, } 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) }