Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }

Comment on lines +51 to +53
Copy link

Copilot AI Jan 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
# Async runtime for TUI events
tokio = { version = "1", features = ["sync", "time", "rt-multi-thread"] }

Copilot uses AI. Check for mistakes.
[dev-dependencies]
assert_cmd = "2"
predicates = "3"
Expand Down
1 change: 1 addition & 0 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
10 changes: 10 additions & 0 deletions src/commands/tui.rs
Original file line number Diff line number Diff line change
@@ -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)
}
9 changes: 9 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
//! ```

mod commands;
mod tui;

use anyhow::Result;
use clap::{Parser, Subcommand};
Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -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),
Expand Down
295 changes: 295 additions & 0 deletions src/tui/app.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,295 @@
//! 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<String>,
pub uptime_seconds: Option<u64>,
}

/// 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<ServiceInfo>,

/// 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) {
Copy link

Copilot AI Jan 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
match fgp_daemon::lifecycle::start_service(&service.name) {
match commands::start::run(&service.name) {

Copilot uses AI. Check for mistakes.
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) {
Copy link

Copilot AI Jan 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
match fgp_daemon::lifecycle::stop_service(&service.name) {
match commands::stop::run(&service.name) {

Copilot uses AI. Check for mistakes.
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<ServiceInfo> {
let services_dir = fgp_daemon::lifecycle::fgp_services_dir();
Copy link

Copilot AI Jan 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
let services_dir = fgp_daemon::lifecycle::fgp_services_dir();
let services_dir = commands::fgp_services_dir();

Copilot uses AI. Check for mistakes.

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);
Copy link

Copilot AI Jan 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
let socket_path = fgp_daemon::lifecycle::service_socket_path(&name);
let socket_path = crate::commands::service_socket_path(&name);

Copilot uses AI. Check for mistakes.
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<String>, Option<u64>) {
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)
Comment on lines +286 to +293
Copy link

Copilot AI Jan 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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),

Copilot uses AI. Check for mistakes.
}
}
Loading