use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use mecha10_core::health::HealthReport;
use mecha10_core::messages::HealthLevel;
use mecha10_diagnostics::prelude::*;
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
Frame,
};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiagnosticsCategory {
System,
Redis,
Streaming,
Nodes,
Godot,
}
impl DiagnosticsCategory {
fn all() -> Vec<Self> {
vec![Self::System, Self::Redis, Self::Streaming, Self::Nodes, Self::Godot]
}
fn label(&self) -> &str {
match self {
Self::System => "System",
Self::Redis => "Redis",
Self::Streaming => "Streaming",
Self::Nodes => "Nodes",
Self::Godot => "Godot",
}
}
fn icon(&self) -> &str {
match self {
Self::System => "💻",
Self::Redis => "📡",
Self::Streaming => "🎥",
Self::Nodes => "🔌",
Self::Godot => "🎮",
}
}
}
#[derive(Default)]
struct DiagnosticsStateInner {
system: Option<DiagnosticMessage<SystemResourceMetrics>>,
redis_info: Option<DiagnosticMessage<RedisServerInfoMetrics>>,
streaming_pipeline: Option<DiagnosticMessage<StreamingPipelineMetrics>>,
streaming_encoding: Option<DiagnosticMessage<EncodingMetrics>>,
streaming_bandwidth: Option<DiagnosticMessage<BandwidthMetrics>>,
nodes: HashMap<String, HealthReport>,
godot_connection: Option<DiagnosticMessage<GodotConnectionMetrics>>,
godot_performance: Option<DiagnosticMessage<GodotPerformanceMetrics>>,
}
#[derive(Clone)]
pub struct DiagnosticsState {
inner: Arc<Mutex<DiagnosticsStateInner>>,
}
impl DiagnosticsState {
pub fn new() -> Self {
Self {
inner: Arc::new(Mutex::new(DiagnosticsStateInner::default())),
}
}
pub fn update_system(&self, msg: DiagnosticMessage<SystemResourceMetrics>) {
if let Ok(mut inner) = self.inner.lock() {
inner.system = Some(msg);
}
}
pub fn update_redis_info(&self, msg: DiagnosticMessage<RedisServerInfoMetrics>) {
if let Ok(mut inner) = self.inner.lock() {
inner.redis_info = Some(msg);
}
}
pub fn update_streaming_pipeline(&self, msg: DiagnosticMessage<StreamingPipelineMetrics>) {
if let Ok(mut inner) = self.inner.lock() {
inner.streaming_pipeline = Some(msg);
}
}
pub fn update_streaming_encoding(&self, msg: DiagnosticMessage<EncodingMetrics>) {
if let Ok(mut inner) = self.inner.lock() {
inner.streaming_encoding = Some(msg);
}
}
pub fn update_streaming_bandwidth(&self, msg: DiagnosticMessage<BandwidthMetrics>) {
if let Ok(mut inner) = self.inner.lock() {
inner.streaming_bandwidth = Some(msg);
}
}
pub fn update_node_health(&self, report: HealthReport) {
if let Ok(mut inner) = self.inner.lock() {
inner.nodes.insert(report.node_id.clone(), report);
}
}
pub fn update_godot_connection(&self, msg: DiagnosticMessage<GodotConnectionMetrics>) {
if let Ok(mut inner) = self.inner.lock() {
inner.godot_connection = Some(msg);
}
}
pub fn update_godot_performance(&self, msg: DiagnosticMessage<GodotPerformanceMetrics>) {
if let Ok(mut inner) = self.inner.lock() {
inner.godot_performance = Some(msg);
}
}
}
impl Default for DiagnosticsState {
fn default() -> Self {
Self::new()
}
}
pub struct DiagnosticsTui {
state: DiagnosticsState,
categories: Vec<DiagnosticsCategory>,
selected_index: usize,
detail_scroll_offset: usize,
should_quit: bool,
}
impl DiagnosticsTui {
pub fn new(state: DiagnosticsState) -> Self {
Self {
state,
categories: DiagnosticsCategory::all(),
selected_index: 0,
detail_scroll_offset: 0,
should_quit: false,
}
}
pub fn should_quit(&self) -> bool {
self.should_quit
}
fn selected_category(&self) -> DiagnosticsCategory {
self.categories[self.selected_index]
}
pub fn draw(&mut self, f: &mut Frame) {
let vertical_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(10), Constraint::Length(3), ])
.split(f.area());
let horizontal_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(28), Constraint::Min(40), ])
.split(vertical_chunks[0]);
self.draw_sidebar(f, horizontal_chunks[0]);
self.draw_details(f, horizontal_chunks[1]);
self.draw_footer(f, vertical_chunks[1]);
}
fn draw_sidebar(&mut self, f: &mut Frame, area: Rect) {
let items: Vec<ListItem> = self
.categories
.iter()
.enumerate()
.map(|(i, cat)| {
let is_selected = i == self.selected_index;
let style = if is_selected {
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
let prefix = if is_selected { "â–¶ " } else { " " };
let formatted = format!("{}{} {}", prefix, cat.icon(), cat.label());
ListItem::new(formatted).style(style)
})
.collect();
let list = List::new(items).block(
Block::default()
.borders(Borders::ALL)
.title(" Categories ")
.border_style(Style::default().fg(Color::Cyan)),
);
let mut state = ListState::default();
state.select(Some(self.selected_index));
f.render_stateful_widget(list, area, &mut state);
}
fn draw_details(&self, f: &mut Frame, area: Rect) {
match self.selected_category() {
DiagnosticsCategory::System => self.draw_system_details(f, area),
DiagnosticsCategory::Redis => self.draw_redis_details(f, area),
DiagnosticsCategory::Streaming => self.draw_streaming_details(f, area),
DiagnosticsCategory::Nodes => self.draw_nodes_details(f, area),
DiagnosticsCategory::Godot => self.draw_godot_details(f, area),
}
}
fn draw_system_details(&self, f: &mut Frame, area: Rect) {
let inner = self.state.inner.lock().unwrap();
let mut lines = vec![
Line::from(vec![Span::styled(
"💻 SYSTEM RESOURCES",
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
)]),
Line::from(""),
];
if let Some(msg) = &inner.system {
let m = &msg.payload;
let cpu_bar = self.make_bar(m.cpu_percent, 100.0, 20);
lines.push(Line::from(vec![
Span::raw("CPU: "),
Span::styled(cpu_bar, self.percent_color(m.cpu_percent)),
Span::raw(format!(" {:.1}%", m.cpu_percent)),
]));
let mem_bar = self.make_bar(m.memory_percent, 100.0, 20);
let mem_used_gb = m.memory_used_bytes as f64 / 1_073_741_824.0;
let mem_total_gb = m.memory_total_bytes as f64 / 1_073_741_824.0;
lines.push(Line::from(vec![
Span::raw("Memory: "),
Span::styled(mem_bar, self.percent_color(m.memory_percent)),
Span::raw(format!(
" {:.1}% ({:.1} / {:.1} GB)",
m.memory_percent, mem_used_gb, mem_total_gb
)),
]));
let disk_bar = self.make_bar(m.disk_percent, 100.0, 20);
let disk_used_gb = m.disk_used_bytes as f64 / 1_073_741_824.0;
let disk_total_gb = m.disk_total_bytes as f64 / 1_073_741_824.0;
lines.push(Line::from(vec![
Span::raw("Disk: "),
Span::styled(disk_bar, self.percent_color(m.disk_percent)),
Span::raw(format!(
" {:.1}% ({:.0} / {:.0} GB)",
m.disk_percent, disk_used_gb, disk_total_gb
)),
]));
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
"Network:",
Style::default().add_modifier(Modifier::BOLD),
)]));
let rx_mbps = m.network_rx_bytes_per_sec as f64 / 1_048_576.0;
let tx_mbps = m.network_tx_bytes_per_sec as f64 / 1_048_576.0;
lines.push(Line::from(format!(" RX: {:.2} MB/s", rx_mbps)));
lines.push(Line::from(format!(" TX: {:.2} MB/s", tx_mbps)));
if !m.cpu_per_core.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
"Per-Core CPU:",
Style::default().add_modifier(Modifier::BOLD),
)]));
for (i, cpu) in m.cpu_per_core.iter().enumerate() {
let bar = self.make_bar(*cpu, 100.0, 10);
lines.push(Line::from(vec![
Span::raw(format!(" Core {}: ", i)),
Span::styled(bar, self.percent_color(*cpu)),
Span::raw(format!(" {:.1}%", cpu)),
]));
}
}
} else {
lines.push(Line::from(vec![Span::styled(
"Waiting for metrics...",
Style::default().fg(Color::DarkGray),
)]));
}
let paragraph = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.title(" Details ")
.border_style(Style::default().fg(Color::Yellow)),
)
.wrap(Wrap { trim: false })
.scroll((self.detail_scroll_offset as u16, 0));
f.render_widget(paragraph, area);
}
fn draw_redis_details(&self, f: &mut Frame, area: Rect) {
let inner = self.state.inner.lock().unwrap();
let mut lines = vec![
Line::from(vec![Span::styled(
"📡 REDIS",
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
)]),
Line::from(""),
];
if let Some(msg) = &inner.redis_info {
let m = &msg.payload;
lines.push(Line::from(vec![
Span::raw("Status: "),
Span::styled("🟢 Connected", Style::default().fg(Color::Green)),
]));
lines.push(Line::from(format!("Version: {}", m.redis_version)));
lines.push(Line::from(format!(
"Uptime: {}",
self.format_duration(m.uptime_seconds)
)));
lines.push(Line::from(format!("Clients: {}", m.connected_clients)));
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
"Memory:",
Style::default().add_modifier(Modifier::BOLD),
)]));
let used_mb = m.used_memory as f64 / 1_048_576.0;
let peak_mb = m.used_memory_peak as f64 / 1_048_576.0;
lines.push(Line::from(format!(" Used: {:.1} MB", used_mb)));
lines.push(Line::from(format!(" Peak: {:.1} MB", peak_mb)));
lines.push(Line::from(format!(" Keys: {}", m.db0_keys)));
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
"Operations:",
Style::default().add_modifier(Modifier::BOLD),
)]));
lines.push(Line::from(format!(" Ops/sec: {}", m.instantaneous_ops_per_sec)));
lines.push(Line::from(format!(" Total cmds: {}", m.total_commands_processed)));
let total_hits = m.keyspace_hits + m.keyspace_misses;
if total_hits > 0 {
let hit_rate = (m.keyspace_hits as f64 / total_hits as f64) * 100.0;
lines.push(Line::from(format!(" Hit rate: {:.1}%", hit_rate)));
}
} else {
lines.push(Line::from(vec![Span::styled(
"Waiting for metrics...",
Style::default().fg(Color::DarkGray),
)]));
}
let paragraph = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.title(" Details ")
.border_style(Style::default().fg(Color::Yellow)),
)
.wrap(Wrap { trim: false })
.scroll((self.detail_scroll_offset as u16, 0));
f.render_widget(paragraph, area);
}
fn draw_streaming_details(&self, f: &mut Frame, area: Rect) {
let inner = self.state.inner.lock().unwrap();
let mut lines = vec![
Line::from(vec![Span::styled(
"🎥 STREAMING PIPELINE",
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
)]),
Line::from(""),
];
if let Some(msg) = &inner.streaming_pipeline {
let m = &msg.payload;
lines.push(Line::from(format!("FPS: {:.1}", m.fps)));
lines.push(Line::from(format!("Frames Received: {}", m.frames_received)));
lines.push(Line::from(format!("Frames Encoded: {}", m.frames_encoded)));
lines.push(Line::from(format!("Frames Sent: {}", m.frames_sent)));
let drop_style = if m.frames_dropped > 0 {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::Green)
};
lines.push(Line::from(vec![
Span::raw("Frames Dropped: "),
Span::styled(format!("{}", m.frames_dropped), drop_style),
if m.frames_dropped > 0 {
Span::styled(" âš ", Style::default().fg(Color::Yellow))
} else {
Span::styled(" ✓", Style::default().fg(Color::Green))
},
]));
let bandwidth_kbps = m.bytes_per_second as f64 / 1024.0;
lines.push(Line::from(format!("Bandwidth: {:.1} Kbps", bandwidth_kbps)));
} else {
lines.push(Line::from(vec![Span::styled(
"No streaming data",
Style::default().fg(Color::DarkGray),
)]));
}
if let Some(msg) = &inner.streaming_bandwidth {
let m = &msg.payload;
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
"Bandwidth Details:",
Style::default().add_modifier(Modifier::BOLD),
)]));
let bitrate_mbps = m.bitrate_bps as f64 / 1_000_000.0;
let target_mbps = m.target_bitrate_bps as f64 / 1_000_000.0;
lines.push(Line::from(format!(" Bitrate: {:.2} Mbps", bitrate_mbps)));
lines.push(Line::from(format!(" Target: {:.2} Mbps", target_mbps)));
lines.push(Line::from(format!(" Utilization: {:.1}%", m.utilization * 100.0)));
lines.push(Line::from(format!(" Avg Frame: {} bytes", m.avg_frame_size_bytes)));
}
if let Some(msg) = &inner.streaming_encoding {
let m = &msg.payload;
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
"Encoding:",
Style::default().add_modifier(Modifier::BOLD),
)]));
lines.push(Line::from(format!(" Queue Depth: {}", m.queue_depth)));
lines.push(Line::from(format!(" Slow Frames: {}", m.slow_frames)));
}
let paragraph = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.title(" Details ")
.border_style(Style::default().fg(Color::Yellow)),
)
.wrap(Wrap { trim: false })
.scroll((self.detail_scroll_offset as u16, 0));
f.render_widget(paragraph, area);
}
fn draw_nodes_details(&self, f: &mut Frame, area: Rect) {
let inner = self.state.inner.lock().unwrap();
let mut lines = vec![
Line::from(vec![Span::styled(
"🔌 NODES",
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
)]),
Line::from(""),
];
if inner.nodes.is_empty() {
lines.push(Line::from(vec![Span::styled(
"No nodes reporting health",
Style::default().fg(Color::DarkGray),
)]));
lines.push(Line::from(""));
lines.push(Line::from("Nodes report to /system/health topic."));
lines.push(Line::from("Start nodes with: mecha10 dev"));
} else {
let mut nodes: Vec<_> = inner.nodes.values().collect();
nodes.sort_by(|a, b| a.node_id.cmp(&b.node_id));
let now_us = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_micros() as u64)
.unwrap_or(0);
for report in nodes {
let (icon, status_style, status_text) = match report.status.level {
HealthLevel::Ok => ("🟢", Style::default().fg(Color::Green), "Ok"),
HealthLevel::Degraded => ("🟡", Style::default().fg(Color::Yellow), "Degraded"),
HealthLevel::Error => ("🔴", Style::default().fg(Color::Red), "Error"),
HealthLevel::Unknown => ("⚪", Style::default().fg(Color::DarkGray), "Unknown"),
};
let age_secs = if report.reported_at > 0 && now_us > report.reported_at {
(now_us - report.reported_at) / 1_000_000
} else {
0
};
let age_str = if age_secs < 60 {
format!("{}s ago", age_secs)
} else {
format!("{}m ago", age_secs / 60)
};
lines.push(Line::from(vec![
Span::raw(format!("{} ", icon)),
Span::styled(&report.node_id, Style::default().add_modifier(Modifier::BOLD)),
]));
lines.push(Line::from(vec![
Span::raw(" Status: "),
Span::styled(status_text, status_style),
Span::raw(format!(" Priority: {:?}", report.priority)),
]));
lines.push(Line::from(format!(" Last report: {}", age_str)));
if !report.status.message.is_empty() {
lines.push(Line::from(vec![
Span::raw(" â”” "),
Span::styled(&report.status.message, Style::default().fg(Color::DarkGray)),
]));
}
lines.push(Line::from(""));
}
}
let paragraph = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.title(" Details ")
.border_style(Style::default().fg(Color::Yellow)),
)
.wrap(Wrap { trim: false })
.scroll((self.detail_scroll_offset as u16, 0));
f.render_widget(paragraph, area);
}
fn draw_godot_details(&self, f: &mut Frame, area: Rect) {
let inner = self.state.inner.lock().unwrap();
let mut lines = vec![
Line::from(vec![Span::styled(
"🎮 GODOT SIMULATION",
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
)]),
Line::from(""),
];
if let Some(msg) = &inner.godot_connection {
let m = &msg.payload;
let (ctrl_icon, ctrl_style) = if m.control_connected {
("🟢", Style::default().fg(Color::Green))
} else {
("🔴", Style::default().fg(Color::Red))
};
let ctrl_status = if m.control_connected {
"Connected"
} else {
"Disconnected"
};
lines.push(Line::from(vec![
Span::raw("Control: "),
Span::raw(format!("{} ", ctrl_icon)),
Span::styled(ctrl_status, ctrl_style),
]));
lines.push(Line::from(format!(
" Uptime: {} Reconnects: {}",
self.format_duration(m.control_uptime_seconds),
m.control_reconnects
)));
let (cam_icon, cam_style) = if m.camera_connected {
("🟢", Style::default().fg(Color::Green))
} else {
("🔴", Style::default().fg(Color::Red))
};
let cam_status = if m.camera_connected {
"Connected"
} else {
"Disconnected"
};
lines.push(Line::from(vec![
Span::raw("Camera: "),
Span::raw(format!("{} ", cam_icon)),
Span::styled(cam_status, cam_style),
]));
lines.push(Line::from(format!(
" Uptime: {} Reconnects: {}",
self.format_duration(m.camera_uptime_seconds),
m.camera_reconnects
)));
} else {
lines.push(Line::from(vec![Span::styled(
"No connection data",
Style::default().fg(Color::DarkGray),
)]));
}
if let Some(msg) = &inner.godot_performance {
let m = &msg.payload;
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
"Performance:",
Style::default().add_modifier(Modifier::BOLD),
)]));
let fps_style = if m.fps < m.target_fps * 0.9 {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::Green)
};
lines.push(Line::from(vec![
Span::raw(" FPS: "),
Span::styled(format!("{:.1}", m.fps), fps_style),
Span::raw(format!(" / {:.0} target", m.target_fps)),
]));
lines.push(Line::from(format!(" Frame Time: {:.2} ms", m.frame_time_ms)));
lines.push(Line::from(format!(" Physics: {:.2} ms", m.physics_time_ms)));
lines.push(Line::from(format!(" Render: {:.2} ms", m.render_time_ms)));
if m.dropped_frames > 0 {
lines.push(Line::from(vec![
Span::raw(" Dropped: "),
Span::styled(format!("{}", m.dropped_frames), Style::default().fg(Color::Yellow)),
]));
}
}
let paragraph = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.title(" Details ")
.border_style(Style::default().fg(Color::Yellow)),
)
.wrap(Wrap { trim: false })
.scroll((self.detail_scroll_offset as u16, 0));
f.render_widget(paragraph, area);
}
fn draw_footer(&self, f: &mut Frame, area: Rect) {
let footer_text = vec![Line::from(vec![
Span::raw(" "),
Span::styled("↑/↓:", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
Span::raw(" Navigate "),
Span::styled("j/k:", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::raw(" Scroll "),
Span::styled("q/Esc:", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
Span::raw(" Exit"),
])];
let footer = Paragraph::new(footer_text).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray)),
);
f.render_widget(footer, area);
}
pub fn handle_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Char('q') | KeyCode::Esc => {
self.should_quit = true;
}
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.should_quit = true;
}
KeyCode::Up => {
if self.selected_index > 0 {
self.selected_index -= 1;
self.detail_scroll_offset = 0;
}
}
KeyCode::Down => {
if self.selected_index < self.categories.len().saturating_sub(1) {
self.selected_index += 1;
self.detail_scroll_offset = 0;
}
}
KeyCode::Char('k') => {
self.detail_scroll_offset = self.detail_scroll_offset.saturating_sub(1);
}
KeyCode::Char('j') => {
self.detail_scroll_offset += 1;
}
KeyCode::PageUp => {
self.detail_scroll_offset = self.detail_scroll_offset.saturating_sub(10);
}
KeyCode::PageDown => {
self.detail_scroll_offset += 10;
}
_ => {}
}
}
fn make_bar(&self, value: f64, max: f64, width: usize) -> String {
let ratio = (value / max).clamp(0.0, 1.0);
let filled = (ratio * width as f64).round() as usize;
let empty = width.saturating_sub(filled);
format!("{}{}", "â–ˆ".repeat(filled), "â–‘".repeat(empty))
}
fn percent_color(&self, percent: f64) -> Style {
if percent >= 90.0 {
Style::default().fg(Color::Red)
} else if percent >= 70.0 {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::Green)
}
}
fn format_duration(&self, seconds: u64) -> String {
if seconds < 60 {
format!("{}s", seconds)
} else if seconds < 3600 {
format!("{}m {}s", seconds / 60, seconds % 60)
} else if seconds < 86400 {
let hours = seconds / 3600;
let mins = (seconds % 3600) / 60;
format!("{}h {}m", hours, mins)
} else {
let days = seconds / 86400;
let hours = (seconds % 86400) / 3600;
format!("{}d {}h", days, hours)
}
}
}