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