Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 0 additions & 7 deletions .mcp.json

This file was deleted.

1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
When testing systemctl-tui, run it in debug mode (i.e. do not specific `--release`).
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@AGENTS.md
4 changes: 3 additions & 1 deletion src/action.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::{
components::home::Mode,
systemd::{UnitId, UnitWithStatus},
systemd::{UnitFile, UnitId, UnitWithStatus},
};

#[derive(Debug, Clone)]
Expand All @@ -14,7 +14,9 @@ pub enum Action {
Resize(u16, u16),
ToggleShowLogger,
RefreshServices,
RefreshUnitFiles,
SetServices(Vec<UnitWithStatus>),
SetUnitFiles(Vec<UnitFile>),
EnterMode(Mode),
EnterError(String),
CancelTask,
Expand Down
3 changes: 3 additions & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());

Expand Down
110 changes: 97 additions & 13 deletions src/components/home.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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<UnitFile>) {
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)
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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<Fut>(&mut self, service: UnitId, action_name: String, cancel_token: CancellationToken, action: Fut)
where
fn service_action<Fut>(
&mut self,
service: UnitId,
action_name: String,
cancel_token: CancellationToken,
action: Fut,
refresh_unit_files: bool,
) where
Fut: Future<Output = anyhow::Result<()>> + Send + 'static,
{
let tx = self.action_tx.clone().unwrap();
Expand Down Expand Up @@ -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 {
Expand All @@ -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);
}
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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];

Expand All @@ -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: "),
Expand Down Expand Up @@ -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),
Expand Down
Loading