Skip to content

Commit bbb53a2

Browse files
wolfieschclaude
andauthored
feat(cli): add interactive TUI dashboard (#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 <ms>] Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * style: fix formatting --------- Co-authored-by: Wolfgang Schoenberger <221313372+wolfiesch@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent c3470e9 commit bbb53a2

8 files changed

Lines changed: 850 additions & 0 deletions

File tree

Cargo.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,13 @@ tabled = "0.16"
4444
# Process management
4545
sysinfo = "0.32"
4646

47+
# TUI framework
48+
ratatui = "0.29"
49+
crossterm = "0.28"
50+
51+
# Async runtime for TUI events
52+
tokio = { version = "1", features = ["sync", "time", "rt-multi-thread"] }
53+
4754
[dev-dependencies]
4855
assert_cmd = "2"
4956
predicates = "3"

src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ pub mod new;
1010
pub mod start;
1111
pub mod status;
1212
pub mod stop;
13+
pub mod tui;
1314
pub mod workflow;
1415

1516
use std::path::PathBuf;

src/commands/tui.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
//! TUI command - Interactive terminal dashboard.
2+
3+
use anyhow::Result;
4+
use std::time::Duration;
5+
6+
/// Run the TUI dashboard.
7+
pub fn run(poll_interval_ms: u64) -> Result<()> {
8+
let poll_interval = Duration::from_millis(poll_interval_ms);
9+
crate::tui::run(poll_interval)
10+
}

src/main.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
//! ```
1414
1515
mod commands;
16+
mod tui;
1617

1718
use anyhow::Result;
1819
use clap::{Parser, Subcommand};
@@ -122,6 +123,13 @@ enum Commands {
122123
open: bool,
123124
},
124125

126+
/// Interactive terminal dashboard
127+
Tui {
128+
/// Service polling interval in milliseconds
129+
#[arg(short, long, default_value = "2000")]
130+
poll: u64,
131+
},
132+
125133
/// Run or validate a workflow
126134
Workflow {
127135
#[command(subcommand)]
@@ -175,6 +183,7 @@ fn main() -> Result<()> {
175183
Commands::Methods { service } => commands::methods::run(&service),
176184
Commands::Health { service } => commands::health::run(&service),
177185
Commands::Dashboard { port, open } => commands::dashboard::run(port, open),
186+
Commands::Tui { poll } => commands::tui::run(poll),
178187
Commands::Workflow { action } => match action {
179188
WorkflowAction::Run { file, verbose } => commands::workflow::run(&file, verbose),
180189
WorkflowAction::Validate { file } => commands::workflow::validate(&file),

src/tui/app.rs

Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
//! Application state for the TUI dashboard.
2+
3+
use std::fs;
4+
use std::time::{Duration, Instant};
5+
6+
/// Service status information.
7+
#[derive(Debug, Clone)]
8+
pub struct ServiceInfo {
9+
pub name: String,
10+
pub status: ServiceStatus,
11+
pub version: Option<String>,
12+
pub uptime_seconds: Option<u64>,
13+
}
14+
15+
/// Service health states.
16+
#[derive(Debug, Clone, Copy, PartialEq)]
17+
#[allow(dead_code)]
18+
pub enum ServiceStatus {
19+
Running,
20+
Stopped,
21+
Unhealthy,
22+
Error,
23+
Starting,
24+
Stopping,
25+
}
26+
27+
impl ServiceStatus {
28+
/// Get the status symbol for display.
29+
pub fn symbol(&self) -> &'static str {
30+
match self {
31+
ServiceStatus::Running => "●",
32+
ServiceStatus::Stopped => "○",
33+
ServiceStatus::Unhealthy => "◐",
34+
ServiceStatus::Error => "●",
35+
ServiceStatus::Starting => "◑",
36+
ServiceStatus::Stopping => "◑",
37+
}
38+
}
39+
40+
/// Get the status text for display.
41+
#[allow(dead_code)]
42+
pub fn text(&self) -> &'static str {
43+
match self {
44+
ServiceStatus::Running => "running",
45+
ServiceStatus::Stopped => "stopped",
46+
ServiceStatus::Unhealthy => "unhealthy",
47+
ServiceStatus::Error => "error",
48+
ServiceStatus::Starting => "starting",
49+
ServiceStatus::Stopping => "stopping",
50+
}
51+
}
52+
}
53+
54+
/// Message type for display.
55+
#[derive(Debug, Clone)]
56+
pub enum MessageType {
57+
Success,
58+
Error,
59+
}
60+
61+
/// Main application state.
62+
pub struct App {
63+
/// List of discovered services.
64+
pub services: Vec<ServiceInfo>,
65+
66+
/// Currently selected service index.
67+
pub selected: usize,
68+
69+
/// Last refresh timestamp.
70+
pub last_refresh: Instant,
71+
72+
/// Whether app should quit.
73+
pub should_quit: bool,
74+
75+
/// Message to display (auto-clears after timeout).
76+
pub message: Option<(String, MessageType, Instant)>,
77+
78+
/// Message display duration.
79+
pub message_timeout: Duration,
80+
81+
/// Whether help overlay is visible.
82+
pub show_help: bool,
83+
}
84+
85+
impl App {
86+
/// Create a new app instance.
87+
pub fn new() -> Self {
88+
Self {
89+
services: Vec::new(),
90+
selected: 0,
91+
last_refresh: Instant::now(),
92+
should_quit: false,
93+
message: None,
94+
message_timeout: Duration::from_secs(3),
95+
show_help: false,
96+
}
97+
}
98+
99+
/// Tick handler - called on each frame.
100+
pub fn tick(&mut self) {
101+
// Clear expired messages
102+
if let Some((_, _, created)) = &self.message {
103+
if created.elapsed() >= self.message_timeout {
104+
self.message = None;
105+
}
106+
}
107+
}
108+
109+
/// Refresh service list from filesystem.
110+
pub fn refresh_services(&mut self) {
111+
self.services = discover_services();
112+
self.last_refresh = Instant::now();
113+
114+
// Ensure selection is valid
115+
if self.selected >= self.services.len() && !self.services.is_empty() {
116+
self.selected = self.services.len() - 1;
117+
}
118+
}
119+
120+
/// Select the previous service.
121+
pub fn select_previous(&mut self) {
122+
if !self.services.is_empty() && self.selected > 0 {
123+
self.selected -= 1;
124+
}
125+
}
126+
127+
/// Select the next service.
128+
pub fn select_next(&mut self) {
129+
if !self.services.is_empty() && self.selected < self.services.len() - 1 {
130+
self.selected += 1;
131+
}
132+
}
133+
134+
/// Select the first service.
135+
pub fn select_first(&mut self) {
136+
self.selected = 0;
137+
}
138+
139+
/// Select the last service.
140+
pub fn select_last(&mut self) {
141+
if !self.services.is_empty() {
142+
self.selected = self.services.len() - 1;
143+
}
144+
}
145+
146+
/// Get the currently selected service.
147+
pub fn selected_service(&self) -> Option<&ServiceInfo> {
148+
self.services.get(self.selected)
149+
}
150+
151+
/// Start the selected service.
152+
pub fn start_selected(&mut self) {
153+
if let Some(service) = self.selected_service().cloned() {
154+
if service.status == ServiceStatus::Stopped || service.status == ServiceStatus::Error {
155+
match fgp_daemon::lifecycle::start_service(&service.name) {
156+
Ok(()) => {
157+
self.set_message(format!("Started {}", service.name), MessageType::Success);
158+
self.refresh_services();
159+
}
160+
Err(e) => {
161+
self.set_message(
162+
format!("Failed to start {}: {}", service.name, e),
163+
MessageType::Error,
164+
);
165+
}
166+
}
167+
}
168+
}
169+
}
170+
171+
/// Stop the selected service.
172+
pub fn stop_selected(&mut self) {
173+
if let Some(service) = self.selected_service().cloned() {
174+
if service.status == ServiceStatus::Running
175+
|| service.status == ServiceStatus::Unhealthy
176+
{
177+
match fgp_daemon::lifecycle::stop_service(&service.name) {
178+
Ok(()) => {
179+
self.set_message(format!("Stopped {}", service.name), MessageType::Success);
180+
self.refresh_services();
181+
}
182+
Err(e) => {
183+
self.set_message(
184+
format!("Failed to stop {}: {}", service.name, e),
185+
MessageType::Error,
186+
);
187+
}
188+
}
189+
}
190+
}
191+
}
192+
193+
/// Set a message to display.
194+
pub fn set_message(&mut self, text: String, msg_type: MessageType) {
195+
self.message = Some((text, msg_type, Instant::now()));
196+
}
197+
198+
/// Toggle help overlay.
199+
pub fn toggle_help(&mut self) {
200+
self.show_help = !self.show_help;
201+
}
202+
}
203+
204+
impl Default for App {
205+
fn default() -> Self {
206+
Self::new()
207+
}
208+
}
209+
210+
/// Discover all installed services.
211+
fn discover_services() -> Vec<ServiceInfo> {
212+
let services_dir = fgp_daemon::lifecycle::fgp_services_dir();
213+
214+
if !services_dir.exists() {
215+
return Vec::new();
216+
}
217+
218+
let entries = match fs::read_dir(&services_dir) {
219+
Ok(entries) => entries,
220+
Err(_) => return Vec::new(),
221+
};
222+
223+
let mut services = Vec::new();
224+
225+
for entry in entries.flatten() {
226+
let path = entry.path();
227+
228+
if !path.is_dir() {
229+
continue;
230+
}
231+
232+
let name = match path.file_name().and_then(|n| n.to_str()) {
233+
Some(n) => n.to_string(),
234+
None => continue,
235+
};
236+
237+
let socket_path = fgp_daemon::lifecycle::service_socket_path(&name);
238+
let (status, version, uptime) = get_service_status(&name, &socket_path);
239+
240+
services.push(ServiceInfo {
241+
name,
242+
status,
243+
version,
244+
uptime_seconds: uptime,
245+
});
246+
}
247+
248+
// Sort by name
249+
services.sort_by(|a, b| a.name.cmp(&b.name));
250+
services
251+
}
252+
253+
/// Get the status of a service.
254+
fn get_service_status(
255+
_name: &str,
256+
socket_path: &std::path::Path,
257+
) -> (ServiceStatus, Option<String>, Option<u64>) {
258+
if !socket_path.exists() {
259+
return (ServiceStatus::Stopped, None, None);
260+
}
261+
262+
match fgp_daemon::FgpClient::new(socket_path) {
263+
Ok(client) => match client.health() {
264+
Ok(response) if response.ok => {
265+
let result = response.result.unwrap_or_default();
266+
let version = result["version"].as_str().map(String::from);
267+
let uptime = result["uptime_seconds"].as_u64();
268+
let status_str = result["status"].as_str().unwrap_or("running");
269+
270+
let status = match status_str {
271+
"healthy" | "running" => ServiceStatus::Running,
272+
"degraded" | "unhealthy" => ServiceStatus::Unhealthy,
273+
_ => ServiceStatus::Running,
274+
};
275+
276+
(status, version, uptime)
277+
}
278+
_ => (ServiceStatus::Error, None, None),
279+
},
280+
Err(_) => (ServiceStatus::Error, None, None),
281+
}
282+
}
283+
284+
/// Format uptime seconds into human-readable string.
285+
pub fn format_uptime(secs: u64) -> String {
286+
if secs < 60 {
287+
format!("{}s", secs)
288+
} else if secs < 3600 {
289+
format!("{}m {}s", secs / 60, secs % 60)
290+
} else if secs < 86400 {
291+
format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
292+
} else {
293+
format!("{}d {}h", secs / 86400, (secs % 86400) / 3600)
294+
}
295+
}

0 commit comments

Comments
 (0)