use crate::api::models::Metric;
use crate::state::MetricsState;
use crate::ui::{logs::render_api_error_banner, render_tab_bar};
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Cell, Paragraph, Row, Sparkline, Table, TableState, Wrap},
Frame,
};
pub fn render_metrics_view(
frame: &mut Frame,
area: Rect,
state: &MetricsState,
api_error: Option<&str>,
) {
let content_area = if let Some(err) = api_error {
let splits = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(0)])
.split(area);
render_api_error_banner(frame, splits[0], err);
splits[1]
} else {
area
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Min(3), Constraint::Length(1), ])
.split(content_area);
render_tab_bar(frame, chunks[0], "Metrics");
if state.show_detail {
render_metrics_with_detail(frame, chunks[1], state);
} else {
render_metrics_table(frame, chunks[1], state);
}
render_status_bar(frame, chunks[2], state, api_error);
}
fn render_metrics_table(frame: &mut Frame, area: Rect, state: &MetricsState) {
use crate::api::models::MetricValue;
let unique_metrics = state.unique_filtered_metrics();
if unique_metrics.is_empty() && state.error.is_none() {
let block = Block::default()
.borders(Borders::ALL)
.title(" Metrics (0) ");
let inner = block.inner(area);
frame.render_widget(block, area);
let paragraph =
Paragraph::new("No metrics yet — send OTLP data to :4317 (gRPC) or :4318 (HTTP)")
.alignment(Alignment::Center)
.style(Style::default().fg(Color::DarkGray));
if inner.height > 2 {
let v_splits = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(inner.height / 2),
Constraint::Length(1),
Constraint::Min(0),
])
.split(inner);
frame.render_widget(paragraph, v_splits[1]);
} else {
frame.render_widget(paragraph, inner);
}
return;
}
let rows: Vec<Row> = unique_metrics
.iter()
.map(|(metric, count)| {
let latest_value = match &metric.value {
MetricValue::Gauge(v) => format!("{:.2}", v),
MetricValue::Counter(v) => format!("{}", v),
MetricValue::Histogram(h) if h.count > 0 => {
format!("avg {:.1}", h.sum / h.count as f64)
},
MetricValue::Histogram(h) => format!("count={}", h.count),
MetricValue::Summary(s) if s.count > 0 => {
format!("avg {:.1}", s.sum / s.count as f64)
},
MetricValue::Summary(s) => format!("count={}", s.count),
};
let unit = metric.unit.as_deref().unwrap_or("");
let value_with_unit = if unit.is_empty() {
latest_value
} else {
format!("{} {}", latest_value, unit)
};
let type_cell = Cell::from(metric.metric_type.clone()).style(
Style::default()
.fg(get_metric_type_color(&metric.metric_type))
.add_modifier(Modifier::BOLD),
);
Row::new(vec![
Cell::from(truncate_string(&metric.name, 38)),
type_cell,
Cell::from(value_with_unit),
Cell::from(count.to_string()),
Cell::from(truncate_string(
metric.description.as_deref().unwrap_or(""),
40,
)),
])
.height(1)
})
.collect();
let header = Row::new(vec!["Name", "Type", "Latest Value", "Pts", "Description"])
.style(Style::default().add_modifier(Modifier::BOLD))
.bottom_margin(1);
let table = Table::new(
rows,
[
Constraint::Min(30), Constraint::Length(10), Constraint::Length(16), Constraint::Length(5), Constraint::Min(20), ],
)
.header(header)
.block(
Block::default()
.borders(Borders::ALL)
.title(format!(" Metrics ({}) ", unique_metrics.len()))
.border_style(Style::default().fg(Color::Magenta)),
)
.row_highlight_style(
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
let mut table_state = TableState::default();
table_state.select(Some(state.selected_index));
frame.render_stateful_widget(table, area, &mut table_state);
}
fn render_metrics_with_detail(frame: &mut Frame, area: Rect, state: &MetricsState) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(50), Constraint::Percentage(50), ])
.split(area);
render_metrics_table(frame, chunks[0], state);
render_detail_panel(frame, chunks[1], state);
}
fn render_detail_panel(frame: &mut Frame, area: Rect, state: &MetricsState) {
if let Some(metric) = state.selected_metric() {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(12), Constraint::Min(5), ])
.split(area);
render_metric_info(frame, chunks[0], metric);
render_metric_chart(frame, chunks[1], metric, state);
} else {
let paragraph = Paragraph::new("No metric selected").block(
Block::default()
.borders(Borders::ALL)
.title(" Metric Detail "),
);
frame.render_widget(paragraph, area);
}
}
fn render_metric_info(frame: &mut Frame, area: Rect, metric: &Metric) {
use crate::api::models::MetricValue;
let latest_value = match &metric.value {
MetricValue::Gauge(v) => format!("{:.2}", v),
MetricValue::Counter(v) => format!("{}", v),
MetricValue::Histogram(h) => format!("sum={:.2}, count={}", h.sum, h.count),
MetricValue::Summary(s) => format!("sum={:.2}, count={}", s.sum, s.count),
};
let unit = metric.unit.as_deref().unwrap_or("none");
let lines = vec![
Line::from(vec![
Span::styled("Name: ", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(&metric.name),
]),
Line::from(vec![
Span::styled("Type: ", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(&metric.metric_type),
]),
Line::from(vec![
Span::styled("Unit: ", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(unit),
]),
Line::from(""),
Line::from(vec![
Span::styled("Value: ", Style::default().add_modifier(Modifier::BOLD)),
Span::styled(latest_value, Style::default().fg(Color::Green)),
]),
Line::from(""),
Line::from(vec![Span::styled(
"Description:",
Style::default().add_modifier(Modifier::BOLD),
)]),
Line::from(metric.description.as_deref().unwrap_or("No description")),
Line::from(vec![
Span::styled("Timestamp: ", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(crate::ui::logs::format_timestamp_full(metric.timestamp)),
]),
];
let paragraph = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.title(" Metric Info "),
)
.wrap(Wrap { trim: true });
frame.render_widget(paragraph, area);
}
fn render_metric_chart(frame: &mut Frame, area: Rect, metric: &Metric, state: &MetricsState) {
use crate::api::models::MetricValue;
if let Some(history) = state.get_metric_history(&metric.name) {
if history.len() > 1 {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(3), Constraint::Length(3), ])
.split(area);
let min = history.iter().copied().fold(f64::INFINITY, f64::min);
let max = history.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let current = history.last().copied().unwrap_or(0.0);
let (trend_color, trend_indicator) = if history.len() >= 2 {
let prev = history[history.len() - 2];
let change = current - prev;
let change_pct = if prev != 0.0 {
(change / prev) * 100.0
} else {
0.0
};
let color = match &metric.metric_type.to_lowercase().as_str() {
&"counter" if metric.name.contains("error") || metric.name.contains("fail") => {
if current > 0.0 {
Color::Red
} else {
Color::Green
}
},
_ => {
if change_pct.abs() < 1.0 {
Color::Green } else if change > 0.0 {
Color::Yellow } else {
Color::Cyan }
},
};
let indicator = if change > 0.0 {
"↑"
} else if change < 0.0 {
"↓"
} else {
"→"
};
(color, indicator)
} else {
(Color::Green, "→")
};
let sparkline_data: Vec<u64> = history.iter().map(|&v| v.max(0.0) as u64).collect();
let max_u64 = max.max(0.0) as u64;
let sparkline = Sparkline::default()
.block(
Block::default()
.borders(Borders::ALL)
.title(format!(" Trend (last {} points) ", history.len())),
)
.data(&sparkline_data)
.style(Style::default().fg(trend_color))
.max(max_u64)
.direction(ratatui::widgets::RenderDirection::LeftToRight);
frame.render_widget(sparkline, chunks[0]);
let stats_text = format!(
"Min: {:.2} Max: {:.2} Current: {:.2} {}",
min, max, current, trend_indicator
);
let stats = Paragraph::new(stats_text)
.style(Style::default().fg(trend_color))
.block(Block::default().borders(Borders::ALL).title(" Stats "));
frame.render_widget(stats, chunks[1]);
return;
}
}
let display_text = match &metric.value {
MetricValue::Gauge(v) => format!("Current: {:.2}", v),
MetricValue::Counter(v) => format!("Total: {}", v),
MetricValue::Histogram(h) => {
format!(
"Count: {}, Sum: {:.2}, Buckets: {}",
h.count,
h.sum,
h.buckets.len()
)
},
MetricValue::Summary(s) => {
format!(
"Count: {}, Sum: {:.2}, Quantiles: {}",
s.count,
s.sum,
s.quantiles.len()
)
},
};
let paragraph = Paragraph::new(vec![
Line::from(display_text),
Line::from(""),
Line::from(Span::styled(
"Waiting for more data points...",
Style::default().fg(Color::DarkGray),
)),
])
.block(
Block::default()
.borders(Borders::ALL)
.title(" Current Value "),
);
frame.render_widget(paragraph, area);
}
fn render_status_bar(frame: &mut Frame, area: Rect, state: &MetricsState, api_error: Option<&str>) {
let mut status_parts = vec![];
status_parts.push(Span::styled(
" METRICS ",
Style::default()
.fg(Color::Black)
.bg(Color::Magenta)
.add_modifier(Modifier::BOLD),
));
status_parts.push(Span::raw(" "));
if api_error.is_some() {
status_parts.push(Span::styled(
"Disconnected",
Style::default().fg(Color::Red),
));
} else {
status_parts.push(Span::styled("Connected", Style::default().fg(Color::Green)));
}
status_parts.push(Span::styled(
format!(" | Metrics: {} ", state.unique_filtered_metrics().len()),
Style::default().fg(Color::Gray),
));
if !state.search_query.is_empty() {
status_parts.push(Span::raw(" "));
status_parts.push(Span::styled(
format!(" 🔍 {} ", state.search_query),
Style::default().fg(Color::Yellow),
));
}
if !state.filters.is_empty() {
status_parts.push(Span::raw(" "));
let filter_text = state
.filters
.iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect::<Vec<_>>()
.join(", ");
status_parts.push(Span::styled(
format!(" 🔧 {} ", filter_text),
Style::default().fg(Color::Cyan),
));
}
if let Some(error) = &state.error {
status_parts.push(Span::raw(" "));
status_parts.push(Span::styled(
format!(" ⚠ {} ", error),
Style::default().fg(Color::Red),
));
}
status_parts.push(Span::raw(" | "));
status_parts.push(Span::styled(
"↑↓/jk: Navigate | Enter: Detail | /: Search | f: Filter | q: Quit",
Style::default().fg(Color::Gray),
));
let status_line = Line::from(status_parts);
let paragraph = Paragraph::new(status_line);
frame.render_widget(paragraph, area);
}
fn get_metric_type_color(metric_type: &str) -> Color {
match metric_type.to_lowercase().as_str() {
"counter" => Color::Green,
"gauge" => Color::Blue,
"histogram" => Color::Magenta,
"summary" => Color::Yellow,
_ => Color::White,
}
}
fn truncate_string(s: &str, max_len: usize) -> String {
if s.len() <= max_len {
s.to_string()
} else {
format!("{}...", &s[..max_len - 3])
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::models::{MetricValue, Resource};
use std::collections::HashMap;
fn create_test_metric(name: &str, value: MetricValue) -> Metric {
Metric {
name: name.to_string(),
description: Some("Test metric".to_string()),
unit: Some("ms".to_string()),
metric_type: "gauge".to_string(),
value,
timestamp: 1713360896789,
resource: Some(Resource {
attributes: HashMap::new(),
}),
attributes: HashMap::new(),
}
}
#[test]
fn test_truncate_string() {
let short = "Hello";
assert_eq!(truncate_string(short, 10), "Hello");
let long = "This is a very long string that needs truncation";
let truncated = truncate_string(long, 20);
assert_eq!(truncated.len(), 20);
assert!(truncated.ends_with("..."));
}
#[test]
fn test_truncate_string_exact_length() {
let exact = "Exactly20Characters!";
assert_eq!(truncate_string(exact, 20), exact);
}
#[test]
fn test_truncate_string_empty() {
assert_eq!(truncate_string("", 10), "");
}
#[test]
fn test_gauge_metric_formatting() {
let metric = create_test_metric("test.gauge", MetricValue::Gauge(42.5));
assert_eq!(metric.name, "test.gauge");
assert_eq!(metric.unit, Some("ms".to_string()));
if let MetricValue::Gauge(v) = metric.value {
let formatted = format!("{:.2}", v);
assert_eq!(formatted, "42.50");
} else {
panic!("Expected Gauge value");
}
}
#[test]
fn test_counter_metric_formatting() {
let metric = create_test_metric("test.counter", MetricValue::Counter(100));
if let MetricValue::Counter(v) = metric.value {
let formatted = format!("{}", v);
assert_eq!(formatted, "100");
} else {
panic!("Expected Counter value");
}
}
#[test]
fn test_histogram_metric_formatting() {
use otelite_core::api::HistogramValue;
let metric = create_test_metric(
"test.histogram",
MetricValue::Histogram(HistogramValue {
count: 10,
sum: 123.45,
buckets: vec![],
}),
);
if let MetricValue::Histogram(h) = metric.value {
let formatted = format!("sum={:.2}, count={}", h.sum, h.count);
assert_eq!(formatted, "sum=123.45, count=10");
} else {
panic!("Expected Histogram value");
}
}
#[test]
fn test_summary_metric_formatting() {
use otelite_core::api::SummaryValue;
let metric = create_test_metric(
"test.summary",
MetricValue::Summary(SummaryValue {
count: 5,
sum: 67.89,
quantiles: vec![],
}),
);
if let MetricValue::Summary(s) = metric.value {
let formatted = format!("sum={:.2}, count={}", s.sum, s.count);
assert_eq!(formatted, "sum=67.89, count=5");
} else {
panic!("Expected Summary value");
}
}
#[test]
fn test_metric_with_no_unit() {
let mut metric = create_test_metric("test.no_unit", MetricValue::Gauge(1.0));
metric.unit = None;
let unit = metric.unit.as_deref().unwrap_or("none");
assert_eq!(unit, "none");
}
#[test]
fn test_metric_with_no_description() {
let mut metric = create_test_metric("test.no_desc", MetricValue::Gauge(1.0));
metric.description = None;
let desc = metric.description.as_deref().unwrap_or("No description");
assert_eq!(desc, "No description");
}
#[test]
fn test_histogram_with_buckets() {
use crate::api::models::{HistogramBucket, HistogramValue};
let metric = create_test_metric(
"test.histogram",
MetricValue::Histogram(HistogramValue {
count: 100,
sum: 500.0,
buckets: vec![
HistogramBucket {
upper_bound: 10.0,
count: 10,
},
HistogramBucket {
upper_bound: 20.0,
count: 20,
},
HistogramBucket {
upper_bound: 30.0,
count: 30,
},
HistogramBucket {
upper_bound: 40.0,
count: 40,
},
],
}),
);
if let MetricValue::Histogram(h) = metric.value {
assert_eq!(h.buckets.len(), 4);
} else {
panic!("Expected Histogram value");
}
}
#[test]
fn test_summary_with_quantiles() {
use crate::api::models::{Quantile, SummaryValue};
let metric = create_test_metric(
"test.summary",
MetricValue::Summary(SummaryValue {
count: 50,
sum: 250.0,
quantiles: vec![
Quantile {
quantile: 0.5,
value: 100.0,
},
Quantile {
quantile: 0.9,
value: 200.0,
},
Quantile {
quantile: 0.99,
value: 250.0,
},
],
}),
);
if let MetricValue::Summary(s) = metric.value {
assert_eq!(s.quantiles.len(), 3);
} else {
panic!("Expected Summary value");
}
}
}