use crate::core::engine::CoreEngine;
use crate::health::NetworkMonitor;
use crate::logs::{export, LogBuffer, LogStreamManager};
use crate::models::LogEntry;
use anyhow::Result;
use chrono::Local;
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers, MouseButton, MouseEventKind},
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};
use tokio::sync::mpsc;
use tracing::info;
pub struct TuiInterface {
terminal: Terminal<CrosstermBackend<io::Stdout>>,
table_state: TableState,
last_refresh: Instant,
log_messages: VecDeque<String>,
view_mode: ViewMode,
log_stream_manager: LogStreamManager,
service_log_buffer: LogBuffer,
log_receiver: Option<mpsc::UnboundedReceiver<LogEntry>>,
log_paused: bool,
log_scroll_offset: usize,
export_message: Option<String>,
search_mode: bool,
search_query: String,
log_level_filter: Option<crate::models::LogLevel>,
selection_mode: bool,
selection_start: Option<usize>,
selection_end: Option<usize>,
}
#[derive(PartialEq, Clone)]
enum ViewMode {
Dashboard,
ServiceDetails,
ServiceLogs,
}
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,
log_stream_manager: LogStreamManager::new(),
service_log_buffer: LogBuffer::new(100),
log_receiver: None,
log_paused: false,
log_scroll_offset: 0,
export_message: None,
search_mode: false,
search_query: String::new(),
log_level_filter: None,
selection_mode: false,
selection_start: None,
selection_end: None,
})
}
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.view_mode != ViewMode::ServiceLogs && 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));
}
if let Some(ref mut rx) = self.log_receiver {
while let Ok(entry) = rx.try_recv() {
if !self.log_paused {
self.service_log_buffer.push(entry);
self.log_scroll_offset = 0;
}
}
}
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;
let service_log_buffer = &self.service_log_buffer;
let log_paused = self.log_paused;
let log_scroll_offset = self.log_scroll_offset;
let export_message = self.export_message.clone();
let search_mode = self.search_mode;
let search_query = self.search_query.clone();
let log_level_filter = self.log_level_filter;
let selection_mode = self.selection_mode;
let selection_start = self.selection_start;
let selection_end = self.selection_end;
self.terminal.draw(|f| {
let size = f.size();
match view_mode {
ViewMode::ServiceLogs => {
Self::render_service_logs_static(f, engine, size, &table_state, service_log_buffer, log_paused, log_scroll_offset, export_message.as_deref(), search_mode, &search_query, log_level_filter, selection_mode, selection_start, selection_end);
}
ViewMode::ServiceDetails => {
Self::render_service_details_static(f, engine, size, &table_state, &log_messages);
}
ViewMode::Dashboard => {
Self::render_dashboard_static(f, engine, size, &table_state, &log_messages, last_refresh);
}
}
})?;
if self.export_message.is_some() {
tokio::time::sleep(Duration::from_secs(3)).await;
self.export_message = None;
}
if event::poll(Duration::from_millis(100))? {
match event::read()? {
Event::Key(key) => {
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
if self.view_mode == ViewMode::ServiceLogs && self.selection_mode {
if let (Some(start), Some(end)) = (self.selection_start, self.selection_end) {
if let Err(e) = self.copy_selected_logs_to_clipboard(start, end).await {
self.add_log(format!("Failed to copy to clipboard: {}", e));
} else {
self.add_log("Copied to clipboard!".to_string());
self.selection_mode = false;
self.selection_start = None;
self.selection_end = None;
}
}
continue;
}
}
if key.modifiers.contains(KeyModifiers::CONTROL) {
if self.view_mode == ViewMode::ServiceLogs && !self.selection_mode {
match key.code {
KeyCode::Char('f') | KeyCode::Char('F') => {
let page_size = 10;
self.log_scroll_offset = self.log_scroll_offset.saturating_sub(page_size);
continue;
}
KeyCode::Char('b') | KeyCode::Char('B') => {
let page_size = 10;
let max_offset = self.service_log_buffer.len().saturating_sub(1);
self.log_scroll_offset = (self.log_scroll_offset + page_size).min(max_offset);
continue;
}
_ => {}
}
}
}
if self.search_mode && self.view_mode == ViewMode::ServiceLogs {
match key.code {
KeyCode::Enter => {
self.search_mode = false;
}
KeyCode::Esc => {
self.search_mode = false;
self.search_query.clear();
}
KeyCode::Backspace => {
self.search_query.pop();
}
KeyCode::Char(c) => {
self.search_query.push(c);
}
_ => {}
}
continue;
}
if self.selection_mode && self.view_mode == ViewMode::ServiceLogs {
match key.code {
KeyCode::Esc => {
self.selection_mode = false;
self.selection_start = None;
self.selection_end = None;
}
KeyCode::Up | KeyCode::Char('k') => {
if let Some(end) = self.selection_end {
if end < self.service_log_buffer.len().saturating_sub(1) {
self.selection_end = Some(end + 1);
}
} else {
let current = self.log_scroll_offset;
self.selection_start = Some(current);
self.selection_end = Some(current);
}
}
KeyCode::Down | KeyCode::Char('j') => {
if let Some(end) = self.selection_end {
if end > 0 {
self.selection_end = Some(end - 1);
}
} else {
let current = self.log_scroll_offset;
self.selection_start = Some(current);
self.selection_end = Some(current);
}
}
KeyCode::Char('y') => {
if let (Some(start), Some(end)) = (self.selection_start, self.selection_end) {
let (start_idx, end_idx) = if start <= end {
(start, end)
} else {
(end, start)
};
if let Err(e) = self.copy_selected_logs_to_clipboard(start_idx, end_idx).await {
self.add_log(format!("Failed to copy to clipboard: {}", e));
} else {
self.add_log("Copied to clipboard!".to_string());
self.selection_mode = false;
self.selection_start = None;
self.selection_end = None;
}
}
}
_ => {}
}
continue;
}
match key.code {
KeyCode::Char('q') => {
match self.view_mode {
ViewMode::ServiceLogs => {
self.log_receiver = None;
self.service_log_buffer.clear();
self.log_paused = false;
self.log_scroll_offset = 0;
self.view_mode = ViewMode::ServiceDetails;
}
ViewMode::ServiceDetails => {
self.view_mode = ViewMode::Dashboard;
}
ViewMode::Dashboard => {
break;
}
}
}
KeyCode::Esc => {
match self.view_mode {
ViewMode::ServiceLogs => {
self.log_receiver = None;
self.service_log_buffer.clear();
self.log_paused = false;
self.log_scroll_offset = 0;
self.view_mode = ViewMode::ServiceDetails;
}
ViewMode::ServiceDetails => {
self.view_mode = ViewMode::Dashboard;
}
ViewMode::Dashboard => {
break;
}
}
}
KeyCode::Char('r') => {
if self.view_mode != ViewMode::ServiceLogs {
self.add_log("Manual refresh requested".to_string());
engine.refresh().await?;
self.last_refresh = Instant::now();
self.add_log("Refresh complete".to_string());
}
}
KeyCode::Char('l') | KeyCode::Char('L') => {
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!("Starting log stream for: {}", service.name));
match self.log_stream_manager.start_streaming(service).await {
Ok(rx) => {
self.log_receiver = Some(rx);
self.service_log_buffer.clear();
self.log_paused = false;
self.log_scroll_offset = 0;
self.view_mode = ViewMode::ServiceLogs;
}
Err(e) => {
self.add_log(format!("Failed to start log streaming: {}", e));
}
}
}
}
}
}
KeyCode::Char(' ') => {
if self.view_mode == ViewMode::ServiceLogs {
self.log_paused = !self.log_paused;
}
}
KeyCode::Char('e') | KeyCode::Char('E') => {
if self.view_mode == ViewMode::ServiceLogs {
if let Some(selected) = self.table_state.selected() {
let services = engine.get_registry().all_services();
if let Some(service) = services.get(selected) {
match export::export_logs(service, &self.service_log_buffer).await {
Ok(path) => {
self.export_message = Some(format!("Exported to: {:?}", path));
info!("Logs exported to: {:?}", path);
}
Err(e) => {
self.export_message = Some(format!("Export failed: {}", e));
}
}
}
}
}
}
KeyCode::Down | KeyCode::Char('j') => {
if self.view_mode == ViewMode::ServiceLogs {
if !self.selection_mode {
if self.log_scroll_offset > 0 {
self.log_scroll_offset -= 1;
}
}
} else {
self.next(engine.get_registry().count());
}
}
KeyCode::Up | KeyCode::Char('k') => {
if self.view_mode == ViewMode::ServiceLogs {
if !self.selection_mode {
if self.log_scroll_offset < self.service_log_buffer.len().saturating_sub(1) {
self.log_scroll_offset += 1;
}
}
} else {
self.previous();
}
}
KeyCode::PageDown => {
if self.view_mode == ViewMode::ServiceLogs && !self.selection_mode {
let page_size = 10; self.log_scroll_offset = self.log_scroll_offset.saturating_sub(page_size);
}
}
KeyCode::PageUp => {
if self.view_mode == ViewMode::ServiceLogs && !self.selection_mode {
let page_size = 10; let max_offset = self.service_log_buffer.len().saturating_sub(1);
self.log_scroll_offset = (self.log_scroll_offset + page_size).min(max_offset);
}
}
KeyCode::Home => {
if self.view_mode == ViewMode::ServiceLogs && !self.selection_mode {
self.log_scroll_offset = self.service_log_buffer.len().saturating_sub(1);
}
}
KeyCode::End => {
if self.view_mode == ViewMode::ServiceLogs && !self.selection_mode {
self.log_scroll_offset = 0;
}
}
KeyCode::Char('g') => {
if self.view_mode == ViewMode::ServiceLogs && !self.selection_mode {
self.log_scroll_offset = self.service_log_buffer.len().saturating_sub(1);
}
}
KeyCode::Char('G') => {
if self.view_mode == ViewMode::ServiceLogs && !self.selection_mode {
self.log_scroll_offset = 0;
}
}
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));
}
}
}
}
KeyCode::Char('/') => {
if self.view_mode == ViewMode::ServiceLogs {
self.search_mode = true;
self.search_query.clear();
}
}
KeyCode::Char('1') => {
if self.view_mode == ViewMode::ServiceLogs {
self.log_level_filter = Some(crate::models::LogLevel::Error);
}
}
KeyCode::Char('2') => {
if self.view_mode == ViewMode::ServiceLogs {
self.log_level_filter = Some(crate::models::LogLevel::Warn);
}
}
KeyCode::Char('3') => {
if self.view_mode == ViewMode::ServiceLogs {
self.log_level_filter = Some(crate::models::LogLevel::Info);
}
}
KeyCode::Char('4') => {
if self.view_mode == ViewMode::ServiceLogs {
self.log_level_filter = Some(crate::models::LogLevel::Debug);
}
}
KeyCode::Char('0') => {
if self.view_mode == ViewMode::ServiceLogs {
self.log_level_filter = None;
}
}
KeyCode::Char('v') => {
if self.view_mode == ViewMode::ServiceLogs {
self.selection_mode = true;
let current = self.log_scroll_offset;
self.selection_start = Some(current);
self.selection_end = Some(current);
}
}
_ => {}
}
}
Event::Mouse(mouse) => {
if self.view_mode == ViewMode::ServiceLogs {
match mouse.kind {
MouseEventKind::ScrollUp => {
if !self.selection_mode {
let max_offset = self.service_log_buffer.len().saturating_sub(1);
self.log_scroll_offset = (self.log_scroll_offset + 3).min(max_offset);
}
}
MouseEventKind::ScrollDown => {
if !self.selection_mode {
if self.log_scroll_offset >= 3 {
self.log_scroll_offset -= 3;
} else {
self.log_scroll_offset = 0;
}
}
}
MouseEventKind::Down(MouseButton::Left) => {
if !self.selection_mode {
self.selection_mode = true;
let current = self.log_scroll_offset;
self.selection_start = Some(current);
self.selection_end = Some(current);
}
}
_ => {}
}
}
continue;
}
_ => {}
}
}
}
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();
}
}
async fn copy_selected_logs_to_clipboard(&self, start_idx: usize, end_idx: usize) -> Result<()> {
use arboard::Clipboard;
let filtered_entries: Vec<&LogEntry> = self.service_log_buffer
.entries()
.iter()
.filter(|entry| {
if let Some(filter_level) = self.log_level_filter {
if entry.level != filter_level {
return false;
}
}
if !self.search_query.is_empty() {
if !entry.message.to_lowercase().contains(&self.search_query.to_lowercase()) {
return false;
}
}
true
})
.collect();
let total_lines = filtered_entries.len();
if total_lines == 0 {
return Ok(());
}
let actual_start = total_lines.saturating_sub(1).saturating_sub(start_idx.min(total_lines.saturating_sub(1)));
let actual_end = total_lines.saturating_sub(1).saturating_sub(end_idx.min(total_lines.saturating_sub(1)));
let (start, end) = if actual_start <= actual_end {
(actual_start, actual_end + 1)
} else {
(actual_end, actual_start + 1)
};
let selected_entries: Vec<String> = filtered_entries
.iter()
.rev()
.skip(start)
.take(end - start)
.map(|entry| {
format!(
"[{} {}] {}",
entry.timestamp.format("%Y-%m-%d %H:%M:%S%.3f"),
entry.level.as_str(),
entry.message
)
})
.collect();
let text = selected_entries.join("\n");
let mut clipboard = Clipboard::new()?;
clipboard.set_text(text)?;
Ok(())
}
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_text = vec![
Line::from(vec![
Span::styled(" ╭───╮ ", Style::default().fg(Color::Yellow)),
Span::styled("DARPAN", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
Span::styled(" - ", Style::default().fg(Color::White)),
Span::styled(project_name, Style::default().fg(Color::Green)),
Span::styled(" | ", Style::default().fg(Color::DarkGray)),
Span::styled("Live Monitor", Style::default().fg(Color::Magenta)),
Span::styled(" | ", Style::default().fg(Color::DarkGray)),
Span::styled("chiraglabs", Style::default().fg(Color::Blue).add_modifier(Modifier::ITALIC)),
]),
Line::from(vec![
Span::styled(" │︵⌄︵│ ", Style::default().fg(Color::Yellow)),
Span::styled("Linux Service Monitor", Style::default().fg(Color::DarkGray)),
]),
];
let header = Paragraph::new(header_text)
.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 | L: logs | 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_text = vec![
Line::from(vec![
Span::styled(" ╭───╮ ", Style::default().fg(Color::Yellow)),
Span::styled("Service Details", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
Span::styled(" | ", Style::default().fg(Color::DarkGray)),
Span::styled("chiraglabs", Style::default().fg(Color::Blue).add_modifier(Modifier::ITALIC)),
]),
];
let header = Paragraph::new(header_text)
.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.push(Line::from(""));
let role_str = match service.role {
crate::models::ServiceRole::Frontend => "Frontend",
crate::models::ServiceRole::Backend => "Backend",
crate::models::ServiceRole::Database => "Database",
crate::models::ServiceRole::Cache => "Cache",
crate::models::ServiceRole::Unknown => "Unknown",
};
lines.push(Line::from(vec![
Span::styled("Role: ", Style::default().add_modifier(Modifier::BOLD)),
Span::styled(role_str, Style::default().fg(Color::Cyan)),
]));
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled("═══ Network Activity ═══", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
]));
let traffic_stats = NetworkMonitor::get_traffic_stats(service);
if NetworkMonitor::is_port_listening(service.port) {
lines.push(Line::from(vec![
Span::styled("Port Status: ", Style::default().add_modifier(Modifier::BOLD)),
Span::styled("LISTENING ", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
Span::styled("✓", Style::default().fg(Color::Green)),
]));
} else {
lines.push(Line::from(vec![
Span::styled("Port Status: ", Style::default().add_modifier(Modifier::BOLD)),
Span::styled("NOT LISTENING ", Style::default().fg(Color::Red)),
Span::styled("✗", Style::default().fg(Color::Red)),
]));
}
let conn_style = if traffic_stats.active_connections > 0 {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::Yellow)
};
lines.push(Line::from(vec![
Span::styled("Active Connections: ", Style::default().add_modifier(Modifier::BOLD)),
Span::styled(traffic_stats.active_connections.to_string(), conn_style.add_modifier(Modifier::BOLD)),
if traffic_stats.is_active {
Span::styled(" (● ACTIVE)", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD))
} else if traffic_stats.active_connections > 0 {
Span::styled(" (idle)", Style::default().fg(Color::DarkGray))
} else {
Span::styled(" (no traffic)", Style::default().fg(Color::DarkGray))
},
]));
if matches!(service.role, crate::models::ServiceRole::Frontend | crate::models::ServiceRole::Backend) {
if traffic_stats.bytes_in > 0 || traffic_stats.bytes_out > 0 {
let mb_in = traffic_stats.bytes_in as f64 / 1_048_576.0;
let mb_out = traffic_stats.bytes_out as f64 / 1_048_576.0;
lines.push(Line::from(vec![
Span::styled("Traffic: ", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(format!("{:.2} MB in, {:.2} MB out", mb_in, mb_out)),
]));
}
if let Some(last_conn) = traffic_stats.last_connection_time {
lines.push(Line::from(vec![
Span::styled("Last Activity: ", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(last_conn.format("%H:%M:%S").to_string()),
]));
}
}
if let Some(pid) = service.pid {
if let Some(stats) = NetworkMonitor::get_process_stats(pid) {
lines.push(Line::from(vec![
Span::styled("Process Connections: ", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(format!("{} established, {} listening",
stats.established_connections,
stats.listening_sockets)),
]));
}
}
}
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(" Activity Monitor (Darpan internal) ")
.title_style(Style::default().fg(Color::DarkGray))
.border_style(Style::default().fg(Color::DarkGray)));
f.render_widget(logs, chunks[2]);
let footer = Paragraph::new(vec![
Line::from(vec![
Span::styled(" q/Esc: ", Style::default().fg(Color::Yellow)),
Span::raw("back | "),
Span::styled("L: ", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
Span::styled("VIEW SERVICE LOGS ", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
Span::raw("| "),
Span::styled("r: ", Style::default().fg(Color::Yellow)),
Span::raw("refresh"),
]),
])
.style(Style::default());
f.render_widget(footer, chunks[3]);
}
fn render_service_logs_static(
f: &mut Frame,
engine: &CoreEngine,
size: Rect,
table_state: &TableState,
log_buffer: &LogBuffer,
is_paused: bool,
scroll_offset: usize,
export_message: Option<&str>,
search_mode: bool,
search_query: &str,
log_level_filter: Option<crate::models::LogLevel>,
selection_mode: bool,
selection_start: Option<usize>,
selection_end: Option<usize>,
) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Length(2), Constraint::Min(10), Constraint::Length(3), ])
.split(size);
let selected = table_state.selected().unwrap_or(0);
let services = engine.get_registry().all_services();
let service_name = services.get(selected).map(|s| s.name.as_str()).unwrap_or("Unknown");
let header_text = vec![
Line::from(vec![
Span::styled(" ╭───╮ ", Style::default().fg(Color::Yellow)),
Span::styled("SERVICE LOGS: ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
Span::styled(service_name, Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
Span::styled(" | ", Style::default().fg(Color::DarkGray)),
Span::styled("chiraglabs", Style::default().fg(Color::Blue).add_modifier(Modifier::ITALIC)),
]),
];
let header = Paragraph::new(header_text)
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL));
f.render_widget(header, chunks[0]);
let log_source = if let Some(service) = services.get(selected) {
if service.container_id.is_some() {
"Docker"
} else if service.systemd_unit.is_some() {
"Systemd"
} else if service.log_file_path.is_some() {
"File"
} else if service.pid.is_some() {
"Process"
} else {
"Unknown"
}
} else {
"Unknown"
};
let filtered_count = log_buffer.entries().iter().filter(|entry| {
if let Some(filter_level) = log_level_filter {
if entry.level != filter_level {
return false;
}
}
if !search_query.is_empty() {
if !entry.message.to_lowercase().contains(&search_query.to_lowercase()) {
return false;
}
}
true
}).count();
let current_line = if filtered_count > 0 {
filtered_count.saturating_sub(scroll_offset)
} else {
0
};
let total_lines = filtered_count;
let scroll_info = if total_lines > 0 {
format!("Line {}/{}", current_line, total_lines)
} else {
"No logs".to_string()
};
let status_text = if is_paused {
format!(
" Source: {} | Total: {} | {} | ⏸ PAUSED (Space to resume) ",
log_source,
log_buffer.len(),
scroll_info
)
} else {
format!(
" Source: {} | Total: {} | {} | ● LIVE (Space to pause) ",
log_source,
log_buffer.len(),
scroll_info
)
};
let status = Paragraph::new(status_text)
.style(Style::default().fg(if is_paused { Color::Yellow } else { Color::Green }))
.block(Block::default().borders(Borders::ALL));
f.render_widget(status, chunks[1]);
let filtered_entries: Vec<&LogEntry> = log_buffer
.entries()
.iter()
.filter(|entry| {
if let Some(filter_level) = log_level_filter {
if entry.level != filter_level {
return false;
}
}
if !search_query.is_empty() {
if !entry.message.to_lowercase().contains(&search_query.to_lowercase()) {
return false;
}
}
true
})
.collect();
let total_filtered = filtered_entries.len();
let log_entries: Vec<ListItem> = filtered_entries
.iter()
.rev()
.skip(scroll_offset)
.take(chunks[2].height.saturating_sub(2) as usize)
.enumerate()
.map(|(display_idx, entry)| {
let timestamp_str = entry.timestamp.format("%H:%M:%S%.3f").to_string();
let level_str = entry.level.as_str();
let level_color = entry.level.color();
let is_selected = if selection_mode {
if let (Some(start), Some(end)) = (selection_start, selection_end) {
let actual_idx = total_filtered.saturating_sub(1).saturating_sub(scroll_offset + display_idx);
let (sel_start, sel_end) = if start <= end { (start, end) } else { (end, start) };
actual_idx >= sel_start && actual_idx <= sel_end
} else {
false
}
} else {
false
};
let base_style = if is_selected {
Style::default().bg(Color::Yellow).fg(Color::Black)
} else {
Style::default()
};
let message_spans = if !search_query.is_empty() {
highlight_search(&entry.message, search_query)
} else {
vec![Span::raw(&entry.message)]
};
let message_spans: Vec<Span> = if is_selected {
let full_text: String = message_spans.iter()
.map(|s| s.content.as_ref())
.collect();
vec![Span::styled(full_text, base_style.fg(Color::Black))]
} else {
message_spans
};
let mut line_spans = vec![
Span::styled(
format!("[{}] ", timestamp_str),
if is_selected { base_style.fg(Color::Black) } else { Style::default().fg(Color::DarkGray) },
),
Span::styled(
format!("{} ", level_str),
if is_selected { base_style.fg(Color::Black).add_modifier(Modifier::BOLD) } else { Style::default().fg(level_color).add_modifier(Modifier::BOLD) },
),
];
line_spans.extend(message_spans);
ListItem::new(Line::from(line_spans))
})
.collect();
let logs_block = Block::default()
.borders(Borders::ALL)
.title(if is_paused {
" Logs (Paused - use ↑↓ to scroll) "
} else {
" Logs (Live) "
});
let logs = List::new(log_entries).block(logs_block);
f.render_widget(logs, chunks[2]);
let mut footer_lines = vec![];
if search_mode {
footer_lines.push(Line::from(vec![
Span::styled("🔍 Search: ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled(search_query, Style::default().fg(Color::White)),
Span::styled(" (Enter to apply, Esc to cancel)", Style::default().fg(Color::DarkGray)),
]));
} else if !search_query.is_empty() || log_level_filter.is_some() {
let mut status_spans = vec![Span::styled("Active: ", Style::default().add_modifier(Modifier::BOLD))];
if !search_query.is_empty() {
status_spans.push(Span::styled(
format!("Search: '{}' ", search_query),
Style::default().fg(Color::Yellow),
));
}
if let Some(level) = log_level_filter {
status_spans.push(Span::styled(
format!("Filter: {} ", level.as_str()),
Style::default().fg(level.color()),
));
}
status_spans.push(Span::styled("(0 to clear)", Style::default().fg(Color::DarkGray)));
footer_lines.push(Line::from(status_spans));
}
if selection_mode {
footer_lines.push(Line::from(vec![
Span::styled("SELECTION MODE: ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("↑↓", Style::default().fg(Color::Cyan)),
Span::raw(" Extend | "),
Span::styled("y", Style::default().fg(Color::Green)),
Span::raw(" or "),
Span::styled("Ctrl+C", Style::default().fg(Color::Green)),
Span::raw(" Copy | "),
Span::styled("Esc", Style::default().fg(Color::Red)),
Span::raw(" Cancel"),
]));
}
footer_lines.push(Line::from(vec![
Span::styled("↑↓", Style::default().fg(Color::Cyan)),
Span::raw(" or "),
Span::styled("Mouse Wheel", Style::default().fg(Color::Cyan)),
Span::raw(" Scroll | "),
Span::styled("PgUp/PgDn", Style::default().fg(Color::Cyan)),
Span::raw(" Page | "),
Span::styled("g/G", Style::default().fg(Color::Cyan)),
Span::raw(" Top/Bottom | "),
Span::styled("Space", Style::default().fg(Color::Yellow)),
Span::raw(" Pause | "),
Span::styled("/", Style::default().fg(Color::Magenta)),
Span::raw(" Search | "),
Span::styled("v", Style::default().fg(Color::Blue)),
Span::raw(" or "),
Span::styled("Click", Style::default().fg(Color::Blue)),
Span::raw(" Select | "),
Span::styled("1-4", Style::default().fg(Color::Green)),
Span::raw(" Filter | "),
Span::styled("E", Style::default().fg(Color::Cyan)),
Span::raw(" Export | "),
Span::styled("q", Style::default().fg(Color::Red)),
Span::raw(" Back"),
]));
if let Some(msg) = export_message {
footer_lines.push(Line::from(vec![
Span::styled("📁 ", Style::default().fg(Color::Green)),
Span::styled(msg, Style::default().fg(Color::Green).add_modifier(Modifier::ITALIC)),
]));
}
let footer = Paragraph::new(footer_lines)
.style(Style::default().fg(Color::Gray))
.block(Block::default().borders(Borders::ALL));
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));
}
}
fn highlight_search<'a>(text: &'a str, query: &str) -> Vec<Span<'a>> {
if query.is_empty() {
return vec![Span::raw(text)];
}
let lower_text = text.to_lowercase();
let lower_query = query.to_lowercase();
let mut spans = vec![];
let mut last_end = 0;
for (idx, _) in lower_text.match_indices(&lower_query) {
if idx > last_end {
spans.push(Span::raw(&text[last_end..idx]));
}
spans.push(Span::styled(
&text[idx..idx + query.len()],
Style::default().fg(Color::Black).bg(Color::Yellow).add_modifier(Modifier::BOLD),
));
last_end = idx + query.len();
}
if last_end < text.len() {
spans.push(Span::raw(&text[last_end..]));
}
spans
}
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();
}
}