diff --git a/crates/xtop-cli/src/main.rs b/crates/xtop-cli/src/main.rs index d4f9a03..f8ed928 100644 --- a/crates/xtop-cli/src/main.rs +++ b/crates/xtop-cli/src/main.rs @@ -66,26 +66,65 @@ fn key_event_to_str(key: &KeyEvent) -> String { // Embedded default asset files (shipped with the binary) const DEFAULT_THEMES: &[(&str, &str)] = &[ ("x", include_str!("../../../assets/themes/x.jsonc")), - ("madrid", include_str!("../../../assets/themes/madrid.jsonc")), - ("lahabana", include_str!("../../../assets/themes/lahabana.jsonc")), + ( + "madrid", + include_str!("../../../assets/themes/madrid.jsonc"), + ), + ( + "lahabana", + include_str!("../../../assets/themes/lahabana.jsonc"), + ), ("paris", include_str!("../../../assets/themes/paris.jsonc")), ("tokio", include_str!("../../../assets/themes/tokio.jsonc")), ("oslo", include_str!("../../../assets/themes/oslo.jsonc")), - ("helsinki", include_str!("../../../assets/themes/helsinki.jsonc")), - ("berlin", include_str!("../../../assets/themes/berlin.jsonc")), - ("london", include_str!("../../../assets/themes/london.jsonc")), + ( + "helsinki", + include_str!("../../../assets/themes/helsinki.jsonc"), + ), + ( + "berlin", + include_str!("../../../assets/themes/berlin.jsonc"), + ), + ( + "london", + include_str!("../../../assets/themes/london.jsonc"), + ), ("praha", include_str!("../../../assets/themes/praha.jsonc")), - ("bogota", include_str!("../../../assets/themes/bogota.jsonc")), + ( + "bogota", + include_str!("../../../assets/themes/bogota.jsonc"), + ), ]; const DEFAULT_LAYOUTS: &[(&str, &str)] = &[ - ("dashboard", include_str!("../../../assets/layouts/dashboard.jsonc")), - ("vertical", include_str!("../../../assets/layouts/vertical.jsonc")), - ("horizontal", include_str!("../../../assets/layouts/horizontal.jsonc")), - ("cpu_focus", include_str!("../../../assets/layouts/cpu_focus.jsonc")), - ("memory_focus", include_str!("../../../assets/layouts/memory_focus.jsonc")), - ("network_focus", include_str!("../../../assets/layouts/network_focus.jsonc")), - ("process_focus", include_str!("../../../assets/layouts/process_focus.jsonc")), + ( + "dashboard", + include_str!("../../../assets/layouts/dashboard.jsonc"), + ), + ( + "vertical", + include_str!("../../../assets/layouts/vertical.jsonc"), + ), + ( + "horizontal", + include_str!("../../../assets/layouts/horizontal.jsonc"), + ), + ( + "cpu_focus", + include_str!("../../../assets/layouts/cpu_focus.jsonc"), + ), + ( + "memory_focus", + include_str!("../../../assets/layouts/memory_focus.jsonc"), + ), + ( + "network_focus", + include_str!("../../../assets/layouts/network_focus.jsonc"), + ), + ( + "process_focus", + include_str!("../../../assets/layouts/process_focus.jsonc"), + ), ]; fn ensure_default_assets() { @@ -160,7 +199,9 @@ fn print_usage() { eprintln!(" xtop Start the TUI system monitor"); eprintln!(" xtop mcp Start MCP server (stdio transport) for AI agents"); eprintln!(" xtop plugin list List installed plugins"); - eprintln!(" xtop plugin install Install a plugin from github.com/xscriptor/xtop/plugins/"); + eprintln!( + " xtop plugin install Install a plugin from github.com/xscriptor/xtop/plugins/" + ); eprintln!(" xtop plugin install Install a plugin from a git URL"); eprintln!(" xtop plugin scaffold Create a new plugin crate"); } @@ -315,7 +356,10 @@ fn cmd_plugin_install(name_or_url: &str) -> anyhow::Result<()> { // --- Copy into local plugins/ directory --- let target_dir = plugins_dir.join(&plugin_dir_name); if target_dir.exists() { - anyhow::bail!("Plugin '{}' already exists at plugins/{plugin_dir_name}", pkg_name); + anyhow::bail!( + "Plugin '{}' already exists at plugins/{plugin_dir_name}", + pkg_name + ); } fs::create_dir_all(&plugins_dir)?; cp_recursive(&plugin_src, &target_dir)?; @@ -331,17 +375,11 @@ fn cmd_plugin_install(name_or_url: &str) -> anyhow::Result<()> { let new_ws = if ws_content.contains(sentinel_entry) { ws_content.replace( sentinel_entry, - &format!( - "{sentinel_entry}\n{member_entry}," - ), + &format!("{sentinel_entry}\n{member_entry},"), ) } else { // Fallback: insert before the closing ] of members - ws_content.replacen( - "]", - &format!(" {member_entry},\n]"), - 1, - ) + ws_content.replacen("]", &format!(" {member_entry},\n]"), 1) }; fs::write(&workspace_toml, &new_ws)?; @@ -350,17 +388,12 @@ fn cmd_plugin_install(name_or_url: &str) -> anyhow::Result<()> { // Build dependency path relative to crates/xtop-cli/ let dep_path = format!("../../plugins/{plugin_dir_name}"); - let dep_line = format!( - "{pkg_name} = {{ path = \"{dep_path}\", optional = true }}" - ); + let dep_line = format!("{pkg_name} = {{ path = \"{dep_path}\", optional = true }}"); if !cli_content.contains(&dep_line) { // Find the last optional plugin dependency and insert after it let marker = "# Optional plugins (behind feature flags)"; - let new_cli = cli_content.replace( - marker, - &format!("{marker}\n{dep_line}"), - ); + let new_cli = cli_content.replace(marker, &format!("{marker}\n{dep_line}")); fs::write(&cli_toml, &new_cli)?; } @@ -375,11 +408,7 @@ fn cmd_plugin_install(name_or_url: &str) -> anyhow::Result<()> { &format!("{sentinel_feature}\n{feature_line}"), ) } else { - cli_content2.replacen( - "[features]", - &format!("[features]\n{feature_line}"), - 1, - ) + cli_content2.replacen("[features]", &format!("[features]\n{feature_line}"), 1) }; fs::write(&cli_toml, &new_cli2)?; } @@ -601,9 +630,8 @@ fn main() -> anyhow::Result<()> { // Give plugins first chance to consume the key let key_str_clone = key_str.clone(); - let key_consumed = state.with_plugin_manager_mut(|mgr, this| { - mgr.handle_key(this, &key_str_clone) - }); + let key_consumed = + state.with_plugin_manager_mut(|mgr, this| mgr.handle_key(this, &key_str_clone)); if key_consumed { continue; } @@ -689,7 +717,7 @@ fn main() -> anyhow::Result<()> { } _ => {} } - }, + } } } } diff --git a/crates/xtop-core/src/application/plugin_manager.rs b/crates/xtop-core/src/application/plugin_manager.rs index 2b02164..b955043 100644 --- a/crates/xtop-core/src/application/plugin_manager.rs +++ b/crates/xtop-core/src/application/plugin_manager.rs @@ -50,8 +50,9 @@ impl PluginManager { ) -> Result<(), PluginError> { let id = plugin.manifest().id.clone(); let data_dir = self.plugin_data_base.join(&id); - std::fs::create_dir_all(&data_dir) - .map_err(|e| PluginError::Recoverable(format!("failed to create plugin data dir for {id}: {e}")))?; + std::fs::create_dir_all(&data_dir).map_err(|e| { + PluginError::Recoverable(format!("failed to create plugin data dir for {id}: {e}")) + })?; let capabilities = plugin.manifest().capabilities.clone(); let mut ctx = PluginContext { @@ -159,7 +160,9 @@ impl PluginManager { let mut ctx = Self::build_context(&base, plugin, state); return plugin.execute(&mut ctx, action, params); } - Err(PluginError::Recoverable(format!("plugin not found: {plugin_id}"))) + Err(PluginError::Recoverable(format!( + "plugin not found: {plugin_id}" + ))) } /// List all loaded plugin manifests (for display / status). @@ -184,5 +187,3 @@ impl PluginManager { } } } - - diff --git a/crates/xtop-core/src/application/state.rs b/crates/xtop-core/src/application/state.rs index 043a405..80ae584 100644 --- a/crates/xtop-core/src/application/state.rs +++ b/crates/xtop-core/src/application/state.rs @@ -1,8 +1,8 @@ use crate::application::history::MetricsHistory; use crate::application::plugin_manager::PluginManager; use crate::domain::keybinding::{Action, Keybindings}; -use crate::domain::metrics::SystemInfo; use crate::domain::layout::LayoutDef; +use crate::domain::metrics::SystemInfo; use crate::domain::metrics::SystemSnapshot; use crate::domain::plugin::WidgetRegistration; use crate::domain::system_info::SystemDataProvider; diff --git a/crates/xtop-core/src/domain/keybinding.rs b/crates/xtop-core/src/domain/keybinding.rs index 9b1daaf..d2a4866 100644 --- a/crates/xtop-core/src/domain/keybinding.rs +++ b/crates/xtop-core/src/domain/keybinding.rs @@ -32,20 +32,48 @@ pub struct Keybindings { pub cycle_sort: Vec, } -fn vec_one_q() -> Vec { vec!["q".into()] } -fn vec_one_question() -> Vec { vec!["?".into()] } -fn vec_one_t() -> Vec { vec!["t".into()] } -fn vec_one_shift_t() -> Vec { vec!["T".into()] } -fn vec_one_l() -> Vec { vec!["l".into()] } -fn vec_one_f() -> Vec { vec!["f".into()] } -fn vec_one_shift_f() -> Vec { vec!["F".into()] } -fn vec_one_slash() -> Vec { vec!["/".into()] } -fn vec_one_ctrl_p() -> Vec { vec!["ctrl+p".into(), "ctrl+P".into()] } -fn vec_one_escape() -> Vec { vec!["escape".into()] } -fn vec_one_k() -> Vec { vec!["k".into()] } -fn vec_one_up() -> Vec { vec!["up".into()] } -fn vec_one_down() -> Vec { vec!["down".into()] } -fn vec_one_s() -> Vec { vec!["s".into()] } +fn vec_one_q() -> Vec { + vec!["q".into()] +} +fn vec_one_question() -> Vec { + vec!["?".into()] +} +fn vec_one_t() -> Vec { + vec!["t".into()] +} +fn vec_one_shift_t() -> Vec { + vec!["T".into()] +} +fn vec_one_l() -> Vec { + vec!["l".into()] +} +fn vec_one_f() -> Vec { + vec!["f".into()] +} +fn vec_one_shift_f() -> Vec { + vec!["F".into()] +} +fn vec_one_slash() -> Vec { + vec!["/".into()] +} +fn vec_one_ctrl_p() -> Vec { + vec!["ctrl+p".into(), "ctrl+P".into()] +} +fn vec_one_escape() -> Vec { + vec!["escape".into()] +} +fn vec_one_k() -> Vec { + vec!["k".into()] +} +fn vec_one_up() -> Vec { + vec!["up".into()] +} +fn vec_one_down() -> Vec { + vec!["down".into()] +} +fn vec_one_s() -> Vec { + vec!["s".into()] +} impl Default for Keybindings { fn default() -> Self { diff --git a/crates/xtop-core/src/domain/layout.rs b/crates/xtop-core/src/domain/layout.rs index c6d2ba4..cceca80 100644 --- a/crates/xtop-core/src/domain/layout.rs +++ b/crates/xtop-core/src/domain/layout.rs @@ -74,9 +74,10 @@ impl TryFrom for LayoutArea { Some(SizeRaw::Num(n)) => LayoutConstraint::Length(n), Some(SizeRaw::Str(s)) if s == "*" => LayoutConstraint::Fill, Some(SizeRaw::Str(s)) if s.ends_with('%') => { - let pct = s.trim_end_matches('%').parse::().map_err(|_| { - format!("invalid percentage: {s}") - })?; + let pct = s + .trim_end_matches('%') + .parse::() + .map_err(|_| format!("invalid percentage: {s}"))?; LayoutConstraint::Percentage(pct) } Some(SizeRaw::Str(s)) => { diff --git a/crates/xtop-core/src/domain/plugin.rs b/crates/xtop-core/src/domain/plugin.rs index f832733..d6336e6 100644 --- a/crates/xtop-core/src/domain/plugin.rs +++ b/crates/xtop-core/src/domain/plugin.rs @@ -58,7 +58,9 @@ impl std::error::Error for PluginError {} /// A widget that a plugin registers for rendering in the TUI. pub struct WidgetRegistration { pub name: String, - pub render: std::sync::Arc, + pub render: std::sync::Arc< + dyn Fn(&mut ratatui::Frame, &AppState, ratatui::prelude::Rect) + Send + Sync, + >, } impl Debug for WidgetRegistration { @@ -111,7 +113,12 @@ impl PluginContext<'_> { /// Set alert thresholds for CPU, memory, and disk. /// Requires `ModifyConfig` capability. - pub fn set_alert_thresholds(&mut self, cpu: f64, mem: f64, disk: f64) -> Result<(), PluginError> { + pub fn set_alert_thresholds( + &mut self, + cpu: f64, + mem: f64, + disk: f64, + ) -> Result<(), PluginError> { self.check_capability(&PluginCapability::ModifyConfig)?; self.state.set_alert_thresholds(cpu, mem, disk); Ok(()) @@ -182,11 +189,7 @@ pub trait Plugin: Debug + Send { /// Called when a key is pressed. /// Return `Ok(true)` if the plugin consumed the key event. - fn on_key( - &mut self, - _ctx: &mut PluginContext, - _key: &str, - ) -> Result { + fn on_key(&mut self, _ctx: &mut PluginContext, _key: &str) -> Result { Ok(false) } diff --git a/crates/xtop-core/src/domain/theme.rs b/crates/xtop-core/src/domain/theme.rs index 12db302..3fa6c8a 100644 --- a/crates/xtop-core/src/domain/theme.rs +++ b/crates/xtop-core/src/domain/theme.rs @@ -72,8 +72,7 @@ impl<'de> Deserialize<'de> for Theme { } let name = name.ok_or_else(|| de::Error::missing_field("name"))?; - let palette_str = - palette.ok_or_else(|| de::Error::missing_field("palette"))?; + let palette_str = palette.ok_or_else(|| de::Error::missing_field("palette"))?; let mut palette = [[0u8; 3]; 16]; for (i, hex) in palette_str.iter().enumerate() { diff --git a/crates/xtop-core/src/infrastructure/composite_provider.rs b/crates/xtop-core/src/infrastructure/composite_provider.rs index cbfe338..4cd083e 100644 --- a/crates/xtop-core/src/infrastructure/composite_provider.rs +++ b/crates/xtop-core/src/infrastructure/composite_provider.rs @@ -55,31 +55,19 @@ impl SystemDataProvider for CompositeProvider { } fn disk_io(&self) -> Vec { - self.first_non_empty( - || self.primary.disk_io(), - |e| e.disk_io(), - ) + self.first_non_empty(|| self.primary.disk_io(), |e| e.disk_io()) } fn batteries(&self) -> Vec { - self.first_non_empty( - || self.primary.batteries(), - |e| e.batteries(), - ) + self.first_non_empty(|| self.primary.batteries(), |e| e.batteries()) } fn gpu_info(&self) -> Vec { - self.first_non_empty( - || self.primary.gpu_info(), - |e| e.gpu_info(), - ) + self.first_non_empty(|| self.primary.gpu_info(), |e| e.gpu_info()) } fn docker_info(&self) -> Vec { - self.first_non_empty( - || self.primary.docker_info(), - |e| e.docker_info(), - ) + self.first_non_empty(|| self.primary.docker_info(), |e| e.docker_info()) } fn system_info(&self) -> SystemInfo { diff --git a/crates/xtop-core/src/infrastructure/layout_loader.rs b/crates/xtop-core/src/infrastructure/layout_loader.rs index 3a059d6..e551bcb 100644 --- a/crates/xtop-core/src/infrastructure/layout_loader.rs +++ b/crates/xtop-core/src/infrastructure/layout_loader.rs @@ -130,14 +130,20 @@ fn dashboard_layout() -> LayoutDef { name: "Dashboard".into(), root: split_v(vec![ area(3, widget("header")), - pct(45, split_h(vec![ - pct(50, widget("cpu")), - pct(50, split_v(vec![ - pct(33, widget("memory")), - pct(33, widget("storage")), - pct(34, widget("network")), - ])), - ])), + pct( + 45, + split_h(vec![ + pct(50, widget("cpu")), + pct( + 50, + split_v(vec![ + pct(33, widget("memory")), + pct(33, widget("storage")), + pct(34, widget("network")), + ]), + ), + ]), + ), pct(52, widget("processes")), ]), } @@ -199,10 +205,10 @@ fn network_focus_layout() -> LayoutDef { name: "Network Focus".into(), root: split_v(vec![ area(3, widget("header")), - pct(50, split_h(vec![ - pct(50, widget("network")), - pct(50, widget("disk_io")), - ])), + pct( + 50, + split_h(vec![pct(50, widget("network")), pct(50, widget("disk_io"))]), + ), fill(widget("processes")), ]), } @@ -213,12 +219,15 @@ fn process_focus_layout() -> LayoutDef { name: "Process Focus".into(), root: split_v(vec![ area(3, widget("header")), - area(8, split_h(vec![ - pct(25, widget("cpu")), - pct(25, widget("memory")), - pct(25, widget("storage")), - pct(25, widget("network")), - ])), + area( + 8, + split_h(vec![ + pct(25, widget("cpu")), + pct(25, widget("memory")), + pct(25, widget("storage")), + pct(25, widget("network")), + ]), + ), fill(widget("processes")), ]), } diff --git a/crates/xtop-core/src/infrastructure/sysinfo_provider.rs b/crates/xtop-core/src/infrastructure/sysinfo_provider.rs index ce2648e..bdcf373 100644 --- a/crates/xtop-core/src/infrastructure/sysinfo_provider.rs +++ b/crates/xtop-core/src/infrastructure/sysinfo_provider.rs @@ -191,7 +191,11 @@ impl SystemDataProvider for SysinfoProvider { .iter() .map(|(pid, p)| { let start = p.start_time(); - let run = if start > 0 { now.saturating_sub(start) } else { 0 }; + let run = if start > 0 { + now.saturating_sub(start) + } else { + 0 + }; ProcessInfo { pid: pid.as_u32(), name: p.name().to_string_lossy().to_string(), @@ -199,12 +203,20 @@ impl SystemDataProvider for SysinfoProvider { memory: p.memory(), user_id: p.user_id().map(|u| u.to_string()), state: format!("{:?}", p.status()), - cmd: p.cmd().first().map(|c| c.to_string_lossy().to_string()).unwrap_or_default(), + cmd: p + .cmd() + .first() + .map(|c| c.to_string_lossy().to_string()) + .unwrap_or_default(), // P0 exe_path: p.exe().map(|e| e.to_string_lossy().to_string()), parent_pid: p.parent().map(|ppid| ppid.as_u32()), - cmd_full: p.cmd().iter().map(|c| c.to_string_lossy().to_string()).collect(), + cmd_full: p + .cmd() + .iter() + .map(|c| c.to_string_lossy().to_string()) + .collect(), // P1 start_time: start, @@ -219,7 +231,11 @@ impl SystemDataProvider for SysinfoProvider { open_files_limit: p.open_files_limit().unwrap_or(0) as u64, disk_total_read_bytes: p.disk_usage().total_read_bytes, disk_total_write_bytes: p.disk_usage().total_written_bytes, - environ: p.environ().iter().map(|e| e.to_string_lossy().to_string()).collect(), + environ: p + .environ() + .iter() + .map(|e| e.to_string_lossy().to_string()) + .collect(), session_id: p.session_id().map(|s| s.as_u32()), } }) @@ -352,9 +368,11 @@ impl SysinfoProvider { #[cfg(target_os = "linux")] fn read_cpu_governor(_cpu_id: usize) -> String { - std::fs::read_to_string(format!("/sys/devices/system/cpu/cpu{_cpu_id}/cpufreq/scaling_governor")) - .map(|s| s.trim().to_string()) - .unwrap_or_default() + std::fs::read_to_string(format!( + "/sys/devices/system/cpu/cpu{_cpu_id}/cpufreq/scaling_governor" + )) + .map(|s| s.trim().to_string()) + .unwrap_or_default() } #[cfg(not(target_os = "linux"))] @@ -400,7 +418,11 @@ fn read_interface_ips() -> HashMap> { let start = i * 4; let group = &addr_hex[start..start + 4]; let trimmed = group.trim_start_matches('0'); - let val = u16::from_str_radix(if trimmed.is_empty() { "0" } else { trimmed }, 16).unwrap_or(0); + let val = u16::from_str_radix( + if trimmed.is_empty() { "0" } else { trimmed }, + 16, + ) + .unwrap_or(0); format!("{:x}", val) }) .collect::>() @@ -469,11 +491,14 @@ fn read_batteries() -> Vec { let (time_to_full, time_to_empty) = if power_now != 0 && power_now.abs() > 0 { if state == "Charging" { - let remaining = charge_full.unwrap_or(0).saturating_sub(charge_now.unwrap_or(0)); + let remaining = charge_full + .unwrap_or(0) + .saturating_sub(charge_now.unwrap_or(0)); let secs = (remaining as f64 / power_now.abs() as f64 * 3600.0) as u64; (Some(secs), None) } else if state == "Discharging" { - let secs = (charge_now.unwrap_or(0) as f64 / power_now.abs() as f64 * 3600.0) as u64; + let secs = + (charge_now.unwrap_or(0) as f64 / power_now.abs() as f64 * 3600.0) as u64; (None, Some(secs)) } else { (None, None) @@ -544,13 +569,16 @@ fn read_gpu_info() -> Vec { if fname.starts_with("card") && !fname.contains('-') { let base = entry.path(); let dev = base.join("device"); - let gpu_name = std::fs::read_to_string(dev.join("product_name")).ok() + let gpu_name = std::fs::read_to_string(dev.join("product_name")) + .ok() .map(|s| s.trim().to_string()) .unwrap_or_else(|| fname.clone()); - let mem_total = std::fs::read_to_string(dev.join("mem_info_vram_total")).ok() + let mem_total = std::fs::read_to_string(dev.join("mem_info_vram_total")) + .ok() .and_then(|s| s.trim().parse::().ok()) .unwrap_or(0); - let mem_used = std::fs::read_to_string(dev.join("mem_info_vram_used")).ok() + let mem_used = std::fs::read_to_string(dev.join("mem_info_vram_used")) + .ok() .and_then(|s| s.trim().parse::().ok()) .unwrap_or(0); let temp = find_hwmon_temp(&base.join("device"), "gpu").unwrap_or(0.0); diff --git a/crates/xtop-core/src/infrastructure/theme_loader.rs b/crates/xtop-core/src/infrastructure/theme_loader.rs index b769948..653aeeb 100644 --- a/crates/xtop-core/src/infrastructure/theme_loader.rs +++ b/crates/xtop-core/src/infrastructure/theme_loader.rs @@ -17,9 +17,8 @@ fn default_theme() -> Theme { make_theme( "x", [ - "#050505", "#fc618d", "#7bd88f", "#fce566", "#fd9353", "#948ae3", "#5ad4e6", - "#f7f1ff", "#0f0f0f", "#fc618d", "#7bd88f", "#fce566", "#fd9353", "#948ae3", - "#5ad4e6", "#f7f1ff", + "#050505", "#fc618d", "#7bd88f", "#fce566", "#fd9353", "#948ae3", "#5ad4e6", "#f7f1ff", + "#0f0f0f", "#fc618d", "#7bd88f", "#fce566", "#fd9353", "#948ae3", "#5ad4e6", "#f7f1ff", ], ) } diff --git a/crates/xtop-tui/src/color.rs b/crates/xtop-tui/src/color.rs index 88d9d55..6cfd77e 100644 --- a/crates/xtop-tui/src/color.rs +++ b/crates/xtop-tui/src/color.rs @@ -17,4 +17,3 @@ pub fn gauge_gradient(pct: f64, alert_at: f64) -> usize { 2 } } - diff --git a/crates/xtop-tui/src/render/cpu.rs b/crates/xtop-tui/src/render/cpu.rs index b1944cc..5351b3b 100644 --- a/crates/xtop-tui/src/render/cpu.rs +++ b/crates/xtop-tui/src/render/cpu.rs @@ -128,15 +128,11 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) { .bounds([x_min, x_max]) .labels(vec![Span::raw("")]), ) - .y_axis( - Axis::default() - .bounds([0.0, 100.0]) - .labels(vec![ - Span::raw("0%"), - Span::raw("50%"), - Span::raw("100%"), - ]), - ); + .y_axis(Axis::default().bounds([0.0, 100.0]).labels(vec![ + Span::raw("0%"), + Span::raw("50%"), + Span::raw("100%"), + ])); f.render_widget(chart, chart_area); } } diff --git a/crates/xtop-tui/src/render/disk_io.rs b/crates/xtop-tui/src/render/disk_io.rs index 5dbcdc1..cd0ce1a 100644 --- a/crates/xtop-tui/src/render/disk_io.rs +++ b/crates/xtop-tui/src/render/disk_io.rs @@ -73,7 +73,12 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) { .bg(bg), ) .percent((d.write_speed / max_speed * 100.0) as u16) - .label(format!(" W: {}/s Tot R: {} Tot W: {}", write_speed, format_bytes(d.read_bytes), format_bytes(d.write_bytes))); + .label(format!( + " W: {}/s Tot R: {} Tot W: {}", + write_speed, + format_bytes(d.read_bytes), + format_bytes(d.write_bytes) + )); f.render_widget(write_gauge, sub[1]); } } diff --git a/crates/xtop-tui/src/render/header.rs b/crates/xtop-tui/src/render/header.rs index 959682f..d3f7496 100644 --- a/crates/xtop-tui/src/render/header.rs +++ b/crates/xtop-tui/src/render/header.rs @@ -30,7 +30,11 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) { let text: Vec = if wide { vec![Line::from(format!( "{} | {} | {} | Uptime: {} | Load: {:.2} {:.2} {:.2}{}", - if host.is_empty() { "xtop".to_string() } else { host.clone() }, + if host.is_empty() { + "xtop".to_string() + } else { + host.clone() + }, state.current_theme.name, mode_str, format_uptime(uptime), @@ -46,11 +50,7 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) { format!("{} | {}", host, mode_str) }; vec![ - Line::from(format!( - "{} | Uptime: {}", - host_part, - format_uptime(uptime), - )), + Line::from(format!("{} | Uptime: {}", host_part, format_uptime(uptime),)), Line::from(format!( "Load: {:.2} {:.2} {:.2}{}", load.one, load.five, load.fifteen, extras, diff --git a/crates/xtop-tui/src/render/memory.rs b/crates/xtop-tui/src/render/memory.rs index 918ef47..3480083 100644 --- a/crates/xtop-tui/src/render/memory.rs +++ b/crates/xtop-tui/src/render/memory.rs @@ -12,7 +12,11 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) { let snap = state.snapshot(); let mem_alert = snap.memory.percent > state.alerts.mem_high; - let mem_color_idx = if mem_alert { 1 } else { gauge_gradient(snap.memory.percent, state.alerts.mem_high) }; + let mem_color_idx = if mem_alert { + 1 + } else { + gauge_gradient(snap.memory.percent, state.alerts.mem_high) + }; let mut title = "Memory".to_string(); if mem_alert { @@ -127,10 +131,10 @@ fn render_chart(f: &mut Frame, state: &AppState, area: Rect, _bg: Color) { .bounds([x_min, x_max]) .labels(vec![Span::raw("")]), ) - .y_axis( - Axis::default() - .bounds([0.0, 100.0]) - .labels(vec![Span::raw("0%"), Span::raw("50%"), Span::raw("100%")]), - ); + .y_axis(Axis::default().bounds([0.0, 100.0]).labels(vec![ + Span::raw("0%"), + Span::raw("50%"), + Span::raw("100%"), + ])); f.render_widget(chart, area); } diff --git a/crates/xtop-tui/src/render/network.rs b/crates/xtop-tui/src/render/network.rs index e1502b6..aa1639a 100644 --- a/crates/xtop-tui/src/render/network.rs +++ b/crates/xtop-tui/src/render/network.rs @@ -32,10 +32,30 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) { .constraints([Constraint::Length(4), Constraint::Min(0)]) .split(inner); - render_stats(f, state, chunks[0], fg, total_rx, total_tx, total_rx_speed, total_tx_speed, &snap.networks); + render_stats( + f, + state, + chunks[0], + fg, + total_rx, + total_tx, + total_rx_speed, + total_tx_speed, + &snap.networks, + ); render_net_chart(f, state, chunks[1], bg); } else { - render_stats(f, state, inner, fg, total_rx, total_tx, total_rx_speed, total_tx_speed, &snap.networks); + render_stats( + f, + state, + inner, + fg, + total_rx, + total_tx, + total_rx_speed, + total_tx_speed, + &snap.networks, + ); } } @@ -137,14 +157,10 @@ fn render_net_chart(f: &mut Frame, state: &AppState, area: Rect, _bg: Color) { .bounds([x_min, x_max]) .labels(vec![Span::raw("")]), ) - .y_axis( - Axis::default() - .bounds([0.0, max_val]) - .labels(vec![ - Span::raw("0"), - Span::raw(format!("{:.0}", max_val / 2.0)), - Span::raw(format!("{:.0}", max_val)), - ]), - ); + .y_axis(Axis::default().bounds([0.0, max_val]).labels(vec![ + Span::raw("0"), + Span::raw(format!("{:.0}", max_val / 2.0)), + Span::raw(format!("{:.0}", max_val)), + ])); f.render_widget(chart, area); } diff --git a/crates/xtop-tui/src/render/palette.rs b/crates/xtop-tui/src/render/palette.rs index 54f4c11..e1f1f11 100644 --- a/crates/xtop-tui/src/render/palette.rs +++ b/crates/xtop-tui/src/render/palette.rs @@ -78,12 +78,7 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) { list_state.select(Some(state.palette.selected)); let list = List::new(items) - .highlight_style( - Style::default() - .fg(bg) - .bg(fg) - .add_modifier(Modifier::BOLD), - ) + .highlight_style(Style::default().fg(bg).bg(fg).add_modifier(Modifier::BOLD)) .highlight_symbol("▸ "); f.render_stateful_widget(list, list_area, &mut list_state); } diff --git a/crates/xtop-tui/src/render/processes.rs b/crates/xtop-tui/src/render/processes.rs index 23cd5e5..565c169 100644 --- a/crates/xtop-tui/src/render/processes.rs +++ b/crates/xtop-tui/src/render/processes.rs @@ -43,7 +43,11 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) { // Sort match state.process_sort { xtop_core::application::state::ProcessSortBy::Cpu => { - items.sort_by(|a, b| b.cpu_usage.partial_cmp(&a.cpu_usage).unwrap_or(std::cmp::Ordering::Equal)); + items.sort_by(|a, b| { + b.cpu_usage + .partial_cmp(&a.cpu_usage) + .unwrap_or(std::cmp::Ordering::Equal) + }); } xtop_core::application::state::ProcessSortBy::Memory => { items.sort_by_key(|b| std::cmp::Reverse(b.memory)); @@ -93,11 +97,7 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) { let table = Table::new(rows, widths) .header( Row::new(vec!["PID", "Name", "CPU%", "Mem", "User"]) - .style( - Style::default() - .fg(accent) - .add_modifier(Modifier::BOLD), - ) + .style(Style::default().fg(accent).add_modifier(Modifier::BOLD)) .bottom_margin(1), ) .row_highlight_style( diff --git a/plugins/xtop-plugin-sentinel/src/lib.rs b/plugins/xtop-plugin-sentinel/src/lib.rs index 35b88a7..133c9fe 100644 --- a/plugins/xtop-plugin-sentinel/src/lib.rs +++ b/plugins/xtop-plugin-sentinel/src/lib.rs @@ -1,11 +1,11 @@ pub mod alert; pub mod mcp; -use std::collections::HashMap; -use std::fmt::Debug; use ratatui::prelude::*; use ratatui::widgets::{Block, Borders, Paragraph}; use regex::Regex; +use std::collections::HashMap; +use std::fmt::Debug; use xtop_core::application::state::AppState; use xtop_core::domain::metrics::ProcessInfo; use xtop_core::domain::plugin::{ @@ -18,9 +18,20 @@ use alert::{SentinelAlert, Severity}; // Known threat patterns (Rule 6) // --------------------------------------------------------------------------- const KNOWN_THREAT_NAMES: &[&str] = &[ - "minerd", "cpu_miner", "xmrig", "kdevtmpfsi", "kinsing", "diagree", - "watchbog", "sysguard", "crond64", "mkfile", "sysupdate", - "xmrig-nvidia", "xmrig-amd", "moneroocean", + "minerd", + "cpu_miner", + "xmrig", + "kdevtmpfsi", + "kinsing", + "diagree", + "watchbog", + "sysguard", + "crond64", + "mkfile", + "sysupdate", + "xmrig-nvidia", + "xmrig-amd", + "moneroocean", ]; const KNOWN_THREAT_CMDS: &[&str] = &[ @@ -44,8 +55,8 @@ const SUSPICIOUS_PATH_PREFIXES: &[&str] = &[ /// Processes allowed to be orphans (PPID=1). const ALLOWED_ORPHANS: &[&str] = &[ - "systemd", "init", "launchd", "sshd", "login", "getty", "nginx", - "apache2", "httpd", "bash", "sh", "zsh", "tmux", "screen", + "systemd", "init", "launchd", "sshd", "login", "getty", "nginx", "apache2", "httpd", "bash", + "sh", "zsh", "tmux", "screen", ]; /// Browsers whose children are monitored (Rule 5). @@ -55,8 +66,14 @@ const BROWSER_NAMES: &[&str] = &[ /// Browser helper/sandbox processes that are allowed children. const BROWSER_HELPERS: &[&str] = &[ - "helper", "plugin_container", "plugin_host", "gpu_process", - "renderer", "utility", "crashpad", "updater", + "helper", + "plugin_container", + "plugin_host", + "gpu_process", + "renderer", + "utility", + "crashpad", + "updater", ]; /// Pipe/download patterns for Rule 7. @@ -74,8 +91,8 @@ const PIPE_PATTERNS: &[&str] = &[ /// High-thread-count processes that are allowed (Rule 8). const ALLOWED_HIGH_THREAD: &[&str] = &[ - "chrome", "firefox", "code", "Code", "idea", "java", "dotnet", - "python", "node", "mysqld", "postgres", "Xorg", "dockerd", + "chrome", "firefox", "code", "Code", "idea", "java", "dotnet", "python", "node", "mysqld", + "postgres", "Xorg", "dockerd", ]; /// Maximum alerts per cycle @@ -149,8 +166,8 @@ impl SentinelPlugin { fn system_summary(&self, ctx: &PluginContext) -> String { let snap = ctx.snapshot(); - let cpu_pct: f64 = snap.cpus.iter().map(|c| c.usage).sum::() - / snap.cpus.len().max(1) as f64; + let cpu_pct: f64 = + snap.cpus.iter().map(|c| c.usage).sum::() / snap.cpus.len().max(1) as f64; let mem_gb = (snap.memory.used as f64 / 1073741824.0 * 10.0).round() / 10.0; let mem_total_gb = (snap.memory.total as f64 / 1073741824.0 * 10.0).round() / 10.0; let net_ifaces: Vec<&str> = snap.networks.iter().map(|n| n.name.as_str()).collect(); @@ -166,7 +183,8 @@ impl SentinelPlugin { "uptime_secs": snap.uptime, "hostname": snap.sys_info.hostname, "alerts": self.alerts.len(), - })).unwrap_or_default() + })) + .unwrap_or_default() } // ----------------------------------------------------------------------- @@ -184,12 +202,13 @@ impl SentinelPlugin { let pattern_str = pattern_str.trim(); if pattern_str.is_empty() { - return Err(PluginError::Recoverable("search pattern cannot be empty".into())); + return Err(PluginError::Recoverable( + "search pattern cannot be empty".into(), + )); } - let re = Regex::new(pattern_str).map_err(|e| { - PluginError::Recoverable(format!("invalid regex: {e}")) - })?; + let re = Regex::new(pattern_str) + .map_err(|e| PluginError::Recoverable(format!("invalid regex: {e}")))?; let snap = ctx.snapshot(); let mut matched: Vec = snap @@ -208,7 +227,11 @@ impl SentinelPlugin { }) .collect(); - matched.sort_by(|a, b| b.cpu_usage.partial_cmp(&a.cpu_usage).unwrap_or(std::cmp::Ordering::Equal)); + matched.sort_by(|a, b| { + b.cpu_usage + .partial_cmp(&a.cpu_usage) + .unwrap_or(std::cmp::Ordering::Equal) + }); matched.truncate(100); Ok(Self::fmt_process_list(&matched)) } @@ -230,10 +253,13 @@ impl SentinelPlugin { let mut procs: Vec = snap.processes; if let Some(pattern) = filter_pattern { - let re = Regex::new(pattern).map_err(|e| { - PluginError::Recoverable(format!("invalid regex in filter: {e}")) - })?; - procs.retain(|p| re.is_match(&p.name) || re.is_match(&p.cmd) || p.exe_path.as_deref().map_or(false, |e| re.is_match(e))); + let re = Regex::new(pattern) + .map_err(|e| PluginError::Recoverable(format!("invalid regex in filter: {e}")))?; + procs.retain(|p| { + re.is_match(&p.name) + || re.is_match(&p.cmd) + || p.exe_path.as_deref().map_or(false, |e| re.is_match(e)) + }); } procs.truncate(count); @@ -241,13 +267,15 @@ impl SentinelPlugin { } fn process_info(&self, ctx: &PluginContext, pid_str: &str) -> Result { - let pid = pid_str.parse::().map_err(|_| { - PluginError::Recoverable(format!("invalid pid: {pid_str}")) - })?; + let pid = pid_str + .parse::() + .map_err(|_| PluginError::Recoverable(format!("invalid pid: {pid_str}")))?; let snap = ctx.snapshot(); - let proc = snap.processes.iter().find(|p| p.pid == pid).ok_or_else(|| { - PluginError::Recoverable(format!("process {pid} not found")) - })?; + let proc = snap + .processes + .iter() + .find(|p| p.pid == pid) + .ok_or_else(|| PluginError::Recoverable(format!("process {pid} not found")))?; Ok(serde_json::to_string(&Self::fmt_process(proc)).unwrap_or_default()) } @@ -280,7 +308,11 @@ impl SentinelPlugin { if ALLOWED_ORPHANS.iter().any(|a| name_lower.contains(a)) { return None; } - let severity = if proc.run_time < 60 { Severity::Critical } else { Severity::Warning }; + let severity = if proc.run_time < 60 { + Severity::Critical + } else { + Severity::Warning + }; Some(SentinelAlert::new( "orphan_process", severity, @@ -311,15 +343,20 @@ impl SentinelPlugin { // Check if name is a known system process but exe is not at a canonical path let known_system_names = ["svchost", "lsass", "launchd", "sshd", "systemd", "init"]; if known_system_names.contains(&name_lower.as_str()) { - let canonical = exe.starts_with("/usr/") || exe.starts_with("/bin/") - || exe.starts_with("/sbin/") || exe.starts_with("/System/"); + let canonical = exe.starts_with("/usr/") + || exe.starts_with("/bin/") + || exe.starts_with("/sbin/") + || exe.starts_with("/System/"); if !canonical { return Some(SentinelAlert::new( "process_masquerading", Severity::Critical, proc.pid, proc.name.clone(), - format!("process name '{}' masquerades as system process; exe: {exe}", proc.name), + format!( + "process name '{}' masquerades as system process; exe: {exe}", + proc.name + ), )); } } @@ -333,7 +370,10 @@ impl SentinelPlugin { Severity::Warning, proc.pid, proc.name.clone(), - format!("name '{}' differs from exe stem '{stem}' ({exe})", proc.name), + format!( + "name '{}' differs from exe stem '{stem}' ({exe})", + proc.name + ), )); } } @@ -355,15 +395,29 @@ impl SentinelPlugin { return None; } // Known SUID binaries that are allowed - let known_suid = ["/usr/bin/sudo", "/usr/bin/passwd", "/bin/ping", - "/usr/bin/ping", "/bin/su", "/usr/bin/su", "/usr/bin/newgrp", - "/usr/bin/gpasswd", "/usr/bin/chsh", "/usr/bin/chfn", - "/usr/bin/mount", "/usr/bin/umount"]; + let known_suid = [ + "/usr/bin/sudo", + "/usr/bin/passwd", + "/bin/ping", + "/usr/bin/ping", + "/bin/su", + "/usr/bin/su", + "/usr/bin/newgrp", + "/usr/bin/gpasswd", + "/usr/bin/chsh", + "/usr/bin/chfn", + "/usr/bin/mount", + "/usr/bin/umount", + ]; let exe = proc.exe_path.as_deref().unwrap_or(""); if known_suid.contains(&exe) { return None; } - let severity = if euid == "0" { Severity::Critical } else { Severity::Warning }; + let severity = if euid == "0" { + Severity::Critical + } else { + Severity::Warning + }; Some(SentinelAlert::new( "privilege_escalation", severity, @@ -374,7 +428,11 @@ impl SentinelPlugin { } /// Rule 5: Suspicious child of a browser process. - fn rule_suspicious_child_of_browser(&self, proc: &ProcessInfo, parent_map: &HashMap) -> Option { + fn rule_suspicious_child_of_browser( + &self, + proc: &ProcessInfo, + parent_map: &HashMap, + ) -> Option { let ppid = match proc.parent_pid { Some(pid) => pid, None => return None, @@ -398,7 +456,10 @@ impl SentinelPlugin { Severity::Warning, proc.pid, proc.name.clone(), - format!("browser '{}' spawned unknown child '{}'", parent.name, proc.name), + format!( + "browser '{}' spawned unknown child '{}'", + parent.name, proc.name + ), )) } @@ -416,7 +477,9 @@ impl SentinelPlugin { } let cmd_joined = proc.cmd_full.join(" ").to_lowercase(); if KNOWN_THREAT_CMDS.iter().any(|t| { - Regex::new(t).ok().map_or(false, |re| re.is_match(&cmd_joined)) + Regex::new(t) + .ok() + .map_or(false, |re| re.is_match(&cmd_joined)) }) { return Some(SentinelAlert::new( "known_threat_pattern", @@ -467,7 +530,10 @@ impl SentinelPlugin { severity, proc.pid, proc.name.clone(), - format!("{} threads (CPU: {:.1}%)", proc.thread_count, proc.cpu_usage), + format!( + "{} threads (CPU: {:.1}%)", + proc.thread_count, proc.cpu_usage + ), )) } @@ -477,8 +543,10 @@ impl SentinelPlugin { return None; } let name_lower = proc.name.to_lowercase(); - let allowed = ["mysql", "postgres", "nginx", "httpd", "apache", - "chrome", "firefox", "code", "java", "dotnet", "dockerd"]; + let allowed = [ + "mysql", "postgres", "nginx", "httpd", "apache", "chrome", "firefox", "code", "java", + "dotnet", "dockerd", + ]; if allowed.iter().any(|a| name_lower.contains(a)) { return None; } @@ -492,18 +560,11 @@ impl SentinelPlugin { } /// Rule 10: Spawn storm detection. - fn rule_spawn_storm( - &mut self, - proc: &ProcessInfo, - now_run_time: u64, - ) -> Option { + fn rule_spawn_storm(&mut self, proc: &ProcessInfo, now_run_time: u64) -> Option { if proc.run_time > 120 { return None; } - let entry = self - .spawn_history - .entry(proc.name.clone()) - .or_default(); + let entry = self.spawn_history.entry(proc.name.clone()).or_default(); entry.push((proc.pid, proc.start_time)); // Purge entries older than 120s entry.retain(|(_, start)| now_run_time.saturating_sub(*start) < 120); @@ -529,11 +590,8 @@ impl SentinelPlugin { let mut alerts: Vec = Vec::new(); // Build parent PID map for Rule 5 - let parent_map: HashMap = snap - .processes - .iter() - .map(|p| (p.pid, p)) - .collect(); + let parent_map: HashMap = + snap.processes.iter().map(|p| (p.pid, p)).collect(); let now_run_time = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -585,15 +643,15 @@ impl SentinelPlugin { "expected cpu,mem,disk (3 comma-separated values)".into(), )); } - let cpu = parts[0].parse::().map_err(|e| { - PluginError::Recoverable(format!("invalid cpu threshold: {e}")) - })?; - let mem = parts[1].parse::().map_err(|e| { - PluginError::Recoverable(format!("invalid mem threshold: {e}")) - })?; - let disk = parts[2].parse::().map_err(|e| { - PluginError::Recoverable(format!("invalid disk threshold: {e}")) - })?; + let cpu = parts[0] + .parse::() + .map_err(|e| PluginError::Recoverable(format!("invalid cpu threshold: {e}")))?; + let mem = parts[1] + .parse::() + .map_err(|e| PluginError::Recoverable(format!("invalid mem threshold: {e}")))?; + let disk = parts[2] + .parse::() + .map_err(|e| PluginError::Recoverable(format!("invalid disk threshold: {e}")))?; Ok((cpu, mem, disk)) } } @@ -614,7 +672,8 @@ impl Plugin for SentinelPlugin { id: "sentinel".to_string(), name: "Sentinel".to_string(), version: "0.1.0".to_string(), - description: "AI-aware system monitoring, management, and heuristic threat detection".to_string(), + description: "AI-aware system monitoring, management, and heuristic threat detection" + .to_string(), capabilities: vec![ PluginCapability::ReadSystemInfo, PluginCapability::KillProcesses, @@ -655,13 +714,21 @@ impl Plugin for SentinelPlugin { .title(" Sentinel ") .borders(Borders::ALL) .border_style(Style::default().fg(Color::Rgb(accent[0], accent[1], accent[2]))) - .style(Style::default().bg(Color::Rgb(bg[0], bg[1], bg[2])).fg(Color::Rgb(fg[0], fg[1], fg[2]))); + .style( + Style::default() + .bg(Color::Rgb(bg[0], bg[1], bg[2])) + .fg(Color::Rgb(fg[0], fg[1], fg[2])), + ); let inner = block.inner(area); f.render_widget(block, area); let chunks = Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Length(1), Constraint::Length(1), Constraint::Min(0)]) + .constraints([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(0), + ]) .split(inner); let status = Paragraph::new("Agent: monitoring for threats -- use MCP to interact") @@ -689,18 +756,21 @@ impl Plugin for SentinelPlugin { "processes.search" => self.search_processes(ctx, params), "process.info" => self.process_info(ctx, params), "process.kill" => { - let pid = params.parse::().map_err(|_| { - PluginError::Recoverable(format!("invalid pid: {params}")) - })?; - let ok = ctx.kill_process(pid) + let pid = params + .parse::() + .map_err(|_| PluginError::Recoverable(format!("invalid pid: {params}")))?; + let ok = ctx + .kill_process(pid) .map_err(|e| PluginError::Recoverable(e.to_string()))?; Ok(serde_json::to_string(&serde_json::json!({ "killed": ok, "pid": pid, - })).unwrap_or_default()) + })) + .unwrap_or_default()) } "process.alerts" => { - let alerts_json: Vec = self.alerts.iter().map(Self::fmt_alert).collect(); + let alerts_json: Vec = + self.alerts.iter().map(Self::fmt_alert).collect(); Ok(serde_json::to_string(&alerts_json).unwrap_or_default()) } "threshold.set" => { @@ -709,7 +779,8 @@ impl Plugin for SentinelPlugin { .map_err(|e| PluginError::Recoverable(e.to_string()))?; Ok(serde_json::to_string(&serde_json::json!({ "cpu": cpu, "mem": mem, "disk": disk, "set": true, - })).unwrap_or_default()) + })) + .unwrap_or_default()) } "threshold.get" => { let alerts = ctx.state().alerts; @@ -717,7 +788,8 @@ impl Plugin for SentinelPlugin { "cpu": alerts.cpu_high, "mem": alerts.mem_high, "disk": alerts.disk_high, - })).unwrap_or_default()) + })) + .unwrap_or_default()) } "config.get" => { let s = ctx.state(); @@ -726,7 +798,8 @@ impl Plugin for SentinelPlugin { "layout": s.current_layout_name(), "interval_ms": s.update_interval_ms, "hostname": s.sys_info.hostname, - })).unwrap_or_default()) + })) + .unwrap_or_default()) } "config.set" => { if let Some(val) = params.strip_prefix("interval_ms=") { @@ -737,19 +810,24 @@ impl Plugin for SentinelPlugin { .map_err(|e| PluginError::Recoverable(e.to_string()))?; Ok(serde_json::to_string(&serde_json::json!({ "interval_ms": ms, "set": true, - })).unwrap_or_default()) + })) + .unwrap_or_default()) } else if let Some(name) = params.strip_prefix("theme=") { - let ok = ctx.set_theme_by_name(name) + let ok = ctx + .set_theme_by_name(name) .map_err(|e| PluginError::Recoverable(e.to_string()))?; Ok(serde_json::to_string(&serde_json::json!({ "theme": name, "set": ok, - })).unwrap_or_default()) + })) + .unwrap_or_default()) } else if let Some(name) = params.strip_prefix("layout=") { - let ok = ctx.set_layout_by_name(name) + let ok = ctx + .set_layout_by_name(name) .map_err(|e| PluginError::Recoverable(e.to_string()))?; Ok(serde_json::to_string(&serde_json::json!({ "layout": name, "set": ok, - })).unwrap_or_default()) + })) + .unwrap_or_default()) } else { Err(PluginError::Recoverable( "expected interval_ms=, theme=, or layout=".into(), @@ -757,20 +835,38 @@ impl Plugin for SentinelPlugin { } } "alerts.status" => { - let critical = self.alerts.iter().filter(|a| matches!(a.severity, Severity::Critical)).count(); - let warning = self.alerts.iter().filter(|a| matches!(a.severity, Severity::Warning)).count(); - let info_count = self.alerts.iter().filter(|a| matches!(a.severity, Severity::Info)).count(); - let top: Vec = self.alerts.iter().take(5).map(Self::fmt_alert).collect(); + let critical = self + .alerts + .iter() + .filter(|a| matches!(a.severity, Severity::Critical)) + .count(); + let warning = self + .alerts + .iter() + .filter(|a| matches!(a.severity, Severity::Warning)) + .count(); + let info_count = self + .alerts + .iter() + .filter(|a| matches!(a.severity, Severity::Info)) + .count(); + let top: Vec = + self.alerts.iter().take(5).map(Self::fmt_alert).collect(); Ok(serde_json::to_string(&serde_json::json!({ "total": self.alerts.len(), "critical": critical, "warning": warning, "info": info_count, "alerts": top, - })).unwrap_or_default()) + })) + .unwrap_or_default()) } "plugin.status" => { - let critical = self.alerts.iter().filter(|a| matches!(a.severity, Severity::Critical)).count(); + let critical = self + .alerts + .iter() + .filter(|a| matches!(a.severity, Severity::Critical)) + .count(); Ok(serde_json::to_string(&serde_json::json!({ "enabled": self.enabled, "ticks": self.tick_count, @@ -778,7 +874,8 @@ impl Plugin for SentinelPlugin { "last_result": self.last_action_result, "active_alerts": self.alerts.len(), "critical_alerts": critical, - })).unwrap_or_default()) + })) + .unwrap_or_default()) } _ => { return Err(PluginError::UnknownAction(action.to_string())); diff --git a/plugins/xtop-plugin-sentinel/src/mcp.rs b/plugins/xtop-plugin-sentinel/src/mcp.rs index ccc52db..d6a34c4 100644 --- a/plugins/xtop-plugin-sentinel/src/mcp.rs +++ b/plugins/xtop-plugin-sentinel/src/mcp.rs @@ -41,16 +41,16 @@ pub fn run_server(state: &mut AppState) -> anyhow::Result<()> { continue; } - let parsed: serde_json::Value = serde_json::from_str(&line) - .map_err(|e| anyhow::anyhow!("invalid JSON-RPC: {e}"))?; + let parsed: serde_json::Value = + serde_json::from_str(&line).map_err(|e| anyhow::anyhow!("invalid JSON-RPC: {e}"))?; let id = parsed.get("id").cloned(); - let method = parsed - .get("method") - .and_then(|m| m.as_str()) - .unwrap_or(""); + let method = parsed.get("method").and_then(|m| m.as_str()).unwrap_or(""); - let params = parsed.get("params").cloned().unwrap_or(serde_json::Value::Null); + let params = parsed + .get("params") + .cloned() + .unwrap_or(serde_json::Value::Null); let response = match method { "initialize" => handle_initialize(id, ¶ms), @@ -91,12 +91,18 @@ fn make_error(id: Option, code: i32, message: String) -> serd // MCP: initialize // --------------------------------------------------------------------------- -fn handle_initialize(id: Option, _params: &serde_json::Value) -> serde_json::Value { - make_result(id, serde_json::json!({ - "protocolVersion": PROTOCOL_VERSION, - "capabilities": { "tools": {} }, - "serverInfo": { "name": SERVER_NAME, "version": SERVER_VERSION } - })) +fn handle_initialize( + id: Option, + _params: &serde_json::Value, +) -> serde_json::Value { + make_result( + id, + serde_json::json!({ + "protocolVersion": PROTOCOL_VERSION, + "capabilities": { "tools": {} }, + "serverInfo": { "name": SERVER_NAME, "version": SERVER_VERSION } + }), + ) } // --------------------------------------------------------------------------- @@ -104,110 +110,113 @@ fn handle_initialize(id: Option, _params: &serde_json::Value) // --------------------------------------------------------------------------- fn handle_tools_list(id: Option) -> serde_json::Value { - make_result(id, serde_json::json!({ - "tools": [ - { - "name": "system_summary", - "description": "Get a high-level system health summary (CPU, memory, disks, network, uptime, hostname)", - "inputSchema": { "type": "object", "properties": {} } - }, - { - "name": "processes_top", - "description": "Get top N processes by CPU usage, with optional regex filter", - "inputSchema": { - "type": "object", - "properties": { - "count": { "type": "integer", "description": "Number of processes (default 10)", "default": 10 }, - "filter": { "type": "string", "description": "Optional regex to filter by name or command" } + make_result( + id, + serde_json::json!({ + "tools": [ + { + "name": "system_summary", + "description": "Get a high-level system health summary (CPU, memory, disks, network, uptime, hostname)", + "inputSchema": { "type": "object", "properties": {} } + }, + { + "name": "processes_top", + "description": "Get top N processes by CPU usage, with optional regex filter", + "inputSchema": { + "type": "object", + "properties": { + "count": { "type": "integer", "description": "Number of processes (default 10)", "default": 10 }, + "filter": { "type": "string", "description": "Optional regex to filter by name or command" } + } } - } - }, - { - "name": "processes_search", - "description": "Search processes using regex. Fields: name, cmd, user, state, exe, cwd", - "inputSchema": { - "type": "object", - "properties": { - "pattern": { "type": "string", "description": "Regex pattern" }, - "fields": { "type": "string", "description": "Fields to search: name,cmd,user,state,exe,cwd (default: name)" } - }, - "required": ["pattern"] - } - }, - { - "name": "process_info", - "description": "Get detailed info about a process by PID (includes exe, ppid, threads, cwd)", - "inputSchema": { - "type": "object", - "properties": { - "pid": { "type": "integer", "description": "Process ID" } - }, - "required": ["pid"] - } - }, - { - "name": "process_kill", - "description": "Terminate a process by PID", - "inputSchema": { - "type": "object", - "properties": { - "pid": { "type": "integer", "description": "Process ID to kill" } - }, - "required": ["pid"] - } - }, - { - "name": "threshold_set", - "description": "Set alert thresholds for CPU, memory, and disk (percentages)", - "inputSchema": { - "type": "object", - "properties": { - "cpu": { "type": "number", "description": "CPU threshold" }, - "mem": { "type": "number", "description": "Memory threshold" }, - "disk": { "type": "number", "description": "Disk threshold" } - }, - "required": ["cpu", "mem", "disk"] - } - }, - { - "name": "threshold_get", - "description": "Get current alert threshold values", - "inputSchema": { "type": "object", "properties": {} } - }, - { - "name": "config_get", - "description": "Get current xtop configuration", - "inputSchema": { "type": "object", "properties": {} } - }, - { - "name": "config_set", - "description": "Update configuration: interval_ms, theme, or layout", - "inputSchema": { - "type": "object", - "properties": { - "interval_ms": { "type": "integer", "description": "Update interval in milliseconds" }, - "theme": { "type": "string", "description": "Theme name" }, - "layout": { "type": "string", "description": "Layout name" } + }, + { + "name": "processes_search", + "description": "Search processes using regex. Fields: name, cmd, user, state, exe, cwd", + "inputSchema": { + "type": "object", + "properties": { + "pattern": { "type": "string", "description": "Regex pattern" }, + "fields": { "type": "string", "description": "Fields to search: name,cmd,user,state,exe,cwd (default: name)" } + }, + "required": ["pattern"] + } + }, + { + "name": "process_info", + "description": "Get detailed info about a process by PID (includes exe, ppid, threads, cwd)", + "inputSchema": { + "type": "object", + "properties": { + "pid": { "type": "integer", "description": "Process ID" } + }, + "required": ["pid"] + } + }, + { + "name": "process_kill", + "description": "Terminate a process by PID", + "inputSchema": { + "type": "object", + "properties": { + "pid": { "type": "integer", "description": "Process ID to kill" } + }, + "required": ["pid"] } + }, + { + "name": "threshold_set", + "description": "Set alert thresholds for CPU, memory, and disk (percentages)", + "inputSchema": { + "type": "object", + "properties": { + "cpu": { "type": "number", "description": "CPU threshold" }, + "mem": { "type": "number", "description": "Memory threshold" }, + "disk": { "type": "number", "description": "Disk threshold" } + }, + "required": ["cpu", "mem", "disk"] + } + }, + { + "name": "threshold_get", + "description": "Get current alert threshold values", + "inputSchema": { "type": "object", "properties": {} } + }, + { + "name": "config_get", + "description": "Get current xtop configuration", + "inputSchema": { "type": "object", "properties": {} } + }, + { + "name": "config_set", + "description": "Update configuration: interval_ms, theme, or layout", + "inputSchema": { + "type": "object", + "properties": { + "interval_ms": { "type": "integer", "description": "Update interval in milliseconds" }, + "theme": { "type": "string", "description": "Theme name" }, + "layout": { "type": "string", "description": "Layout name" } + } + } + }, + { + "name": "process_alerts", + "description": "Get all heuristic alerts as a JSON array (suspicious_exe_path, masquerading, known_threat, pipe_download, orphan, privilege_escalation, browser_child, thread_anomaly, fd_anomaly, spawn_storm)", + "inputSchema": { "type": "object", "properties": {} } + }, + { + "name": "alerts_status", + "description": "Get alert summary with counts by severity (critical, warning, info)", + "inputSchema": { "type": "object", "properties": {} } + }, + { + "name": "plugin_status", + "description": "Get Sentinel plugin internal status", + "inputSchema": { "type": "object", "properties": {} } } - }, - { - "name": "process_alerts", - "description": "Get all heuristic alerts as a JSON array (suspicious_exe_path, masquerading, known_threat, pipe_download, orphan, privilege_escalation, browser_child, thread_anomaly, fd_anomaly, spawn_storm)", - "inputSchema": { "type": "object", "properties": {} } - }, - { - "name": "alerts_status", - "description": "Get alert summary with counts by severity (critical, warning, info)", - "inputSchema": { "type": "object", "properties": {} } - }, - { - "name": "plugin_status", - "description": "Get Sentinel plugin internal status", - "inputSchema": { "type": "object", "properties": {} } - } - ] - })) + ] + }), + ) } // --------------------------------------------------------------------------- @@ -220,7 +229,10 @@ fn handle_tools_call( state: &mut AppState, ) -> serde_json::Value { let name = params.get("name").and_then(|n| n.as_str()).unwrap_or(""); - let args = params.get("arguments").cloned().unwrap_or(serde_json::Value::Null); + let args = params + .get("arguments") + .cloned() + .unwrap_or(serde_json::Value::Null); // Map MCP tool name -> Sentinel action + params string let (action, params_str): (&str, String) = match name { @@ -304,14 +316,16 @@ fn handle_tools_call( state.on_tick(); // Execute via Sentinel plugin - let result_str = state.with_plugin_manager_mut(|mgr, this| { - mgr.execute(this, "sentinel", action, ¶ms_str) - }); + let result_str = state + .with_plugin_manager_mut(|mgr, this| mgr.execute(this, "sentinel", action, ¶ms_str)); match result_str { - Ok(json_str) => make_result(id, serde_json::json!({ - "content": [{"type": "text", "text": json_str}] - })), + Ok(json_str) => make_result( + id, + serde_json::json!({ + "content": [{"type": "text", "text": json_str}] + }), + ), Err(e) => make_error(id, -32000, e.to_string()), } }