1use crate::client::ZinitHandle;
10use crossterm::{
11 event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
12 execute,
13 terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
14};
15use ratatui::{
16 Frame, Terminal,
17 backend::CrosstermBackend,
18 layout::{Constraint, Direction, Layout, Rect},
19 style::{Color, Modifier, Style},
20 text::{Line, Span},
21 widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap},
22};
23use std::io;
24use std::time::{Duration, Instant};
25
26#[derive(Clone, Debug)]
28struct ServiceInfo {
29 name: String,
30 state: String,
31 pid: i32,
32 #[allow(dead_code)]
33 target: String,
34 memory_kb: u64,
35 cpu_percent: f32,
36 process_tree: Vec<(i32, String, Vec<i32>)>,
38}
39
40struct App {
42 handle: ZinitHandle,
44 services: Vec<ServiceInfo>,
46 selected: ListState,
48 logs: Vec<String>,
50 log_scroll: usize,
52 show_help: bool,
54 show_detail: bool,
56 status_message: Option<(String, Instant)>,
58 focus: Focus,
60 following_logs: bool,
62}
63
64#[derive(PartialEq, Clone, Copy)]
65enum Focus {
66 Services,
67 Logs,
68}
69
70impl App {
71 fn new(handle: ZinitHandle) -> Self {
72 let mut selected = ListState::default();
73 selected.select(Some(0));
74 Self {
75 handle,
76 services: Vec::new(),
77 selected,
78 logs: Vec::new(),
79 log_scroll: 0,
80 show_help: false,
81 show_detail: false,
82 status_message: None,
83 focus: Focus::Services,
84 following_logs: true,
85 }
86 }
87
88 fn set_status(&mut self, msg: impl Into<String>) {
89 self.status_message = Some((msg.into(), Instant::now()));
90 }
91
92 fn selected_service(&self) -> Option<&ServiceInfo> {
93 self.selected.selected().and_then(|i| self.services.get(i))
94 }
95
96 fn next_service(&mut self) {
97 if self.services.is_empty() {
98 return;
99 }
100 let i = match self.selected.selected() {
101 Some(i) => {
102 if i >= self.services.len() - 1 {
103 0
104 } else {
105 i + 1
106 }
107 }
108 None => 0,
109 };
110 self.selected.select(Some(i));
111 self.following_logs = true;
112 }
113
114 fn previous_service(&mut self) {
115 if self.services.is_empty() {
116 return;
117 }
118 let i = match self.selected.selected() {
119 Some(i) => {
120 if i == 0 {
121 self.services.len() - 1
122 } else {
123 i - 1
124 }
125 }
126 None => 0,
127 };
128 self.selected.select(Some(i));
129 self.following_logs = true;
130 }
131
132 fn scroll_logs_up(&mut self) {
133 if self.log_scroll > 0 {
134 self.log_scroll -= 1;
135 self.following_logs = false;
136 }
137 }
138
139 fn scroll_logs_down(&mut self, visible_height: usize) {
140 let max_scroll = self.logs.len().saturating_sub(visible_height);
141 if self.log_scroll < max_scroll {
142 self.log_scroll += 1;
143 }
144 if self.log_scroll >= max_scroll {
145 self.following_logs = true;
146 }
147 }
148
149 fn scroll_logs_to_end(&mut self) {
150 self.following_logs = true;
151 }
152
153 fn toggle_focus(&mut self) {
154 self.focus = match self.focus {
155 Focus::Services => Focus::Logs,
156 Focus::Logs => Focus::Services,
157 };
158 }
159
160 fn fetch_services(&mut self) {
162 let mut services = Vec::new();
163 if let Ok(names) = self.handle.list() {
164 for name in names {
165 if let Ok(status) = self.handle.status(&name) {
166 let process_tree: Vec<(i32, String, Vec<i32>)> = status
168 .process_tree
169 .processes
170 .iter()
171 .map(|(&pid, node)| (pid, node.name.clone(), node.children.clone()))
172 .collect();
173
174 services.push(ServiceInfo {
175 name: status.name,
176 state: status.state,
177 pid: status.pid,
178 target: status.target,
179 memory_kb: 0,
180 cpu_percent: 0.0,
181 process_tree,
182 });
183 }
184 }
185 }
186 services.sort_by(|a, b| a.name.cmp(&b.name));
187 self.services = services;
188 }
189
190 fn fetch_selected_stats(&mut self) {
192 if let Some(idx) = self.selected.selected()
193 && let Some(service) = self.services.get(idx)
194 {
195 let name = service.name.clone();
196 if let Ok(stats) = self.handle.stats(&name)
197 && let Some(s) = self.services.get_mut(idx)
198 {
199 s.memory_kb = stats.memory_usage / 1024;
200 s.cpu_percent = stats.cpu_usage;
201 }
202 }
203 }
204
205 fn fetch_logs(&mut self) {
207 if let Some(service) = self.selected_service() {
208 let name = service.name.clone();
209 if let Ok(logs) = self.handle.logs_filter(&name) {
210 self.logs = logs;
211 }
212 }
213 }
214
215 fn start_service(&mut self) {
217 if let Some(service) = self.selected_service() {
218 let name = service.name.clone();
219 match self.handle.start(&name) {
220 Ok(_) => self.set_status(format!("Started: {}", name)),
221 Err(e) => self.set_status(format!("Error: {}", e)),
222 }
223 self.fetch_services();
224 }
225 }
226
227 fn stop_service(&mut self) {
228 if let Some(service) = self.selected_service() {
229 let name = service.name.clone();
230 match self.handle.stop(&name) {
231 Ok(_) => self.set_status(format!("Stopped: {}", name)),
232 Err(e) => self.set_status(format!("Error: {}", e)),
233 }
234 self.fetch_services();
235 }
236 }
237
238 fn restart_service(&mut self) {
239 if let Some(service) = self.selected_service() {
240 let name = service.name.clone();
241 match self.handle.restart(&name) {
242 Ok(_) => self.set_status(format!("Restarted: {}", name)),
243 Err(e) => self.set_status(format!("Error: {}", e)),
244 }
245 self.fetch_services();
246 }
247 }
248
249 fn delete_service(&mut self) {
250 if let Some(service) = self.selected_service() {
251 let name = service.name.clone();
252 match self.handle.delete(&name) {
253 Ok(_) => self.set_status(format!("Deleted: {}", name)),
254 Err(e) => self.set_status(format!("Error: {}", e)),
255 }
256 self.fetch_services();
257 if let Some(selected) = self.selected.selected()
259 && selected >= self.services.len()
260 && !self.services.is_empty()
261 {
262 self.selected.select(Some(self.services.len() - 1));
263 }
264 }
265 }
266
267 fn kill_service(&mut self, signal: &str) {
268 if let Some(service) = self.selected_service() {
269 let name = service.name.clone();
270 let process_count = service.process_tree.len();
271 match self.handle.kill(&name, signal) {
272 Ok(_) => {
273 if process_count > 1 {
274 self.set_status(format!(
275 "Sent {} to {} ({} processes)",
276 signal, name, process_count
277 ))
278 } else {
279 self.set_status(format!("Sent {} to {}", signal, name))
280 }
281 }
282 Err(e) => self.set_status(format!("Error: {}", e)),
283 }
284 self.fetch_services();
285 }
286 }
287}
288
289fn state_color(state: &str) -> Color {
291 if state.starts_with("Exited") {
292 return Color::Magenta;
293 }
294 match state.to_lowercase().as_str() {
295 "running" => Color::Green,
296 "success" => Color::Green,
297 "spawned" => Color::Yellow,
298 "blocked" => Color::Yellow,
299 "stopped" => Color::Red,
300 "failed" => Color::Red,
301 "testfailed" => Color::Red,
302 "exited" => Color::Magenta,
303 _ => Color::Gray,
304 }
305}
306
307fn format_memory(kb: u64) -> String {
308 if kb >= 1024 * 1024 {
309 format!("{:.1} GB", kb as f64 / (1024.0 * 1024.0))
310 } else if kb >= 1024 {
311 format!("{:.1} MB", kb as f64 / 1024.0)
312 } else {
313 format!("{} KB", kb)
314 }
315}
316
317fn draw(frame: &mut Frame, app: &App) {
319 let chunks = Layout::default()
321 .direction(Direction::Vertical)
322 .constraints([
323 Constraint::Length(3), Constraint::Min(10), Constraint::Length(3), ])
327 .split(frame.area());
328
329 let title = Paragraph::new(Line::from(vec![
331 Span::styled(
332 "Zinit ",
333 Style::default()
334 .fg(Color::Cyan)
335 .add_modifier(Modifier::BOLD),
336 ),
337 Span::styled("Interactive Mode", Style::default().fg(Color::White)),
338 Span::raw(" | "),
339 Span::styled("?", Style::default().fg(Color::Yellow)),
340 Span::raw(" Help "),
341 Span::styled("Q", Style::default().fg(Color::Yellow)),
342 Span::raw(" Quit"),
343 ]))
344 .block(
345 Block::default()
346 .borders(Borders::ALL)
347 .border_style(Style::default().fg(Color::Cyan)),
348 );
349 frame.render_widget(title, chunks[0]);
350
351 let main_chunks = Layout::default()
353 .direction(Direction::Horizontal)
354 .constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
355 .split(chunks[1]);
356
357 draw_services(frame, app, main_chunks[0]);
358 draw_logs(frame, app, main_chunks[1]);
359
360 let status_text = if let Some((msg, time)) = &app.status_message {
362 if time.elapsed() < Duration::from_secs(5) {
363 msg.clone()
364 } else {
365 get_default_status(app)
366 }
367 } else {
368 get_default_status(app)
369 };
370
371 let status = Paragraph::new(status_text)
372 .style(Style::default().fg(Color::White))
373 .block(
374 Block::default()
375 .borders(Borders::ALL)
376 .border_style(Style::default().fg(Color::DarkGray)),
377 );
378 frame.render_widget(status, chunks[2]);
379
380 if app.show_help {
382 draw_help_popup(frame);
383 }
384
385 if app.show_detail
387 && let Some(service) = app.selected_service()
388 {
389 draw_detail_popup(frame, service);
390 }
391}
392
393fn get_default_status(app: &App) -> String {
394 let service_info = app
395 .selected_service()
396 .map(|s| {
397 format!(
398 "Service: {} | State: {} | PID: {} | Enter: details",
399 s.name, s.state, s.pid
400 )
401 })
402 .unwrap_or_else(|| "No service selected".to_string());
403
404 let focus_indicator = match app.focus {
405 Focus::Services => "[Services]",
406 Focus::Logs => "[Logs]",
407 };
408
409 format!("{} | {} | Tab: switch focus", service_info, focus_indicator)
410}
411
412fn draw_services(frame: &mut Frame, app: &App, area: Rect) {
413 let items: Vec<ListItem> = app
414 .services
415 .iter()
416 .map(|service| {
417 let state_style = Style::default().fg(state_color(&service.state));
418 let content = Line::from(vec![
419 Span::styled(
420 format!("{:2} ", if service.pid > 0 { "●" } else { "○" }),
421 state_style,
422 ),
423 Span::styled(&service.name, Style::default().fg(Color::White)),
424 Span::raw(" "),
425 Span::styled(
426 format!("[{}]", &service.state),
427 state_style.add_modifier(Modifier::DIM),
428 ),
429 ]);
430 ListItem::new(content)
431 })
432 .collect();
433
434 let border_color = if app.focus == Focus::Services {
435 Color::Cyan
436 } else {
437 Color::DarkGray
438 };
439
440 let services_list = List::new(items)
441 .block(
442 Block::default()
443 .title(format!(" Services ({}) ", app.services.len()))
444 .borders(Borders::ALL)
445 .border_style(Style::default().fg(border_color)),
446 )
447 .highlight_style(
448 Style::default()
449 .bg(Color::DarkGray)
450 .add_modifier(Modifier::BOLD),
451 )
452 .highlight_symbol("▶ ");
453
454 frame.render_stateful_widget(services_list, area, &mut app.selected.clone());
455}
456
457fn draw_logs(frame: &mut Frame, app: &App, area: Rect) {
458 let border_color = if app.focus == Focus::Logs {
459 Color::Cyan
460 } else {
461 Color::DarkGray
462 };
463
464 let service_name = app
465 .selected_service()
466 .map(|s| s.name.clone())
467 .unwrap_or_else(|| "none".to_string());
468
469 let visible_height = (area.height as usize).saturating_sub(2); let total_logs = app.logs.len();
471
472 let scroll_offset = if app.following_logs {
474 total_logs.saturating_sub(visible_height)
475 } else {
476 app.log_scroll
477 .min(total_logs.saturating_sub(visible_height))
478 };
479
480 let visible_logs: Vec<Line> = app
481 .logs
482 .iter()
483 .skip(scroll_offset)
484 .take(visible_height)
485 .map(|line| {
486 let style = if line.contains("[+]") {
488 Style::default().fg(Color::Green)
489 } else if line.contains("[-]") {
490 Style::default().fg(Color::Yellow)
491 } else if line.contains("error") || line.contains("Error") || line.contains("ERROR") {
492 Style::default().fg(Color::Red)
493 } else {
494 Style::default().fg(Color::Gray)
495 };
496 Line::from(Span::styled(line.clone(), style))
497 })
498 .collect();
499
500 let follow_indicator = if app.following_logs {
501 " [following]"
502 } else {
503 ""
504 };
505 let scroll_info = format!(
506 " {}/{} ",
507 scroll_offset + visible_height.min(total_logs),
508 total_logs
509 );
510
511 let logs_widget = Paragraph::new(visible_logs)
512 .block(
513 Block::default()
514 .title(format!(" Logs: {}{} ", service_name, follow_indicator))
515 .title_bottom(Line::from(scroll_info).centered())
516 .borders(Borders::ALL)
517 .border_style(Style::default().fg(border_color)),
518 )
519 .wrap(Wrap { trim: false });
520
521 frame.render_widget(logs_widget, area);
522}
523
524fn draw_help_popup(frame: &mut Frame) {
525 let area = frame.area();
526 let popup_area = Rect {
527 x: area.width / 6,
528 y: area.height / 6,
529 width: area.width * 2 / 3,
530 height: area.height * 2 / 3,
531 };
532
533 frame.render_widget(Clear, popup_area);
535
536 let help_text = vec![
537 Line::from(Span::styled(
538 "Keyboard Shortcuts",
539 Style::default()
540 .fg(Color::Cyan)
541 .add_modifier(Modifier::BOLD),
542 )),
543 Line::from(""),
544 Line::from(vec![Span::styled(
545 "Navigation:",
546 Style::default()
547 .fg(Color::Yellow)
548 .add_modifier(Modifier::BOLD),
549 )]),
550 Line::from(vec![
551 Span::styled(" ↑ ", Style::default().fg(Color::Green)),
552 Span::raw("Move up in service list"),
553 ]),
554 Line::from(vec![
555 Span::styled(" ↓/j ", Style::default().fg(Color::Green)),
556 Span::raw("Move down in service list"),
557 ]),
558 Line::from(vec![
559 Span::styled(" Tab ", Style::default().fg(Color::Green)),
560 Span::raw("Switch focus between services and logs"),
561 ]),
562 Line::from(vec![
563 Span::styled(" PgUp/PgDn ", Style::default().fg(Color::Green)),
564 Span::raw("Scroll logs"),
565 ]),
566 Line::from(vec![
567 Span::styled(" g/G ", Style::default().fg(Color::Green)),
568 Span::raw("Go to top/bottom of logs"),
569 ]),
570 Line::from(""),
571 Line::from(vec![Span::styled(
572 "Actions:",
573 Style::default()
574 .fg(Color::Yellow)
575 .add_modifier(Modifier::BOLD),
576 )]),
577 Line::from(vec![
578 Span::styled(" s ", Style::default().fg(Color::Green)),
579 Span::raw("Start selected service"),
580 ]),
581 Line::from(vec![
582 Span::styled(" x ", Style::default().fg(Color::Green)),
583 Span::raw("Stop selected service (graceful, kills all children)"),
584 ]),
585 Line::from(vec![
586 Span::styled(" r ", Style::default().fg(Color::Green)),
587 Span::raw("Restart selected service"),
588 ]),
589 Line::from(vec![
590 Span::styled(" k ", Style::default().fg(Color::Green)),
591 Span::raw("Kill with SIGTERM (all processes in tree)"),
592 ]),
593 Line::from(vec![
594 Span::styled(" K ", Style::default().fg(Color::Green)),
595 Span::raw("Kill with SIGKILL (force kill all)"),
596 ]),
597 Line::from(vec![
598 Span::styled(" d ", Style::default().fg(Color::Green)),
599 Span::raw("Delete (forget) stopped service"),
600 ]),
601 Line::from(vec![
602 Span::styled(" R ", Style::default().fg(Color::Green)),
603 Span::raw("Refresh service list"),
604 ]),
605 Line::from(""),
606 Line::from(vec![Span::styled(
607 "Other:",
608 Style::default()
609 .fg(Color::Yellow)
610 .add_modifier(Modifier::BOLD),
611 )]),
612 Line::from(vec![
613 Span::styled(" ? ", Style::default().fg(Color::Green)),
614 Span::raw("Toggle this help"),
615 ]),
616 Line::from(vec![
617 Span::styled(" Q/Esc ", Style::default().fg(Color::Green)),
618 Span::raw("Quit"),
619 ]),
620 Line::from(""),
621 Line::from(Span::styled(
622 "Press any key to close",
623 Style::default().fg(Color::DarkGray),
624 )),
625 ];
626
627 let help = Paragraph::new(help_text)
628 .block(
629 Block::default()
630 .title(" Help ")
631 .borders(Borders::ALL)
632 .border_style(Style::default().fg(Color::Cyan)),
633 )
634 .style(Style::default().bg(Color::Black));
635
636 frame.render_widget(help, popup_area);
637}
638
639fn draw_detail_popup(frame: &mut Frame, service: &ServiceInfo) {
640 let area = frame.area();
641 let popup_area = Rect {
643 x: area.width / 6,
644 y: area.height / 6,
645 width: area.width * 2 / 3,
646 height: area.height * 2 / 3,
647 };
648
649 frame.render_widget(Clear, popup_area);
651
652 let state_color = state_color(&service.state);
653 let mem_str = format_memory(service.memory_kb);
654
655 let mut detail_text = vec![
656 Line::from(Span::styled(
657 format!(" {} ", service.name),
658 Style::default()
659 .fg(Color::Cyan)
660 .add_modifier(Modifier::BOLD),
661 )),
662 Line::from(""),
663 Line::from(vec![Span::styled(
664 "Status",
665 Style::default()
666 .fg(Color::Yellow)
667 .add_modifier(Modifier::BOLD),
668 )]),
669 Line::from(vec![
670 Span::raw(" State: "),
671 Span::styled(
672 &service.state,
673 Style::default()
674 .fg(state_color)
675 .add_modifier(Modifier::BOLD),
676 ),
677 ]),
678 Line::from(vec![
679 Span::raw(" PID: "),
680 Span::styled(
681 if service.pid > 0 {
682 service.pid.to_string()
683 } else {
684 "-".to_string()
685 },
686 Style::default().fg(Color::White),
687 ),
688 ]),
689 Line::from(""),
690 Line::from(vec![Span::styled(
691 "Resource Usage",
692 Style::default()
693 .fg(Color::Yellow)
694 .add_modifier(Modifier::BOLD),
695 )]),
696 Line::from(vec![
697 Span::raw(" CPU: "),
698 Span::styled(
699 format!("{:.1}%", service.cpu_percent),
700 Style::default().fg(if service.cpu_percent > 80.0 {
701 Color::Red
702 } else if service.cpu_percent > 50.0 {
703 Color::Yellow
704 } else {
705 Color::Green
706 }),
707 ),
708 ]),
709 Line::from(vec![
710 Span::raw(" Memory: "),
711 Span::styled(
712 mem_str,
713 Style::default().fg(if service.memory_kb > 1024 * 1024 {
714 Color::Red
715 } else if service.memory_kb > 512 * 1024 {
716 Color::Yellow
717 } else {
718 Color::Green
719 }),
720 ),
721 ]),
722 ];
723
724 if !service.process_tree.is_empty() {
726 detail_text.push(Line::from(""));
727 detail_text.push(Line::from(vec![Span::styled(
728 format!("Process Tree ({} processes)", service.process_tree.len()),
729 Style::default()
730 .fg(Color::Yellow)
731 .add_modifier(Modifier::BOLD),
732 )]));
733
734 let mut sorted_procs = service.process_tree.clone();
736 sorted_procs.sort_by_key(|(pid, _, _)| *pid);
737
738 for (pid, name, children) in sorted_procs.iter().take(8) {
739 let children_str = if children.is_empty() {
741 String::new()
742 } else {
743 format!(" -> {} children", children.len())
744 };
745 detail_text.push(Line::from(vec![
746 Span::styled(format!(" {:6} ", pid), Style::default().fg(Color::Cyan)),
747 Span::styled(name.clone(), Style::default().fg(Color::White)),
748 Span::styled(children_str, Style::default().fg(Color::DarkGray)),
749 ]));
750 }
751
752 if service.process_tree.len() > 8 {
753 detail_text.push(Line::from(Span::styled(
754 format!(" ... and {} more", service.process_tree.len() - 8),
755 Style::default().fg(Color::DarkGray),
756 )));
757 }
758 }
759
760 detail_text.push(Line::from(""));
761 detail_text.push(Line::from(vec![Span::styled(
762 "Actions",
763 Style::default()
764 .fg(Color::Yellow)
765 .add_modifier(Modifier::BOLD),
766 )]));
767 detail_text.push(Line::from(vec![
768 Span::styled(
769 " s ",
770 Style::default()
771 .fg(Color::Green)
772 .add_modifier(Modifier::BOLD),
773 ),
774 Span::raw("Start "),
775 Span::styled(
776 " k ",
777 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
778 ),
779 Span::raw("Kill (SIGTERM)"),
780 ]));
781 detail_text.push(Line::from(vec![
782 Span::styled(
783 " x ",
784 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
785 ),
786 Span::raw("Stop "),
787 Span::styled(
788 " K ",
789 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
790 ),
791 Span::raw("Kill (SIGKILL)"),
792 ]));
793 detail_text.push(Line::from(vec![
794 Span::styled(
795 " r ",
796 Style::default()
797 .fg(Color::Yellow)
798 .add_modifier(Modifier::BOLD),
799 ),
800 Span::raw("Restart "),
801 Span::styled(
802 " d ",
803 Style::default()
804 .fg(Color::Magenta)
805 .add_modifier(Modifier::BOLD),
806 ),
807 Span::raw("Delete"),
808 ]));
809 detail_text.push(Line::from(""));
810 detail_text.push(Line::from(Span::styled(
811 "Press Esc to close",
812 Style::default().fg(Color::DarkGray),
813 )));
814
815 let detail = Paragraph::new(detail_text)
816 .block(
817 Block::default()
818 .title(" Service Details ")
819 .borders(Borders::ALL)
820 .border_style(Style::default().fg(Color::Cyan)),
821 )
822 .style(Style::default().bg(Color::Black));
823
824 frame.render_widget(detail, popup_area);
825}
826
827pub fn run_tui(handle: ZinitHandle) -> anyhow::Result<()> {
829 enable_raw_mode()?;
831 let mut stdout = io::stdout();
832 execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
833 let backend = CrosstermBackend::new(stdout);
834 let mut terminal = Terminal::new(backend)?;
835
836 let mut app = App::new(handle);
838
839 app.fetch_services();
841 app.fetch_logs();
842
843 let refresh_interval = Duration::from_secs(2);
844 let mut last_refresh = Instant::now();
845 let mut needs_redraw = true;
846
847 loop {
848 if needs_redraw {
850 terminal.draw(|f| draw(f, &app))?;
851 needs_redraw = false;
852 }
853
854 let timeout = Duration::from_millis(200);
856
857 if event::poll(timeout)?
858 && let Event::Key(key) = event::read()?
859 {
860 if app.show_help {
862 app.show_help = false;
863 needs_redraw = true;
864 continue;
865 }
866
867 if app.show_detail {
869 match key.code {
870 KeyCode::Esc | KeyCode::Enter => {
871 app.show_detail = false;
872 }
873 KeyCode::Char('s') => {
874 app.start_service();
875 app.show_detail = false;
876 }
877 KeyCode::Char('x') => {
878 app.stop_service();
879 app.show_detail = false;
880 }
881 KeyCode::Char('r') => {
882 app.restart_service();
883 app.show_detail = false;
884 }
885 KeyCode::Char('d') => {
886 app.delete_service();
887 app.show_detail = false;
888 }
889 KeyCode::Char('k') => {
890 app.kill_service("SIGTERM");
891 app.show_detail = false;
892 }
893 KeyCode::Char('K') => {
894 app.kill_service("SIGKILL");
895 app.show_detail = false;
896 }
897 _ => {}
898 }
899 needs_redraw = true;
900 continue;
901 }
902
903 match (key.code, key.modifiers) {
904 (KeyCode::Char('Q'), _) | (KeyCode::Esc, _) => break,
906 (KeyCode::Char('c'), KeyModifiers::CONTROL) => break,
907
908 (KeyCode::Char('?'), _) => app.show_help = true,
910
911 (KeyCode::Enter, _) => {
913 if app.focus == Focus::Services {
914 app.fetch_selected_stats();
915 app.show_detail = true;
916 }
917 }
918
919 (KeyCode::Up, _) => {
921 if app.focus == Focus::Services {
922 app.previous_service();
923 } else {
924 app.scroll_logs_up();
925 }
926 }
927 (KeyCode::Down, _) | (KeyCode::Char('j'), _) => {
928 if app.focus == Focus::Services {
929 app.next_service();
930 } else {
931 let visible_height = terminal.size()?.height as usize;
932 app.scroll_logs_down(visible_height);
933 }
934 }
935 (KeyCode::Tab, _) => app.toggle_focus(),
936
937 (KeyCode::PageUp, _) => {
939 for _ in 0..10 {
940 app.scroll_logs_up();
941 }
942 }
943 (KeyCode::PageDown, _) => {
944 let visible_height = terminal.size()?.height as usize;
945 for _ in 0..10 {
946 app.scroll_logs_down(visible_height);
947 }
948 }
949 (KeyCode::Char('g'), _) => {
950 app.log_scroll = 0;
951 app.following_logs = false;
952 }
953 (KeyCode::Char('G'), _) => {
954 app.scroll_logs_to_end();
955 }
956
957 (KeyCode::Char('s'), KeyModifiers::NONE) => {
959 app.start_service();
960 }
961 (KeyCode::Char('x'), KeyModifiers::NONE) => {
962 app.stop_service();
963 }
964 (KeyCode::Char('r'), KeyModifiers::NONE) => {
965 app.restart_service();
966 }
967 (KeyCode::Char('d'), _) => {
968 app.delete_service();
969 }
970 (KeyCode::Char('k'), KeyModifiers::NONE) => {
971 app.kill_service("SIGTERM");
972 }
973 (KeyCode::Char('K'), KeyModifiers::SHIFT) => {
974 app.kill_service("SIGKILL");
975 }
976 (KeyCode::Char('R'), _) => {
977 app.fetch_services();
978 app.fetch_logs();
979 app.set_status("Refreshed");
980 }
981
982 _ => {}
983 }
984 needs_redraw = true;
985 }
986
987 if last_refresh.elapsed() >= refresh_interval {
989 last_refresh = Instant::now();
990 app.fetch_services();
991 app.fetch_logs();
992 needs_redraw = true;
993 }
994 }
995
996 disable_raw_mode()?;
998 execute!(
999 terminal.backend_mut(),
1000 LeaveAlternateScreen,
1001 DisableMouseCapture
1002 )?;
1003 terminal.show_cursor()?;
1004
1005 Ok(())
1006}