use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::symbols::Marker;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Axis, Block, Chart, Dataset, GraphType, Paragraph, Wrap};
use ratatui::Frame;
use crate::tui::api::Severity;
use crate::tui::history::Point;
use crate::tui::i18n::{self, Lang};
use crate::tui::model::{Model, TopicMonitor, TopicTab};
use crate::tui::screens::cluster::view::error_text;
use crate::tui::screens::cluster::Unit;
use crate::tui::screens::topics::messages;
use crate::tui::screens::topics::monitor::{
line_points, lines_of, window_ms, ChartDef, LineDef, CHARTS,
};
pub fn fmt(lang: Lang, unit: Unit, value: f64) -> String {
match unit {
Unit::Count | Unit::Bytes => i18n::group(lang, value.round().max(0.0) as u64),
Unit::PerSecond => format!("{}/s", i18n::decimal(lang, value, 1)),
Unit::Seconds => i18n::duration(value.max(0.0) as u64),
}
}
pub fn head_line(m: &Model, mon: &TopicMonitor) -> Line<'static> {
let tx = m.tx();
let th = m.theme;
let key = if mon.tab == TopicTab::Messages {
"topic.screen-messages"
} else {
"topic.screen-charts"
};
let mut spans = vec![Span::styled(tx.tw(key, &[("name", &mon.name)]), th.title())];
if let Some(fail) = &mon.feed.error {
let text = if mon.feed.data.is_some() || !mon.series.is_empty() {
tx.t("state.stale")
} else {
error_text(m, fail)
};
spans.push(Span::styled(
format!(" {} {text}", th.icon(Severity::Warning)),
th.warning(),
));
}
Line::from(spans)
}
pub fn draw(f: &mut Frame, area: Rect, m: &Model) {
let Some(mon) = &m.topics.monitor else { return };
if mon.tab == TopicTab::Messages {
return messages::view::draw(f, area, m, mon);
}
let tx = m.tx();
let th = m.theme;
let [head, main, note] = Layout::vertical([
Constraint::Length(1),
Constraint::Min(7),
Constraint::Length(2),
])
.areas(area);
f.render_widget(Paragraph::new(head_line(m, mon)), head);
let chart = mon.monitor.chart.min(CHARTS.len() - 1);
if mon.monitor.full {
draw_chart(f, main, m, mon, &CHARTS[chart]);
} else {
let width = if main.width >= 100 { 40 } else { 28 };
let [list_area, chart_area] =
Layout::horizontal([Constraint::Length(width), Constraint::Min(20)]).areas(main);
draw_chart_list(f, list_area, m, mon, chart);
draw_chart(f, chart_area, m, mon, &CHARTS[chart]);
}
let at = mon.monitor.frozen.unwrap_or(m.now);
let window = window_ms(m, mon);
let collected = mon
.series
.values()
.map(|ring| ring.collected_ms(at))
.max()
.unwrap_or(0);
let mut text = if collected < window {
tx.tw(
"chart.collected",
&[("time", &i18n::duration(collected / 1000))],
)
} else {
tx.t("chart.history-note")
};
if mon.monitor.frozen.is_some() {
text.push_str(&format!(" · {}", tx.t("chart.paused")));
}
text.push_str(&format!(
" · {}",
tx.t(if mon.monitor.fixed_scale {
"chart.scale-fixed"
} else {
"chart.scale-auto"
})
));
let partial = mon
.series
.keys()
.any(|key| line_points(m, mon, key).iter().any(|p| p.partial));
if partial {
text.push_str(&format!(" · {}", tx.t("chart.partial-samples")));
}
f.render_widget(
Paragraph::new(text)
.style(th.dim())
.wrap(Wrap { trim: false }),
note,
);
}
fn draw_chart_list(f: &mut Frame, area: Rect, m: &Model, mon: &TopicMonitor, selected: usize) {
let tx = m.tx();
let th = m.theme;
let inner_width = usize::from(area.width.saturating_sub(2));
let lines: Vec<Line> = CHARTS
.iter()
.enumerate()
.map(|(i, def)| {
let marker = if i == selected { th.selector() } else { " " };
let value = def
.lines
.first()
.and_then(|(key, _)| line_points(m, mon, key).last().map(|p| p.value))
.map_or_else(|| "—".to_owned(), |v| fmt(m.lang, def.unit, v));
let room = inner_width.saturating_sub(value.chars().count() + 1);
let name: String = format!("{marker}{}", tx.t(def.title))
.chars()
.take(room)
.collect();
let pad = inner_width
.saturating_sub(name.chars().count() + value.chars().count())
.max(1);
let style = if i == selected {
th.selected()
} else {
ratatui::style::Style::default()
};
Line::from(vec![
Span::styled(format!("{name}{}", " ".repeat(pad)), style),
Span::styled(value, if i == selected { style } else { th.dim() }),
])
})
.collect();
f.render_widget(
Paragraph::new(lines).block(
Block::bordered()
.title(format!(" {} ", tx.t("chart.list")))
.border_style(th.accent()),
),
area,
);
}
fn bounds(fixed: bool, values: &[f64]) -> (f64, f64) {
let high = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
if fixed {
return (
0.0,
if high.is_finite() && high > 0.0 {
high
} else {
1.0
},
);
}
let low = values.iter().copied().fold(f64::INFINITY, f64::min);
let pad = if (high - low).abs() < f64::EPSILON {
high.abs().max(1.0) * 0.1
} else {
(high - low) * 0.1
};
((low - pad).max(0.0), high + pad)
}
pub fn draw_compact_chart(
f: &mut Frame,
area: Rect,
m: &Model,
mon: &TopicMonitor,
def: &ChartDef,
) {
draw_chart_in(f, area, m, mon, def, true);
}
fn draw_chart(f: &mut Frame, area: Rect, m: &Model, mon: &TopicMonitor, def: &ChartDef) {
draw_chart_in(f, area, m, mon, def, false);
}
fn draw_chart_in(
f: &mut Frame,
area: Rect,
m: &Model,
mon: &TopicMonitor,
def: &ChartDef,
compact: bool,
) {
let tx = m.tx();
let th = m.theme;
let lang = m.lang;
let lines: Vec<(LineDef, Vec<Point>)> = lines_of(m, mon, def)
.into_iter()
.map(|line| {
let points = line_points(m, mon, &line.key);
(line, points)
})
.collect();
let mut title = vec![Span::raw(format!(" {} ", tx.t(def.title)))];
for (i, (line, points)) in lines.iter().enumerate() {
let now = points
.last()
.map_or_else(|| "—".to_owned(), |p| fmt(lang, def.unit, p.value));
let text = if compact {
format!(" {} {now}", th.marker(i))
} else {
format!(" {} {} {now} ", th.marker(i), line.label)
};
title.push(Span::styled(text, th.series(i)));
}
let block = Block::bordered().title(Line::from(title));
let values: Vec<f64> = lines
.iter()
.flat_map(|(_, points)| points.iter().map(|p| p.value))
.collect();
if values.is_empty() {
let inner = area.inner(ratatui::layout::Margin::new(2, 1));
f.render_widget(block, area);
f.render_widget(Paragraph::new(tx.t("chart.no-data")).style(th.dim()), inner);
return;
}
let at = mon.monitor.frozen.unwrap_or(m.now);
let window_s = window_ms(m, mon) as f64 / 1000.0;
let xy: Vec<Vec<(f64, f64)>> = lines
.iter()
.map(|(_, points)| {
points
.iter()
.map(|p| ((p.at as f64 - at as f64) / 1000.0, p.value))
.collect()
})
.collect();
let (low, high) = bounds(mon.monitor.fixed_scale, &values);
let mid = (low + high) / 2.0;
let datasets: Vec<Dataset> = xy
.iter()
.enumerate()
.filter(|(_, points)| !points.is_empty())
.map(|(i, points)| {
Dataset::default()
.marker(if th.ascii {
Marker::Dot
} else {
Marker::Braille
})
.graph_type(GraphType::Line)
.style(th.series(i))
.data(points)
})
.collect();
let chart = Chart::new(datasets)
.legend_position(None)
.block(block)
.x_axis(
Axis::default()
.bounds([-window_s, 0.0])
.labels(vec![
format!("-{}", i18n::duration(window_s as u64)),
tx.t("chart.now"),
])
.style(th.dim()),
)
.y_axis(
Axis::default()
.bounds([low, high])
.labels(vec![
fmt(lang, def.unit, low),
fmt(lang, def.unit, mid),
fmt(lang, def.unit, high),
])
.style(th.dim()),
);
f.render_widget(chart, area);
}