use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use mecha10_core::enum_str_method;
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 super::tui_layout::{render_footer_hints, standard_tui_layout};
use std::sync::{Arc, Mutex};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiagnosticsCategory {
System,
Redis,
Nodes,
}
impl DiagnosticsCategory {
fn all() -> Vec<Self> {
vec![Self::System, Self::Redis, Self::Nodes]
}
}
enum_str_method!(
fn label(DiagnosticsCategory) -> &str {
System => "System",
Redis => "Redis",
Nodes => "Nodes",
}
);
enum_str_method!(
fn icon(DiagnosticsCategory) -> &str {
System => "💻",
Redis => "📡",
Nodes => "🔌",
}
);
#[derive(Default)]
struct DiagnosticsStateInner {
system: Option<DiagnosticMessage<SystemResourceMetrics>>,
redis_info: Option<DiagnosticMessage<RedisServerInfoMetrics>>,
nodes: HashMap<String, HealthReport>,
}
#[derive(Clone)]
pub struct DiagnosticsState {
inner: Arc<Mutex<DiagnosticsStateInner>>,
}
macro_rules! update_field_method {
($name:ident, $field:ident, $metrics:ty) => {
pub fn $name(&self, msg: DiagnosticMessage<$metrics>) {
if let Ok(mut inner) = self.inner.lock() {
inner.$field = Some(msg);
}
}
};
}
impl DiagnosticsState {
pub fn new() -> Self {
Self {
inner: Arc::new(Mutex::new(DiagnosticsStateInner::default())),
}
}
update_field_method!(update_system, system, SystemResourceMetrics);
update_field_method!(update_redis_info, redis_info, RedisServerInfoMetrics);
pub fn update_node_health(&self, report: HealthReport) {
if let Ok(mut inner) = self.inner.lock() {
inner.nodes.insert(report.node_id.clone(), report);
}
}
}
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 (content_area, footer_area) = standard_tui_layout(f.area());
let horizontal_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(28), Constraint::Min(40)])
.split(content_area);
self.draw_sidebar(f, horizontal_chunks[0]);
self.draw_details(f, horizontal_chunks[1]);
self.draw_footer(f, footer_area);
}
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::Nodes => self.draw_nodes_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_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_footer(&self, f: &mut Frame, area: Rect) {
render_footer_hints(
f,
area,
&[
("↑/↓", "Navigate", Color::Cyan),
("j/k", "Scroll", Color::Yellow),
("q/Esc", "Exit", Color::Red),
],
);
}
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)
}
}
}