zinit_client/
tui.rs

1//! Interactive TUI for Zinit service management
2//!
3//! Provides a full-screen terminal interface with:
4//! - Service list with status indicators
5//! - Start/stop/restart/delete actions
6//! - Live log viewer
7//! - Keyboard navigation
8
9use 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/// Service information for display
27#[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 (pid -> (name, children_pids))
37    process_tree: Vec<(i32, String, Vec<i32>)>,
38}
39
40/// Application state
41struct App {
42    /// Zinit handle for sync RPC
43    handle: ZinitHandle,
44    /// List of services
45    services: Vec<ServiceInfo>,
46    /// Selected service index
47    selected: ListState,
48    /// Log lines for selected service
49    logs: Vec<String>,
50    /// Log scroll position
51    log_scroll: usize,
52    /// Whether to show help popup
53    show_help: bool,
54    /// Whether to show service detail popup
55    show_detail: bool,
56    /// Status message
57    status_message: Option<(String, Instant)>,
58    /// Current focus: Services or Logs
59    focus: Focus,
60    /// Whether logs are being followed
61    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    /// Fetch services from server
161    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                    // Convert process tree to display format
167                    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    /// Fetch stats for selected service
191    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    /// Fetch logs for selected service
206    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    /// Execute a service action
216    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            // Adjust selection if needed
258            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
289/// Format service state with color
290fn 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
317/// Draw the UI
318fn draw(frame: &mut Frame, app: &App) {
319    // Create main layout
320    let chunks = Layout::default()
321        .direction(Direction::Vertical)
322        .constraints([
323            Constraint::Length(3), // Title
324            Constraint::Min(10),   // Main content
325            Constraint::Length(3), // Status bar
326        ])
327        .split(frame.area());
328
329    // Title
330    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    // Main content - split into services and logs
352    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    // Status bar
361    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    // Help popup
381    if app.show_help {
382        draw_help_popup(frame);
383    }
384
385    // Detail popup
386    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); // Account for borders
470    let total_logs = app.logs.len();
471
472    // Calculate scroll position
473    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            // Color the log line based on content
487            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    // Clear the popup area
534    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    // Make popup larger to fit process tree
642    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    // Clear the popup area
650    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    // Add process tree section if there are processes
725    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        // Sort processes by PID for consistent display
735        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            // Limit to 8 processes
740            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
827/// Run the TUI
828pub fn run_tui(handle: ZinitHandle) -> anyhow::Result<()> {
829    // Setup terminal
830    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    // Create app state
837    let mut app = App::new(handle);
838
839    // Initial fetch
840    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        // Only draw when needed
849        if needs_redraw {
850            terminal.draw(|f| draw(f, &app))?;
851            needs_redraw = false;
852        }
853
854        // Wait for events with 200ms timeout (allows periodic refresh check)
855        let timeout = Duration::from_millis(200);
856
857        if event::poll(timeout)?
858            && let Event::Key(key) = event::read()?
859        {
860            // If help is shown, any key closes it
861            if app.show_help {
862                app.show_help = false;
863                needs_redraw = true;
864                continue;
865            }
866
867            // Handle detail popup
868            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                // Quit
905                (KeyCode::Char('Q'), _) | (KeyCode::Esc, _) => break,
906                (KeyCode::Char('c'), KeyModifiers::CONTROL) => break,
907
908                // Help
909                (KeyCode::Char('?'), _) => app.show_help = true,
910
911                // Open detail popup with stats
912                (KeyCode::Enter, _) => {
913                    if app.focus == Focus::Services {
914                        app.fetch_selected_stats();
915                        app.show_detail = true;
916                    }
917                }
918
919                // Navigation (arrow keys only, k/K reserved for kill)
920                (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                // Log scrolling
938                (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                // Actions
958                (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        // Auto-refresh services and logs
988        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    // Restore terminal
997    disable_raw_mode()?;
998    execute!(
999        terminal.backend_mut(),
1000        LeaveAlternateScreen,
1001        DisableMouseCapture
1002    )?;
1003    terminal.show_cursor()?;
1004
1005    Ok(())
1006}