1use ratatui::layout::{Constraint, Layout, Rect};
5use ratatui::symbols::Marker;
6use ratatui::text::{Line, Span};
7use ratatui::widgets::{Axis, Block, Chart, Dataset, GraphType, Paragraph, Wrap};
8use ratatui::Frame;
9
10use crate::tui::api::Severity;
11use crate::tui::history::Point;
12use crate::tui::i18n::{self, Lang};
13use crate::tui::model::{Model, TopicMonitor, TopicTab};
14use crate::tui::screens::cluster::view::error_text;
15use crate::tui::screens::cluster::Unit;
16use crate::tui::screens::topics::messages;
17use crate::tui::screens::topics::monitor::{
18 line_points, lines_of, window_ms, ChartDef, LineDef, CHARTS,
19};
20
21pub fn fmt(lang: Lang, unit: Unit, value: f64) -> String {
23 match unit {
24 Unit::Count | Unit::Bytes => i18n::group(lang, value.round().max(0.0) as u64),
25 Unit::PerSecond => format!("{}/s", i18n::decimal(lang, value, 1)),
26 Unit::Seconds => i18n::duration(value.max(0.0) as u64),
27 }
28}
29
30pub fn head_line(m: &Model, mon: &TopicMonitor) -> Line<'static> {
33 let tx = m.tx();
34 let th = m.theme;
35 let key = if mon.tab == TopicTab::Messages {
36 "topic.screen-messages"
37 } else {
38 "topic.screen-charts"
39 };
40 let mut spans = vec![Span::styled(tx.tw(key, &[("name", &mon.name)]), th.title())];
41 if let Some(fail) = &mon.feed.error {
42 let text = if mon.feed.data.is_some() || !mon.series.is_empty() {
43 tx.t("state.stale")
44 } else {
45 error_text(m, fail)
46 };
47 spans.push(Span::styled(
48 format!(" {} {text}", th.icon(Severity::Warning)),
49 th.warning(),
50 ));
51 }
52 Line::from(spans)
53}
54
55pub fn draw(f: &mut Frame, area: Rect, m: &Model) {
58 let Some(mon) = &m.topics.monitor else { return };
59 if mon.tab == TopicTab::Messages {
60 return messages::view::draw(f, area, m, mon);
61 }
62 let tx = m.tx();
63 let th = m.theme;
64 let [head, main, note] = Layout::vertical([
65 Constraint::Length(1),
66 Constraint::Min(7),
67 Constraint::Length(2),
68 ])
69 .areas(area);
70
71 f.render_widget(Paragraph::new(head_line(m, mon)), head);
72
73 let chart = mon.monitor.chart.min(CHARTS.len() - 1);
74 if mon.monitor.full {
75 draw_chart(f, main, m, mon, &CHARTS[chart]);
76 } else {
77 let width = if main.width >= 100 { 40 } else { 28 };
78 let [list_area, chart_area] =
79 Layout::horizontal([Constraint::Length(width), Constraint::Min(20)]).areas(main);
80 draw_chart_list(f, list_area, m, mon, chart);
81 draw_chart(f, chart_area, m, mon, &CHARTS[chart]);
82 }
83
84 let at = mon.monitor.frozen.unwrap_or(m.now);
86 let window = window_ms(m, mon);
87 let collected = mon
88 .series
89 .values()
90 .map(|ring| ring.collected_ms(at))
91 .max()
92 .unwrap_or(0);
93 let mut text = if collected < window {
94 tx.tw(
95 "chart.collected",
96 &[("time", &i18n::duration(collected / 1000))],
97 )
98 } else {
99 tx.t("chart.history-note")
100 };
101 if mon.monitor.frozen.is_some() {
102 text.push_str(&format!(" · {}", tx.t("chart.paused")));
103 }
104 text.push_str(&format!(
105 " · {}",
106 tx.t(if mon.monitor.fixed_scale {
107 "chart.scale-fixed"
108 } else {
109 "chart.scale-auto"
110 })
111 ));
112 let partial = mon
113 .series
114 .keys()
115 .any(|key| line_points(m, mon, key).iter().any(|p| p.partial));
116 if partial {
117 text.push_str(&format!(" · {}", tx.t("chart.partial-samples")));
118 }
119 f.render_widget(
120 Paragraph::new(text)
121 .style(th.dim())
122 .wrap(Wrap { trim: false }),
123 note,
124 );
125}
126
127fn draw_chart_list(f: &mut Frame, area: Rect, m: &Model, mon: &TopicMonitor, selected: usize) {
129 let tx = m.tx();
130 let th = m.theme;
131 let inner_width = usize::from(area.width.saturating_sub(2));
132 let lines: Vec<Line> = CHARTS
133 .iter()
134 .enumerate()
135 .map(|(i, def)| {
136 let marker = if i == selected { th.selector() } else { " " };
137 let value = def
138 .lines
139 .first()
140 .and_then(|(key, _)| line_points(m, mon, key).last().map(|p| p.value))
141 .map_or_else(|| "—".to_owned(), |v| fmt(m.lang, def.unit, v));
142 let room = inner_width.saturating_sub(value.chars().count() + 1);
143 let name: String = format!("{marker}{}", tx.t(def.title))
144 .chars()
145 .take(room)
146 .collect();
147 let pad = inner_width
148 .saturating_sub(name.chars().count() + value.chars().count())
149 .max(1);
150 let style = if i == selected {
151 th.selected()
152 } else {
153 ratatui::style::Style::default()
154 };
155 Line::from(vec![
156 Span::styled(format!("{name}{}", " ".repeat(pad)), style),
157 Span::styled(value, if i == selected { style } else { th.dim() }),
158 ])
159 })
160 .collect();
161 f.render_widget(
162 Paragraph::new(lines).block(
163 Block::bordered()
164 .title(format!(" {} ", tx.t("chart.list")))
165 .border_style(th.accent()),
166 ),
167 area,
168 );
169}
170
171fn bounds(fixed: bool, values: &[f64]) -> (f64, f64) {
173 let high = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
174 if fixed {
175 return (
176 0.0,
177 if high.is_finite() && high > 0.0 {
178 high
179 } else {
180 1.0
181 },
182 );
183 }
184 let low = values.iter().copied().fold(f64::INFINITY, f64::min);
185 let pad = if (high - low).abs() < f64::EPSILON {
186 high.abs().max(1.0) * 0.1
187 } else {
188 (high - low) * 0.1
189 };
190 ((low - pad).max(0.0), high + pad)
191}
192
193pub fn draw_compact_chart(
195 f: &mut Frame,
196 area: Rect,
197 m: &Model,
198 mon: &TopicMonitor,
199 def: &ChartDef,
200) {
201 draw_chart_in(f, area, m, mon, def, true);
202}
203
204fn draw_chart(f: &mut Frame, area: Rect, m: &Model, mon: &TopicMonitor, def: &ChartDef) {
205 draw_chart_in(f, area, m, mon, def, false);
206}
207
208fn draw_chart_in(
209 f: &mut Frame,
210 area: Rect,
211 m: &Model,
212 mon: &TopicMonitor,
213 def: &ChartDef,
214 compact: bool,
215) {
216 let tx = m.tx();
217 let th = m.theme;
218 let lang = m.lang;
219 let lines: Vec<(LineDef, Vec<Point>)> = lines_of(m, mon, def)
220 .into_iter()
221 .map(|line| {
222 let points = line_points(m, mon, &line.key);
223 (line, points)
224 })
225 .collect();
226 let mut title = vec![Span::raw(format!(" {} ", tx.t(def.title)))];
228 for (i, (line, points)) in lines.iter().enumerate() {
229 let now = points
230 .last()
231 .map_or_else(|| "—".to_owned(), |p| fmt(lang, def.unit, p.value));
232 let text = if compact {
233 format!(" {} {now}", th.marker(i))
234 } else {
235 format!(" {} {} {now} ", th.marker(i), line.label)
236 };
237 title.push(Span::styled(text, th.series(i)));
238 }
239 let block = Block::bordered().title(Line::from(title));
240 let values: Vec<f64> = lines
241 .iter()
242 .flat_map(|(_, points)| points.iter().map(|p| p.value))
243 .collect();
244 if values.is_empty() {
245 let inner = area.inner(ratatui::layout::Margin::new(2, 1));
246 f.render_widget(block, area);
247 f.render_widget(Paragraph::new(tx.t("chart.no-data")).style(th.dim()), inner);
248 return;
249 }
250 let at = mon.monitor.frozen.unwrap_or(m.now);
251 let window_s = window_ms(m, mon) as f64 / 1000.0;
252 let xy: Vec<Vec<(f64, f64)>> = lines
253 .iter()
254 .map(|(_, points)| {
255 points
256 .iter()
257 .map(|p| ((p.at as f64 - at as f64) / 1000.0, p.value))
258 .collect()
259 })
260 .collect();
261 let (low, high) = bounds(mon.monitor.fixed_scale, &values);
262 let mid = (low + high) / 2.0;
263 let datasets: Vec<Dataset> = xy
264 .iter()
265 .enumerate()
266 .filter(|(_, points)| !points.is_empty())
267 .map(|(i, points)| {
268 Dataset::default()
269 .marker(if th.ascii {
270 Marker::Dot
271 } else {
272 Marker::Braille
273 })
274 .graph_type(GraphType::Line)
275 .style(th.series(i))
276 .data(points)
277 })
278 .collect();
279 let chart = Chart::new(datasets)
280 .legend_position(None)
281 .block(block)
282 .x_axis(
283 Axis::default()
284 .bounds([-window_s, 0.0])
285 .labels(vec![
286 format!("-{}", i18n::duration(window_s as u64)),
287 tx.t("chart.now"),
288 ])
289 .style(th.dim()),
290 )
291 .y_axis(
292 Axis::default()
293 .bounds([low, high])
294 .labels(vec![
295 fmt(lang, def.unit, low),
296 fmt(lang, def.unit, mid),
297 fmt(lang, def.unit, high),
298 ])
299 .style(th.dim()),
300 );
301 f.render_widget(chart, area);
302}