1use ratatui::layout::{Constraint, Layout, Rect};
4use ratatui::style::{Modifier, Style};
5use ratatui::symbols::Marker;
6use ratatui::text::{Line, Span};
7use ratatui::widgets::{
8 Axis, Block, Cell, Chart, Dataset, GraphType, Paragraph, Row as TableRow, Table, Wrap,
9};
10use ratatui::Frame;
11
12use crate::tui::api::{NodeStatus, Severity};
13use crate::tui::history;
14use crate::tui::i18n::{self, Lang};
15use crate::tui::model::{ClusterView, Fail, Model, Pane};
16use crate::tui::screens::cluster::{
17 self, line_points, selected_index, selected_row, status_of, window_ms, Row, Unit, MON_CHARTS,
18};
19use crate::tui::view::widgets::{self, partial_seal, scrolling, short};
20
21pub fn draw(f: &mut Frame, area: Rect, m: &Model) {
22 match m.cluster.view {
23 ClusterView::List => draw_list(f, area, m),
24 ClusterView::Detail => draw_detail(f, area, m),
25 ClusterView::Monitor => draw_monitor(f, area, m),
26 }
27}
28
29pub fn error_text(m: &Model, fail: &Fail) -> String {
31 let tx = m.tx();
32 if fail.is_offline() {
33 tx.tw(
34 "error.offline",
35 &[("url", &m.url), ("message", &fail.message)],
36 )
37 } else if fail.is_denied() {
38 widgets::denied_text(m, fail)
39 } else if fail.code == "cluster.node-draining" {
40 tx.t("error.draining")
41 } else {
42 tx.tw(
43 "error.api",
44 &[("code", &fail.code), ("message", &fail.message)],
45 )
46 }
47}
48
49fn draw_message(f: &mut Frame, area: Rect, text: String, style: Style) {
50 f.render_widget(
51 Paragraph::new(text).style(style).wrap(Wrap { trim: false }),
52 area,
53 );
54}
55
56fn draw_list(f: &mut Frame, area: Rect, m: &Model) {
57 let tx = m.tx();
58 let th = m.theme;
59 if m.me.data.is_none() {
60 match &m.me.error {
61 Some(fail) => draw_message(f, area, error_text(m, fail), th.critical()),
62 None => draw_message(f, area, tx.t("state.loading"), th.dim()),
63 }
64 return;
65 }
66 let rows = cluster::rows(m);
67 if rows.is_empty() {
68 let error = m.status.error.as_ref().or(m.cluster.nodes.error.as_ref());
69 match error {
70 Some(fail) => draw_message(f, area, error_text(m, fail), th.critical()),
71 None => draw_message(f, area, tx.t("state.loading"), th.dim()),
72 }
73 return;
74 }
75 if !m.is_admin() || m.status.data.is_none() {
76 draw_limited(f, area, m, &rows);
77 return;
78 }
79 let conditions = m.status.data.as_ref().map_or(0, |s| s.conditions.len());
80 let partial = m.status.data.as_ref().is_some_and(|s| s.partial);
81 let cond_height = u16::try_from(conditions.clamp(1, 3)).unwrap_or(3) + 2;
82 let stopped = m.status.data.as_ref().is_some_and(|s| s.stopped);
83 let summary_height = 1 + u16::from(partial) + u16::from(stopped);
84 let [cond_area, summary_area, table_area, chart_area] = Layout::vertical([
85 Constraint::Length(cond_height),
86 Constraint::Length(summary_height),
87 Constraint::Min(4),
88 Constraint::Length(STRIP_HEIGHT),
89 ])
90 .areas(area);
91 draw_conditions(f, cond_area, m);
92 draw_summary(f, summary_area, m);
93 draw_nodes(f, table_area, m, &rows);
94 draw_strip(f, chart_area, m);
95}
96
97fn draw_limited(f: &mut Frame, area: Rect, m: &Model, rows: &[Row]) {
99 let th = m.theme;
100 let tx = m.tx();
101 let reason = match &m.status_denied {
102 Some(fail) => widgets::denied_text(m, fail),
103 None => tx.t("denied.root"),
104 };
105 let [note, table] = Layout::vertical([Constraint::Length(3), Constraint::Min(3)]).areas(area);
106 f.render_widget(
107 Paragraph::new(format!("{} {reason}", th.icon(Severity::Warning)))
108 .style(th.warning())
109 .wrap(Wrap { trim: false }),
110 note,
111 );
112 let header = TableRow::new([
113 tx.t("col.node"),
114 tx.t("col.state"),
115 tx.t("col.drain"),
116 tx.t("col.version"),
117 ])
118 .style(th.title());
119 let selected = selected_index(m, rows);
120 let body: Vec<TableRow> = rows
121 .iter()
122 .enumerate()
123 .map(|(i, r)| {
124 let version = m
125 .cluster
126 .nodes
127 .data
128 .as_ref()
129 .and_then(|n| n.items.iter().find(|x| x.node_id == r.id))
130 .map(|x| x.version.clone())
131 .unwrap_or_default();
132 let marker = if selected == Some(i) {
133 th.selector()
134 } else {
135 " "
136 };
137 let id = if r.is_self {
138 format!("{marker}{} *", short(&r.id))
139 } else {
140 format!("{marker}{}", short(&r.id))
141 };
142 let row = TableRow::new([id, state_word(m, r), r.drain_state.clone(), version]);
143 if selected == Some(i) {
144 row.style(th.selected())
145 } else {
146 row
147 }
148 })
149 .collect();
150 let widths = [
151 Constraint::Length(14),
152 Constraint::Length(14),
153 Constraint::Length(10),
154 Constraint::Fill(1),
155 ];
156 f.render_widget(
157 Table::new(body, widths).header(header).block(
158 Block::bordered().title(format!(" {} ", tx.tn("cluster.nodes", rows.len() as u64))),
159 ),
160 table,
161 );
162}
163
164fn state_word(m: &Model, row: &Row) -> String {
165 let th = m.theme;
166 if !row.reachable {
167 return format!("{} {}", th.offline(), m.tx().t("state.unreachable"));
168 }
169 if row.state.is_empty() {
170 return "—".to_owned();
171 }
172 row.state.clone()
173}
174
175fn draw_conditions(f: &mut Frame, area: Rect, m: &Model) {
176 let tx = m.tx();
177 let th = m.theme;
178 let focused = m.cluster.pane == Pane::Conditions;
179 let title = format!(" {} ", tx.t("cluster.conditions"));
180 let border = if focused {
181 th.accent()
182 } else {
183 Style::default()
184 };
185 let block = Block::bordered().title(title).border_style(border);
186 let Some(status) = &m.status.data else {
187 f.render_widget(block, area);
188 return;
189 };
190 let lines: Vec<Line> = if status.conditions.is_empty() {
191 vec![Line::from(Span::styled(
192 format!("{} {}", th.icon(Severity::Ok), tx.t("health.ok")),
193 th.ok(),
194 ))]
195 } else {
196 status
197 .conditions
198 .iter()
199 .enumerate()
200 .map(|(i, c)| {
201 let name = tx.t(&format!("condition.{}", c.name));
202 let marker = if focused && i == m.cluster.condition {
203 th.selector()
204 } else {
205 " "
206 };
207 let style = if focused && i == m.cluster.condition {
208 th.severity(c.severity).patch(th.selected())
209 } else {
210 th.severity(c.severity)
211 };
212 Line::from(Span::styled(
213 format!("{marker}{} {name}", th.icon(c.severity)),
214 style,
215 ))
216 })
217 .collect()
218 };
219 f.render_widget(Paragraph::new(lines).block(block), area);
220}
221
222fn draw_summary(f: &mut Frame, area: Rect, m: &Model) {
223 let tx = m.tx();
224 let th = m.theme;
225 let Some(status) = &m.status.data else { return };
226 let members: Vec<String> = status
227 .members
228 .iter()
229 .map(|(state, n)| format!("{state} {n}"))
230 .collect();
231 let quorum = if status.quorum {
232 format!("{} {}", th.icon(Severity::Ok), tx.t("summary.quorum"))
233 } else {
234 format!(
235 "{} {}",
236 th.icon(Severity::Critical),
237 tx.t("summary.no-quorum")
238 )
239 };
240 let quorum_style = if status.quorum {
241 th.ok()
242 } else {
243 th.critical()
244 };
245 let mut spans = vec![
246 Span::raw(format!(
247 "{}: {} · ",
248 tx.t("summary.members"),
249 members.join(", ")
250 )),
251 Span::styled(quorum, quorum_style),
252 Span::raw(format!(
253 " · {} {} · {} {}",
254 tx.t("summary.table"),
255 status.members_version,
256 tx.t("summary.syncing"),
257 i18n::group(m.lang, status.syncing_blocks)
258 )),
259 ];
260 if m.status.is_stale() {
261 spans.push(Span::styled(
262 format!(" · {}", tx.t("state.stale")),
263 th.warning(),
264 ));
265 }
266 let mut lines = vec![Line::from(spans)];
267 if status.stopped {
268 let key = if status.planned_stop {
269 "summary.stopped-planned"
270 } else {
271 "summary.stopped"
272 };
273 let text = tx.tw(
274 key,
275 &[("at", &status.stopped_at.clone().unwrap_or_default())],
276 );
277 lines.push(Line::from(Span::styled(
278 format!("{} {text}", th.icon(Severity::Critical)),
279 th.critical(),
280 )));
281 }
282 if status.partial {
283 lines.push(partial_seal(m, th, &status.missing));
284 }
285 f.render_widget(Paragraph::new(lines), area);
286}
287
288fn draw_nodes(f: &mut Frame, area: Rect, m: &Model, rows: &[Row]) {
289 let tx = m.tx();
290 let th = m.theme;
291 let lang = m.lang;
292 let full = area.width >= 100;
293 let focused = m.cluster.pane == Pane::Nodes;
294 let mut headers = vec![
295 tx.t("col.node"),
296 tx.t("col.state"),
297 tx.t("col.drain"),
298 tx.t("col.memory"),
299 tx.t("col.messages"),
300 ];
301 let mut widths = vec![
302 Constraint::Length(14),
303 Constraint::Length(10),
304 Constraint::Length(9),
305 Constraint::Length(14),
306 Constraint::Length(15),
307 ];
308 if full {
309 headers.push(tx.t("col.disk-free"));
310 widths.push(Constraint::Length(10));
311 }
312 headers.extend([tx.t("col.sync"), tx.t("col.requests")]);
313 widths.extend([Constraint::Length(5), Constraint::Length(5)]);
314 if full {
315 headers.push(tx.t("col.rejected"));
316 widths.push(Constraint::Length(6));
317 }
318 let selected = selected_index(m, rows);
319 let body: Vec<TableRow> = rows
320 .iter()
321 .enumerate()
322 .map(|(i, r)| {
323 let marker = if selected == Some(i) {
324 th.selector()
325 } else {
326 " "
327 };
328 let mut cells = vec![Cell::from(format!("{marker}{}", short(&r.id)))];
329 match status_of(m, &r.id) {
330 Some(s) => {
331 let ratio = s.memory_ratio();
332 let mem_style = if ratio >= 0.95 {
333 th.critical()
334 } else if ratio >= 0.8 {
335 th.warning()
336 } else {
337 Style::default()
338 };
339 cells.push(Cell::from(r.state.clone()));
340 cells.push(Cell::from(r.drain_state.clone()));
341 let memory = if s.memory_limit == 0 {
342 i18n::bytes(lang, s.memory_used)
343 } else {
344 format!("{} {:>3}%", th.bar(ratio, 8), (ratio * 100.0).round())
345 };
346 cells.push(Cell::from(memory).style(mem_style));
347 cells.push(Cell::from(format!(
349 "{}/{}",
350 i18n::group(lang, s.memory_messages),
351 i18n::group(lang, s.disk_messages)
352 )));
353 if full {
354 cells.push(Cell::from(i18n::bytes(lang, s.disk_free)));
355 }
356 cells.push(Cell::from(i18n::group(lang, s.syncing_blocks)));
357 cells.push(Cell::from(format!("{}", s.active_requests.round())));
358 if full {
359 cells.push(Cell::from(format!("{}", s.rate_limited.round())));
360 }
361 }
362 None if r.reachable => {
363 cells.push(Cell::from(r.state.clone()));
365 cells.push(Cell::from(r.drain_state.clone()));
366 let blanks = widths.len() - cells.len();
367 cells.extend((0..blanks).map(|_| Cell::from("—")));
368 }
369 None => {
370 cells.push(Cell::from(state_word(m, r)).style(th.critical()));
371 let blanks = widths.len() - cells.len();
372 cells.extend((0..blanks).map(|_| Cell::from("—")));
373 }
374 }
375 let row = TableRow::new(cells);
376 if selected == Some(i) {
377 row.style(th.selected())
378 } else if !r.reachable {
379 row.style(th.critical())
380 } else {
381 row
382 }
383 })
384 .collect();
385 let border = if focused {
386 th.accent()
387 } else {
388 Style::default()
389 };
390 let table = Table::new(body, widths)
391 .header(TableRow::new(headers).style(th.title()))
392 .block(
393 Block::bordered()
394 .title(format!(" {} ", tx.tn("cluster.nodes", rows.len() as u64)))
395 .border_style(border),
396 );
397 f.render_widget(table, area);
398}
399
400const STRIP_HEIGHT: u16 = 10;
402
403fn draw_strip(f: &mut Frame, area: Rect, m: &Model) {
406 if area.height == 0 {
407 return;
408 }
409 let Some(row) = selected_row(m) else { return };
410 let nodes = [row.id];
411 let cells = Layout::horizontal([Constraint::Ratio(1, 3); 3]).split(area);
412 for (index, cell) in STRIP_CHARTS.iter().zip(cells.iter()) {
413 draw_chart(f, *cell, m, &MON_CHARTS[*index], &nodes, true);
414 }
415}
416
417const STRIP_CHARTS: [usize; 3] = [0, 1, 3];
419
420fn pair(m: &Model, label: &str, value: impl Into<String>) -> Line<'static> {
423 Line::from(vec![
424 Span::styled(format!("{label}: "), m.theme.dim()),
425 Span::raw(value.into()),
426 ])
427}
428
429fn heading(m: &Model, text: String) -> Line<'static> {
430 Line::from(Span::styled(
431 text,
432 m.theme.title().add_modifier(Modifier::UNDERLINED),
433 ))
434}
435
436pub fn detail_lines(m: &Model) -> Vec<Line<'static>> {
438 let tx = m.tx();
439 let th = m.theme;
440 let lang = m.lang;
441 let Some(row) = selected_row(m) else {
442 return vec![Line::from(tx.t("detail.no-node"))];
443 };
444 let mut lines = vec![Line::from(Span::styled(
445 format!("{} {}", tx.t("detail.node"), row.id),
446 th.title(),
447 ))];
448 let Some(s) = status_of(m, &row.id) else {
449 lines.push(Line::from(Span::styled(
450 format!("{} {}", th.offline(), tx.t("state.unreachable")),
451 th.critical(),
452 )));
453 return lines;
454 };
455 lines.push(pair(
456 m,
457 &tx.t("col.state"),
458 format!("{} · {}: {}", s.state, tx.t("col.drain"), s.drain_state),
459 ));
460 lines.push(pair(m, &tx.t("col.version"), s.version.clone()));
461 if let Some(started) = s.started_at.as_deref().and_then(i18n::parse_instant) {
462 let up = u64::try_from((m.wall_ms - started) / 1000).unwrap_or(0);
463 lines.push(pair(
464 m,
465 &tx.t("detail.started"),
466 format!(
467 "{} ({})",
468 i18n::date_time(lang, started),
469 i18n::duration(up)
470 ),
471 ));
472 }
473 lines.push(Line::default());
474 lines.push(heading(m, tx.t("detail.storage")));
475 let ratio = s.memory_ratio();
476 let mem_style = if ratio >= 0.95 {
477 th.critical()
478 } else if ratio >= 0.8 {
479 th.warning()
480 } else {
481 th.ok()
482 };
483 lines.push(Line::from(vec![
484 Span::styled(format!("{}: ", tx.t("col.memory")), th.dim()),
485 Span::styled(
486 if s.memory_limit == 0 {
487 format!(
488 "{} ({})",
489 i18n::bytes(lang, s.memory_used),
490 tx.t("detail.no-limit")
491 )
492 } else {
493 format!(
494 "{} {:>3}% {} / {}",
495 th.bar(ratio, 20),
496 (ratio * 100.0).round(),
497 i18n::bytes(lang, s.memory_used),
498 i18n::bytes(lang, s.memory_limit)
499 )
500 },
501 mem_style,
502 ),
503 ]));
504 lines.push(pair(
505 m,
506 &tx.t("detail.messages"),
507 tx.tw(
508 "detail.messages-value",
509 &[
510 ("memory", &i18n::group(lang, s.memory_messages)),
511 ("disk", &i18n::group(lang, s.disk_messages)),
512 ],
513 ),
514 ));
515 lines.push(pair(
516 m,
517 &tx.t("col.disk-free"),
518 i18n::bytes(lang, s.disk_free),
519 ));
520 lines.push(Line::default());
521 lines.push(heading(m, tx.t("detail.signals")));
522 lines.extend(signal_lines(m, s));
523 lines.push(Line::default());
524 lines.push(heading(m, tx.t("detail.backup")));
525 lines.extend(backup_lines(m));
526 if let Some(watch) = m.cluster.watch.as_ref().filter(|w| w.node == row.id) {
527 lines.push(Line::default());
528 lines.push(heading(m, tx.t("detail.drain")));
529 let text = match &m.cluster.drain.data {
530 Some(d) => tx.tw(
531 "detail.drain-progress",
532 &[
533 ("state", &d.state),
534 ("pending", &i18n::group(lang, d.pending)),
535 ],
536 ),
537 None => tx.t("state.loading"),
538 };
539 let _ = watch;
540 lines.push(Line::from(text));
541 }
542 lines.push(Line::default());
543 lines.push(heading(m, tx.t("detail.metrics")));
544 match &m.cluster.metrics.data {
545 Some(metrics) => {
546 for sample in metrics.samples.iter().take(500) {
547 lines.push(Line::from(vec![
548 Span::raw(sample.series()),
549 Span::styled(format!(" {}", sample.value), th.accent()),
550 ]));
551 }
552 }
553 None => lines.push(Line::from(Span::styled(
554 match &m.cluster.metrics.error {
555 Some(fail) => error_text(m, fail),
556 None => tx.t("state.loading"),
557 },
558 th.dim(),
559 ))),
560 }
561 lines
562}
563
564fn signal_lines(m: &Model, s: &NodeStatus) -> Vec<Line<'static>> {
565 let tx = m.tx();
566 let th = m.theme;
567 let lang = m.lang;
568 let flag = |bad: bool| if bad { th.warning() } else { Style::default() };
569 let quorum = if s.quorum {
570 Span::styled(
571 format!("{} {}", th.icon(Severity::Ok), tx.t("summary.quorum")),
572 th.ok(),
573 )
574 } else {
575 Span::styled(
576 format!(
577 "{} {}",
578 th.icon(Severity::Critical),
579 tx.t("summary.no-quorum")
580 ),
581 th.critical(),
582 )
583 };
584 let count = |label: &str, n: u64| {
585 Line::from(vec![
586 Span::styled(format!("{label}: "), th.dim()),
587 Span::styled(i18n::group(lang, n), flag(n > 0)),
588 ])
589 };
590 let rtt = match s.rtt_p95 {
591 Some(v) => fmt_unit(lang, Unit::Seconds, v),
592 None => "—".to_owned(),
593 };
594 vec![
595 Line::from(vec![
596 Span::styled(format!("{}: ", tx.t("detail.quorum")), th.dim()),
597 quorum,
598 ]),
599 count(&tx.t("detail.channels"), s.channels),
600 count(&tx.t("detail.syncing"), s.syncing_blocks),
601 count(&tx.t("detail.below-replication"), s.below_replication),
602 count(&tx.t("detail.below-quorum"), s.below_quorum),
603 count(&tx.t("detail.frozen"), s.frozen_nodes),
604 pair(
605 m,
606 &tx.t("detail.requests"),
607 format!("{}", s.active_requests.round()),
608 ),
609 pair(
610 m,
611 &tx.t("detail.rejected"),
612 format!("{}", s.rate_limited.round()),
613 ),
614 pair(m, &tx.t("detail.rtt"), rtt),
615 ]
616}
617
618fn backup_lines(m: &Model) -> Vec<Line<'static>> {
619 let tx = m.tx();
620 let th = m.theme;
621 let lang = m.lang;
622 match &m.cluster.backup.data {
623 Some(Some(b)) => {
624 let yes_no = |v: Option<bool>| match v {
625 Some(true) => tx.t("value.yes"),
626 Some(false) => tx.t("value.no"),
627 None => "—".to_owned(),
628 };
629 let last = b
630 .last_success
631 .as_deref()
632 .and_then(i18n::parse_instant)
633 .map_or_else(|| "—".to_owned(), |ms| i18n::date_time(lang, ms));
634 vec![
635 pair(m, &tx.t("detail.s3-reachable"), yes_no(b.reachable)),
636 pair(
637 m,
638 &tx.t("detail.pending"),
639 b.pending.map_or("—".to_owned(), |n| i18n::group(lang, n)),
640 ),
641 pair(m, &tx.t("detail.last-success"), last),
642 pair(
643 m,
644 &tx.t("detail.segments"),
645 b.segments.map_or("—".to_owned(), |n| i18n::group(lang, n)),
646 ),
647 ]
648 }
649 Some(None) => vec![Line::from(Span::styled(
650 tx.t("detail.backup-off"),
651 th.dim(),
652 ))],
653 None => vec![Line::from(Span::styled(
654 match &m.cluster.backup.error {
655 Some(fail) => error_text(m, fail),
656 None => tx.t("state.loading"),
657 },
658 th.dim(),
659 ))],
660 }
661}
662
663fn draw_detail(f: &mut Frame, area: Rect, m: &Model) {
664 scrolling(f, area, detail_lines(m), m.cluster.scroll);
665}
666
667pub fn fmt_unit(lang: Lang, unit: Unit, value: f64) -> String {
670 match unit {
671 Unit::Bytes => i18n::bytes(lang, value.max(0.0) as u64),
672 Unit::Count => i18n::group(lang, value.round().max(0.0) as u64),
673 Unit::PerSecond => format!("{}/s", i18n::decimal(lang, value, 2)),
674 Unit::Seconds if value < 1.0 => format!("{} ms", i18n::decimal(lang, value * 1000.0, 1)),
675 Unit::Seconds => format!("{} s", i18n::decimal(lang, value, 2)),
676 }
677}
678
679fn charted_nodes(m: &Model) -> Vec<String> {
681 cluster::rows(m)
682 .into_iter()
683 .filter(|row| {
684 MON_CHARTS.iter().any(|def| {
685 def.lines
686 .first()
687 .is_some_and(|(s, _)| m.history.series(&row.id, *s).is_some())
688 })
689 })
690 .map(|row| row.id)
691 .collect()
692}
693
694fn draw_monitor(f: &mut Frame, area: Rect, m: &Model) {
697 let tx = m.tx();
698 let th = m.theme;
699 let nodes = charted_nodes(m);
700 let chart = m.cluster.monitor.chart.min(MON_CHARTS.len() - 1);
701 let def = &MON_CHARTS[chart];
702 let [main, legend_area, note_area] = Layout::vertical([
703 Constraint::Min(5),
704 Constraint::Length(1),
705 Constraint::Length(2),
706 ])
707 .areas(area);
708 if m.cluster.monitor.full {
709 draw_chart(f, main, m, def, &nodes, false);
710 } else {
711 let width = if main.width >= 100 { 40 } else { 28 };
712 let [list_area, chart_area] =
713 Layout::horizontal([Constraint::Length(width), Constraint::Min(20)]).areas(main);
714 draw_chart_list(f, list_area, m, &nodes, chart);
715 draw_chart(f, chart_area, m, def, &nodes, false);
716 }
717
718 let legend: Vec<Span> = nodes
720 .iter()
721 .enumerate()
722 .map(|(i, id)| Span::styled(format!(" {} {} ", th.marker(i), short(id)), th.series(i)))
723 .collect();
724 f.render_widget(Paragraph::new(Line::from(legend)), legend_area);
725
726 let at = m.cluster.monitor.frozen.unwrap_or(m.now);
728 let window = window_ms(m);
729 let collected = def
730 .lines
731 .first()
732 .into_iter()
733 .flat_map(|(series, _)| {
734 nodes
735 .iter()
736 .filter_map(|id| m.history.series(id, *series).map(|r| r.collected_ms(at)))
737 })
738 .max()
739 .unwrap_or(0);
740 let mut note = if collected < window {
741 tx.tw(
742 "chart.collected",
743 &[("time", &i18n::duration(collected / 1000))],
744 )
745 } else {
746 tx.t("chart.history-note")
747 };
748 if m.cluster.monitor.frozen.is_some() {
749 note.push_str(&format!(" · {}", tx.t("chart.paused")));
750 }
751 note.push_str(&format!(
752 " · {}",
753 tx.t(if m.cluster.monitor.fixed_scale {
754 "chart.scale-fixed"
755 } else {
756 "chart.scale-auto"
757 })
758 ));
759 let partial = def.lines.iter().take(1).any(|(series, _)| {
760 nodes
761 .iter()
762 .any(|id| line_points(m, id, *series).iter().any(|p| p.partial))
763 });
764 if partial {
765 note.push_str(&format!(" · {}", tx.t("chart.partial-samples")));
766 }
767 f.render_widget(
768 Paragraph::new(note)
769 .style(th.dim())
770 .wrap(Wrap { trim: false }),
771 note_area,
772 );
773}
774
775fn cluster_figure(m: &Model, def: &cluster::ChartDef, nodes: &[String]) -> Option<f64> {
778 let (series, _) = def.lines.first().copied()?;
779 let lasts: Vec<f64> = nodes
780 .iter()
781 .filter_map(|id| line_points(m, id, series).last().map(|p| p.value))
782 .collect();
783 if lasts.is_empty() {
784 return None;
785 }
786 Some(if def.unit == Unit::Seconds {
787 lasts.iter().copied().fold(0.0, f64::max)
788 } else {
789 lasts.iter().sum()
790 })
791}
792
793fn draw_chart_list(f: &mut Frame, area: Rect, m: &Model, nodes: &[String], selected: usize) {
795 let tx = m.tx();
796 let th = m.theme;
797 let inner_width = usize::from(area.width.saturating_sub(2));
798 let lines: Vec<Line> = MON_CHARTS
799 .iter()
800 .enumerate()
801 .map(|(i, def)| {
802 let marker = if i == selected { th.selector() } else { " " };
803 let value = cluster_figure(m, def, nodes)
804 .map_or_else(|| "—".to_owned(), |v| fmt_unit(m.lang, def.unit, v));
805 let room = inner_width.saturating_sub(value.chars().count() + 1);
807 let name: String = format!("{marker}{}", tx.t(def.title))
808 .chars()
809 .take(room)
810 .collect();
811 let pad = inner_width
812 .saturating_sub(name.chars().count() + value.chars().count())
813 .max(1);
814 let style = if i == selected {
815 th.selected()
816 } else {
817 ratatui::style::Style::default()
818 };
819 Line::from(vec![
820 Span::styled(format!("{name}{}", " ".repeat(pad)), style),
821 Span::styled(value, if i == selected { style } else { th.dim() }),
822 ])
823 })
824 .collect();
825 f.render_widget(
826 Paragraph::new(lines).block(
827 Block::bordered()
828 .title(format!(" {} ", tx.t("chart.list")))
829 .border_style(th.accent()),
830 ),
831 area,
832 );
833}
834
835fn draw_chart(
837 f: &mut Frame,
838 area: Rect,
839 m: &Model,
840 def: &cluster::ChartDef,
841 nodes: &[String],
842 compact: bool,
843) {
844 let tx = m.tx();
845 let th = m.theme;
846 let lang = m.lang;
847 let Some((series, _)) = def.lines.first().copied() else {
848 return;
849 };
850 let at = m.cluster.monitor.frozen.unwrap_or(m.now);
851 let window_s = window_ms(m) as f64 / 1000.0;
852 let lines: Vec<Vec<history::Point>> =
853 nodes.iter().map(|id| line_points(m, id, series)).collect();
854 let xy: Vec<Vec<(f64, f64)>> = lines
855 .iter()
856 .map(|points| {
857 points
858 .iter()
859 .map(|p| ((p.at as f64 - at as f64) / 1000.0, p.value))
860 .collect()
861 })
862 .collect();
863 let values: Vec<f64> = lines.iter().flatten().map(|p| p.value).collect();
864 if values.is_empty() {
865 f.render_widget(
866 Block::bordered().title(format!(" {} ", tx.t(def.title))),
867 area,
868 );
869 let inner = area.inner(ratatui::layout::Margin::new(2, 1));
870 draw_message(f, inner, tx.t("chart.no-data"), th.dim());
871 return;
872 }
873
874 let lasts: Vec<f64> = lines
876 .iter()
877 .filter_map(|l| l.last().map(|p| p.value))
878 .collect();
879 let figure = if def.unit == Unit::Seconds {
880 let worst = lasts.iter().copied().fold(0.0, f64::max);
881 if compact {
882 fmt_unit(lang, def.unit, worst)
883 } else {
884 tx.tw(
885 "chart.worst",
886 &[("value", &fmt_unit(lang, def.unit, worst))],
887 )
888 }
889 } else {
890 let sum: f64 = lasts.iter().sum();
891 if compact {
892 fmt_unit(lang, def.unit, sum)
893 } else {
894 tx.tw("chart.total", &[("value", &fmt_unit(lang, def.unit, sum))])
895 }
896 };
897
898 let (low, high) = scale(m, nodes, def, &values);
899 let mid = (low + high) / 2.0;
900 let datasets: Vec<Dataset> = xy
901 .iter()
902 .enumerate()
903 .filter(|(_, points)| !points.is_empty())
904 .map(|(i, points)| {
905 Dataset::default()
906 .marker(if th.ascii {
907 Marker::Dot
908 } else {
909 Marker::Braille
910 })
911 .graph_type(GraphType::Line)
912 .style(th.series(i))
913 .data(points)
914 })
915 .collect();
916 let chart = Chart::new(datasets)
917 .legend_position(None)
918 .block(Block::bordered().title(format!(" {} · {} ", tx.t(def.title), figure)))
919 .x_axis(
920 Axis::default()
921 .bounds([-window_s, 0.0])
922 .labels(vec![
923 format!("-{}", i18n::duration(window_s as u64)),
924 tx.t("chart.now"),
925 ])
926 .style(th.dim()),
927 )
928 .y_axis(
929 Axis::default()
930 .bounds([low, high])
931 .labels(vec![
932 fmt_unit(lang, def.unit, low),
933 fmt_unit(lang, def.unit, mid),
934 fmt_unit(lang, def.unit, high),
935 ])
936 .style(th.dim()),
937 );
938 f.render_widget(chart, area);
939}
940
941fn scale(m: &Model, nodes: &[String], def: &cluster::ChartDef, values: &[f64]) -> (f64, f64) {
943 if m.cluster.monitor.fixed_scale {
944 let high = def
945 .lines
946 .iter()
947 .take(1)
948 .flat_map(|(series, _)| nodes.iter().map(move |id| (id, *series)))
949 .flat_map(|(id, series)| {
950 m.history
951 .series(id, series)
952 .map(|r| r.window(m.now, m.history.retention_ms()))
953 .unwrap_or_default()
954 })
955 .map(|p| p.value)
956 .fold(f64::NEG_INFINITY, f64::max);
957 return (
958 0.0,
959 if high.is_finite() && high > 0.0 {
960 high
961 } else {
962 1.0
963 },
964 );
965 }
966 let low = values.iter().copied().fold(f64::INFINITY, f64::min);
967 let high = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
968 let pad = if (high - low).abs() < f64::EPSILON {
969 high.abs().max(1.0) * 0.1
970 } else {
971 (high - low) * 0.1
972 };
973 let floor = if low >= 0.0 {
974 (low - pad).max(0.0)
975 } else {
976 low - pad
977 };
978 (floor, high + pad)
979}