#![allow(unused)]
use std::io;
use std::path::PathBuf;
use std::time::{Duration, Instant};
use anyhow::Result;
use clap::Parser;
use crossterm::{
event::{DisableMouseCapture, EnableMouseCapture, Event},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Layout},
Terminal,
};
mod app;
mod data;
mod events;
mod source;
mod ui;
#[cfg(feature = "subscribe")]
mod subscribe;
use app::{App, View};
use source::{DataSource, FileSource, StreamSource};
#[derive(Parser, Debug)]
#[command(name = "buswatch")]
#[command(about = "Diagnostic TUI for monitoring Caryatid message bus activity")]
struct Args {
#[cfg_attr(
feature = "subscribe",
arg(short, long, default_value = "monitor.json", conflicts_with_all = ["connect", "subscribe"])
)]
#[cfg_attr(
not(feature = "subscribe"),
arg(short, long, default_value = "monitor.json", conflicts_with_all = ["connect"])
)]
file: PathBuf,
#[cfg_attr(
feature = "subscribe",
arg(short, long, conflicts_with_all = ["file", "subscribe"])
)]
#[cfg_attr(
not(feature = "subscribe"),
arg(short, long, conflicts_with_all = ["file"])
)]
connect: Option<String>,
#[cfg(feature = "subscribe")]
#[arg(short, long, conflicts_with_all = ["file", "connect"])]
subscribe: Option<PathBuf>,
#[cfg(feature = "subscribe")]
#[arg(
long,
default_value = "caryatid.monitor.snapshot",
requires = "subscribe"
)]
topic: String,
#[arg(short, long, default_value = "1")]
refresh: u64,
#[arg(long, default_value = "1s")]
pending_warn: String,
#[arg(long, default_value = "10s")]
pending_crit: String,
#[arg(long, default_value = "1000")]
unread_warn: u64,
#[arg(long, default_value = "5000")]
unread_crit: u64,
#[cfg_attr(
feature = "subscribe",
arg(short, long, conflicts_with_all = ["connect", "subscribe"])
)]
#[cfg_attr(
not(feature = "subscribe"),
arg(short, long, conflicts_with_all = ["connect"])
)]
export: Option<PathBuf>,
}
fn main() -> Result<()> {
let args = Args::parse();
let pending_warn = data::duration::parse_duration(&args.pending_warn)
.unwrap_or(std::time::Duration::from_secs(1));
let pending_crit = data::duration::parse_duration(&args.pending_crit)
.unwrap_or(std::time::Duration::from_secs(10));
let thresholds = data::Thresholds {
pending_warning: pending_warn,
pending_critical: pending_crit,
unread_warning: args.unread_warn,
unread_critical: args.unread_crit,
};
if let Some(export_path) = args.export {
return export_to_file(&args.file, &export_path, &thresholds);
}
if let Some(ref addr) = args.connect {
return run_with_tcp(addr, thresholds);
}
#[cfg(feature = "subscribe")]
if let Some(ref config_path) = args.subscribe {
return run_with_subscribe(config_path, &args.topic, thresholds);
}
run_with_file(&args.file, thresholds, Duration::from_secs(args.refresh))
}
fn run_with_file(path: &PathBuf, thresholds: data::Thresholds, refresh: Duration) -> Result<()> {
let source = Box::new(FileSource::new(path));
run_tui(source, thresholds, refresh)
}
#[cfg(feature = "subscribe")]
fn run_with_subscribe(
config_path: &std::path::Path,
topic: &str,
thresholds: data::Thresholds,
) -> Result<()> {
use subscribe::create_subscriber;
let rt = tokio::runtime::Runtime::new()?;
let (source, handle) = rt.block_on(async {
let (source, handle) = create_subscriber(config_path, topic).await?;
Ok::<_, anyhow::Error>((source, handle))
})?;
let result = run_tui(Box::new(source), thresholds, Duration::from_millis(100));
handle.abort();
result
}
fn run_with_tcp(addr: &str, thresholds: data::Thresholds) -> Result<()> {
let rt = tokio::runtime::Runtime::new()?;
let source = rt.block_on(async {
use tokio::net::TcpStream;
println!("Connecting to {}...", addr);
match TcpStream::connect(addr).await {
Ok(stream) => {
println!("Connected!");
Ok(Box::new(StreamSource::spawn(stream, addr)) as Box<dyn DataSource>)
}
Err(e) => Err(anyhow::anyhow!("Failed to connect to {}: {}", addr, e)),
}
})?;
run_tui(source, thresholds, Duration::from_millis(100))
}
fn run_tui(
source: Box<dyn DataSource>,
thresholds: data::Thresholds,
refresh_interval: Duration,
) -> Result<()> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic| {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen);
original_hook(panic);
}));
let mut app = App::new(source, thresholds);
let _ = app.reload_data();
let result = run_app(&mut terminal, &mut app, refresh_interval);
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
result
}
fn run_app(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut App,
refresh_interval: Duration,
) -> Result<()> {
let mut last_refresh = Instant::now();
const MIN_WIDTH: u16 = 60;
const MIN_HEIGHT: u16 = 12;
while app.running {
terminal.draw(|frame| {
let area = frame.area();
if area.width < MIN_WIDTH || area.height < MIN_HEIGHT {
let msg = format!(
"Terminal too small: {}x{}\nMinimum: {}x{}\n\nResize to continue",
area.width, area.height, MIN_WIDTH, MIN_HEIGHT
);
let paragraph = ratatui::widgets::Paragraph::new(msg)
.alignment(ratatui::layout::Alignment::Center)
.style(ratatui::style::Style::default().fg(ratatui::style::Color::Yellow));
let centered = ratatui::layout::Rect::new(0, area.height / 2 - 2, area.width, 5);
frame.render_widget(paragraph, centered);
return;
}
let chunks = Layout::vertical([
Constraint::Length(1), Constraint::Length(1), Constraint::Min(8), Constraint::Length(1), ])
.split(area);
ui::common::render_header(frame, app, chunks[0]);
ui::common::render_tabs(frame, app, chunks[1]);
match app.current_view {
View::Summary => ui::summary::render(frame, app, chunks[2]),
View::Bottleneck => ui::bottleneck::render(frame, app, chunks[2]),
View::DataFlow => ui::flow::render(frame, app, chunks[2]),
}
ui::common::render_status_bar(frame, app, chunks[3]);
if app.show_detail_overlay {
ui::detail::render_overlay(frame, app, area);
}
if app.show_help {
ui::common::render_help(frame, app, area);
}
})?;
if let Some(event) = events::poll_event(Duration::from_millis(100))? {
match event {
Event::Key(key) => events::handle_key_event(app, key),
Event::Mouse(mouse) => {
events::handle_mouse_event(app, mouse, 3);
}
Event::Resize(_, _) => {
}
_ => {}
}
}
if last_refresh.elapsed() >= refresh_interval {
let _ = app.reload_data();
last_refresh = Instant::now();
}
}
Ok(())
}
fn export_to_file(
monitor_path: &std::path::Path,
export_path: &std::path::Path,
thresholds: &data::Thresholds,
) -> Result<()> {
use std::io::Write;
let monitor_data = data::MonitorData::load(monitor_path, thresholds)?;
let mut export = serde_json::Map::new();
let mut summary = serde_json::Map::new();
summary.insert(
"total_modules".to_string(),
serde_json::json!(monitor_data.modules.len()),
);
let healthy = monitor_data
.modules
.iter()
.filter(|m| m.health == data::HealthStatus::Healthy)
.count();
let warning = monitor_data
.modules
.iter()
.filter(|m| m.health == data::HealthStatus::Warning)
.count();
let critical = monitor_data
.modules
.iter()
.filter(|m| m.health == data::HealthStatus::Critical)
.count();
summary.insert("healthy".to_string(), serde_json::json!(healthy));
summary.insert("warning".to_string(), serde_json::json!(warning));
summary.insert("critical".to_string(), serde_json::json!(critical));
let total_reads: u64 = monitor_data.modules.iter().map(|m| m.total_read).sum();
let total_writes: u64 = monitor_data.modules.iter().map(|m| m.total_written).sum();
summary.insert("total_reads".to_string(), serde_json::json!(total_reads));
summary.insert("total_writes".to_string(), serde_json::json!(total_writes));
export.insert("summary".to_string(), serde_json::Value::Object(summary));
let modules: Vec<serde_json::Value> = monitor_data
.modules
.iter()
.map(|m| {
serde_json::json!({
"name": m.name,
"total_read": m.total_read,
"total_written": m.total_written,
"health": format!("{:?}", m.health),
"reads": m.reads.iter().map(|r| {
serde_json::json!({
"topic": r.topic,
"read": r.read,
"pending_for": r.pending_for.map(|d| format!("{:?}", d)),
"unread": r.unread,
"status": format!("{:?}", r.status)
})
}).collect::<Vec<_>>(),
"writes": m.writes.iter().map(|w| {
serde_json::json!({
"topic": w.topic,
"written": w.written,
"pending_for": w.pending_for.map(|d| format!("{:?}", d)),
"status": format!("{:?}", w.status)
})
}).collect::<Vec<_>>()
})
})
.collect();
export.insert("modules".to_string(), serde_json::Value::Array(modules));
let bottlenecks: Vec<serde_json::Value> = monitor_data
.unhealthy_topics()
.iter()
.map(|(module, topic)| {
serde_json::json!({
"module": module.name,
"topic": topic.topic(),
"status": format!("{:?}", topic.status()),
"pending_for": topic.pending_for().map(|d| format!("{:?}", d))
})
})
.collect();
export.insert(
"bottlenecks".to_string(),
serde_json::Value::Array(bottlenecks),
);
let json = serde_json::to_string_pretty(&serde_json::Value::Object(export))?;
let mut file = std::fs::File::create(export_path)?;
file.write_all(json.as_bytes())?;
println!("Exported monitor state to: {}", export_path.display());
Ok(())
}