diff --git a/.mcp.json b/.mcp.json deleted file mode 100644 index 5a617db..0000000 --- a/.mcp.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "mcpServers": { - "server-name": { - "command": "imprint" - } - } -} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6d2c195 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +When testing systemctl-tui, run it in debug mode (i.e. do not specific `--release`). \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..eef4bd2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md \ No newline at end of file diff --git a/src/action.rs b/src/action.rs index 9f3e87e..d855362 100644 --- a/src/action.rs +++ b/src/action.rs @@ -1,6 +1,6 @@ use crate::{ components::home::Mode, - systemd::{UnitId, UnitWithStatus}, + systemd::{UnitFile, UnitId, UnitWithStatus}, }; #[derive(Debug, Clone)] @@ -14,7 +14,9 @@ pub enum Action { Resize(u16, u16), ToggleShowLogger, RefreshServices, + RefreshUnitFiles, SetServices(Vec), + SetUnitFiles(Vec), EnterMode(Mode), EnterError(String), CancelTask, diff --git a/src/app.rs b/src/app.rs index fcbf9ae..b893c88 100644 --- a/src/app.rs +++ b/src/app.rs @@ -67,6 +67,9 @@ impl App { .context("Unable to get services. Check that systemd is running and try running this tool with sudo.")?; self.home.lock().await.set_units(units); + // Fetch unit files (includes enablement state and disabled units not returned by ListUnits) + action_tx.send(Action::RefreshUnitFiles)?; + let mut terminal = TerminalHandler::new(self.home.clone()); let mut event = EventHandler::new(self.home.clone(), action_tx.clone()); diff --git a/src/components/home.rs b/src/components/home.rs index 647ca7d..9261d46 100644 --- a/src/components/home.rs +++ b/src/components/home.rs @@ -27,7 +27,7 @@ use std::{ use super::{logger::Logger, Component, Frame}; use crate::{ action::Action, - systemd::{self, Scope, UnitId, UnitScope, UnitWithStatus}, + systemd::{self, Scope, UnitFile, UnitId, UnitScope, UnitWithStatus}, }; #[derive(Debug, Default, Copy, Clone, PartialEq)] @@ -219,6 +219,40 @@ impl Home { self.refresh_filtered_units(); } + pub fn sort_units(&mut self) { + self.all_units.sort_by(|_, a, _, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + } + + /// Merge unit file info (enablement state, file path) into existing units. + /// Also adds units that aren't returned by ListUnits (e.g. disabled, static, masked). + pub fn merge_unit_files(&mut self, unit_files: Vec) { + for unit_file in unit_files { + let id = unit_file.id(); + if let Some(unit) = self.all_units.get_mut(&id) { + // Update existing unit with enablement state and file path + unit.enablement_state = Some(unit_file.enablement_state); + unit.file_path = Some(Ok(unit_file.path)); + } else if unit_file.enablement_state != "generated" && unit_file.enablement_state != "alias" { + // Add units not returned by ListUnits (disabled, static, masked, etc.) + // Skip generated units - they're created dynamically by systemd generators and aren't user-manageable + // Skip alias units - they're just symlinks to other units already in the list + let new_unit = UnitWithStatus { + name: unit_file.name, + scope: unit_file.scope, + description: String::new(), + file_path: Some(Ok(unit_file.path)), + load_state: "not-loaded".into(), + activation_state: "inactive".into(), + sub_state: "dead".into(), + enablement_state: Some(unit_file.enablement_state), + }; + self.all_units.insert(id, new_unit); + } + } + self.sort_units(); + self.refresh_filtered_units(); + } + // Update units in-place, then filter the list // This is inefficient but it's fast enough // (on gen 13 i7: ~100 microseconds to update, ~100 microseconds to filter) @@ -285,7 +319,7 @@ impl Home { } } - fn refresh_filtered_units(&mut self) { + pub fn refresh_filtered_units(&mut self) { let previously_selected = self.selected_service(); let search_value = self.input.value(); @@ -337,29 +371,47 @@ impl Home { fn start_service(&mut self, service: UnitId) { let cancel_token = CancellationToken::new(); let future = systemd::start_service(service.clone(), cancel_token.clone()); - self.service_action(service, "Start".into(), cancel_token, future); + self.service_action(service, "Start".into(), cancel_token, future, false); } fn stop_service(&mut self, service: UnitId) { let cancel_token = CancellationToken::new(); let future = systemd::stop_service(service.clone(), cancel_token.clone()); - self.service_action(service, "Stop".into(), cancel_token, future); + self.service_action(service, "Stop".into(), cancel_token, future, false); } fn reload_service(&mut self, service: UnitId) { let cancel_token = CancellationToken::new(); let future = systemd::reload(service.scope, cancel_token.clone()); - self.service_action(service, "Reload".into(), cancel_token, future); + self.service_action(service, "Reload".into(), cancel_token, future, false); } fn restart_service(&mut self, service: UnitId) { let cancel_token = CancellationToken::new(); let future = systemd::restart_service(service.clone(), cancel_token.clone()); - self.service_action(service, "Restart".into(), cancel_token, future); + self.service_action(service, "Restart".into(), cancel_token, future, false); + } + + fn enable_service(&mut self, service: UnitId) { + let cancel_token = CancellationToken::new(); + let future = systemd::enable_service(service.clone(), cancel_token.clone()); + self.service_action(service, "Enable".into(), cancel_token, future, true); + } + + fn disable_service(&mut self, service: UnitId) { + let cancel_token = CancellationToken::new(); + let future = systemd::disable_service(service.clone(), cancel_token.clone()); + self.service_action(service, "Disable".into(), cancel_token, future, true); } - fn service_action(&mut self, service: UnitId, action_name: String, cancel_token: CancellationToken, action: Fut) - where + fn service_action( + &mut self, + service: UnitId, + action_name: String, + cancel_token: CancellationToken, + action: Fut, + refresh_unit_files: bool, + ) where Fut: Future> + Send + 'static, { let tx = self.action_tx.clone().unwrap(); @@ -401,6 +453,9 @@ impl Home { } spinner_task.abort(); tx.send(Action::RefreshServices).unwrap(); + if refresh_unit_files { + tx.send(Action::RefreshUnitFiles).unwrap(); + } // Refresh a bit more frequently after a service action for _ in 0..3 { @@ -413,7 +468,7 @@ impl Home { fn kill_service(&mut self, service: UnitId, signal: String) { let cancel_token = CancellationToken::new(); let future = systemd::kill_service(service.clone(), signal.clone(), cancel_token.clone()); - self.service_action(service, format!("Kill with {}", signal), cancel_token, future); + self.service_action(service, format!("Kill with {}", signal), cancel_token, future, false); } } @@ -696,15 +751,14 @@ impl Component for Home { MenuItem::new("Stop", Action::StopService(selected.unit.id()), Some(KeyCode::Char('t'))), MenuItem::new("Restart", Action::RestartService(selected.unit.id()), Some(KeyCode::Char('r'))), MenuItem::new("Reload", Action::ReloadService(selected.unit.id()), Some(KeyCode::Char('l'))), + MenuItem::new("Enable", Action::EnableService(selected.unit.id()), Some(KeyCode::Char('n'))), + MenuItem::new("Disable", Action::DisableService(selected.unit.id()), Some(KeyCode::Char('d'))), MenuItem::new("Kill", Action::EnterMode(Mode::SignalMenu), Some(KeyCode::Char('k'))), MenuItem::new( "Open logs in pager", Action::OpenLogsInPager { logs: self.logs.clone() }, Some(KeyCode::Char('o')), ), - // TODO add these - // MenuItem::new("Enable", Action::EnableService(selected.clone())), - // MenuItem::new("Disable", Action::DisableService(selected.clone())), ]; if let Some(Ok(file_path)) = &selected.unit.file_path { @@ -819,6 +873,8 @@ impl Component for Home { Action::StopService(service_name) => self.stop_service(service_name), Action::ReloadService(service_name) => self.reload_service(service_name), Action::RestartService(service_name) => self.restart_service(service_name), + Action::EnableService(service_name) => self.enable_service(service_name), + Action::DisableService(service_name) => self.disable_service(service_name), Action::RefreshServices => { let tx = self.action_tx.clone().unwrap(); let scope = self.scope; @@ -834,6 +890,24 @@ impl Component for Home { self.update_units(units); return Some(Action::Render); }, + Action::RefreshUnitFiles => { + let tx = self.action_tx.clone().unwrap(); + let scope = self.scope; + tokio::spawn(async move { + match systemd::get_unit_files(scope).await { + Ok(unit_files) => { + let _ = tx.send(Action::SetUnitFiles(unit_files)); + }, + Err(e) => { + error!("Failed to get unit files: {:?}", e); + }, + } + }); + }, + Action::SetUnitFiles(unit_files) => { + self.merge_unit_files(unit_files); + return Some(Action::Render); + }, Action::KillService(service_name, signal) => self.kill_service(service_name, signal), Action::SpinnerTick => { self.spinner_tick = self.spinner_tick.wrapping_add(1); @@ -961,7 +1035,7 @@ impl Component for Home { let selected_item = self.filtered_units.selected(); let right_panel = - Layout::new(Direction::Vertical, [Constraint::Min(7), Constraint::Percentage(100)]).split(right_panel); + Layout::new(Direction::Vertical, [Constraint::Min(8), Constraint::Percentage(100)]).split(right_panel); let details_panel = right_panel[0]; let logs_panel = right_panel[1]; @@ -973,6 +1047,7 @@ impl Component for Home { let props_lines = vec![ Line::from("Description: "), + Line::from("Enablement: "), Line::from("Scope: "), Line::from("Loaded: "), Line::from("Active: "), @@ -1005,8 +1080,17 @@ impl Component for Home { UnitScope::User => "User", }; + let enablement_state = m.unit.enablement_state.as_deref().unwrap_or(""); + let enablement_color = match enablement_state { + "enabled" => Color::Green, + "disabled" => Color::Yellow, + "masked" => Color::Red, + _ => Color::Reset, + }; + let lines = vec![ colored_line(&m.unit.description, Color::Reset), + colored_line(enablement_state, enablement_color), colored_line(scope, Color::Reset), colored_line(&m.unit.load_state, load_color), line_color_string(active_state_value, active_color), diff --git a/src/systemd.rs b/src/systemd.rs index 3000957..588a5f5 100644 --- a/src/systemd.rs +++ b/src/systemd.rs @@ -115,6 +115,69 @@ pub enum Scope { All, } +/// Represents a unit file from ListUnitFiles (includes disabled units not returned by ListUnits) +#[derive(Debug, Clone)] +pub struct UnitFile { + pub name: String, + pub scope: UnitScope, + pub enablement_state: String, + pub path: String, +} + +impl UnitFile { + pub fn id(&self) -> UnitId { + UnitId { name: self.name.clone(), scope: self.scope } + } +} + +/// Get unit files for all services, INCLUDING DISABLED ONES (ListUnits doesn't include those) +/// This is slower than get_all_services. Takes about 100ms (user) and 300ms (global) on 13th gen Intel i7 +pub async fn get_unit_files(scope: Scope) -> Result> { + let start = std::time::Instant::now(); + + let mut unit_scopes = vec![]; + match scope { + Scope::Global => unit_scopes.push(UnitScope::Global), + Scope::User => unit_scopes.push(UnitScope::User), + Scope::All => { + unit_scopes.push(UnitScope::Global); + unit_scopes.push(UnitScope::User); + }, + } + + let mut ret = vec![]; + let is_root = nix::unistd::geteuid().is_root(); + + for unit_scope in unit_scopes { + let connection = get_connection(unit_scope).await?; + let manager_proxy = ManagerProxy::new(&connection).await?; + let unit_files = match manager_proxy.list_unit_files_by_patterns(vec![], vec!["*.service".into()]).await { + Ok(files) => files, + Err(e) => { + if is_root && unit_scope == UnitScope::User { + error!("Failed to get user unit files, ignoring because we're running as root"); + vec![] + } else { + return Err(e.into()); + } + }, + }; + + let services = unit_files + .into_iter() + .filter_map(|(path, state)| { + let rust_path = std::path::Path::new(&path); + let file_name = rust_path.file_name()?.to_str()?; + Some(UnitFile { name: file_name.to_string(), scope: unit_scope, enablement_state: state, path }) + }) + .collect::>(); + ret.extend(services); + } + + info!("Loaded {} unit files in {:?}", ret.len(), start.elapsed()); + Ok(ret) +} + // this takes like 5-10 ms on 13th gen Intel i7 (scope=all) pub async fn get_all_services(scope: Scope, services: &[String]) -> Result> { let start = std::time::Instant::now(); @@ -275,6 +338,55 @@ pub async fn restart_service(service: UnitId, cancel_token: CancellationToken) - } } +pub async fn enable_service(service: UnitId, cancel_token: CancellationToken) -> Result<()> { + async fn enable(service: UnitId) -> Result<()> { + let connection = get_connection(service.scope).await?; + let manager_proxy = ManagerProxy::new(&connection).await?; + let files = vec![service.name]; + let (_, changes) = manager_proxy.enable_unit_files(files, false, false).await?; + + for (change_type, name, destination) in changes { + info!("{}: {} -> {}", change_type, name, destination); + } + // Enabling without reloading puts things in a weird state where `systemctl status foo` tells you to run daemon-reload + manager_proxy.reload().await?; + Ok(()) + } + + tokio::select! { + _ = cancel_token.cancelled() => { + anyhow::bail!("cancelled"); + } + result = enable(service) => { + result + } + } +} + +pub async fn disable_service(service: UnitId, cancel_token: CancellationToken) -> Result<()> { + async fn disable(service: UnitId) -> Result<()> { + let connection = get_connection(service.scope).await?; + let manager_proxy = ManagerProxy::new(&connection).await?; + let files = vec![service.name]; + let changes = manager_proxy.disable_unit_files(files, false).await?; + + for (change_type, name, destination) in changes { + info!("{}: {} -> {}", change_type, name, destination); + } + manager_proxy.reload().await?; + Ok(()) + } + + tokio::select! { + _ = cancel_token.cancelled() => { + anyhow::bail!("cancelled"); + } + result = disable(service) => { + result + } + } +} + // useless function only added to test that cancellation works pub async fn sleep_test(_service: String, cancel_token: CancellationToken) -> Result<()> { // god these select macros are ugly, is there really no better way to select? @@ -399,6 +511,14 @@ pub trait Manager { /// [📖](https://www.freedesktop.org/software/systemd/man/systemd.directives.html#Reload()) Call interface method `Reload`. #[zbus(name = "Reload")] fn reload(&self) -> zbus::Result<()>; + + /// [📖](https://www.freedesktop.org/software/systemd/man/latest/systemd.directives.html#ListUnitFilesByPatterns()) Call interface method `ListUnitFilesByPatterns`. + #[zbus(name = "ListUnitFilesByPatterns")] + fn list_unit_files_by_patterns( + &self, + states: Vec, + patterns: Vec, + ) -> zbus::Result>; } /// Proxy object for `org.freedesktop.systemd1.Unit`.