use crate::core::engine::CoreEngine;
use anyhow::Result;
use chrono::Local;
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, Paragraph, Row, Table, TableState, Wrap},
Frame, Terminal,
};
use std::collections::VecDeque;
use std::io;
use std::time::{Duration, Instant};
pub struct TuiInterface {
terminal: Terminal<CrosstermBackend<io::Stdout>>,
table_state: TableState,
last_refresh: Instant,
log_messages: VecDeque<String>,
view_mode: ViewMode,
}
#[derive(PartialEq, Clone)]
enum ViewMode {
Dashboard,
ServiceDetails,
}
impl TuiInterface {
pub fn new() -> Result<Self> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let terminal = Terminal::new(backend)?;
Ok(Self {
terminal,
table_state: TableState::default(),
last_refresh: Instant::now(),
log_messages: VecDeque::with_capacity(100),
view_mode: ViewMode::Dashboard,
})
}
pub async fn run(&mut self, engine: &mut CoreEngine) -> Result<()> {
self.add_log("Starting Darpan...".to_string());
engine.refresh().await?;
self.add_log(format!("Detected {} services", engine.get_registry().count()));
self.table_state.select(Some(0));
loop {
if self.last_refresh.elapsed() > Duration::from_secs(5) {
self.add_log("Refreshing health checks...".to_string());
engine.check_health().await?;
self.last_refresh = Instant::now();
let registry = engine.get_registry();
let healthy = registry.all_services().iter().filter(|s| s.is_healthy()).count();
let total = registry.count();
self.add_log(format!("Health check: {}/{} healthy", healthy, total));
}
let view_mode = self.view_mode.clone();
let table_state = self.table_state.clone();
let log_messages = self.log_messages.clone();
let last_refresh = self.last_refresh;
self.terminal.draw(|f| {
let size = f.size();
if view_mode == ViewMode::ServiceDetails {
Self::render_service_details_static(f, engine, size, &table_state, &log_messages);
} else {
Self::render_dashboard_static(f, engine, size, &table_state, &log_messages, last_refresh);
}
})?;
if event::poll(Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? {
match key.code {
KeyCode::Char('q') => {
if self.view_mode == ViewMode::ServiceDetails {
self.view_mode = ViewMode::Dashboard;
} else {
break;
}
}
KeyCode::Esc => {
if self.view_mode == ViewMode::ServiceDetails {
self.view_mode = ViewMode::Dashboard;
} else {
break;
}
}
KeyCode::Char('r') => {
self.add_log("Manual refresh requested".to_string());
engine.refresh().await?;
self.last_refresh = Instant::now();
self.add_log("Refresh complete".to_string());
}
KeyCode::Down | KeyCode::Char('j') => {
self.next(engine.get_registry().count());
}
KeyCode::Up | KeyCode::Char('k') => {
self.previous();
}
KeyCode::Enter => {
if self.view_mode == ViewMode::Dashboard {
self.view_mode = ViewMode::ServiceDetails;
if let Some(selected) = self.table_state.selected() {
let services = engine.get_registry().all_services();
if let Some(service) = services.get(selected) {
self.add_log(format!("Viewing details: {}", service.name));
}
}
}
}
_ => {}
}
}
}
}
Ok(())
}
fn add_log(&mut self, message: String) {
let timestamp = Local::now().format("%H:%M:%S");
self.log_messages.push_back(format!("[{}] {}", timestamp, message));
if self.log_messages.len() > 100 {
self.log_messages.pop_front();
}
}
fn render_dashboard_static(f: &mut Frame, engine: &CoreEngine, size: Rect, table_state: &TableState, log_messages: &VecDeque<String>, last_refresh: Instant) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Percentage(45), Constraint::Percentage(25), Constraint::Percentage(25), Constraint::Length(2), ])
.split(size);
let project_name = engine
.project_path()
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown");
let header = Paragraph::new(format!("Darpan - {} | Live Monitor | chiraglabs", project_name))
.style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL));
f.render_widget(header, chunks[0]);
let registry = engine.get_registry();
let services = registry.all_services();
let rows: Vec<Row> = services
.iter()
.map(|service| {
let health = service.health_status.as_ref();
let status_symbol = health.map(|h| h.status.symbol()).unwrap_or("?");
let status_color = health
.map(|h| match h.status {
crate::models::HealthStatus::Healthy => Color::Green,
crate::models::HealthStatus::Degraded => Color::Yellow,
crate::models::HealthStatus::Unhealthy => Color::Red,
crate::models::HealthStatus::NotRunning => Color::Red,
crate::models::HealthStatus::Unknown => Color::Gray,
})
.unwrap_or(Color::Gray);
let service_type_str = match &service.service_type {
crate::models::ServiceType::HttpServer => "HTTP",
crate::models::ServiceType::Database(_) => "Database",
crate::models::ServiceType::MessageQueue(_) => "Queue",
crate::models::ServiceType::Cache(_) => "Cache",
crate::models::ServiceType::Search(_) => "Search",
crate::models::ServiceType::DockerContainer => "Docker",
crate::models::ServiceType::Custom => "Custom",
};
let response = health
.map(|h| {
if h.response_time_ms > 0 {
format!("{}ms", h.response_time_ms)
} else {
"N/A".to_string()
}
})
.unwrap_or_else(|| "N/A".to_string());
Row::new(vec![
Span::styled(status_symbol, Style::default().fg(status_color)),
Span::raw(&service.name),
Span::raw(service_type_str),
Span::raw(format!("{}:{}", service.host, service.port)),
Span::raw(response),
])
})
.collect();
let table = Table::new(
rows,
[
Constraint::Length(3),
Constraint::Min(20),
Constraint::Length(10),
Constraint::Length(18),
Constraint::Length(8),
],
)
.header(
Row::new(vec!["", "NAME", "TYPE", "HOST:PORT", "RESP"])
.style(Style::default().add_modifier(Modifier::BOLD))
.bottom_margin(1),
)
.block(
Block::default()
.borders(Borders::ALL)
.title(format!(" Services ({}) - Press Enter for details ", services.len())),
)
.highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD))
.highlight_symbol(">> ");
let mut ts = table_state.clone();
f.render_stateful_widget(table, chunks[1], &mut ts);
let selected = table_state.selected().unwrap_or(0);
let details_text = if let Some(service) = services.get(selected) {
let mut lines = vec![
Line::from(vec![
Span::styled("Service: ", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(&service.name),
]),
];
if let Some(health) = &service.health_status {
let (status_text, color) = match health.status {
crate::models::HealthStatus::Healthy => ("HEALTHY", Color::Green),
crate::models::HealthStatus::Degraded => ("DEGRADED", Color::Yellow),
crate::models::HealthStatus::Unhealthy => ("UNHEALTHY", Color::Red),
crate::models::HealthStatus::NotRunning => ("DOWN", Color::Red),
crate::models::HealthStatus::Unknown => ("UNKNOWN", Color::Gray),
};
lines.push(Line::from(vec![
Span::styled("Status: ", Style::default().add_modifier(Modifier::BOLD)),
Span::styled(status_text, Style::default().fg(color)),
]));
if let Some(details) = &health.details {
lines.push(Line::from(vec![
Span::styled("Info: ", Style::default().add_modifier(Modifier::BOLD)),
Span::styled(details, Style::default().fg(Color::Yellow)),
]));
}
}
lines
} else {
vec![Line::from("No service selected")]
};
let details = Paragraph::new(details_text)
.block(Block::default().borders(Borders::ALL).title(" Quick Info "))
.wrap(Wrap { trim: true });
f.render_widget(details, chunks[2]);
let log_items: Vec<ListItem> = log_messages
.iter()
.rev()
.take(chunks[3].height.saturating_sub(2) as usize)
.rev()
.map(|log| ListItem::new(log.as_str()))
.collect();
let logs = List::new(log_items)
.block(Block::default().borders(Borders::ALL).title(" Live Logs "));
f.render_widget(logs, chunks[3]);
let seconds_ago = last_refresh.elapsed().as_secs();
let footer = Paragraph::new(format!(
" q: quit | r: refresh | ↑↓/jk: navigate | Enter: details | Updated: {}s ago ",
seconds_ago
))
.style(Style::default().fg(Color::Gray));
f.render_widget(footer, chunks[4]);
}
fn render_service_details_static(f: &mut Frame, engine: &CoreEngine, size: Rect, table_state: &TableState, log_messages: &VecDeque<String>) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Percentage(70), Constraint::Percentage(25), Constraint::Length(2), ])
.split(size);
let header = Paragraph::new("Service Details | chiraglabs")
.style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL));
f.render_widget(header, chunks[0]);
let selected = table_state.selected().unwrap_or(0);
let services = engine.get_registry().all_services();
let details_text = if let Some(service) = services.get(selected) {
let mut lines = vec![
Line::from(vec![
Span::styled("Service Name: ", Style::default().add_modifier(Modifier::BOLD).fg(Color::Cyan)),
Span::raw(&service.name),
]),
Line::from(""),
];
if let Some(health) = &service.health_status {
let (status_text, color) = match health.status {
crate::models::HealthStatus::Healthy => ("✓ HEALTHY", Color::Green),
crate::models::HealthStatus::Degraded => ("âš DEGRADED", Color::Yellow),
crate::models::HealthStatus::Unhealthy => ("✗ UNHEALTHY", Color::Red),
crate::models::HealthStatus::NotRunning => ("✗ DOWN", Color::Red),
crate::models::HealthStatus::Unknown => ("? UNKNOWN", Color::Gray),
};
lines.push(Line::from(vec![
Span::styled("Status: ", Style::default().add_modifier(Modifier::BOLD)),
Span::styled(status_text, Style::default().fg(color).add_modifier(Modifier::BOLD)),
]));
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled("Location: ", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(format!("{}:{}", service.host, service.port)),
]));
if let Some(pid) = service.pid {
lines.push(Line::from(vec![
Span::styled("Process ID: ", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(format!("{}", pid)),
]));
}
if let Some(cmd) = &service.command_line {
lines.push(Line::from(vec![
Span::styled("Command: ", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(cmd),
]));
}
lines.push(Line::from(""));
if health.response_time_ms > 0 {
lines.push(Line::from(vec![
Span::styled("Response Time: ", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(format!("{}ms", health.response_time_ms)),
]));
}
if let Some(details) = &health.details {
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled("Details: ", Style::default().add_modifier(Modifier::BOLD).fg(Color::Yellow)),
]));
lines.push(Line::from(Span::styled(details, Style::default().fg(Color::Yellow))));
}
if let Some(suggestion) = &health.suggestion {
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled("Suggestion: ", Style::default().add_modifier(Modifier::BOLD).fg(Color::Green)),
]));
lines.push(Line::from(Span::styled(suggestion, Style::default().fg(Color::Cyan))));
}
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled("Last Checked: ", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(health.last_checked.format("%H:%M:%S").to_string()),
]));
}
lines
} else {
vec![Line::from("No service selected")]
};
let details = Paragraph::new(details_text)
.block(Block::default().borders(Borders::ALL).title(" Detailed Information "))
.wrap(Wrap { trim: true });
f.render_widget(details, chunks[1]);
let log_items: Vec<ListItem> = log_messages
.iter()
.rev()
.take(chunks[2].height.saturating_sub(2) as usize)
.rev()
.map(|log| ListItem::new(log.as_str()))
.collect();
let logs = List::new(log_items)
.block(Block::default().borders(Borders::ALL).title(" Live Logs "));
f.render_widget(logs, chunks[2]);
let footer = Paragraph::new(" q/Esc: back to dashboard | r: refresh ")
.style(Style::default().fg(Color::Gray));
f.render_widget(footer, chunks[3]);
}
fn next(&mut self, max: usize) {
let i = match self.table_state.selected() {
Some(i) => {
if i >= max.saturating_sub(1) {
max.saturating_sub(1)
} else {
i + 1
}
}
None => 0,
};
self.table_state.select(Some(i));
}
fn previous(&mut self) {
let i = match self.table_state.selected() {
Some(i) => {
if i == 0 {
0
} else {
i - 1
}
}
None => 0,
};
self.table_state.select(Some(i));
}
}
impl Drop for TuiInterface {
fn drop(&mut self) {
let _ = disable_raw_mode();
let _ = execute!(
self.terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
);
let _ = self.terminal.show_cursor();
}
}