use crate::app::{AppMode, AppState, PanelState};
use humantime::format_duration;
use ratatui::{
prelude::*,
widgets::{
Axis, Block, Borders, Chart, Clear, Dataset, GraphType, List, ListItem, Paragraph, Wrap,
},
};
use std::collections::HashMap;
pub fn draw_ui(frame: &mut Frame, app: &AppState) {
let size = frame.area();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(5),
Constraint::Length(2),
])
.split(size);
let title_text = format!(
"{} — range={} step={} panels={} {}(r to refresh, +/- range, [] pan, 0 live, q quit)",
app.title,
format_duration(app.range),
format_duration(app.step),
app.panels.len(),
if app.is_live() { "" } else { "⏸ PAUSED " }
);
let title_block = Block::default()
.borders(Borders::ALL)
.title(Line::from(title_text).alignment(Alignment::Center));
frame.render_widget(title_block, chunks[0]);
let area = chunks[1];
let charts_block = Block::default().borders(Borders::ALL);
frame.render_widget(charts_block, area);
let inner_area = area.inner(Margin {
vertical: 1,
horizontal: 1,
});
if app.mode == AppMode::Fullscreen || app.mode == AppMode::FullscreenInspect {
if let Some(p) = app.panels.get(app.selected_panel) {
render_panel(frame, inner_area, p, app, true, app.cursor_x);
}
} else {
let has_grid = app.panels.iter().any(|p| p.grid.is_some());
let panel_rects = if has_grid {
calculate_grid_layout(inner_area, app)
} else {
calculate_two_column_layout(inner_area, app)
};
for (rect, panel_idx) in &panel_rects {
if let Some(p) = app.panels.get(*panel_idx) {
let is_selected = *panel_idx == app.selected_panel;
render_panel(frame, *rect, p, app, is_selected, app.cursor_x);
}
}
if !has_grid && app.panels.is_empty() {
} else if has_grid {
}
}
let errors = app.panels.iter().filter(|p| p.last_error.is_some()).count();
let panel_count_display =
if app.mode == AppMode::Fullscreen || app.mode == AppMode::FullscreenInspect {
"1 (Fullscreen)".to_string()
} else {
format!("{}", app.panels.len())
};
let mode_display = match app.mode {
AppMode::Normal => "NORMAL",
AppMode::Search => "SEARCH",
AppMode::Fullscreen => "FULLSCREEN",
AppMode::Inspect => "INSPECT",
AppMode::FullscreenInspect => "FULLSCREEN INSPECT",
};
let summary = format!(
"Mode: {} | Prom: {} | range={} step={:?} refresh={} | panels={} (skipped {}) errors={} | keys: ↑/↓ scroll, r refresh, +/- range, q quit, ? debug:{}",
mode_display,
app.prometheus.base,
format_duration(app.range),
app.step,
format_duration(app.refresh_every),
panel_count_display,
app.skipped_panels,
errors,
if app.debug_bar { "on" } else { "off" }
);
let mut detail = String::new();
if app.debug_bar {
let debug_panel: Option<&PanelState> = if app.panels.iter().any(|p| p.grid.is_some()) {
app.panels
.iter()
.filter(|p| p.grid.is_some())
.min_by_key(|p| {
let g = p.grid.unwrap();
(g.y, g.x)
})
} else {
app.panels.get(0)
};
if let Some(p) = debug_panel {
let url = p.last_url.as_deref().unwrap_or("-");
detail = format!(
"last panel: {} | samples={} | url={} ",
p.title, p.last_samples, url
);
}
}
if app.mode == AppMode::Inspect {
if let Some(cx) = app.cursor_x {
let cursor_time = chrono::DateTime::from_timestamp(cx as i64, 0)
.map(|dt| dt.format("%H:%M:%S").to_string())
.unwrap_or_default();
detail = format!("Cursor: {} | {}", cursor_time, detail);
}
}
let footer = Paragraph::new(format!("{}\n{}", summary, detail)).wrap(Wrap { trim: true });
frame.render_widget(footer, chunks[2]);
if app.mode == AppMode::Search {
let area = centered_rect(60, 20, size);
let block = Block::default()
.title(" Search Panels ")
.borders(Borders::ALL)
.border_style(Style::default().fg(app.theme.border_selected));
frame.render_widget(Clear, area); frame.render_widget(block, area);
let inner_area = area.inner(Margin {
vertical: 1,
horizontal: 1,
});
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(1)])
.split(inner_area);
let input = Paragraph::new(format!("> {}", app.search_query))
.style(Style::default().fg(app.theme.text));
frame.render_widget(input, chunks[0]);
let results: Vec<ListItem> = app
.search_results
.iter()
.map(|&idx| {
let p = &app.panels[idx];
ListItem::new(format!("• {}", p.title))
})
.collect();
let list = List::new(results)
.block(Block::default().borders(Borders::TOP))
.highlight_style(
Style::default()
.fg(app.theme.title)
.add_modifier(Modifier::BOLD)
.bg(app.theme.background), )
.highlight_symbol(">> ");
let mut list_state = ratatui::widgets::ListState::default();
if !app.search_results.is_empty() {
list_state.select(Some(0));
}
frame.render_stateful_widget(list, chunks[1], &mut list_state);
}
}
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(r);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(popup_layout[1])[1]
}
fn calculate_grid_layout(area: Rect, app: &AppState) -> Vec<(Rect, usize)> {
let mut results = Vec::new();
let grid_cols: u16 = 24;
let cell_w = std::cmp::max(1, area.width / grid_cols);
let cell_h = std::cmp::max(3, area.height / 24);
let scroll_offset = app.vertical_scroll as u16 * cell_h;
for (i, p) in app.panels.iter().enumerate() {
if let Some(g) = p.grid {
if g.x < 0 || g.y < 0 || g.w <= 0 || g.h <= 0 {
continue;
}
let x = area.x.saturating_add((g.x as u16).saturating_mul(cell_w));
let y_absolute = (g.y as u16).saturating_mul(cell_h);
if y_absolute < scroll_offset {
continue;
}
let y = area
.y
.saturating_add(y_absolute.saturating_sub(scroll_offset));
let w = (g.w as u16).saturating_mul(cell_w);
let h = (g.h as u16).saturating_mul(cell_h);
if y >= area.bottom() {
continue;
}
let rect = Rect {
x,
y,
width: w.min(area.right().saturating_sub(x)),
height: h.min(area.bottom().saturating_sub(y)),
};
if rect.width >= 8 && rect.height >= 4 {
results.push((rect, i));
}
}
}
let extras: Vec<(usize, &PanelState)> = app
.panels
.iter()
.enumerate()
.filter(|(_, p)| p.grid.is_none())
.collect();
if !extras.is_empty() {
let max_y_h = app
.panels
.iter()
.filter_map(|p| {
let g = p.grid?;
Some(g.y + g.h)
})
.max()
.unwrap_or(0);
let start_y_px = area
.y
.saturating_add((max_y_h as u16).saturating_mul(cell_h));
if start_y_px < area.bottom() {
let extras_area = Rect {
x: area.x,
y: start_y_px,
width: area.width,
height: area.bottom().saturating_sub(start_y_px),
};
let extra_indices: Vec<usize> = extras.iter().map(|(i, _)| *i).collect();
let extra_rects = calculate_two_column_layout_subset(extras_area, app, &extra_indices);
results.extend(extra_rects);
}
}
results
}
fn calculate_two_column_layout(area: Rect, app: &AppState) -> Vec<(Rect, usize)> {
let indices: Vec<usize> = (0..app.panels.len()).collect();
calculate_two_column_layout_subset(area, app, &indices)
}
fn calculate_two_column_layout_subset(
area: Rect,
app: &AppState,
panel_indices: &[usize],
) -> Vec<(Rect, usize)> {
let mut results = Vec::new();
if panel_indices.is_empty() {
return results;
}
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(area);
let panel_height = 12u16;
let rows_fit = (area.height / panel_height).saturating_mul(2).max(1) as usize;
let start = app
.vertical_scroll
.min(panel_indices.len().saturating_sub(rows_fit));
let end = (start + rows_fit).min(panel_indices.len());
let visible_indices = &panel_indices[start..end];
let mut left_indices = Vec::new();
let mut right_indices = Vec::new();
for (i, &original_idx) in visible_indices.iter().enumerate() {
if i % 2 == 0 {
left_indices.push(original_idx);
} else {
right_indices.push(original_idx);
}
}
let left_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(vec![Constraint::Length(panel_height); left_indices.len()])
.split(cols[0]);
let right_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(vec![Constraint::Length(panel_height); right_indices.len()])
.split(cols[1]);
for (rect, &idx) in left_chunks.iter().zip(left_indices.iter()) {
results.push((*rect, idx));
}
for (rect, &idx) in right_chunks.iter().zip(right_indices.iter()) {
results.push((*rect, idx));
}
results
}
pub fn hit_test(app: &AppState, area: Rect, x: u16, y: u16) -> Option<(usize, Rect)> {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(5),
Constraint::Length(2),
])
.split(area);
let charts_area = chunks[1];
let inner_area = charts_area.inner(Margin {
vertical: 1,
horizontal: 1,
});
if !inner_area.contains(ratatui::layout::Position { x, y }) {
return None;
}
if app.mode == AppMode::Fullscreen || app.mode == AppMode::FullscreenInspect {
return Some((app.selected_panel, inner_area));
}
let has_grid = app.panels.iter().any(|p| p.grid.is_some());
let panel_rects = if has_grid {
calculate_grid_layout(inner_area, app)
} else {
calculate_two_column_layout(inner_area, app)
};
for (rect, idx) in panel_rects {
if rect.contains(ratatui::layout::Position { x, y }) {
return Some((idx, rect));
}
}
None
}
fn render_panel(
frame: &mut Frame,
area: Rect,
p: &PanelState,
app: &AppState,
is_selected: bool,
cursor_x: Option<f64>,
) {
let theme = &app.theme;
let border_style = if is_selected {
Style::default().fg(theme.border_selected)
} else {
Style::default().fg(theme.border)
};
if let Some(err) = &p.last_error {
let block = Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.title(Span::styled(
format!("{} — ERROR", p.title),
Style::default().fg(theme.title),
));
let para = Paragraph::new(err.clone())
.block(block)
.wrap(Wrap { trim: true })
.style(Style::default().fg(theme.text));
frame.render_widget(para, area);
return;
}
let block = Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.title(Span::styled(
p.title.clone(),
Style::default().fg(theme.title),
));
frame.render_widget(block.clone(), area);
let inner_area = block.inner(area);
match p.panel_type {
crate::app::PanelType::Graph | crate::app::PanelType::Unknown => {
render_graph_panel(frame, inner_area, p, app, cursor_x);
}
crate::app::PanelType::Gauge => {
render_gauge(frame, inner_area, p, app);
}
crate::app::PanelType::BarGauge => {
render_bar_gauge(frame, inner_area, p, app);
}
crate::app::PanelType::Table => {
render_table(frame, inner_area, p, app);
}
crate::app::PanelType::Stat => {
render_stat(frame, inner_area, p, app);
}
crate::app::PanelType::Heatmap => {
render_heatmap(frame, inner_area, p, app);
}
}
}
fn render_graph_panel(
frame: &mut Frame,
area: Rect,
p: &PanelState,
app: &AppState,
cursor_x: Option<f64>,
) {
let theme = &app.theme;
let use_hash_colors = p.series.len() > theme.palette.len();
let cursor_values: HashMap<String, f64> = if let Some(cx) = cursor_x {
p.series
.iter()
.filter_map(|s| {
let closest = s.points.iter().min_by(|a, b| {
let da = (a.0 - cx).abs();
let db = (b.0 - cx).abs();
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
});
if let Some((ts, val)) = closest {
if (ts - cx).abs() <= app.step.as_secs_f64() * 2.0 {
Some((s.name.clone(), *val))
} else {
None
}
} else {
None
}
})
.collect()
} else {
HashMap::new()
};
let legend_height = if !p.series.is_empty() && area.height > 5 {
2
} else {
0
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(legend_height)])
.split(area);
let chart_area = chunks[0];
let legend_area = chunks[1];
let now = (chrono::Utc::now().timestamp() - app.time_offset.as_secs() as i64) as f64;
let start = now - app.range.as_secs_f64();
let y_bounds = calculate_y_bounds(p);
let mut chart_datasets = Vec::new();
let mut legend_items = Vec::new();
let mut cursor_dataset = vec![];
let mut threshold_datasets = vec![];
let mut threshold_overlay_datasets = Vec::new();
let mut threshold_labels_info = Vec::new();
if let Some(th) = &p.thresholds {
for step in th.steps.iter().filter(|s| s.value.is_some()) {
let val = step.value.unwrap();
let abs_val = match th.mode {
crate::app::ThresholdMode::Absolute => val,
crate::app::ThresholdMode::Percentage => {
let min = p.min.unwrap_or(0.0);
let max = p.max.unwrap_or(100.0);
min + (val / 100.0) * (max - min)
}
};
let mut dataset = Vec::new();
if app.threshold_marker.starts_with("dashed") || th.style.as_deref() == Some("dashed") {
let points_count = 15; let step_x = (now - start) / points_count as f64;
for i in 0..=points_count {
let x = start + (i as f64 * step_x);
dataset.push((x, abs_val));
}
} else {
dataset.push((start, abs_val));
dataset.push((now, abs_val));
}
threshold_datasets.push(dataset);
threshold_labels_info.push((abs_val, step.color));
}
for (i, step) in th.steps.iter().filter(|s| s.value.is_some()).enumerate() {
if app.threshold_marker.ends_with("line") {
continue;
}
let (marker, graph_type) = match app.threshold_marker.to_lowercase().as_str() {
"braille" => (ratatui::symbols::Marker::Braille, GraphType::Line),
"block" => (ratatui::symbols::Marker::Block, GraphType::Line),
"bar" => (ratatui::symbols::Marker::Bar, GraphType::Line),
"half-block" => (ratatui::symbols::Marker::HalfBlock, GraphType::Line),
"quadrant" => (ratatui::symbols::Marker::Quadrant, GraphType::Line),
"sextant" => (ratatui::symbols::Marker::Sextant, GraphType::Line),
"octant" => (ratatui::symbols::Marker::Octant, GraphType::Line),
"dashed" | "dashed-braille" => {
(ratatui::symbols::Marker::Braille, GraphType::Scatter)
}
"dashed-block" => (ratatui::symbols::Marker::Block, GraphType::Scatter),
"dashed-bar" => (ratatui::symbols::Marker::Bar, GraphType::Scatter),
"dashed-half-block" => (ratatui::symbols::Marker::HalfBlock, GraphType::Scatter),
"dashed-quadrant" => (ratatui::symbols::Marker::Quadrant, GraphType::Scatter),
"dashed-sextant" => (ratatui::symbols::Marker::Sextant, GraphType::Scatter),
"dashed-octant" => (ratatui::symbols::Marker::Octant, GraphType::Scatter),
"dashed-dot" => (ratatui::symbols::Marker::Dot, GraphType::Scatter),
_ => (ratatui::symbols::Marker::Dot, GraphType::Line),
};
threshold_overlay_datasets.push(
Dataset::default()
.name("")
.marker(marker)
.graph_type(graph_type)
.style(Style::default().fg(step.color))
.data(&threshold_datasets[i]),
);
}
}
for (i, s) in p.series.iter().enumerate() {
let color = if use_hash_colors {
get_hash_color(&s.name)
} else {
theme.palette[i % theme.palette.len()]
};
let data = if s.visible { s.points.as_slice() } else { &[] };
let mut name = s.name.clone();
if let Some(val) = cursor_values.get(&s.name) {
name.push_str(&format!(" ({})", format_si(*val)));
} else if let Some(val) = s.value {
name.push_str(&format!(" ({})", format_si(val)));
}
if name.is_empty() {
name = format!("Series {}", i);
}
legend_items.push(Span::styled(format!("■ "), Style::default().fg(color)));
legend_items.push(Span::styled(
format!("{} ", name),
Style::default().fg(theme.text),
));
chart_datasets.push(
Dataset::default()
.name("")
.marker(ratatui::symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::default().fg(color))
.data(data),
);
}
if let Some(cx) = cursor_x {
cursor_dataset.push((cx, y_bounds[0]));
cursor_dataset.push((cx, y_bounds[1]));
chart_datasets.push(
Dataset::default()
.name("")
.marker(ratatui::symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::default().fg(Color::White))
.data(&cursor_dataset),
);
}
let x_labels = vec![
Span::styled(format_time(start), Style::default().fg(theme.text)),
Span::styled(format_time(now), Style::default().fg(theme.text)),
];
let y_axis_height = chart_area.height.saturating_sub(1).max(2) as usize;
let mut y_labels = vec![Span::raw(""); y_axis_height];
y_labels[0] = Span::styled(format_si(y_bounds[0]), Style::default().fg(theme.text));
y_labels[y_axis_height - 1] =
Span::styled(format_si(y_bounds[1]), Style::default().fg(theme.text));
if y_bounds[1] > y_bounds[0] {
for (th_val, color) in &threshold_labels_info {
if *th_val > y_bounds[0] && *th_val < y_bounds[1] {
let ratio = (*th_val - y_bounds[0]) / (y_bounds[1] - y_bounds[0]);
let index = (ratio * (y_axis_height - 1) as f64).round() as usize;
let index = index.min(y_axis_height - 2).max(1);
y_labels[index] = Span::styled(format_si(*th_val), Style::default().fg(*color));
}
}
}
let y_max_width = y_labels.iter().map(|s| s.width() as u16).max().unwrap_or(0);
let chart = Chart::new(chart_datasets)
.x_axis(
Axis::default()
.bounds([start, now])
.labels(x_labels.clone())
.style(Style::default().fg(theme.text)),
)
.y_axis(
Axis::default()
.style(Style::default().fg(Color::Gray))
.bounds(y_bounds)
.labels(y_labels.clone()),
);
frame.render_widget(chart, chart_area);
let chart_left = chart_area.left() + y_max_width + 1; let chart_right = chart_area.right();
let chart_bottom = chart_area.bottom().saturating_sub(2); let chart_top = chart_area.top();
if !threshold_overlay_datasets.is_empty() && chart_top <= chart_bottom {
let threshold_chart = Chart::new(threshold_overlay_datasets)
.x_axis(
Axis::default()
.bounds([start, now])
.labels(x_labels)
.style(Style::default().fg(theme.text)),
)
.y_axis(
Axis::default()
.style(Style::default().fg(Color::Gray))
.bounds(y_bounds)
.labels(y_labels),
);
let mut threshold_buf = ratatui::buffer::Buffer::empty(chart_area);
threshold_chart.render(chart_area, &mut threshold_buf);
let buf = frame.buffer_mut();
for y in chart_top..=chart_bottom {
for x in chart_left..chart_right {
let Some(src_cell) = threshold_buf.cell((x, y)) else {
continue;
};
if let Some(dst_cell) = buf.cell_mut((x, y)) {
overlay_threshold_cell(dst_cell, src_cell);
}
}
}
}
if app.threshold_marker.ends_with("line") && y_bounds[1] > y_bounds[0] {
let buf = frame.buffer_mut();
let chart_h = chart_bottom.saturating_sub(chart_top) as f64;
if chart_h > 0.0 {
let is_dashed = app.threshold_marker.starts_with("dashed");
let line_char = if is_dashed { '-' } else { '─' };
for (th_val, color) in &threshold_labels_info {
if *th_val > y_bounds[0] && *th_val < y_bounds[1] {
let ratio = (*th_val - y_bounds[0]) / (y_bounds[1] - y_bounds[0]);
let y_offset = (ratio * chart_h).round() as u16;
let phys_y = chart_bottom.saturating_sub(y_offset);
if phys_y >= chart_top && phys_y <= chart_bottom {
for x in chart_left..chart_right {
if is_dashed && x % 2 == 0 {
continue;
}
if let Some(cell) = buf.cell_mut((x, phys_y)) {
if should_draw_threshold_on_cell(cell) {
cell.set_char(line_char)
.set_style(Style::default().fg(*color));
}
}
}
}
}
}
}
}
if legend_height > 0 {
let legend = Paragraph::new(Line::from(legend_items)).wrap(Wrap { trim: true });
frame.render_widget(legend, legend_area);
}
}
fn should_draw_threshold_on_cell(cell: &ratatui::buffer::Cell) -> bool {
cell.symbol().chars().all(char::is_whitespace)
}
fn overlay_threshold_cell(dst: &mut ratatui::buffer::Cell, src: &ratatui::buffer::Cell) {
if should_draw_threshold_on_cell(dst) && !should_draw_threshold_on_cell(src) {
dst.set_symbol(src.symbol()).set_style(src.style());
}
}
fn render_gauge(frame: &mut Frame, area: Rect, p: &PanelState, app: &AppState) {
let theme = &app.theme;
let (value, name) = p
.series
.iter()
.filter(|s| s.visible)
.find_map(|s| s.value.map(|v| (v, s.name.clone())))
.unwrap_or((0.0, "No data".to_string()));
let min = p.min.unwrap_or(0.0);
let max = p
.max
.unwrap_or_else(|| if value > 100.0 { value * 1.2 } else { 100.0 });
let color = p.get_color_for_value(value).unwrap_or(theme.palette[0]);
let ratio = if max > min {
((value - min) / (max - min)).clamp(0.0, 1.0)
} else {
0.0
};
let gauge = ratatui::widgets::Gauge::default()
.block(Block::default().borders(Borders::NONE))
.gauge_style(Style::default().fg(color).bg(Color::DarkGray))
.ratio(ratio)
.label(format!("{} ({})", format_si(value), name));
frame.render_widget(gauge, area);
}
fn render_bar_gauge(frame: &mut Frame, area: Rect, p: &PanelState, app: &AppState) {
let theme = &app.theme;
let mut max_label_len = 3;
let scale = 1000.0;
let mut valid_series: Vec<_> = p
.series
.iter()
.filter(|s| s.visible && s.value.is_some())
.collect();
valid_series.sort_by(|a, b| {
let v_a = a.value.unwrap_or(0.0);
let v_b = b.value.unwrap_or(0.0);
v_b.partial_cmp(&v_a).unwrap_or(std::cmp::Ordering::Equal)
});
let max_bars = (area.width / 4).saturating_sub(1).max(1) as usize;
valid_series.truncate(max_bars);
let mut bars = Vec::with_capacity(valid_series.len());
for s in valid_series {
let v = s.value.unwrap();
max_label_len = max_label_len.max(s.name.len());
let color = p.get_color_for_value(v).unwrap_or(theme.palette[0]);
let bar = ratatui::widgets::Bar::default()
.value((v * scale) as u64)
.text_value(format_si(v))
.label(ratatui::text::Line::from(s.name.as_str()))
.style(Style::default().fg(color))
.value_style(Style::default().fg(theme.text).bg(color));
bars.push(bar);
}
if bars.is_empty() {
let para = Paragraph::new("No data").style(Style::default().fg(theme.text));
frame.render_widget(para, area);
return;
}
let bar_width = (area.width / bars.len() as u16)
.saturating_sub(1)
.min(max_label_len as u16)
.max(3);
let bar_group = ratatui::widgets::BarGroup::default().bars(&bars);
let bar_chart = ratatui::widgets::BarChart::default()
.block(Block::default().borders(Borders::NONE))
.data(bar_group)
.bar_width(bar_width)
.bar_gap(1);
frame.render_widget(bar_chart, area);
}
fn render_table(frame: &mut Frame, area: Rect, p: &PanelState, app: &AppState) {
let theme = &app.theme;
let header = ["Series", "Value"];
let rows: Vec<ratatui::widgets::Row> = p
.series
.iter()
.filter(|s| s.visible)
.map(|s| {
let val_str = s.value.map(format_si).unwrap_or_else(|| "-".to_string());
let color = s
.value
.and_then(|v| p.get_color_for_value(v))
.unwrap_or(theme.text);
ratatui::widgets::Row::new(vec![
ratatui::text::Span::styled(s.name.clone(), Style::default().fg(theme.text)),
ratatui::text::Span::styled(val_str, Style::default().fg(color)),
])
})
.collect();
if rows.is_empty() {
let para = Paragraph::new("No data").style(Style::default().fg(theme.text));
frame.render_widget(para, area);
return;
}
let table = ratatui::widgets::Table::new(
rows,
[Constraint::Percentage(70), Constraint::Percentage(30)],
)
.header(
ratatui::widgets::Row::new(header)
.style(
Style::default()
.fg(theme.title)
.add_modifier(Modifier::BOLD),
)
.bottom_margin(1),
)
.block(Block::default().borders(Borders::NONE))
.column_spacing(1);
frame.render_widget(table, area);
}
fn render_stat(frame: &mut Frame, area: Rect, p: &PanelState, app: &AppState) {
let theme = &app.theme;
let (value, name) = p
.series
.iter()
.filter(|s| s.visible)
.find_map(|s| s.value.map(|v| (v, s.name.clone())))
.unwrap_or((0.0, "No data".to_string()));
let color = p.get_color_for_value(value).unwrap_or(theme.palette[0]);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
.split(area);
let val_str = format_si(value);
let big_value = Paragraph::new(val_str)
.style(Style::default().fg(color).add_modifier(Modifier::BOLD))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::NONE));
frame.render_widget(big_value, chunks[0]);
if let Some(s) = p.series.iter().find(|s| s.visible && s.name == name) {
let data: Vec<u64> = s.points.iter().map(|(_, v)| *v as u64).collect();
let sparkline = ratatui::widgets::Sparkline::default()
.block(Block::default().borders(Borders::NONE))
.data(&data)
.style(Style::default().fg(color));
frame.render_widget(sparkline, chunks[1]);
}
}
fn render_heatmap(frame: &mut Frame, area: Rect, p: &PanelState, app: &AppState) {
let theme = &app.theme;
if p.series.is_empty() {
let para = Paragraph::new("No data").style(Style::default().fg(theme.text));
frame.render_widget(para, area);
return;
}
let rows_available = area.height.saturating_sub(2) as usize; let cols_available = area.width as usize;
if rows_available == 0 || cols_available == 0 {
return;
}
let visible_series: Vec<_> = p.series.iter().filter(|s| s.visible).collect();
if visible_series.is_empty() {
let para = Paragraph::new("No visible series").style(Style::default().fg(theme.text));
frame.render_widget(para, area);
return;
}
let (mut global_min, mut global_max) = (f64::MAX, f64::MIN);
for s in &visible_series {
for (_, v) in &s.points {
if v.is_finite() {
global_min = global_min.min(*v);
global_max = global_max.max(*v);
}
}
}
if !global_min.is_finite() || !global_max.is_finite() || global_min == global_max {
let para = Paragraph::new("Invalid data range").style(Style::default().fg(theme.text));
frame.render_widget(para, area);
return;
}
let mut lines = Vec::new();
for series in visible_series.iter().take(rows_available) {
let mut spans = Vec::new();
let total_points = series.points.len();
if total_points == 0 {
continue;
}
let step = (total_points as f64 / cols_available as f64).max(1.0);
for col_idx in 0..cols_available {
let point_idx = ((col_idx as f64 * step) as usize).min(total_points - 1);
let (_, value) = series.points[point_idx];
let color = if value.is_finite() {
let normalized = ((value - global_min) / (global_max - global_min)).clamp(0.0, 1.0);
value_to_heatmap_color(normalized)
} else {
Color::DarkGray
};
spans.push(Span::styled("█", Style::default().fg(color)));
}
lines.push(Line::from(spans));
}
if lines.is_empty() {
let para = Paragraph::new("No data to display").style(Style::default().fg(theme.text));
frame.render_widget(para, area);
return;
}
let heatmap_widget = Paragraph::new(lines).block(Block::default().borders(Borders::NONE));
frame.render_widget(heatmap_widget, area);
}
fn value_to_heatmap_color(normalized: f64) -> Color {
if normalized < 0.33 {
Color::Cyan
} else if normalized < 0.66 {
Color::Yellow
} else {
Color::Red
}
}
fn calculate_y_bounds(p: &PanelState) -> [f64; 2] {
let mut min = f64::MAX;
let mut max = f64::MIN;
let mut has_data = false;
for s in &p.series {
if !s.visible {
continue;
}
for &(_, v) in &s.points {
if !v.is_finite() {
continue;
}
if v < min {
min = v;
}
if v > max {
max = v;
}
has_data = true;
}
}
if !has_data {
return [0.0, 1.0];
}
if min == max {
min -= 1.0;
max += 1.0;
}
if p.y_axis_mode == crate::app::YAxisMode::ZeroBased {
if min > 0.0 {
min = 0.0;
} else if max < 0.0 {
max = 0.0;
}
}
let range = max - min;
[min - range * 0.05, max + range * 0.05]
}
fn format_si(val: f64) -> String {
let abs = val.abs();
if abs >= 1e9 {
format!("{:.2}G", val / 1e9)
} else if abs >= 1e6 {
format!("{:.2}M", val / 1e6)
} else if abs >= 1e3 {
format!("{:.2}k", val / 1e3)
} else {
format!("{:.2}", val)
}
}
fn format_time(ts: f64) -> String {
use chrono::TimeZone;
if let Some(dt) = chrono::Utc.timestamp_opt(ts as i64, 0).single() {
dt.format("%H:%M:%S").to_string()
} else {
format!("{}", ts)
}
}
fn get_hash_color(name: &str) -> Color {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
name.hash(&mut hasher);
let hash = hasher.finish();
let hue = (hash % 360) as f32;
let saturation = 60.0 + ((hash >> 8) % 30) as f32;
let lightness = 45.0 + ((hash >> 16) % 20) as f32;
hsl_to_rgb(hue, saturation, lightness)
}
fn hsl_to_rgb(h: f32, s: f32, l: f32) -> Color {
let s = s / 100.0;
let l = l / 100.0;
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs());
let m = l - c / 2.0;
let (r, g, b) = if h < 60.0 {
(c, x, 0.0)
} else if h < 120.0 {
(x, c, 0.0)
} else if h < 180.0 {
(0.0, c, x)
} else if h < 240.0 {
(0.0, x, c)
} else if h < 300.0 {
(x, 0.0, c)
} else {
(c, 0.0, x)
};
Color::Rgb(
((r + m) * 255.0) as u8,
((g + m) * 255.0) as u8,
((b + m) * 255.0) as u8,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::{SeriesView, YAxisMode};
fn create_test_panel() -> PanelState {
PanelState {
title: "test".to_string(),
exprs: vec![],
legends: vec![],
series: vec![],
last_error: None,
last_url: None,
last_samples: 0,
grid: None,
y_axis_mode: YAxisMode::Auto,
panel_type: crate::app::PanelType::Graph,
thresholds: None,
min: None,
max: None,
}
}
#[test]
fn test_calculate_y_bounds_basic() {
let mut p = create_test_panel();
p.series.push(SeriesView {
name: "test".to_string(),
value: None,
points: vec![(0.0, 10.0), (1.0, 20.0)],
visible: true,
});
let bounds = calculate_y_bounds(&p);
assert!(bounds[0] < 10.0);
assert!(bounds[1] > 20.0);
}
#[test]
fn test_calculate_y_bounds_nan() {
let mut p = create_test_panel();
p.series.push(SeriesView {
name: "test".to_string(),
value: None,
points: vec![(0.0, 10.0), (1.0, f64::NAN), (2.0, 20.0)],
visible: true,
});
let bounds = calculate_y_bounds(&p);
assert!(bounds[0] < 10.0); assert!(bounds[1] > 20.0);
}
#[test]
fn test_calculate_y_bounds_infinity() {
let mut p = create_test_panel();
p.series.push(SeriesView {
name: "test".to_string(),
value: None,
points: vec![(0.0, 10.0), (1.0, f64::INFINITY), (2.0, 20.0)],
visible: true,
});
let bounds = calculate_y_bounds(&p);
assert!(bounds[0] < 10.0); assert!(bounds[1] > 20.0);
}
#[test]
fn test_calculate_y_bounds_zero_based() {
let mut p = create_test_panel();
p.y_axis_mode = YAxisMode::ZeroBased;
p.series.push(SeriesView {
name: "test".to_string(),
value: None,
points: vec![(0.0, 10.0), (1.0, 20.0)],
visible: true,
});
let bounds = calculate_y_bounds(&p);
assert_eq!(bounds[0], -1.0);
assert!(bounds[1] > 20.0);
}
#[test]
fn test_should_draw_threshold_on_cell_empty() {
let cell = ratatui::buffer::Cell::default();
assert!(should_draw_threshold_on_cell(&cell));
}
#[test]
fn test_should_draw_threshold_on_cell_filled() {
let mut cell = ratatui::buffer::Cell::default();
cell.set_char('x');
assert!(!should_draw_threshold_on_cell(&cell));
}
#[test]
fn test_overlay_threshold_cell_copies_when_destination_is_empty() {
let mut dst = ratatui::buffer::Cell::default();
let mut src = ratatui::buffer::Cell::default();
src.set_char('-').set_style(Style::default().fg(Color::Red));
overlay_threshold_cell(&mut dst, &src);
assert_eq!(dst.symbol(), "-");
assert_eq!(dst.style().fg, Some(Color::Red));
}
#[test]
fn test_overlay_threshold_cell_keeps_existing_destination_marker() {
let mut dst = ratatui::buffer::Cell::default();
dst.set_char('x')
.set_style(Style::default().fg(Color::LightBlue));
let mut src = ratatui::buffer::Cell::default();
src.set_char('-').set_style(Style::default().fg(Color::Red));
overlay_threshold_cell(&mut dst, &src);
assert_eq!(dst.symbol(), "x");
assert_eq!(dst.style().fg, Some(Color::LightBlue));
}
}