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 tokio::spawn(async move {
907 match systemd::get_unit_files(scope).await {
908 Ok(unit_files) => {
909 let _ = tx.send(Action::SetUnitFiles(unit_files));
910 },
911 Err(e) => {
912 error!("Failed to get unit files: {:?}", e);
913 },
914 }
915 });
916 },
917 Action::SetUnitFiles(unit_files) => {
918 self.merge_unit_files(unit_files);
919 return Some(Action::Render);
920 },
921 Action::KillService(service_name, signal) => self.kill_service(service_name, signal),
922 Action::SpinnerTick => {
923 self.spinner_tick = self.spinner_tick.wrapping_add(1);
924 return Some(Action::Render);
925 },
926 Action::CancelTask => {
927 if let Some(cancel_token) = self.cancel_token.take() {
928 cancel_token.cancel();
929 }
930 self.mode = Mode::ServiceList;
931 return Some(Action::Render);
932 },
933 _ => (),
934 }
935 None
936 }
937
938 fn render(&mut self, f: &mut Frame<'_>, rect: Rect) {
939 let theme = self.theme;
941
942 fn span(s: &str, color: Color) -> Span<'_> {
943 Span::styled(s, Style::default().fg(color))
944 }
945
946 fn colored_line(value: &str, color: Color) -> Line<'_> {
947 Line::from(vec![Span::styled(value, Style::default().fg(color))])
948 }
949
950 let rect = if self.show_logger {
951 let chunks = Layout::new(Direction::Vertical, Constraint::from_percentages([50, 50])).split(rect);
952
953 self.logger.render(f, chunks[1]);
954 chunks[0]
955 } else {
956 rect
957 };
958
959 let rects =
960 Layout::new(Direction::Vertical, [Constraint::Min(3), Constraint::Percentage(100), Constraint::Length(1)])
961 .split(rect);
962 let search_panel = rects[0];
963 let main_panel = rects[1];
964 let help_line_rect = rects[2];
965
966 fn unit_color(unit: &UnitWithStatus) -> Color {
973 if unit.is_active() {
974 Color::Green
975 } else if unit.is_failed() {
976 Color::Red
977 } else if unit.is_not_found() {
978 Color::Yellow
979 } else {
980 Color::Reset
981 }
982 }
983
984 let items: Vec<ListItem> = self
985 .filtered_units
986 .items
987 .iter()
988 .map(|m| {
989 let color = unit_color(&m.unit);
990 let name = m.unit.short_name();
991
992 if m.match_indices.is_empty() {
993 ListItem::new(colored_line(name, color))
994 } else {
995 let mut spans = Vec::new();
997 let mut last_end = 0;
998
999 for &idx in &m.match_indices {
1000 if idx > last_end && idx <= name.len() {
1001 spans.push(Span::styled(&name[last_end..idx], Style::default().fg(color)));
1003 }
1004 if idx < name.len() {
1006 let char_end = name[idx..].chars().next().map(|c| idx + c.len_utf8()).unwrap_or(idx + 1);
1007 spans.push(Span::styled(
1008 &name[idx..char_end],
1009 Style::default().fg(color).add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
1010 ));
1011 last_end = char_end;
1012 }
1013 }
1014
1015 if last_end < name.len() {
1016 spans.push(Span::styled(&name[last_end..], Style::default().fg(color)));
1017 }
1018
1019 ListItem::new(Line::from(spans))
1020 }
1021 })
1022 .collect();
1023
1024 let items = List::new(items)
1026 .block(
1027 Block::default()
1028 .borders(Borders::ALL)
1029 .border_type(BorderType::Rounded)
1030 .border_style(if self.mode == Mode::ServiceList {
1031 Style::default().fg(theme.accent)
1032 } else {
1033 Style::default()
1034 })
1035 .title("─Services"),
1036 )
1037 .highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD));
1038
1039 let chunks =
1040 Layout::new(Direction::Horizontal, [Constraint::Min(30), Constraint::Percentage(100)]).split(main_panel);
1041 let right_panel = chunks[1];
1042
1043 f.render_stateful_widget(items, chunks[0], &mut self.filtered_units.state);
1044
1045 let selected_item = self.filtered_units.selected();
1046
1047 let right_panel =
1048 Layout::new(Direction::Vertical, [Constraint::Min(8), Constraint::Percentage(100)]).split(right_panel);
1049 let details_panel = right_panel[0];
1050 let logs_panel = right_panel[1];
1051
1052 let details_block = Block::default().title("─Details").borders(Borders::ALL).border_type(BorderType::Rounded);
1053 let details_panel_panes = Layout::new(Direction::Horizontal, [Constraint::Min(14), Constraint::Percentage(100)])
1054 .split(details_block.inner(details_panel));
1055 let props_pane = details_panel_panes[0];
1056 let values_pane = details_panel_panes[1];
1057
1058 let props_lines = vec![
1059 Line::from("Description: "),
1060 Line::from("Enablement: "),
1061 Line::from("Scope: "),
1062 Line::from("Loaded: "),
1063 Line::from("Active: "),
1064 Line::from("Unit file: "),
1065 ];
1066
1067 let details_text = if let Some(m) = selected_item {
1068 fn line_color_string<'a>(value: String, color: Color) -> Line<'a> {
1069 Line::from(vec![Span::styled(value, Style::default().fg(color))])
1070 }
1071
1072 let load_color = match m.unit.load_state.as_str() {
1073 "loaded" => Color::Green,
1074 "not-found" => Color::Yellow,
1075 "error" => Color::Red,
1076 _ => Color::Reset,
1077 };
1078
1079 let active_color = match m.unit.activation_state.as_str() {
1080 "active" => Color::Green,
1081 "inactive" => Color::Reset,
1082 "failed" => Color::Red,
1083 _ => Color::Reset,
1084 };
1085
1086 let active_state_value = format!("{} ({})", m.unit.activation_state, m.unit.sub_state);
1087
1088 let scope = match m.unit.scope {
1089 UnitScope::Global => "Global",
1090 UnitScope::User => "User",
1091 };
1092
1093 let enablement_state = m.unit.enablement_state.as_deref().unwrap_or("");
1094 let enablement_color = match enablement_state {
1095 "enabled" => Color::Green,
1096 "disabled" => Color::Yellow,
1097 "masked" => Color::Red,
1098 _ => Color::Reset,
1099 };
1100
1101 let lines = vec![
1102 colored_line(&m.unit.description, Color::Reset),
1103 colored_line(enablement_state, enablement_color),
1104 colored_line(scope, Color::Reset),
1105 colored_line(&m.unit.load_state, load_color),
1106 line_color_string(active_state_value, active_color),
1107 match &m.unit.file_path {
1108 Some(Ok(file_path)) => Line::from(file_path.as_str()),
1109 Some(Err(e)) => colored_line(e, Color::Red),
1110 None => Line::from(""),
1111 },
1112 ];
1113
1114 lines
1115 } else {
1116 vec![]
1117 };
1118
1119 let paragraph = Paragraph::new(details_text).style(Style::default());
1120
1121 let props_widget = Paragraph::new(props_lines).alignment(ratatui::layout::Alignment::Right);
1122 f.render_widget(props_widget, props_pane);
1123
1124 f.render_widget(paragraph, values_pane);
1125 f.render_widget(details_block, details_panel);
1126
1127 let log_lines = self
1128 .logs
1129 .iter()
1130 .rev()
1131 .map(|l| {
1132 if let Some((timestamp, rest)) = l.split_once(' ') {
1133 if let Some(formatted_date) = parse_journalctl_timestamp(timestamp) {
1134 return Line::from(vec![
1135 Span::styled(formatted_date, Style::default().add_modifier(Modifier::DIM)),
1136 Span::raw(" "),
1137 Span::raw(rest),
1138 ]);
1139 }
1140 }
1141
1142 Line::from(l.as_str())
1143 })
1144 .collect_vec();
1145
1146 let paragraph = Paragraph::new(log_lines)
1147 .block(Block::default().title("─Service Logs").borders(Borders::ALL).border_type(BorderType::Rounded))
1148 .style(Style::default())
1149 .wrap(Wrap { trim: true })
1150 .scroll((self.logs_scroll_offset, 0));
1151 f.render_widget(paragraph, logs_panel);
1152
1153 let width = search_panel.width.max(3) - 3; let scroll = self.input.visual_scroll(width as usize);
1155 let input = Paragraph::new(self.input.value())
1156 .style(match self.mode {
1157 Mode::Search => Style::default().fg(theme.accent),
1158 _ => Style::default(),
1159 })
1160 .scroll((0, scroll as u16))
1161 .block(Block::default().borders(Borders::ALL).border_type(BorderType::Rounded).title(Line::from(vec![
1162 Span::raw("─Search "),
1163 Span::styled("(", Style::default().fg(theme.muted_alt)),
1164 Span::styled("ctrl+f", Style::default().add_modifier(Modifier::BOLD).fg(theme.kbd)),
1165 Span::styled(" or ", Style::default().fg(theme.muted_alt)),
1166 Span::styled("/", Style::default().add_modifier(Modifier::BOLD).fg(theme.kbd)),
1167 Span::styled(")", Style::default().fg(theme.muted_alt)),
1168 ])));
1169 f.render_widget(input, search_panel);
1170 let help_width = 24;
1172 let help_area = Rect::new(search_panel.x + search_panel.width - help_width - 2, search_panel.y, help_width, 1);
1173 f.render_widget(Clear, help_area);
1174 let help_text = Paragraph::new(Line::from(vec![
1175 Span::raw(" Press "),
1176 Span::styled("?", Style::default().add_modifier(Modifier::BOLD).fg(theme.kbd)),
1177 Span::raw(" or "),
1178 Span::styled("F1", Style::default().add_modifier(Modifier::BOLD).fg(theme.kbd)),
1179 Span::raw(" for help "),
1180 ]))
1181 .style(Style::default().fg(theme.muted_alt));
1182 f.render_widget(help_text, help_area);
1183
1184 if self.mode == Mode::Search {
1185 f.set_cursor_position((
1186 (search_panel.x + 1 + self.input.cursor() as u16).min(search_panel.x + search_panel.width - 2),
1187 search_panel.y + 1,
1188 ));
1189 }
1190
1191 if self.mode == Mode::Help {
1192 let popup = centered_rect_abs(50, 18, f.area());
1193
1194 let primary = |s| Span::styled(s, Style::default().fg(theme.primary));
1195 let help_lines = vec![
1196 Line::from(""),
1197 Line::from(Span::styled("Shortcuts", Style::default().add_modifier(Modifier::UNDERLINED))),
1198 Line::from(""),
1199 Line::from(vec![primary("ctrl+C"), Span::raw(" or "), primary("ctrl+Q"), Span::raw(" to quit")]),
1200 Line::from(vec![primary("ctrl+L"), Span::raw(" toggles the logger pane")]),
1201 Line::from(vec![primary("PageUp"), Span::raw(" / "), primary("PageDown"), Span::raw(" scroll the logs")]),
1202 Line::from(vec![primary("Home"), Span::raw(" / "), primary("End"), Span::raw(" scroll to top/bottom")]),
1203 Line::from(vec![primary("Enter"), Span::raw(" or "), primary("Space"), Span::raw(" open the action menu")]),
1204 Line::from(vec![primary("?"), Span::raw(" / "), primary("F1"), Span::raw(" open this help pane")]),
1205 Line::from(""),
1206 Line::from(Span::styled("Vim Style Shortcuts", Style::default().add_modifier(Modifier::UNDERLINED))),
1207 Line::from(""),
1208 Line::from(vec![primary("j"), Span::raw(" navigate down")]),
1209 Line::from(vec![primary("k"), Span::raw(" navigate up")]),
1210 Line::from(vec![primary("ctrl+U"), Span::raw(" / "), primary("ctrl+D"), Span::raw(" scroll the logs")]),
1211 ];
1212
1213 let name = env!("CARGO_PKG_NAME");
1214 let version = env!("CARGO_PKG_VERSION");
1215 let title = format!("─Help for {name} v{version}");
1216
1217 let paragraph = Paragraph::new(help_lines)
1218 .block(Block::default().title(title).borders(Borders::ALL).border_type(BorderType::Rounded))
1219 .style(Style::default())
1220 .wrap(Wrap { trim: true });
1221
1222 f.render_widget(Clear, popup);
1223 f.render_widget(paragraph, popup);
1224 }
1225
1226 if self.mode == Mode::Error {
1227 let popup = centered_rect_abs(50, 12, f.area());
1228 let error_lines = self.error_message.split('\n').map(Line::from).collect_vec();
1229 let paragraph = Paragraph::new(error_lines)
1230 .block(
1231 Block::default()
1232 .title("─Error")
1233 .borders(Borders::ALL)
1234 .border_type(BorderType::Rounded)
1235 .border_style(Style::default().fg(Color::Red)),
1236 )
1237 .wrap(Wrap { trim: true });
1238
1239 f.render_widget(Clear, popup);
1240 f.render_widget(paragraph, popup);
1241 }
1242
1243 let selected_item = match self.filtered_units.selected() {
1244 Some(s) => s,
1245 None => return,
1246 };
1247
1248 let version = format!("v{}", env!("CARGO_PKG_VERSION"));
1251
1252 let help_line_rects =
1253 Layout::new(Direction::Horizontal, [Constraint::Fill(1), Constraint::Length(version.len() as u16)])
1254 .split(help_line_rect);
1255 let help_rect = help_line_rects[0];
1256 let version_rect = help_line_rects[1];
1257
1258 let help_line = match self.mode {
1259 Mode::Search => Line::from(span("Show actions: <enter>", theme.primary)),
1260 Mode::ServiceList => {
1261 Line::from(span("Show actions: <enter> | Open logs in pager: o | Edit unit file: e | Quit: q", theme.primary))
1262 },
1263 Mode::Help => Line::from(span("Close menu: <esc>", theme.primary)),
1264 Mode::ActionMenu => Line::from(span("Execute action: <enter> | Close menu: <esc>", theme.primary)),
1265 Mode::Processing => Line::from(span("Cancel task: <esc>", theme.primary)),
1266 Mode::Error => Line::from(span("Close menu: <esc>", theme.primary)),
1267 Mode::SignalMenu => Line::from(span("Send signal: <enter> | Close menu: <esc>", theme.primary)),
1268 };
1269
1270 f.render_widget(help_line, help_rect);
1271 f.render_widget(Line::from(version), version_rect);
1272
1273 let title = format!("Actions for {}", selected_item.unit.name);
1274 let mut min_width = title.len() as u16 + 2; min_width = min_width.max(24); let popup_width = min_width.min(f.area().width);
1278
1279 if self.mode == Mode::ActionMenu || self.mode == Mode::SignalMenu {
1280 let title_prefix = if self.mode == Mode::ActionMenu { "Actions" } else { "Signals" };
1281 let title = format!("{} for {}", title_prefix, selected_item.unit.name);
1282 let height = self.menu_items.items.len() as u16 + 2;
1283 let popup = centered_rect_abs(popup_width, height, f.area());
1284
1285 let items: Vec<ListItem> = self
1286 .menu_items
1287 .items
1288 .iter()
1289 .map(|i| {
1290 let key_string = Span::styled(format!(" {:1} ", i.key_string()), Style::default().fg(theme.primary));
1291 let line = Line::from(vec![key_string, Span::raw(&i.name)]);
1292 ListItem::new(line)
1293 })
1294 .collect();
1295 let items = List::new(items)
1296 .block(
1297 Block::default()
1298 .borders(Borders::ALL)
1299 .border_type(BorderType::Rounded)
1300 .border_style(Style::default().fg(theme.accent))
1301 .title(title),
1302 )
1303 .highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD));
1304
1305 f.render_widget(Clear, popup);
1306 f.render_stateful_widget(items, popup, &mut self.menu_items.state);
1307 }
1308
1309 if self.mode == Mode::Processing {
1310 let height = self.menu_items.items.len() as u16 + 2;
1311 let popup = centered_rect_abs(popup_width, height, f.area());
1312
1313 static SPINNER_CHARS: &[char] = &['⣷', '⣯', '⣟', '⡿', '⢿', '⣻', '⣽', '⣾'];
1314
1315 let spinner_char = SPINNER_CHARS[self.spinner_tick as usize % SPINNER_CHARS.len()];
1316 let paragraph = Paragraph::new(vec![Line::from(format!("{spinner_char}"))])
1318 .block(
1319 Block::default()
1320 .title("Processing")
1321 .border_type(BorderType::Rounded)
1322 .borders(Borders::ALL)
1323 .border_style(Style::default().fg(theme.accent)),
1324 )
1325 .style(Style::default())
1326 .wrap(Wrap { trim: true });
1327
1328 f.render_widget(Clear, popup);
1329 f.render_widget(paragraph, popup);
1330 }
1331 }
1332}
1333
1334fn _centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
1336 let popup_layout = Layout::new(
1337 Direction::Vertical,
1338 [
1339 Constraint::Percentage((100 - percent_y) / 2),
1340 Constraint::Percentage(percent_y),
1341 Constraint::Percentage((100 - percent_y) / 2),
1342 ],
1343 )
1344 .split(r);
1345
1346 Layout::new(
1347 Direction::Horizontal,
1348 [
1349 Constraint::Percentage((100 - percent_x) / 2),
1350 Constraint::Percentage(percent_x),
1351 Constraint::Percentage((100 - percent_x) / 2),
1352 ],
1353 )
1354 .split(popup_layout[1])[1]
1355}
1356
1357fn centered_rect_abs(width: u16, height: u16, r: Rect) -> Rect {
1358 let offset_x = (r.width.saturating_sub(width)) / 2;
1359 let offset_y = (r.height.saturating_sub(height)) / 2;
1360 let width = width.min(r.width);
1361 let height = height.min(r.height);
1362
1363 Rect::new(offset_x, offset_y, width, height)
1364}
1365
1366fn parse_journalctl_timestamp(timestamp: &str) -> Option<String> {
1371 DateTime::parse_from_str(timestamp, "%Y-%m-%dT%H:%M:%S%z").ok().map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
1373}
1374
1375#[cfg(test)]
1376mod tests {
1377 use super::*;
1378
1379 #[test]
1380 fn test_parse_timestamp_systemd_v255_and_later() {
1381 let timestamp = "2025-04-26T06:04:45-07:00";
1384 let result = parse_journalctl_timestamp(timestamp);
1385 assert_eq!(result, Some("2025-04-26 06:04".to_string()));
1386 }
1387
1388 #[test]
1389 fn test_parse_timestamp_systemd_before_v255() {
1390 let timestamp = "2025-10-06T11:07:44-0700";
1392 let result = parse_journalctl_timestamp(timestamp);
1393 assert_eq!(result, Some("2025-10-06 11:07".to_string()));
1394 }
1395}