1use chrono::DateTime;
2use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
3use futures::Future;
4use fuzzy_matcher::{skim::SkimMatcherV2, FuzzyMatcher};
5use indexmap::IndexMap;
6use itertools::Itertools;
7use ratatui::{
8 layout::{Constraint, Direction, Layout, Rect},
9 style::{Color, Modifier, Style},
10 text::{Line, Span},
11 widgets::{Block, BorderType, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap},
12};
13use tokio::{
14 io::AsyncBufReadExt,
15 sync::mpsc::{self, UnboundedSender},
16 task::JoinHandle,
17};
18use tokio_util::sync::CancellationToken;
19use tracing::{error, info, warn};
20use tui_input::{backend::crossterm::EventHandler, Input};
21
22use std::{
23 process::{Command, Stdio},
24 time::Duration,
25};
26
27use super::{logger::Logger, Component, Frame};
28use crate::{
29 action::Action,
30 systemd::{self, diagnose_missing_logs, parse_journalctl_error, Scope, UnitFile, UnitId, UnitScope, UnitWithStatus},
31};
32
33#[derive(Debug, Default, Copy, Clone, PartialEq)]
34pub enum Mode {
35 #[default]
36 Search,
37 ServiceList,
38 Help,
39 ActionMenu,
40 Processing,
41 Error,
42 SignalMenu,
43}
44
45#[derive(Clone, Copy)]
46pub struct Theme {
47 pub primary: Color, pub accent: Color, pub kbd: Color, pub muted: Color, pub muted_alt: Color, }
53
54impl Default for Theme {
55 fn default() -> Self {
56 Self::detect()
57 }
58}
59
60impl Theme {
61 pub fn detect() -> Self {
62 let is_light = terminal_light::luma().is_ok_and(|luma| luma > 0.5);
63
64 if is_light {
65 Self {
66 primary: Color::Blue,
67 accent: Color::Green,
68 kbd: Color::Blue,
69 muted: Color::DarkGray,
70 muted_alt: Color::Reset,
71 }
72 } else {
73 Self {
74 primary: Color::Cyan,
75 accent: Color::LightGreen,
76 kbd: Color::Gray, muted: Color::Gray,
78 muted_alt: Color::DarkGray,
79 }
80 }
81 }
82}
83
84#[derive(Clone)]
86pub struct MatchedUnit {
87 pub unit: UnitWithStatus,
88 pub match_indices: Vec<usize>,
89}
90
91#[derive(Default)]
92pub struct Home {
93 pub scope: Scope,
94 pub limit_units: Vec<String>,
95 pub theme: Theme,
96 pub logger: Logger,
97 pub show_logger: bool,
98 pub all_units: IndexMap<UnitId, UnitWithStatus>,
99 pub filtered_units: StatefulList<MatchedUnit>,
100 pub logs: Vec<String>,
101 pub logs_scroll_offset: u16,
102 pub mode: Mode,
103 pub previous_mode: Option<Mode>,
104 pub input: Input,
105 pub menu_items: StatefulList<MenuItem>,
106 pub cancel_token: Option<CancellationToken>,
107 pub spinner_tick: u8,
108 pub error_message: String,
109 pub action_tx: Option<mpsc::UnboundedSender<Action>>,
110 pub journalctl_tx: Option<std::sync::mpsc::Sender<UnitId>>,
111 pub fuzzy_matcher: SkimMatcherV2,
112}
113
114pub struct MenuItem {
115 pub name: String,
116 pub action: Action,
117 pub key: Option<KeyCode>,
118}
119
120impl MenuItem {
121 pub fn new(name: &str, action: Action, key: Option<KeyCode>) -> Self {
122 Self { name: name.to_owned(), action, key }
123 }
124
125 pub fn key_string(&self) -> String {
126 if let Some(key) = self.key {
127 format!("{key}")
128 } else {
129 String::new()
130 }
131 }
132}
133
134pub struct StatefulList<T> {
135 state: ListState,
136 items: Vec<T>,
137}
138
139impl<T> Default for StatefulList<T> {
140 fn default() -> Self {
141 Self::with_items(vec![])
142 }
143}
144
145impl<T> StatefulList<T> {
146 pub fn with_items(items: Vec<T>) -> StatefulList<T> {
147 StatefulList { state: ListState::default(), items }
148 }
149
150 #[allow(dead_code)]
151 fn selected_mut(&mut self) -> Option<&mut T> {
152 if self.items.is_empty() {
153 return None;
154 }
155 match self.state.selected() {
156 Some(i) => Some(&mut self.items[i]),
157 None => None,
158 }
159 }
160
161 fn selected(&self) -> Option<&T> {
162 if self.items.is_empty() {
163 return None;
164 }
165 match self.state.selected() {
166 Some(i) => Some(&self.items[i]),
167 None => None,
168 }
169 }
170
171 fn next(&mut self) {
172 let i = match self.state.selected() {
173 Some(i) => {
174 if i >= self.items.len().saturating_sub(1) {
175 0
176 } else {
177 i + 1
178 }
179 },
180 None => 0,
181 };
182 self.state.select(Some(i));
183 }
184
185 fn previous(&mut self) {
186 let i = match self.state.selected() {
187 Some(i) => {
188 if i == 0 {
189 self.items.len() - 1
190 } else {
191 i - 1
192 }
193 },
194 None => 0,
195 };
196 self.state.select(Some(i));
197 }
198
199 fn select(&mut self, index: Option<usize>) {
200 self.state.select(index);
201 }
202
203 fn unselect(&mut self) {
204 self.state.select(None);
205 }
206}
207
208impl Home {
209 pub fn new(scope: Scope, limit_units: &[String]) -> Self {
210 let limit_units = limit_units.to_vec();
211 Self { scope, limit_units, ..Default::default() }
212 }
213
214 pub fn set_units(&mut self, units: Vec<UnitWithStatus>) {
215 self.all_units.clear();
216 for unit_status in units.into_iter() {
217 self.all_units.insert(unit_status.id(), unit_status);
218 }
219 self.refresh_filtered_units();
220 }
221
222 pub fn sort_units(&mut self) {
223 self.all_units.sort_by(|_, a, _, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
224 }
225
226 pub fn merge_unit_files(&mut self, unit_files: Vec<UnitFile>) {
229 for unit_file in unit_files {
230 let id = unit_file.id();
231 if let Some(unit) = self.all_units.get_mut(&id) {
232 unit.enablement_state = Some(unit_file.enablement_state);
234 unit.file_path = Some(Ok(unit_file.path));
235 } else if unit_file.enablement_state != "generated" && unit_file.enablement_state != "alias" {
236 let new_unit = UnitWithStatus {
240 name: unit_file.name,
241 scope: unit_file.scope,
242 description: String::new(),
243 file_path: Some(Ok(unit_file.path)),
244 load_state: "not-loaded".into(),
245 activation_state: "inactive".into(),
246 sub_state: "dead".into(),
247 enablement_state: Some(unit_file.enablement_state),
248 };
249 self.all_units.insert(id, new_unit);
250 }
251 }
252 self.sort_units();
253 self.refresh_filtered_units();
254 }
255
256 pub fn update_units(&mut self, units: Vec<UnitWithStatus>) {
261 let now = std::time::Instant::now();
262
263 for unit in units {
264 if let Some(existing) = self.all_units.get_mut(&unit.id()) {
265 existing.update(unit);
266 } else {
267 self.all_units.insert(unit.id(), unit);
268 }
269 }
270 info!("Updated units in {:?}", now.elapsed());
271
272 let now = std::time::Instant::now();
273 self.refresh_filtered_units();
274 info!("Filtered units in {:?}", now.elapsed());
275 }
276
277 pub fn next(&mut self) {
278 self.logs = vec![];
279 self.filtered_units.next();
280 self.get_logs();
281 self.logs_scroll_offset = 0;
282 }
283
284 pub fn previous(&mut self) {
285 self.logs = vec![];
286 self.filtered_units.previous();
287 self.get_logs();
288 self.logs_scroll_offset = 0;
289 }
290
291 pub fn select(&mut self, index: Option<usize>, refresh_logs: bool) {
292 if refresh_logs {
293 self.logs = vec![];
294 }
295 self.filtered_units.select(index);
296 if refresh_logs {
297 self.get_logs();
298 self.logs_scroll_offset = 0;
299 }
300 }
301
302 pub fn unselect(&mut self) {
303 self.logs = vec![];
304 self.filtered_units.unselect();
305 }
306
307 pub fn selected_service(&self) -> Option<UnitId> {
308 self.filtered_units.selected().map(|m| m.unit.id())
309 }
310
311 pub fn get_logs(&mut self) {
312 if let Some(selected) = self.filtered_units.selected() {
313 let unit_id = selected.unit.id();
314 if let Err(e) = self.journalctl_tx.as_ref().unwrap().send(unit_id) {
315 warn!("Error sending unit name to journalctl thread: {}", e);
316 }
317 } else {
318 self.logs = vec![];
319 }
320 }
321
322 pub fn refresh_filtered_units(&mut self) {
323 let previously_selected = self.selected_service();
324 let search_value = self.input.value();
325
326 let matching: Vec<MatchedUnit> = if search_value.is_empty() {
327 self.all_units.values().map(|u| MatchedUnit { unit: u.clone(), match_indices: vec![] }).collect()
329 } else {
330 let mut scored: Vec<(i64, MatchedUnit)> = self
332 .all_units
333 .values()
334 .filter_map(|u| {
335 self
336 .fuzzy_matcher
337 .fuzzy_indices(u.short_name(), search_value)
338 .map(|(score, indices)| (score, MatchedUnit { unit: u.clone(), match_indices: indices }))
339 })
340 .collect();
341
342 scored.sort_by(|a, b| b.0.cmp(&a.0));
344 scored.into_iter().map(|(_, m)| m).collect()
345 };
346
347 self.filtered_units.items = matching;
348
349 if let Some(previously_selected) = previously_selected {
351 if let Some(index) = self
352 .filtered_units
353 .items
354 .iter()
355 .position(|m| m.unit.name == previously_selected.name && m.unit.scope == previously_selected.scope)
356 {
357 self.select(Some(index), false);
358 } else {
359 self.select(Some(0), true);
360 }
361 } else {
362 if !self.filtered_units.items.is_empty() {
364 self.select(Some(0), true);
365 } else {
366 self.unselect();
367 }
368 }
369 }
370
371 fn start_service(&mut self, service: UnitId) {
372 let cancel_token = CancellationToken::new();
373 let future = systemd::start_service(service.clone(), cancel_token.clone());
374 self.service_action(service, "Start".into(), cancel_token, future, false);
375 }
376
377 fn stop_service(&mut self, service: UnitId) {
378 let cancel_token = CancellationToken::new();
379 let future = systemd::stop_service(service.clone(), cancel_token.clone());
380 self.service_action(service, "Stop".into(), cancel_token, future, false);
381 }
382
383 fn reload_service(&mut self, service: UnitId) {
384 let cancel_token = CancellationToken::new();
385 let future = systemd::reload(service.scope, cancel_token.clone());
386 self.service_action(service, "Reload".into(), cancel_token, future, false);
387 }
388
389 fn restart_service(&mut self, service: UnitId) {
390 let cancel_token = CancellationToken::new();
391 let future = systemd::restart_service(service.clone(), cancel_token.clone());
392 self.service_action(service, "Restart".into(), cancel_token, future, false);
393 }
394
395 fn enable_service(&mut self, service: UnitId) {
396 let cancel_token = CancellationToken::new();
397 let future = systemd::enable_service(service.clone(), cancel_token.clone());
398 self.service_action(service, "Enable".into(), cancel_token, future, true);
399 }
400
401 fn disable_service(&mut self, service: UnitId) {
402 let cancel_token = CancellationToken::new();
403 let future = systemd::disable_service(service.clone(), cancel_token.clone());
404 self.service_action(service, "Disable".into(), cancel_token, future, true);
405 }
406
407 fn service_action<Fut>(
408 &mut self,
409 service: UnitId,
410 action_name: String,
411 cancel_token: CancellationToken,
412 action: Fut,
413 refresh_unit_files: bool,
414 ) where
415 Fut: Future<Output = anyhow::Result<()>> + Send + 'static,
416 {
417 let tx = self.action_tx.clone().unwrap();
418
419 self.cancel_token = Some(cancel_token.clone());
420
421 let tx_clone = tx.clone();
422 let spinner_task = tokio::spawn(async move {
423 let mut interval = tokio::time::interval(Duration::from_millis(200));
424 loop {
425 interval.tick().await;
426 tx_clone.send(Action::SpinnerTick).unwrap();
427 }
428 });
429
430 tokio::spawn(async move {
431 tx.send(Action::EnterMode(Mode::Processing)).unwrap();
432 match action.await {
433 Ok(_) => {
434 info!("{} of {:?} service {} succeeded", action_name, service.scope, service.name);
435 tx.send(Action::EnterMode(Mode::ServiceList)).unwrap();
436 },
437 Err(_) if cancel_token.is_cancelled() => {
439 warn!("{} of {:?} service {} was cancelled", action_name, service.scope, service.name)
440 },
441 Err(e) => {
442 error!("{} of {:?} service {} failed: {}", action_name, service.scope, service.name, e);
443 let mut error_string = e.to_string();
444
445 if error_string.contains("AccessDenied") {
446 error_string.push('\n');
447 error_string.push('\n');
448 error_string.push_str("Try running this tool with sudo.");
449 }
450
451 tx.send(Action::EnterError(error_string)).unwrap();
452 },
453 }
454 spinner_task.abort();
455 tx.send(Action::RefreshServices).unwrap();
456 if refresh_unit_files {
457 tx.send(Action::RefreshUnitFiles).unwrap();
458 }
459
460 for _ in 0..3 {
462 tokio::time::sleep(Duration::from_secs(1)).await;
463 tx.send(Action::RefreshServices).unwrap();
464 }
465 });
466 }
467
468 fn kill_service(&mut self, service: UnitId, signal: String) {
469 let cancel_token = CancellationToken::new();
470 let future = systemd::kill_service(service.clone(), signal.clone(), cancel_token.clone());
471 self.service_action(service, format!("Kill with {}", signal), cancel_token, future, false);
472 }
473}
474
475impl Component for Home {
476 fn init(&mut self, tx: UnboundedSender<Action>) -> anyhow::Result<()> {
477 self.action_tx = Some(tx.clone());
478 let (journalctl_tx, journalctl_rx) = std::sync::mpsc::channel::<UnitId>();
481 self.journalctl_tx = Some(journalctl_tx);
482
483 tokio::task::spawn_blocking(move || {
485 let mut last_follow_handle: Option<JoinHandle<()>> = None;
486
487 loop {
488 let mut unit: UnitId = match journalctl_rx.recv() {
489 Ok(unit) => unit,
490 Err(_) => return,
491 };
492
493 while let Ok(service) = journalctl_rx.try_recv() {
495 info!("Skipping logs for {}...", unit.name);
496 unit = service;
497 }
498
499 if let Some(handle) = last_follow_handle.take() {
500 info!("Cancelling previous journalctl task");
501 handle.abort();
502 }
503
504 std::thread::sleep(Duration::from_millis(100));
506
507 match systemd::get_unit_file_location(&unit) {
509 Ok(path) => {
510 let _ = tx.send(Action::SetUnitFilePath { unit: unit.clone(), path: Ok(path) });
511 let _ = tx.send(Action::Render);
512 },
513 Err(e) => {
514 let _ =
516 tx.send(Action::SetUnitFilePath { unit: unit.clone(), path: Err("could not be determined".into()) });
517 let _ = tx.send(Action::Render);
518 error!("Error getting unit file path for {}: {}", unit.name, e);
519 },
520 }
521
522 info!("Getting logs for {}", unit.name);
524 let start = std::time::Instant::now();
525
526 let mut args = vec!["--quiet", "--output=short-iso", "--lines=500", "-u"];
527
528 args.push(&unit.name);
529
530 if unit.scope == UnitScope::User {
531 args.push("--user");
532 }
533
534 match Command::new("journalctl").args(&args).output() {
535 Ok(output) => {
536 if output.status.success() {
537 info!("Got logs for {} in {:?}", unit.name, start.elapsed());
538 if let Ok(stdout) = std::str::from_utf8(&output.stdout) {
539 let mut logs = stdout.trim().split('\n').map(String::from).collect_vec();
540
541 if logs.is_empty() || logs[0].is_empty() {
542 let diagnostic = diagnose_missing_logs(&unit);
543 logs = vec![diagnostic.message()];
544 }
545 let _ = tx.send(Action::SetLogs { unit: unit.clone(), logs });
546 let _ = tx.send(Action::Render);
547 } else {
548 warn!("Error parsing stdout for {}", unit.name);
549 }
550 } else {
551 let stderr = String::from_utf8_lossy(&output.stderr);
552 warn!("Error getting logs for {}: {}", unit.name, stderr);
553 let diagnostic = parse_journalctl_error(&stderr);
554 let _ = tx.send(Action::SetLogs { unit: unit.clone(), logs: vec![diagnostic.message()] });
555 let _ = tx.send(Action::Render);
556 }
557 },
558 Err(e) => {
559 warn!("Error getting logs for {}: {}", unit.name, e);
560 let _ =
561 tx.send(Action::SetLogs { unit: unit.clone(), logs: vec![format!("Failed to run journalctl: {}", e)] });
562 let _ = tx.send(Action::Render);
563 },
564 }
565
566 let tx = tx.clone();
570 last_follow_handle = Some(tokio::spawn(async move {
571 let mut command = tokio::process::Command::new("journalctl");
572 command.arg("-u");
573 command.arg(unit.name.clone());
574 command.arg("--output=short-iso");
575 command.arg("--follow");
576 command.arg("--lines=0");
577 command.arg("--quiet");
578 command.stdout(Stdio::piped());
579 command.stderr(Stdio::piped());
580
581 if unit.scope == UnitScope::User {
582 command.arg("--user");
583 }
584
585 let mut child = command.spawn().expect("failed to execute process");
586
587 let stdout = child.stdout.take().unwrap();
588
589 let reader = tokio::io::BufReader::new(stdout);
590 let mut lines = reader.lines();
591 while let Some(line) = lines.next_line().await.unwrap() {
592 let _ = tx.send(Action::AppendLogLine { unit: unit.clone(), line });
593 let _ = tx.send(Action::Render);
594 }
595 }));
596 }
597 });
598 Ok(())
599 }
600
601 fn handle_key_events(&mut self, key: KeyEvent) -> Vec<Action> {
602 if key.modifiers.contains(KeyModifiers::CONTROL) {
603 match key.code {
604 KeyCode::Char('c') => return vec![Action::Quit],
605 KeyCode::Char('q') => return vec![Action::Quit],
606 KeyCode::Char('z') => return vec![Action::Suspend],
607 KeyCode::Char('f') => return vec![Action::EnterMode(Mode::Search)],
608 KeyCode::Char('l') => return vec![Action::ToggleShowLogger],
609 KeyCode::Char('d') => return vec![Action::ScrollDown(1), Action::Render],
611 KeyCode::Char('u') => return vec![Action::ScrollUp(1), Action::Render],
612 _ => (),
613 }
614 }
615
616 if matches!(key.code, KeyCode::Char('?')) || matches!(key.code, KeyCode::F(1)) {
617 return vec![Action::ToggleHelp, Action::Render];
618 }
619
620 match key.code {
623 KeyCode::PageDown => return vec![Action::ScrollDown(1), Action::Render],
624 KeyCode::PageUp => return vec![Action::ScrollUp(1), Action::Render],
625 KeyCode::Home => return vec![Action::ScrollToTop, Action::Render],
626 KeyCode::End => return vec![Action::ScrollToBottom, Action::Render],
627 _ => (),
628 }
629
630 match self.mode {
631 Mode::ServiceList => {
632 match key.code {
633 KeyCode::Char('q') => vec![Action::Quit],
634 KeyCode::Up | KeyCode::Char('k') => {
635 if self.filtered_units.state.selected() == Some(0) {
637 return vec![Action::EnterMode(Mode::Search)];
638 }
639
640 self.previous();
641 vec![Action::Render]
642 },
643 KeyCode::Down | KeyCode::Char('j') => {
644 self.next();
645 vec![Action::Render]
646 },
647 KeyCode::Char('/') => vec![Action::EnterMode(Mode::Search)],
648 KeyCode::Char('e') => {
649 if let Some(selected) = self.filtered_units.selected() {
650 if let Some(Ok(file_path)) = &selected.unit.file_path {
651 return vec![Action::EditUnitFile { unit: selected.unit.id(), path: file_path.clone() }];
652 }
653 }
654 vec![]
655 },
656 KeyCode::Char('o') => {
657 vec![Action::OpenLogsInPager { logs: self.logs.clone() }]
658 },
659 KeyCode::Enter | KeyCode::Char(' ') => vec![Action::EnterMode(Mode::ActionMenu)],
660 _ => vec![],
661 }
662 },
663 Mode::Help => match key.code {
664 KeyCode::Esc | KeyCode::Enter => vec![Action::ToggleHelp],
665 _ => vec![],
666 },
667 Mode::Error => match key.code {
668 KeyCode::Esc | KeyCode::Enter => vec![Action::EnterMode(Mode::ServiceList)],
669 _ => vec![],
670 },
671 Mode::Search => match key.code {
672 KeyCode::Esc => vec![Action::EnterMode(Mode::ServiceList)],
673 KeyCode::Enter => vec![Action::EnterMode(Mode::ActionMenu)],
674 KeyCode::Down | KeyCode::Tab => {
675 self.next();
676 vec![Action::EnterMode(Mode::ServiceList)]
677 },
678 KeyCode::Up => {
679 self.previous();
680 vec![Action::EnterMode(Mode::ServiceList)]
681 },
682 _ => {
683 let prev_search_value = self.input.value().to_owned();
684 self.input.handle_event(&crossterm::event::Event::Key(key));
685
686 if prev_search_value != self.input.value() {
688 self.refresh_filtered_units();
689 }
690 vec![Action::Render]
691 },
692 },
693 Mode::ActionMenu => match key.code {
694 KeyCode::Esc => vec![Action::EnterMode(Mode::ServiceList)],
695 KeyCode::Down | KeyCode::Char('j') => {
696 self.menu_items.next();
697 vec![Action::Render]
698 },
699 KeyCode::Up | KeyCode::Char('k') => {
700 self.menu_items.previous();
701 vec![Action::Render]
702 },
703 KeyCode::Enter | KeyCode::Char(' ') => match self.menu_items.selected() {
704 Some(i) => vec![i.action.clone()],
705 None => vec![Action::EnterMode(Mode::ServiceList)],
706 },
707 _ => {
708 for item in self.menu_items.items.iter() {
709 if let Some(key_code) = item.key {
710 if key_code == key.code {
711 return vec![item.action.clone()];
712 }
713 }
714 }
715 vec![]
716 },
717 },
718 Mode::Processing => match key.code {
719 KeyCode::Esc => vec![Action::CancelTask],
720 _ => vec![],
721 },
722 Mode::SignalMenu => match key.code {
723 KeyCode::Esc => vec![Action::EnterMode(Mode::ServiceList)],
724 KeyCode::Down | KeyCode::Char('j') => {
725 self.menu_items.next();
726 vec![Action::Render]
727 },
728 KeyCode::Up | KeyCode::Char('k') => {
729 self.menu_items.previous();
730 vec![Action::Render]
731 },
732 KeyCode::Enter | KeyCode::Char(' ') => match self.menu_items.selected() {
733 Some(i) => vec![i.action.clone()],
734 None => vec![Action::EnterMode(Mode::ServiceList)],
735 },
736 _ => {
737 for item in self.menu_items.items.iter() {
738 if let Some(key_code) = item.key {
739 if key_code == key.code {
740 return vec![item.action.clone()];
741 }
742 }
743 }
744 vec![]
745 },
746 },
747 }
748 }
749
750 fn dispatch(&mut self, action: Action) -> Option<Action> {
751 match action {
752 Action::ToggleShowLogger => {
753 self.show_logger = !self.show_logger;
754 return Some(Action::Render);
755 },
756 Action::EnterMode(mode) => {
757 if mode == Mode::ActionMenu {
758 if let Some(selected) = self.filtered_units.selected() {
759 let mut menu_items = vec![
760 MenuItem::new("Start", Action::StartService(selected.unit.id()), Some(KeyCode::Char('s'))),
761 MenuItem::new("Stop", Action::StopService(selected.unit.id()), Some(KeyCode::Char('t'))),
762 MenuItem::new("Restart", Action::RestartService(selected.unit.id()), Some(KeyCode::Char('r'))),
763 MenuItem::new("Reload", Action::ReloadService(selected.unit.id()), Some(KeyCode::Char('l'))),
764 MenuItem::new("Enable", Action::EnableService(selected.unit.id()), Some(KeyCode::Char('n'))),
765 MenuItem::new("Disable", Action::DisableService(selected.unit.id()), Some(KeyCode::Char('d'))),
766 MenuItem::new("Kill", Action::EnterMode(Mode::SignalMenu), Some(KeyCode::Char('k'))),
767 MenuItem::new(
768 "Open logs in pager",
769 Action::OpenLogsInPager { logs: self.logs.clone() },
770 Some(KeyCode::Char('o')),
771 ),
772 ];
773
774 if let Some(Ok(file_path)) = &selected.unit.file_path {
775 menu_items.push(MenuItem::new("Copy unit file path", Action::CopyUnitFilePath, Some(KeyCode::Char('c'))));
776 menu_items.push(MenuItem::new(
777 "Edit unit file",
778 Action::EditUnitFile { unit: selected.unit.id(), path: file_path.clone() },
779 Some(KeyCode::Char('e')),
780 ));
781 }
782
783 self.menu_items = StatefulList::with_items(menu_items);
784 self.menu_items.state.select(Some(0));
785 } else {
786 return None;
787 }
788 } else if mode == Mode::SignalMenu {
789 if let Some(selected) = self.filtered_units.selected() {
790 let signals = vec![
791 ("SIGTERM", KeyCode::Char('t')),
792 ("SIGHUP", KeyCode::Char('h')),
793 ("SIGINT", KeyCode::Char('i')),
794 ("SIGQUIT", KeyCode::Char('q')),
795 ("SIGKILL", KeyCode::Char('k')),
796 ("SIGUSR1", KeyCode::Char('1')),
797 ("SIGUSR2", KeyCode::Char('2')),
798 ];
799
800 let menu_items: Vec<MenuItem> = signals
801 .into_iter()
802 .map(|(name, key_code)| {
803 MenuItem::new(name, Action::KillService(selected.unit.id(), name.to_string()), Some(key_code))
804 })
805 .collect();
806
807 self.menu_items = StatefulList::with_items(menu_items);
808 self.menu_items.state.select(Some(0));
809 } else {
810 return None;
811 }
812 }
813
814 self.mode = mode;
815 return Some(Action::Render);
816 },
817 Action::EnterError(err) => {
818 tracing::error!(err);
819 self.error_message = err;
820 return Some(Action::EnterMode(Mode::Error));
821 },
822 Action::ToggleHelp => {
823 if self.mode != Mode::Help {
824 self.previous_mode = Some(self.mode);
825 self.mode = Mode::Help;
826 } else {
827 self.mode = self.previous_mode.unwrap_or(Mode::Search);
828 }
829 return Some(Action::Render);
830 },
831 Action::CopyUnitFilePath => {
832 if let Some(selected) = self.filtered_units.selected() {
833 if let Some(Ok(file_path)) = &selected.unit.file_path {
834 match clipboard_anywhere::set_clipboard(file_path) {
835 Ok(_) => return Some(Action::EnterMode(Mode::ServiceList)),
836 Err(e) => return Some(Action::EnterError(format!("Error copying to clipboard: {e}"))),
837 }
838 } else {
839 return Some(Action::EnterError("No unit file path available".into()));
840 }
841 }
842 },
843 Action::SetUnitFilePath { unit, path } => {
844 if let Some(unit) = self.all_units.get_mut(&unit) {
845 unit.file_path = Some(path.clone());
846 }
847 self.refresh_filtered_units(); },
849 Action::SetLogs { unit, logs } => {
850 if let Some(selected) = self.filtered_units.selected() {
851 if selected.unit.id() == unit {
852 self.logs = logs;
853 }
854 }
855 },
856 Action::AppendLogLine { unit, line } => {
857 if let Some(selected) = self.filtered_units.selected() {
858 if selected.unit.id() == unit {
859 self.logs.push(line);
860 }
861 }
862 },
863 Action::ScrollUp(offset) => {
864 self.logs_scroll_offset = self.logs_scroll_offset.saturating_sub(offset);
865 info!("scroll offset: {}", self.logs_scroll_offset);
866 },
867 Action::ScrollDown(offset) => {
868 self.logs_scroll_offset = self.logs_scroll_offset.saturating_add(offset);
869 info!("scroll offset: {}", self.logs_scroll_offset);
870 },
871 Action::ScrollToTop => {
872 self.logs_scroll_offset = 0;
873 },
874 Action::ScrollToBottom => {
875 self.logs_scroll_offset = self.logs.len() as u16;
880 },
881
882 Action::StartService(service_name) => self.start_service(service_name),
883 Action::StopService(service_name) => self.stop_service(service_name),
884 Action::ReloadService(service_name) => self.reload_service(service_name),
885 Action::RestartService(service_name) => self.restart_service(service_name),
886 Action::EnableService(service_name) => self.enable_service(service_name),
887 Action::DisableService(service_name) => self.disable_service(service_name),
888 Action::RefreshServices => {
889 let tx = self.action_tx.clone().unwrap();
890 let scope = self.scope;
891 let limit_units = self.limit_units.to_vec();
892 tokio::spawn(async move {
893 let units = systemd::get_all_services(scope, &limit_units)
894 .await
895 .expect("Failed to get services. Check that systemd is running and try running this tool with sudo.");
896 tx.send(Action::SetServices(units)).unwrap();
897 });
898 },
899 Action::SetServices(units) => {
900 self.update_units(units);
901 return Some(Action::Render);
902 },
903 Action::RefreshUnitFiles => {
904 let tx = self.action_tx.clone().unwrap();
905 let scope = self.scope;
906 let limit_units = self.limit_units.clone();
907 tokio::spawn(async move {
908 match systemd::get_unit_files(scope, &limit_units).await {
909 Ok(unit_files) => {
910 let _ = tx.send(Action::SetUnitFiles(unit_files));
911 },
912 Err(e) => {
913 error!("Failed to get unit files: {:?}", e);
914 },
915 }
916 });
917 },
918 Action::SetUnitFiles(unit_files) => {
919 self.merge_unit_files(unit_files);
920 return Some(Action::Render);
921 },
922 Action::KillService(service_name, signal) => self.kill_service(service_name, signal),
923 Action::SpinnerTick => {
924 self.spinner_tick = self.spinner_tick.wrapping_add(1);
925 return Some(Action::Render);
926 },
927 Action::CancelTask => {
928 if let Some(cancel_token) = self.cancel_token.take() {
929 cancel_token.cancel();
930 }
931 self.mode = Mode::ServiceList;
932 return Some(Action::Render);
933 },
934 _ => (),
935 }
936 None
937 }
938
939 fn render(&mut self, f: &mut Frame<'_>, rect: Rect) {
940 let theme = self.theme;
942
943 fn span(s: &str, color: Color) -> Span<'_> {
944 Span::styled(s, Style::default().fg(color))
945 }
946
947 fn colored_line(value: &str, color: Color) -> Line<'_> {
948 Line::from(vec![Span::styled(value, Style::default().fg(color))])
949 }
950
951 let rect = if self.show_logger {
952 let chunks = Layout::new(Direction::Vertical, Constraint::from_percentages([50, 50])).split(rect);
953
954 self.logger.render(f, chunks[1]);
955 chunks[0]
956 } else {
957 rect
958 };
959
960 let rects =
961 Layout::new(Direction::Vertical, [Constraint::Min(3), Constraint::Percentage(100), Constraint::Length(1)])
962 .split(rect);
963 let search_panel = rects[0];
964 let main_panel = rects[1];
965 let help_line_rect = rects[2];
966
967 fn unit_color(unit: &UnitWithStatus) -> Color {
974 if unit.is_active() {
975 Color::Green
976 } else if unit.is_failed() {
977 Color::Red
978 } else if unit.is_not_found() {
979 Color::Yellow
980 } else {
981 Color::Reset
982 }
983 }
984
985 let items: Vec<ListItem> = self
986 .filtered_units
987 .items
988 .iter()
989 .map(|m| {
990 let color = unit_color(&m.unit);
991 let name = m.unit.short_name();
992
993 if m.match_indices.is_empty() {
994 ListItem::new(colored_line(name, color))
995 } else {
996 let mut spans = Vec::new();
998 let mut last_end = 0;
999
1000 for &idx in &m.match_indices {
1001 if idx > last_end && idx <= name.len() {
1002 spans.push(Span::styled(&name[last_end..idx], Style::default().fg(color)));
1004 }
1005 if idx < name.len() {
1007 let char_end = name[idx..].chars().next().map(|c| idx + c.len_utf8()).unwrap_or(idx + 1);
1008 spans.push(Span::styled(
1009 &name[idx..char_end],
1010 Style::default().fg(color).add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
1011 ));
1012 last_end = char_end;
1013 }
1014 }
1015
1016 if last_end < name.len() {
1017 spans.push(Span::styled(&name[last_end..], Style::default().fg(color)));
1018 }
1019
1020 ListItem::new(Line::from(spans))
1021 }
1022 })
1023 .collect();
1024
1025 let items = List::new(items)
1027 .block(
1028 Block::default()
1029 .borders(Borders::ALL)
1030 .border_type(BorderType::Rounded)
1031 .border_style(if self.mode == Mode::ServiceList {
1032 Style::default().fg(theme.accent)
1033 } else {
1034 Style::default()
1035 })
1036 .title("─Services"),
1037 )
1038 .highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD));
1039
1040 let chunks =
1041 Layout::new(Direction::Horizontal, [Constraint::Min(30), Constraint::Percentage(100)]).split(main_panel);
1042 let right_panel = chunks[1];
1043
1044 f.render_stateful_widget(items, chunks[0], &mut self.filtered_units.state);
1045
1046 let selected_item = self.filtered_units.selected();
1047
1048 let right_panel =
1049 Layout::new(Direction::Vertical, [Constraint::Min(8), Constraint::Percentage(100)]).split(right_panel);
1050 let details_panel = right_panel[0];
1051 let logs_panel = right_panel[1];
1052
1053 let details_block = Block::default().title("─Details").borders(Borders::ALL).border_type(BorderType::Rounded);
1054 let details_panel_panes = Layout::new(Direction::Horizontal, [Constraint::Min(14), Constraint::Percentage(100)])
1055 .split(details_block.inner(details_panel));
1056 let props_pane = details_panel_panes[0];
1057 let values_pane = details_panel_panes[1];
1058
1059 let props_lines = vec![
1060 Line::from("Description: "),
1061 Line::from("Enablement: "),
1062 Line::from("Scope: "),
1063 Line::from("Loaded: "),
1064 Line::from("Active: "),
1065 Line::from("Unit file: "),
1066 ];
1067
1068 let details_text = if let Some(m) = selected_item {
1069 fn line_color_string<'a>(value: String, color: Color) -> Line<'a> {
1070 Line::from(vec![Span::styled(value, Style::default().fg(color))])
1071 }
1072
1073 let load_color = match m.unit.load_state.as_str() {
1074 "loaded" => Color::Green,
1075 "not-found" => Color::Yellow,
1076 "error" => Color::Red,
1077 _ => Color::Reset,
1078 };
1079
1080 let active_color = match m.unit.activation_state.as_str() {
1081 "active" => Color::Green,
1082 "inactive" => Color::Reset,
1083 "failed" => Color::Red,
1084 _ => Color::Reset,
1085 };
1086
1087 let active_state_value = format!("{} ({})", m.unit.activation_state, m.unit.sub_state);
1088
1089 let scope = match m.unit.scope {
1090 UnitScope::Global => "Global",
1091 UnitScope::User => "User",
1092 };
1093
1094 let enablement_state = m.unit.enablement_state.as_deref().unwrap_or("");
1095 let enablement_color = match enablement_state {
1096 "enabled" => Color::Green,
1097 "disabled" => Color::Yellow,
1098 "masked" => Color::Red,
1099 _ => Color::Reset,
1100 };
1101
1102 let lines = vec![
1103 colored_line(&m.unit.description, Color::Reset),
1104 colored_line(enablement_state, enablement_color),
1105 colored_line(scope, Color::Reset),
1106 colored_line(&m.unit.load_state, load_color),
1107 line_color_string(active_state_value, active_color),
1108 match &m.unit.file_path {
1109 Some(Ok(file_path)) => Line::from(file_path.as_str()),
1110 Some(Err(e)) => colored_line(e, Color::Red),
1111 None => Line::from(""),
1112 },
1113 ];
1114
1115 lines
1116 } else {
1117 vec![]
1118 };
1119
1120 let paragraph = Paragraph::new(details_text).style(Style::default());
1121
1122 let props_widget = Paragraph::new(props_lines).alignment(ratatui::layout::Alignment::Right);
1123 f.render_widget(props_widget, props_pane);
1124
1125 f.render_widget(paragraph, values_pane);
1126 f.render_widget(details_block, details_panel);
1127
1128 let log_lines = self
1129 .logs
1130 .iter()
1131 .rev()
1132 .map(|l| {
1133 if let Some((timestamp, rest)) = l.split_once(' ') {
1134 if let Some(formatted_date) = parse_journalctl_timestamp(timestamp) {
1135 return Line::from(vec![
1136 Span::styled(formatted_date, Style::default().add_modifier(Modifier::DIM)),
1137 Span::raw(" "),
1138 Span::raw(rest),
1139 ]);
1140 }
1141 }
1142
1143 Line::from(l.as_str())
1144 })
1145 .collect_vec();
1146
1147 let paragraph = Paragraph::new(log_lines)
1148 .block(Block::default().title("─Service Logs").borders(Borders::ALL).border_type(BorderType::Rounded))
1149 .style(Style::default())
1150 .wrap(Wrap { trim: true })
1151 .scroll((self.logs_scroll_offset, 0));
1152 f.render_widget(paragraph, logs_panel);
1153
1154 let width = search_panel.width.max(3) - 3; let scroll = self.input.visual_scroll(width as usize);
1156 let input = Paragraph::new(self.input.value())
1157 .style(match self.mode {
1158 Mode::Search => Style::default().fg(theme.accent),
1159 _ => Style::default(),
1160 })
1161 .scroll((0, scroll as u16))
1162 .block(Block::default().borders(Borders::ALL).border_type(BorderType::Rounded).title(Line::from(vec![
1163 Span::raw("─Search "),
1164 Span::styled("(", Style::default().fg(theme.muted_alt)),
1165 Span::styled("ctrl+f", Style::default().add_modifier(Modifier::BOLD).fg(theme.kbd)),
1166 Span::styled(" or ", Style::default().fg(theme.muted_alt)),
1167 Span::styled("/", Style::default().add_modifier(Modifier::BOLD).fg(theme.kbd)),
1168 Span::styled(")", Style::default().fg(theme.muted_alt)),
1169 ])));
1170 f.render_widget(input, search_panel);
1171 let help_width = 24;
1173 let help_area = Rect::new(search_panel.x + search_panel.width - help_width - 2, search_panel.y, help_width, 1);
1174 f.render_widget(Clear, help_area);
1175 let help_text = Paragraph::new(Line::from(vec![
1176 Span::raw(" Press "),
1177 Span::styled("?", Style::default().add_modifier(Modifier::BOLD).fg(theme.kbd)),
1178 Span::raw(" or "),
1179 Span::styled("F1", Style::default().add_modifier(Modifier::BOLD).fg(theme.kbd)),
1180 Span::raw(" for help "),
1181 ]))
1182 .style(Style::default().fg(theme.muted_alt));
1183 f.render_widget(help_text, help_area);
1184
1185 if self.mode == Mode::Search {
1186 f.set_cursor_position((
1187 (search_panel.x + 1 + self.input.cursor() as u16).min(search_panel.x + search_panel.width - 2),
1188 search_panel.y + 1,
1189 ));
1190 }
1191
1192 if self.mode == Mode::Help {
1193 let popup = centered_rect_abs(50, 18, f.area());
1194
1195 let primary = |s| Span::styled(s, Style::default().fg(theme.primary));
1196 let help_lines = vec![
1197 Line::from(""),
1198 Line::from(Span::styled("Shortcuts", Style::default().add_modifier(Modifier::UNDERLINED))),
1199 Line::from(""),
1200 Line::from(vec![primary("ctrl+C"), Span::raw(" or "), primary("ctrl+Q"), Span::raw(" to quit")]),
1201 Line::from(vec![primary("ctrl+L"), Span::raw(" toggles the logger pane")]),
1202 Line::from(vec![primary("PageUp"), Span::raw(" / "), primary("PageDown"), Span::raw(" scroll the logs")]),
1203 Line::from(vec![primary("Home"), Span::raw(" / "), primary("End"), Span::raw(" scroll to top/bottom")]),
1204 Line::from(vec![primary("Enter"), Span::raw(" or "), primary("Space"), Span::raw(" open the action menu")]),
1205 Line::from(vec![primary("?"), Span::raw(" / "), primary("F1"), Span::raw(" open this help pane")]),
1206 Line::from(""),
1207 Line::from(Span::styled("Vim Style Shortcuts", Style::default().add_modifier(Modifier::UNDERLINED))),
1208 Line::from(""),
1209 Line::from(vec![primary("j"), Span::raw(" navigate down")]),
1210 Line::from(vec![primary("k"), Span::raw(" navigate up")]),
1211 Line::from(vec![primary("ctrl+U"), Span::raw(" / "), primary("ctrl+D"), Span::raw(" scroll the logs")]),
1212 ];
1213
1214 let name = env!("CARGO_PKG_NAME");
1215 let version = env!("CARGO_PKG_VERSION");
1216 let title = format!("─Help for {name} v{version}");
1217
1218 let paragraph = Paragraph::new(help_lines)
1219 .block(Block::default().title(title).borders(Borders::ALL).border_type(BorderType::Rounded))
1220 .style(Style::default())
1221 .wrap(Wrap { trim: true });
1222
1223 f.render_widget(Clear, popup);
1224 f.render_widget(paragraph, popup);
1225 }
1226
1227 if self.mode == Mode::Error {
1228 let popup = centered_rect_abs(50, 12, f.area());
1229 let error_lines = self.error_message.split('\n').map(Line::from).collect_vec();
1230 let paragraph = Paragraph::new(error_lines)
1231 .block(
1232 Block::default()
1233 .title("─Error")
1234 .borders(Borders::ALL)
1235 .border_type(BorderType::Rounded)
1236 .border_style(Style::default().fg(Color::Red)),
1237 )
1238 .wrap(Wrap { trim: true });
1239
1240 f.render_widget(Clear, popup);
1241 f.render_widget(paragraph, popup);
1242 }
1243
1244 let selected_item = match self.filtered_units.selected() {
1245 Some(s) => s,
1246 None => return,
1247 };
1248
1249 let version = format!("v{}", env!("CARGO_PKG_VERSION"));
1252
1253 let help_line_rects =
1254 Layout::new(Direction::Horizontal, [Constraint::Fill(1), Constraint::Length(version.len() as u16)])
1255 .split(help_line_rect);
1256 let help_rect = help_line_rects[0];
1257 let version_rect = help_line_rects[1];
1258
1259 let help_line = match self.mode {
1260 Mode::Search => Line::from(span("Show actions: <enter>", theme.primary)),
1261 Mode::ServiceList => {
1262 Line::from(span("Show actions: <enter> | Open logs in pager: o | Edit unit file: e | Quit: q", theme.primary))
1263 },
1264 Mode::Help => Line::from(span("Close menu: <esc>", theme.primary)),
1265 Mode::ActionMenu => Line::from(span("Execute action: <enter> | Close menu: <esc>", theme.primary)),
1266 Mode::Processing => Line::from(span("Cancel task: <esc>", theme.primary)),
1267 Mode::Error => Line::from(span("Close menu: <esc>", theme.primary)),
1268 Mode::SignalMenu => Line::from(span("Send signal: <enter> | Close menu: <esc>", theme.primary)),
1269 };
1270
1271 f.render_widget(help_line, help_rect);
1272 f.render_widget(Line::from(version), version_rect);
1273
1274 let title = format!("Actions for {}", selected_item.unit.name);
1275 let mut min_width = title.len() as u16 + 2; min_width = min_width.max(24); let popup_width = min_width.min(f.area().width);
1279
1280 if self.mode == Mode::ActionMenu || self.mode == Mode::SignalMenu {
1281 let title_prefix = if self.mode == Mode::ActionMenu { "Actions" } else { "Signals" };
1282 let title = format!("{} for {}", title_prefix, selected_item.unit.name);
1283 let height = self.menu_items.items.len() as u16 + 2;
1284 let popup = centered_rect_abs(popup_width, height, f.area());
1285
1286 let items: Vec<ListItem> = self
1287 .menu_items
1288 .items
1289 .iter()
1290 .map(|i| {
1291 let key_string = Span::styled(format!(" {:1} ", i.key_string()), Style::default().fg(theme.primary));
1292 let line = Line::from(vec![key_string, Span::raw(&i.name)]);
1293 ListItem::new(line)
1294 })
1295 .collect();
1296 let items = List::new(items)
1297 .block(
1298 Block::default()
1299 .borders(Borders::ALL)
1300 .border_type(BorderType::Rounded)
1301 .border_style(Style::default().fg(theme.accent))
1302 .title(title),
1303 )
1304 .highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD));
1305
1306 f.render_widget(Clear, popup);
1307 f.render_stateful_widget(items, popup, &mut self.menu_items.state);
1308 }
1309
1310 if self.mode == Mode::Processing {
1311 let height = self.menu_items.items.len() as u16 + 2;
1312 let popup = centered_rect_abs(popup_width, height, f.area());
1313
1314 static SPINNER_CHARS: &[char] = &['⣷', '⣯', '⣟', '⡿', '⢿', '⣻', '⣽', '⣾'];
1315
1316 let spinner_char = SPINNER_CHARS[self.spinner_tick as usize % SPINNER_CHARS.len()];
1317 let paragraph = Paragraph::new(vec![Line::from(format!("{spinner_char}"))])
1319 .block(
1320 Block::default()
1321 .title("Processing")
1322 .border_type(BorderType::Rounded)
1323 .borders(Borders::ALL)
1324 .border_style(Style::default().fg(theme.accent)),
1325 )
1326 .style(Style::default())
1327 .wrap(Wrap { trim: true });
1328
1329 f.render_widget(Clear, popup);
1330 f.render_widget(paragraph, popup);
1331 }
1332 }
1333}
1334
1335fn _centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
1337 let popup_layout = Layout::new(
1338 Direction::Vertical,
1339 [
1340 Constraint::Percentage((100 - percent_y) / 2),
1341 Constraint::Percentage(percent_y),
1342 Constraint::Percentage((100 - percent_y) / 2),
1343 ],
1344 )
1345 .split(r);
1346
1347 Layout::new(
1348 Direction::Horizontal,
1349 [
1350 Constraint::Percentage((100 - percent_x) / 2),
1351 Constraint::Percentage(percent_x),
1352 Constraint::Percentage((100 - percent_x) / 2),
1353 ],
1354 )
1355 .split(popup_layout[1])[1]
1356}
1357
1358fn centered_rect_abs(width: u16, height: u16, r: Rect) -> Rect {
1359 let offset_x = (r.width.saturating_sub(width)) / 2;
1360 let offset_y = (r.height.saturating_sub(height)) / 2;
1361 let width = width.min(r.width);
1362 let height = height.min(r.height);
1363
1364 Rect::new(offset_x, offset_y, width, height)
1365}
1366
1367fn parse_journalctl_timestamp(timestamp: &str) -> Option<String> {
1372 DateTime::parse_from_str(timestamp, "%Y-%m-%dT%H:%M:%S%z").ok().map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
1374}
1375
1376#[cfg(test)]
1377mod tests {
1378 use super::*;
1379
1380 #[test]
1381 fn test_parse_timestamp_systemd_v255_and_later() {
1382 let timestamp = "2025-04-26T06:04:45-07:00";
1385 let result = parse_journalctl_timestamp(timestamp);
1386 assert_eq!(result, Some("2025-04-26 06:04".to_string()));
1387 }
1388
1389 #[test]
1390 fn test_parse_timestamp_systemd_before_v255() {
1391 let timestamp = "2025-10-06T11:07:44-0700";
1393 let result = parse_journalctl_timestamp(timestamp);
1394 assert_eq!(result, Some("2025-10-06 11:07".to_string()));
1395 }
1396}