feat(cli): add interactive TUI dashboard#2
Conversation
Add terminal-based dashboard for FGP daemon monitoring using Ratatui: - Service list with real-time status (running/stopped/unhealthy/error) - Keyboard navigation (vim keys + arrows) - Start/stop service actions - Auto-refresh with configurable polling interval - Help overlay toggle - Success/error message feedback Usage: fgp tui [--poll <ms>] Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR adds an interactive terminal-based UI (TUI) dashboard for monitoring FGP daemon services using the Ratatui framework. The dashboard provides real-time service status monitoring with color-coded indicators, vim-style keyboard navigation, and service control capabilities (start/stop).
Changes:
- Added TUI dashboard with real-time service monitoring and control
- Implemented keyboard navigation with vim-style bindings and service management actions
- Added auto-refresh with configurable polling interval (default 2000ms)
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| src/tui/ui.rs | UI rendering logic including header, service table, footer with keybindings, and help overlay |
| src/tui/mod.rs | Main TUI entry point and event loop handling keyboard input and refresh events |
| src/tui/event.rs | Event handling system using threads for input, UI ticks, and service polling |
| src/tui/app.rs | Application state management, service discovery, and status checking logic |
| src/main.rs | Added TUI command to CLI enum |
| src/commands/tui.rs | TUI command implementation that delegates to tui module |
| src/commands/mod.rs | Added tui module declaration |
| Cargo.toml | Added ratatui, crossterm dependencies and tokio (unused) |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # Async runtime for TUI events | ||
| tokio = { version = "1", features = ["sync", "time", "rt-multi-thread"] } | ||
|
|
There was a problem hiding this comment.
The tokio dependency is added but not used anywhere in the TUI implementation. The event handling uses std::thread and std::sync::mpsc instead of async. This dependency should be removed to avoid unnecessary bloat.
| # Async runtime for TUI events | |
| tokio = { version = "1", features = ["sync", "time", "rt-multi-thread"] } |
| pub fn start_selected(&mut self) { | ||
| if let Some(service) = self.selected_service().cloned() { | ||
| if service.status == ServiceStatus::Stopped || service.status == ServiceStatus::Error { | ||
| match fgp_daemon::lifecycle::start_service(&service.name) { |
There was a problem hiding this comment.
The code references fgp_daemon::lifecycle::start_service but this module doesn't exist. Other commands in the codebase use commands::start::run directly. This should likely use the existing commands module or duplicate the start logic here, similar to how other commands work.
| match fgp_daemon::lifecycle::start_service(&service.name) { | |
| match commands::start::run(&service.name) { |
| if let Some(service) = self.selected_service().cloned() { | ||
| if service.status == ServiceStatus::Running || service.status == ServiceStatus::Unhealthy | ||
| { | ||
| match fgp_daemon::lifecycle::stop_service(&service.name) { |
There was a problem hiding this comment.
The code references fgp_daemon::lifecycle::stop_service but this module doesn't exist. Other commands use commands::stop::run directly. This should use the existing commands module or implement the stop logic similar to other commands.
| match fgp_daemon::lifecycle::stop_service(&service.name) { | |
| match commands::stop::run(&service.name) { |
|
|
||
| /// Discover all installed services. | ||
| fn discover_services() -> Vec<ServiceInfo> { | ||
| let services_dir = fgp_daemon::lifecycle::fgp_services_dir(); |
There was a problem hiding this comment.
The code references fgp_daemon::lifecycle::fgp_services_dir() but this function doesn't exist in that module. The existing commands use commands::fgp_services_dir() from src/commands/mod.rs. Use the existing function instead.
| let services_dir = fgp_daemon::lifecycle::fgp_services_dir(); | |
| let services_dir = commands::fgp_services_dir(); |
| None => continue, | ||
| }; | ||
|
|
||
| let socket_path = fgp_daemon::lifecycle::service_socket_path(&name); |
There was a problem hiding this comment.
The code references fgp_daemon::lifecycle::service_socket_path() but this function doesn't exist. The existing commands use commands::service_socket_path() from src/commands/mod.rs. Use the existing function instead.
| let socket_path = fgp_daemon::lifecycle::service_socket_path(&name); | |
| let socket_path = crate::commands::service_socket_path(&name); |
| let title = Line::from(vec![ | ||
| Span::styled( | ||
| " FGP Dashboard ", | ||
| Style::default() | ||
| .fg(Color::Cyan) | ||
| .add_modifier(Modifier::BOLD), | ||
| ), | ||
| Span::raw(" "), | ||
| Span::styled( | ||
| format!("Updated: {} ", time_str), |
There was a problem hiding this comment.
Hard-coded whitespace for spacing is fragile and won't adapt to different terminal widths or content lengths. Consider using Layout constraints or calculating spacing dynamically based on available width.
| let title = Line::from(vec![ | |
| Span::styled( | |
| " FGP Dashboard ", | |
| Style::default() | |
| .fg(Color::Cyan) | |
| .add_modifier(Modifier::BOLD), | |
| ), | |
| Span::raw(" "), | |
| Span::styled( | |
| format!("Updated: {} ", time_str), | |
| let left_label = " FGP Dashboard "; | |
| let right_label = format!("Updated: {} ", time_str); | |
| let left_width = left_label.len() as u16; | |
| let right_width = right_label.len() as u16; | |
| let available_width = area.width; | |
| let padding_width = available_width.saturating_sub(left_width + right_width); | |
| let mut padding = String::new(); | |
| padding.reserve(padding_width as usize); | |
| for _ in 0..padding_width { | |
| padding.push(' '); | |
| } | |
| let title = Line::from(vec![ | |
| Span::styled( | |
| left_label, | |
| Style::default() | |
| .fg(Color::Cyan) | |
| .add_modifier(Modifier::BOLD), | |
| ), | |
| Span::raw(padding), | |
| Span::styled( | |
| right_label, |
| if secs < 60 { | ||
| format!("{}s", secs) | ||
| } else if secs < 3600 { | ||
| format!("{}m {}s", secs / 60, secs % 60) | ||
| } else if secs < 86400 { | ||
| format!("{}h {}m", secs / 3600, (secs % 3600) / 60) | ||
| } else { | ||
| format!("{}d {}h", secs / 86400, (secs % 86400) / 3600) |
There was a problem hiding this comment.
This format_uptime function is duplicated from src/commands/status.rs (lines 137-147). Consider extracting this to a shared utility module to avoid code duplication.
| if secs < 60 { | |
| format!("{}s", secs) | |
| } else if secs < 3600 { | |
| format!("{}m {}s", secs / 60, secs % 60) | |
| } else if secs < 86400 { | |
| format!("{}h {}m", secs / 3600, (secs % 3600) / 60) | |
| } else { | |
| format!("{}d {}h", secs / 86400, (secs % 86400) / 3600) | |
| let days = secs / 86_400; | |
| let rem_after_days = secs % 86_400; | |
| let hours = rem_after_days / 3_600; | |
| let rem_after_hours = rem_after_days % 3_600; | |
| let minutes = rem_after_hours / 60; | |
| let seconds = rem_after_hours % 60; | |
| match (days, hours, minutes) { | |
| (0, 0, 0) => format!("{}s", seconds), | |
| (0, 0, _) => format!("{}m {}s", minutes, seconds), | |
| (0, _, _) => format!("{}h {}m", hours, minutes), | |
| _ => format!("{}d {}h", days, hours), |
Summary
Features
↑/k↓/js/Enterxr?q/EscUsage
Test plan
fgp tuiand verify service list appears?qandEsc🤖 Generated with Claude Code