use std::time::{Duration, Instant};
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind};
use ratatui::layout::{Constraint, Layout};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState};
use ratatui::{DefaultTerminal, Frame};
use super::TopError;
use super::config::TopConfig;
use super::metrics::{self, MetricSample, MetricType, ScrapeResult};
const COLUMNS: &[(&str, usize)] = &[("Metric", 0), ("Type", 1), ("Value", 2), ("Labels", 3)];
pub struct DashboardApp {
scrape: ScrapeResult,
rows: Vec<DisplayRow>,
table_state: TableState,
sort_column: usize,
sort_ascending: bool,
metrics_url: String,
last_fetch: Instant,
interval: Duration,
last_error: Option<String>,
fetch_count: u64,
should_quit: bool,
}
#[derive(Clone)]
struct DisplayRow {
name: String,
metric_type: String,
value: String,
labels: String,
raw_value: f64,
}
impl DashboardApp {
pub fn new(config: &TopConfig) -> Self {
Self {
scrape: ScrapeResult::default(),
rows: Vec::new(),
table_state: TableState::default(),
sort_column: 0,
sort_ascending: true,
metrics_url: config.metrics_url.clone(),
last_fetch: Instant::now()
.checked_sub(Duration::from_secs(999))
.unwrap_or_else(Instant::now),
interval: Duration::from_secs(config.interval_secs),
last_error: None,
fetch_count: 0,
should_quit: false,
}
}
fn refresh(&mut self) {
match metrics::fetch_metrics_http(&self.metrics_url) {
Ok(body) => {
self.scrape = metrics::parse_prometheus(&body);
self.last_error = None;
self.fetch_count += 1;
self.rebuild_rows();
}
Err(e) => {
self.last_error = Some(e.to_string());
}
}
self.last_fetch = Instant::now();
}
fn rebuild_rows(&mut self) {
self.rows = self
.scrape
.samples
.iter()
.map(|s| DisplayRow {
name: s.name.clone(),
metric_type: s.metric_type.as_str().to_string(),
value: format_value(s),
labels: s.labels_string(),
raw_value: s.value,
})
.collect();
self.sort_rows();
}
fn sort_rows(&mut self) {
let col = self.sort_column;
let asc = self.sort_ascending;
self.rows.sort_by(|a, b| {
let cmp = match col {
0 => a.name.cmp(&b.name),
1 => a.metric_type.cmp(&b.metric_type),
2 => a
.raw_value
.partial_cmp(&b.raw_value)
.unwrap_or(std::cmp::Ordering::Equal),
3 => a.labels.cmp(&b.labels),
_ => std::cmp::Ordering::Equal,
};
if asc { cmp } else { cmp.reverse() }
});
}
fn cycle_sort(&mut self) {
self.sort_column = (self.sort_column + 1) % COLUMNS.len();
self.sort_ascending = true;
self.sort_rows();
}
fn toggle_sort_direction(&mut self) {
self.sort_ascending = !self.sort_ascending;
self.sort_rows();
}
fn handle_key(&mut self, code: KeyCode) {
match code {
KeyCode::Char('q') | KeyCode::Esc => self.should_quit = true,
KeyCode::Char('j') | KeyCode::Down => self.table_state.select_next(),
KeyCode::Char('k') | KeyCode::Up => self.table_state.select_previous(),
KeyCode::Char('g') | KeyCode::Home => self.table_state.select_first(),
KeyCode::Char('G') | KeyCode::End if !self.rows.is_empty() => {
self.table_state.select(Some(self.rows.len() - 1));
}
KeyCode::Char('s') => self.cycle_sort(),
KeyCode::Char('S') => self.toggle_sort_direction(),
KeyCode::Char('r') => self.refresh(),
_ => {}
}
}
}
fn format_value(sample: &MetricSample) -> String {
let v = sample.value;
if v.is_nan() {
return "NaN".to_string();
}
if v.is_infinite() {
return if v.is_sign_positive() { "+Inf" } else { "-Inf" }.to_string();
}
if v.fract() == 0.0 && v.abs() < 1e15 {
#[allow(clippy::cast_possible_truncation)]
return format!("{}", v as i64);
}
if v.abs() < 1.0 {
return format!("{v:.6}");
}
format!("{v:.2}")
}
fn render(frame: &mut Frame, app: &DashboardApp) {
let area = frame.area();
let layout = Layout::vertical([
Constraint::Length(3),
Constraint::Min(5),
Constraint::Length(1),
])
.split(area);
render_header(frame, app, layout[0]);
render_table(frame, app, layout[1]);
render_footer(frame, app, layout[2]);
}
fn render_header(frame: &mut Frame, app: &DashboardApp, area: ratatui::layout::Rect) {
let total = app.scrape.samples.len();
let counters = app
.scrape
.samples
.iter()
.filter(|s| s.metric_type == MetricType::Counter)
.count();
let gauges = app
.scrape
.samples
.iter()
.filter(|s| s.metric_type == MetricType::Gauge)
.count();
let status = if let Some(ref err) = app.last_error {
Span::styled(format!(" error: {err}"), Style::default().fg(Color::Red))
} else {
Span::styled(
format!(
" {} metrics ({counters} counters, {gauges} gauges) | fetches: {}",
total, app.fetch_count
),
Style::default().fg(Color::Green),
)
};
let header = Paragraph::new(Line::from(vec![
Span::styled(
" dfe top",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" — "),
Span::styled(&app.metrics_url, Style::default().fg(Color::DarkGray)),
]))
.block(Block::default().borders(Borders::BOTTOM));
let status_bar = Paragraph::new(Line::from(vec![status]));
let header_layout = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
])
.split(area);
frame.render_widget(header, header_layout[0]);
frame.render_widget(status_bar, header_layout[1]);
}
fn render_table(frame: &mut Frame, app: &DashboardApp, area: ratatui::layout::Rect) {
let header_cells: Vec<Cell> = COLUMNS
.iter()
.enumerate()
.map(|(i, (name, _))| {
let indicator = if i == app.sort_column {
if app.sort_ascending { " ▲" } else { " ▼" }
} else {
""
};
Cell::from(format!("{name}{indicator}")).style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
})
.collect();
let header = Row::new(header_cells).bottom_margin(0);
let rows: Vec<Row> = app
.rows
.iter()
.map(|r| {
let type_color = match r.metric_type.as_str() {
"counter" => Color::Cyan,
"gauge" => Color::Green,
"histogram" => Color::Magenta,
"summary" => Color::Blue,
_ => Color::DarkGray,
};
Row::new(vec![
Cell::from(r.name.clone()),
Cell::from(r.metric_type.clone()).style(Style::default().fg(type_color)),
Cell::from(r.value.clone()).style(Style::default().fg(Color::White)),
Cell::from(r.labels.clone()).style(Style::default().fg(Color::DarkGray)),
])
})
.collect();
let widths = [
Constraint::Percentage(40),
Constraint::Length(10),
Constraint::Length(16),
Constraint::Percentage(30),
];
let table = Table::new(rows, widths)
.header(header)
.block(Block::default().borders(Borders::ALL).title(" Metrics "))
.row_highlight_style(
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("▶ ");
frame.render_stateful_widget(table, area, &mut app.table_state.clone());
}
fn render_footer(frame: &mut Frame, app: &DashboardApp, area: ratatui::layout::Rect) {
let sort_name = COLUMNS.get(app.sort_column).map_or("?", |(name, _)| name);
let sort_dir = if app.sort_ascending { "asc" } else { "desc" };
let help = Paragraph::new(Line::from(vec![
Span::styled(" q", Style::default().fg(Color::Yellow)),
Span::raw(":quit "),
Span::styled("j/k", Style::default().fg(Color::Yellow)),
Span::raw(":nav "),
Span::styled("s", Style::default().fg(Color::Yellow)),
Span::raw(":sort "),
Span::styled("S", Style::default().fg(Color::Yellow)),
Span::raw(":reverse "),
Span::styled("r", Style::default().fg(Color::Yellow)),
Span::raw(":refresh "),
Span::styled("g/G", Style::default().fg(Color::Yellow)),
Span::raw(":top/bottom "),
Span::raw("| "),
Span::styled(
format!("sort: {sort_name} {sort_dir}"),
Style::default().fg(Color::DarkGray),
),
]))
.style(Style::default().bg(Color::Black));
frame.render_widget(help, area);
}
pub fn run_dashboard(config: &TopConfig) -> Result<(), TopError> {
let mut terminal = ratatui::init();
let result = run_loop(&mut terminal, config);
ratatui::restore();
result
}
fn run_loop(terminal: &mut DefaultTerminal, config: &TopConfig) -> Result<(), TopError> {
let mut app = DashboardApp::new(config);
app.refresh();
if !app.rows.is_empty() {
app.table_state.select(Some(0));
}
loop {
if app.last_fetch.elapsed() >= app.interval {
app.refresh();
}
terminal
.draw(|frame| render(frame, &app))
.map_err(|e| TopError::Terminal(e.to_string()))?;
if event::poll(Duration::from_millis(100)).map_err(|e| TopError::Terminal(e.to_string()))?
&& let Event::Key(key) = event::read().map_err(|e| TopError::Terminal(e.to_string()))?
&& key.kind == KeyEventKind::Press
{
app.handle_key(key.code);
if app.should_quit {
break;
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::*;
#[test]
fn test_format_value_integer() {
let sample = MetricSample {
name: "test".into(),
labels: HashMap::default(),
value: 42.0,
metric_type: MetricType::Counter,
};
assert_eq!(format_value(&sample), "42");
}
#[test]
#[allow(clippy::approx_constant)]
fn test_format_value_float() {
let sample = MetricSample {
name: "test".into(),
labels: HashMap::default(),
value: 3.14159,
metric_type: MetricType::Gauge,
};
assert_eq!(format_value(&sample), "3.14");
}
#[test]
fn test_format_value_small() {
let sample = MetricSample {
name: "test".into(),
labels: HashMap::default(),
value: 0.000_123,
metric_type: MetricType::Gauge,
};
assert_eq!(format_value(&sample), "0.000123");
}
#[test]
fn test_format_value_nan() {
let sample = MetricSample {
name: "test".into(),
labels: HashMap::default(),
value: f64::NAN,
metric_type: MetricType::Untyped,
};
assert_eq!(format_value(&sample), "NaN");
}
#[test]
fn test_dashboard_app_cycle_sort() {
let config = TopConfig::default();
let mut app = DashboardApp::new(&config);
assert_eq!(app.sort_column, 0);
app.cycle_sort();
assert_eq!(app.sort_column, 1);
app.cycle_sort();
assert_eq!(app.sort_column, 2);
app.cycle_sort();
assert_eq!(app.sort_column, 3);
app.cycle_sort();
assert_eq!(app.sort_column, 0);
}
#[test]
fn test_dashboard_app_toggle_sort() {
let config = TopConfig::default();
let mut app = DashboardApp::new(&config);
assert!(app.sort_ascending);
app.toggle_sort_direction();
assert!(!app.sort_ascending);
app.toggle_sort_direction();
assert!(app.sort_ascending);
}
#[test]
fn test_handle_key_quit() {
let config = TopConfig::default();
let mut app = DashboardApp::new(&config);
app.handle_key(KeyCode::Char('q'));
assert!(app.should_quit);
}
#[test]
fn test_handle_key_esc() {
let config = TopConfig::default();
let mut app = DashboardApp::new(&config);
app.handle_key(KeyCode::Esc);
assert!(app.should_quit);
}
}