From 8766f430e9b2373e8956cbed9182d7adfde14130 Mon Sep 17 00:00:00 2001 From: Wolfgang Schoenberger <221313372+wolfiesch@users.noreply.github.com> Date: Wed, 14 Jan 2026 20:58:15 -0800 Subject: [PATCH 1/2] feat(cli): add interactive TUI dashboard 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 ] Co-Authored-By: Claude Opus 4.5 --- Cargo.toml | 7 ++ src/commands/mod.rs | 1 + src/commands/tui.rs | 10 ++ src/main.rs | 9 ++ src/tui/app.rs | 300 ++++++++++++++++++++++++++++++++++++++++++++ src/tui/event.rs | 100 +++++++++++++++ src/tui/mod.rs | 119 ++++++++++++++++++ src/tui/ui.rs | 297 +++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 843 insertions(+) create mode 100644 src/commands/tui.rs create mode 100644 src/tui/app.rs create mode 100644 src/tui/event.rs create mode 100644 src/tui/mod.rs create mode 100644 src/tui/ui.rs diff --git a/Cargo.toml b/Cargo.toml index 8dbed4a..c304e82 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,13 @@ tabled = "0.16" # Process management sysinfo = "0.32" +# TUI framework +ratatui = "0.29" +crossterm = "0.28" + +# Async runtime for TUI events +tokio = { version = "1", features = ["sync", "time", "rt-multi-thread"] } + [dev-dependencies] assert_cmd = "2" predicates = "3" diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 1e1f0b0..d27ca72 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -10,6 +10,7 @@ pub mod new; pub mod start; pub mod status; pub mod stop; +pub mod tui; pub mod workflow; use std::path::PathBuf; diff --git a/src/commands/tui.rs b/src/commands/tui.rs new file mode 100644 index 0000000..43f8123 --- /dev/null +++ b/src/commands/tui.rs @@ -0,0 +1,10 @@ +//! TUI command - Interactive terminal dashboard. + +use anyhow::Result; +use std::time::Duration; + +/// Run the TUI dashboard. +pub fn run(poll_interval_ms: u64) -> Result<()> { + let poll_interval = Duration::from_millis(poll_interval_ms); + crate::tui::run(poll_interval) +} diff --git a/src/main.rs b/src/main.rs index d48e27c..11dbc51 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,6 +13,7 @@ //! ``` mod commands; +mod tui; use anyhow::Result; use clap::{Parser, Subcommand}; @@ -122,6 +123,13 @@ enum Commands { open: bool, }, + /// Interactive terminal dashboard + Tui { + /// Service polling interval in milliseconds + #[arg(short, long, default_value = "2000")] + poll: u64, + }, + /// Run or validate a workflow Workflow { #[command(subcommand)] @@ -175,6 +183,7 @@ fn main() -> Result<()> { Commands::Methods { service } => commands::methods::run(&service), Commands::Health { service } => commands::health::run(&service), Commands::Dashboard { port, open } => commands::dashboard::run(port, open), + Commands::Tui { poll } => commands::tui::run(poll), Commands::Workflow { action } => match action { WorkflowAction::Run { file, verbose } => commands::workflow::run(&file, verbose), WorkflowAction::Validate { file } => commands::workflow::validate(&file), diff --git a/src/tui/app.rs b/src/tui/app.rs new file mode 100644 index 0000000..677a057 --- /dev/null +++ b/src/tui/app.rs @@ -0,0 +1,300 @@ +//! Application state for the TUI dashboard. + +use std::fs; +use std::time::{Duration, Instant}; + +/// Service status information. +#[derive(Debug, Clone)] +pub struct ServiceInfo { + pub name: String, + pub status: ServiceStatus, + pub version: Option, + pub uptime_seconds: Option, +} + +/// Service health states. +#[derive(Debug, Clone, Copy, PartialEq)] +#[allow(dead_code)] +pub enum ServiceStatus { + Running, + Stopped, + Unhealthy, + Error, + Starting, + Stopping, +} + +impl ServiceStatus { + /// Get the status symbol for display. + pub fn symbol(&self) -> &'static str { + match self { + ServiceStatus::Running => "●", + ServiceStatus::Stopped => "○", + ServiceStatus::Unhealthy => "◐", + ServiceStatus::Error => "●", + ServiceStatus::Starting => "◑", + ServiceStatus::Stopping => "◑", + } + } + + /// Get the status text for display. + #[allow(dead_code)] + pub fn text(&self) -> &'static str { + match self { + ServiceStatus::Running => "running", + ServiceStatus::Stopped => "stopped", + ServiceStatus::Unhealthy => "unhealthy", + ServiceStatus::Error => "error", + ServiceStatus::Starting => "starting", + ServiceStatus::Stopping => "stopping", + } + } +} + +/// Message type for display. +#[derive(Debug, Clone)] +pub enum MessageType { + Success, + Error, +} + +/// Main application state. +pub struct App { + /// List of discovered services. + pub services: Vec, + + /// Currently selected service index. + pub selected: usize, + + /// Last refresh timestamp. + pub last_refresh: Instant, + + /// Whether app should quit. + pub should_quit: bool, + + /// Message to display (auto-clears after timeout). + pub message: Option<(String, MessageType, Instant)>, + + /// Message display duration. + pub message_timeout: Duration, + + /// Whether help overlay is visible. + pub show_help: bool, +} + +impl App { + /// Create a new app instance. + pub fn new() -> Self { + Self { + services: Vec::new(), + selected: 0, + last_refresh: Instant::now(), + should_quit: false, + message: None, + message_timeout: Duration::from_secs(3), + show_help: false, + } + } + + /// Tick handler - called on each frame. + pub fn tick(&mut self) { + // Clear expired messages + if let Some((_, _, created)) = &self.message { + if created.elapsed() >= self.message_timeout { + self.message = None; + } + } + } + + /// Refresh service list from filesystem. + pub fn refresh_services(&mut self) { + self.services = discover_services(); + self.last_refresh = Instant::now(); + + // Ensure selection is valid + if self.selected >= self.services.len() && !self.services.is_empty() { + self.selected = self.services.len() - 1; + } + } + + /// Select the previous service. + pub fn select_previous(&mut self) { + if !self.services.is_empty() && self.selected > 0 { + self.selected -= 1; + } + } + + /// Select the next service. + pub fn select_next(&mut self) { + if !self.services.is_empty() && self.selected < self.services.len() - 1 { + self.selected += 1; + } + } + + /// Select the first service. + pub fn select_first(&mut self) { + self.selected = 0; + } + + /// Select the last service. + pub fn select_last(&mut self) { + if !self.services.is_empty() { + self.selected = self.services.len() - 1; + } + } + + /// Get the currently selected service. + pub fn selected_service(&self) -> Option<&ServiceInfo> { + self.services.get(self.selected) + } + + /// Start the selected service. + 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) { + Ok(()) => { + self.set_message( + format!("Started {}", service.name), + MessageType::Success, + ); + self.refresh_services(); + } + Err(e) => { + self.set_message( + format!("Failed to start {}: {}", service.name, e), + MessageType::Error, + ); + } + } + } + } + } + + /// Stop the selected service. + pub fn stop_selected(&mut self) { + 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) { + Ok(()) => { + self.set_message( + format!("Stopped {}", service.name), + MessageType::Success, + ); + self.refresh_services(); + } + Err(e) => { + self.set_message( + format!("Failed to stop {}: {}", service.name, e), + MessageType::Error, + ); + } + } + } + } + } + + /// Set a message to display. + pub fn set_message(&mut self, text: String, msg_type: MessageType) { + self.message = Some((text, msg_type, Instant::now())); + } + + /// Toggle help overlay. + pub fn toggle_help(&mut self) { + self.show_help = !self.show_help; + } +} + +impl Default for App { + fn default() -> Self { + Self::new() + } +} + +/// Discover all installed services. +fn discover_services() -> Vec { + let services_dir = fgp_daemon::lifecycle::fgp_services_dir(); + + if !services_dir.exists() { + return Vec::new(); + } + + let entries = match fs::read_dir(&services_dir) { + Ok(entries) => entries, + Err(_) => return Vec::new(), + }; + + let mut services = Vec::new(); + + for entry in entries.flatten() { + let path = entry.path(); + + if !path.is_dir() { + continue; + } + + let name = match path.file_name().and_then(|n| n.to_str()) { + Some(n) => n.to_string(), + None => continue, + }; + + let socket_path = fgp_daemon::lifecycle::service_socket_path(&name); + let (status, version, uptime) = get_service_status(&name, &socket_path); + + services.push(ServiceInfo { + name, + status, + version, + uptime_seconds: uptime, + }); + } + + // Sort by name + services.sort_by(|a, b| a.name.cmp(&b.name)); + services +} + +/// Get the status of a service. +fn get_service_status( + _name: &str, + socket_path: &std::path::Path, +) -> (ServiceStatus, Option, Option) { + if !socket_path.exists() { + return (ServiceStatus::Stopped, None, None); + } + + match fgp_daemon::FgpClient::new(socket_path) { + Ok(client) => match client.health() { + Ok(response) if response.ok => { + let result = response.result.unwrap_or_default(); + let version = result["version"].as_str().map(String::from); + let uptime = result["uptime_seconds"].as_u64(); + let status_str = result["status"].as_str().unwrap_or("running"); + + let status = match status_str { + "healthy" | "running" => ServiceStatus::Running, + "degraded" | "unhealthy" => ServiceStatus::Unhealthy, + _ => ServiceStatus::Running, + }; + + (status, version, uptime) + } + _ => (ServiceStatus::Error, None, None), + }, + Err(_) => (ServiceStatus::Error, None, None), + } +} + +/// Format uptime seconds into human-readable string. +pub fn format_uptime(secs: u64) -> String { + 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) + } +} diff --git a/src/tui/event.rs b/src/tui/event.rs new file mode 100644 index 0000000..ac134b9 --- /dev/null +++ b/src/tui/event.rs @@ -0,0 +1,100 @@ +//! Event handling for the TUI dashboard. + +use anyhow::Result; +use crossterm::event::{self, Event as CrosstermEvent, KeyEvent}; +use std::sync::mpsc; +use std::thread; +use std::time::Duration; + +/// Application events. +#[derive(Debug)] +#[allow(dead_code)] +pub enum Event { + /// Terminal tick (for UI refresh). + Tick, + /// Key press event. + Key(KeyEvent), + /// Refresh services (from polling). + Refresh, + /// Terminal resize. + Resize(u16, u16), +} + +/// Event handler that manages input and tick events. +pub struct EventHandler { + /// Event receiver. + receiver: mpsc::Receiver, + /// Input handler thread. + #[allow(dead_code)] + input_handle: thread::JoinHandle<()>, + /// Tick handler thread. + #[allow(dead_code)] + tick_handle: thread::JoinHandle<()>, + /// Refresh handler thread. + #[allow(dead_code)] + refresh_handle: thread::JoinHandle<()>, +} + +impl EventHandler { + /// Create a new event handler. + /// + /// # Arguments + /// * `tick_rate` - How often to send tick events (for UI refresh) + /// * `poll_rate` - How often to poll service health + pub fn new(tick_rate: Duration, poll_rate: Duration) -> Self { + let (sender, receiver) = mpsc::channel(); + + // Input handler thread + let input_sender = sender.clone(); + let input_handle = thread::spawn(move || { + loop { + // Poll for input events with a small timeout + if event::poll(Duration::from_millis(50)).unwrap_or(false) { + if let Ok(evt) = event::read() { + let event = match evt { + CrosstermEvent::Key(key) => Some(Event::Key(key)), + CrosstermEvent::Resize(w, h) => Some(Event::Resize(w, h)), + _ => None, + }; + + if let Some(event) = event { + if input_sender.send(event).is_err() { + break; + } + } + } + } + } + }); + + // Tick handler thread + let tick_sender = sender.clone(); + let tick_handle = thread::spawn(move || loop { + thread::sleep(tick_rate); + if tick_sender.send(Event::Tick).is_err() { + break; + } + }); + + // Refresh handler thread (service polling) + let refresh_sender = sender; + let refresh_handle = thread::spawn(move || loop { + thread::sleep(poll_rate); + if refresh_sender.send(Event::Refresh).is_err() { + break; + } + }); + + Self { + receiver, + input_handle, + tick_handle, + refresh_handle, + } + } + + /// Get the next event, blocking until one is available. + pub fn next(&self) -> Result { + Ok(self.receiver.recv()?) + } +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs new file mode 100644 index 0000000..f5dd889 --- /dev/null +++ b/src/tui/mod.rs @@ -0,0 +1,119 @@ +//! TUI Dashboard for FGP daemon monitoring. +//! +//! Interactive terminal UI with real-time service status updates. + +pub mod app; +pub mod event; +pub mod ui; + +use anyhow::Result; +use crossterm::{ + event::{DisableMouseCapture, EnableMouseCapture}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::prelude::*; +use std::io; +use std::time::Duration; + +use app::App; +use event::{Event, EventHandler}; + +/// Run the TUI dashboard. +pub fn run(poll_interval: Duration) -> Result<()> { + // Setup terminal + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + // Create app state and event handler + let mut app = App::new(); + let mut events = EventHandler::new(Duration::from_millis(100), poll_interval); + + // Initial service scan + app.refresh_services(); + + // Main loop + let result = run_app(&mut terminal, &mut app, &mut events); + + // Restore terminal + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + terminal.show_cursor()?; + + result +} + +/// Main application loop. +fn run_app( + terminal: &mut Terminal, + app: &mut App, + events: &mut EventHandler, +) -> Result<()> { + loop { + // Draw UI + terminal.draw(|frame| ui::draw(frame, app))?; + + // Handle events + match events.next()? { + Event::Tick => { + app.tick(); + } + Event::Key(key) => { + use crossterm::event::KeyCode; + + match key.code { + // Quit + KeyCode::Char('q') | KeyCode::Esc => { + app.should_quit = true; + } + // Navigation + KeyCode::Up | KeyCode::Char('k') => { + app.select_previous(); + } + KeyCode::Down | KeyCode::Char('j') => { + app.select_next(); + } + KeyCode::Home => { + app.select_first(); + } + KeyCode::End => { + app.select_last(); + } + // Actions + KeyCode::Char('s') | KeyCode::Enter => { + app.start_selected(); + } + KeyCode::Char('x') => { + app.stop_selected(); + } + KeyCode::Char('r') => { + app.refresh_services(); + } + KeyCode::Char('?') => { + app.toggle_help(); + } + _ => {} + } + } + Event::Refresh => { + app.refresh_services(); + } + Event::Resize(_, _) => { + // Terminal will redraw automatically + } + } + + if app.should_quit { + break; + } + } + + Ok(()) +} diff --git a/src/tui/ui.rs b/src/tui/ui.rs new file mode 100644 index 0000000..5186a8b --- /dev/null +++ b/src/tui/ui.rs @@ -0,0 +1,297 @@ +//! UI rendering for the TUI dashboard. + +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table}, + Frame, +}; + +use super::app::{format_uptime, App, MessageType, ServiceStatus}; + +/// Draw the entire UI. +pub fn draw(frame: &mut Frame, app: &App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Header + Constraint::Min(5), // Service table + Constraint::Length(4), // Footer + ]) + .split(frame.area()); + + draw_header(frame, chunks[0], app); + draw_service_table(frame, chunks[1], app); + draw_footer(frame, chunks[2], app); + + // Draw help overlay if visible + if app.show_help { + draw_help_overlay(frame); + } +} + +/// Draw the header with title and last update time. +fn draw_header(frame: &mut Frame, area: Rect, app: &App) { + let elapsed = app.last_refresh.elapsed().as_secs(); + let time_str = if elapsed < 60 { + format!("{}s ago", elapsed) + } else { + format!("{}m ago", elapsed / 60) + }; + + 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), + Style::default().fg(Color::DarkGray), + ), + ]); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)) + .title(title); + + frame.render_widget(block, area); +} + +/// Draw the service table. +fn draw_service_table(frame: &mut Frame, area: Rect, app: &App) { + let header_cells = ["", "Service", "Status", "Version", "Uptime"] + .iter() + .map(|h| { + Cell::from(*h).style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + }); + let header = Row::new(header_cells).height(1); + + let rows: Vec = app + .services + .iter() + .enumerate() + .map(|(i, service)| { + let selected = i == app.selected; + + // Selection indicator + let selector = if selected { "▸" } else { " " }; + let selector_style = if selected { + Style::default().fg(Color::Cyan) + } else { + Style::default() + }; + + // Status styling + let (status_color, status_text) = match service.status { + ServiceStatus::Running => (Color::Green, format!("{} running", service.status.symbol())), + ServiceStatus::Stopped => (Color::DarkGray, format!("{} stopped", service.status.symbol())), + ServiceStatus::Unhealthy => (Color::Yellow, format!("{} unhealthy", service.status.symbol())), + ServiceStatus::Error => (Color::Red, format!("{} error", service.status.symbol())), + ServiceStatus::Starting => (Color::Blue, format!("{} starting", service.status.symbol())), + ServiceStatus::Stopping => (Color::Blue, format!("{} stopping", service.status.symbol())), + }; + + // Version and uptime + let version = service.version.as_deref().unwrap_or("-"); + let uptime = service + .uptime_seconds + .map(format_uptime) + .unwrap_or_else(|| "-".to_string()); + + // Row styling + let row_style = if selected { + Style::default().bg(Color::DarkGray) + } else { + Style::default() + }; + + Row::new(vec![ + Cell::from(selector).style(selector_style), + Cell::from(service.name.clone()), + Cell::from(status_text).style(Style::default().fg(status_color)), + Cell::from(version.to_string()), + Cell::from(uptime), + ]) + .style(row_style) + }) + .collect(); + + let widths = [ + Constraint::Length(2), // Selector + Constraint::Min(15), // Service name + Constraint::Length(14), // Status + Constraint::Length(10), // Version + Constraint::Length(10), // Uptime + ]; + + let table = Table::new(rows, widths) + .header(header) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)) + .title(Span::styled( + format!(" Services ({}) ", app.services.len()), + Style::default().fg(Color::White), + )), + ) + .row_highlight_style(Style::default()); + + frame.render_widget(table, area); +} + +/// Draw the footer with keybindings and messages. +fn draw_footer(frame: &mut Frame, area: Rect, app: &App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(2), Constraint::Length(2)]) + .split(area); + + // Keybindings + let keybindings = Line::from(vec![ + Span::styled(" [↑/k]", Style::default().fg(Color::Yellow)), + Span::raw(" Up "), + Span::styled("[↓/j]", Style::default().fg(Color::Yellow)), + Span::raw(" Down "), + Span::styled("[s]", Style::default().fg(Color::Green)), + Span::raw(" Start "), + Span::styled("[x]", Style::default().fg(Color::Red)), + Span::raw(" Stop "), + Span::styled("[r]", Style::default().fg(Color::Cyan)), + Span::raw(" Refresh "), + Span::styled("[?]", Style::default().fg(Color::Magenta)), + Span::raw(" Help "), + Span::styled("[q]", Style::default().fg(Color::DarkGray)), + Span::raw(" Quit"), + ]); + + let keybindings_block = Block::default() + .borders(Borders::TOP | Borders::LEFT | Borders::RIGHT) + .border_style(Style::default().fg(Color::DarkGray)); + + let keybindings_paragraph = Paragraph::new(keybindings).block(keybindings_block); + frame.render_widget(keybindings_paragraph, chunks[0]); + + // Message area + let message_line = if let Some((text, msg_type, _)) = &app.message { + let (symbol, color) = match msg_type { + MessageType::Success => ("✓", Color::Green), + MessageType::Error => ("✗", Color::Red), + }; + Line::from(vec![ + Span::styled(format!(" {} ", symbol), Style::default().fg(color)), + Span::styled(text.clone(), Style::default().fg(color)), + ]) + } else { + Line::from("") + }; + + let message_block = Block::default() + .borders(Borders::BOTTOM | Borders::LEFT | Borders::RIGHT) + .border_style(Style::default().fg(Color::DarkGray)); + + let message_paragraph = Paragraph::new(message_line).block(message_block); + frame.render_widget(message_paragraph, chunks[1]); +} + +/// Draw the help overlay. +fn draw_help_overlay(frame: &mut Frame) { + let area = centered_rect(50, 60, frame.area()); + + // Clear the area first + frame.render_widget(Clear, area); + + let help_text = vec![ + Line::from(""), + Line::from(vec![Span::styled( + " Keyboard Shortcuts", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + )]), + Line::from(""), + Line::from(vec![ + Span::styled(" ↑/k ", Style::default().fg(Color::Yellow)), + Span::raw("Select previous service"), + ]), + Line::from(vec![ + Span::styled(" ↓/j ", Style::default().fg(Color::Yellow)), + Span::raw("Select next service"), + ]), + Line::from(vec![ + Span::styled(" Home ", Style::default().fg(Color::Yellow)), + Span::raw("Select first service"), + ]), + Line::from(vec![ + Span::styled(" End ", Style::default().fg(Color::Yellow)), + Span::raw("Select last service"), + ]), + Line::from(""), + Line::from(vec![ + Span::styled(" s/Enter ", Style::default().fg(Color::Green)), + Span::raw("Start selected service"), + ]), + Line::from(vec![ + Span::styled(" x ", Style::default().fg(Color::Red)), + Span::raw("Stop selected service"), + ]), + Line::from(vec![ + Span::styled(" r ", Style::default().fg(Color::Cyan)), + Span::raw("Refresh service list"), + ]), + Line::from(""), + Line::from(vec![ + Span::styled(" ? ", Style::default().fg(Color::Magenta)), + Span::raw("Toggle this help"), + ]), + Line::from(vec![ + Span::styled(" q/Esc ", Style::default().fg(Color::DarkGray)), + Span::raw("Quit"), + ]), + Line::from(""), + ]; + + let help_block = Block::default() + .title(Span::styled( + " Help ", + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + )) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)) + .style(Style::default().bg(Color::Black)); + + let help_paragraph = Paragraph::new(help_text).block(help_block); + frame.render_widget(help_paragraph, area); +} + +/// Create a centered rectangle. +fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(r); + + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup_layout[1])[1] +} From d6ec46f9f7bc8156d205de21408c68ed4b8f54af Mon Sep 17 00:00:00 2001 From: Wolfgang Schoenberger <221313372+wolfiesch@users.noreply.github.com> Date: Wed, 14 Jan 2026 21:05:59 -0800 Subject: [PATCH 2/2] style: fix formatting --- src/tui/app.rs | 13 ++++--------- src/tui/ui.rs | 22 +++++++++++++++++----- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index 677a057..f7665c8 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -154,10 +154,7 @@ impl App { if service.status == ServiceStatus::Stopped || service.status == ServiceStatus::Error { match fgp_daemon::lifecycle::start_service(&service.name) { Ok(()) => { - self.set_message( - format!("Started {}", service.name), - MessageType::Success, - ); + self.set_message(format!("Started {}", service.name), MessageType::Success); self.refresh_services(); } Err(e) => { @@ -174,14 +171,12 @@ impl App { /// Stop the selected service. pub fn stop_selected(&mut self) { if let Some(service) = self.selected_service().cloned() { - if service.status == ServiceStatus::Running || service.status == ServiceStatus::Unhealthy + if service.status == ServiceStatus::Running + || service.status == ServiceStatus::Unhealthy { match fgp_daemon::lifecycle::stop_service(&service.name) { Ok(()) => { - self.set_message( - format!("Stopped {}", service.name), - MessageType::Success, - ); + self.set_message(format!("Stopped {}", service.name), MessageType::Success); self.refresh_services(); } Err(e) => { diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 5186a8b..82a5fd4 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -92,12 +92,24 @@ fn draw_service_table(frame: &mut Frame, area: Rect, app: &App) { // Status styling let (status_color, status_text) = match service.status { - ServiceStatus::Running => (Color::Green, format!("{} running", service.status.symbol())), - ServiceStatus::Stopped => (Color::DarkGray, format!("{} stopped", service.status.symbol())), - ServiceStatus::Unhealthy => (Color::Yellow, format!("{} unhealthy", service.status.symbol())), + ServiceStatus::Running => { + (Color::Green, format!("{} running", service.status.symbol())) + } + ServiceStatus::Stopped => ( + Color::DarkGray, + format!("{} stopped", service.status.symbol()), + ), + ServiceStatus::Unhealthy => ( + Color::Yellow, + format!("{} unhealthy", service.status.symbol()), + ), ServiceStatus::Error => (Color::Red, format!("{} error", service.status.symbol())), - ServiceStatus::Starting => (Color::Blue, format!("{} starting", service.status.symbol())), - ServiceStatus::Stopping => (Color::Blue, format!("{} stopping", service.status.symbol())), + ServiceStatus::Starting => { + (Color::Blue, format!("{} starting", service.status.symbol())) + } + ServiceStatus::Stopping => { + (Color::Blue, format!("{} stopping", service.status.symbol())) + } }; // Version and uptime