Skip to main content

taba_cli/tui/screens/topics/
view.rs

1//! The drawing of the Topics tab: the table of topics with their numbers and the detail of the selected one.
2
3use ratatui::layout::{Alignment, Constraint, Layout, Rect};
4use ratatui::style::Style;
5use ratatui::text::{Line, Span};
6use ratatui::widgets::{Block, Cell, Paragraph, Row as TableRow, Table, Wrap};
7use ratatui::Frame;
8
9use crate::tui::api::{Severity, Topic};
10use crate::tui::i18n;
11use crate::tui::model::Model;
12use crate::tui::screens::cluster::view::error_text;
13use crate::tui::screens::topics::monitor::view::draw_compact_chart;
14use crate::tui::screens::topics::monitor::CHARTS;
15use crate::tui::screens::topics::{numbers, selected_index, visible};
16use crate::tui::view::widgets::partial_seal;
17
18pub fn draw(f: &mut Frame, area: Rect, m: &Model) {
19    let tx = m.tx();
20    let th = m.theme;
21    let Some(list) = &m.topics.list.data else {
22        let (text, style) = match &m.topics.list.error {
23            Some(fail) => (error_text(m, fail), th.warning()),
24            None => (tx.t("state.loading"), th.dim()),
25        };
26        f.render_widget(
27            Paragraph::new(text).style(style).wrap(Wrap { trim: false }),
28            area,
29        );
30        return;
31    };
32    let topics = visible(m);
33    let [head, table, detail] = Layout::vertical([
34        Constraint::Length(1),
35        Constraint::Min(5),
36        Constraint::Length(10),
37    ])
38    .areas(area);
39    let hidden = if m.topics.show_dead {
40        0
41    } else {
42        list.iter().filter(|t| t.dead_letter).count()
43    };
44    let mut spans = vec![
45        Span::styled(
46            tx.tn("topics.count", (list.len() - hidden) as u64),
47            th.title(),
48        ),
49        Span::styled(
50            format!(
51                "  {}",
52                tx.tw("tokens.sorted", &[("by", &tx.t(m.topics.sort.key()))])
53            ),
54            th.dim(),
55        ),
56    ];
57    if !m.topics.filter.is_empty() {
58        spans.push(Span::styled(
59            format!(
60                "  {}",
61                tx.tw(
62                    "tokens.filtered",
63                    &[("text", &m.topics.filter), ("shown", &topics.len())]
64                )
65            ),
66            th.accent(),
67        ));
68    }
69    if hidden > 0 {
70        spans.push(Span::styled(
71            format!("  {}", tx.tw("topics.dead-hidden", &[("n", &hidden)])),
72            th.dim(),
73        ));
74    }
75    if m.topics.list.is_stale() {
76        spans.push(Span::styled(
77            format!("  {} {}", th.icon(Severity::Warning), tx.t("state.stale")),
78            th.warning(),
79        ));
80    }
81    if m.topics.stats.error.is_some() && m.topics.stats.data.is_none() {
82        spans.push(Span::styled(
83            format!(
84                "  {} {}",
85                th.icon(Severity::Warning),
86                tx.t("topics.no-numbers")
87            ),
88            th.warning(),
89        ));
90    } else if let Some(stats) = m.topics.stats.data.as_ref().filter(|s| s.partial) {
91        spans.push(Span::raw("  "));
92        let seal = partial_seal(m, th, &stats.missing);
93        spans.extend(seal.spans);
94    }
95    f.render_widget(Paragraph::new(Line::from(spans)), head);
96    draw_table(f, table, m, &topics);
97    draw_strip(f, detail, m, &topics);
98}
99
100/// Under the table: the three charts of the selected queue side by side. A topic without a queue (a distributor),
101/// or none at all, shows its summary instead.
102fn draw_strip(f: &mut Frame, area: Rect, m: &Model, topics: &[Topic]) {
103    let Some(mon) = crate::tui::screens::topics::preview::current(m) else {
104        return draw_detail(f, area, m, topics);
105    };
106    let cells =
107        Layout::horizontal([Constraint::Ratio(1, CHARTS.len() as u32); CHARTS.len()]).split(area);
108    for (def, cell) in CHARTS.iter().zip(cells.iter()) {
109        draw_compact_chart(f, *cell, m, mon, def);
110    }
111}
112
113/// `queue`, `queue fifo`, `queue vol`, `distributor` or `dead-letter`: the kind and what marks it.
114fn kind_text(m: &Model, topic: &Topic) -> String {
115    let tx = m.tx();
116    if topic.dead_letter {
117        return tx.t("topics.kind.dead-letter");
118    }
119    if topic.is_distributor() {
120        return tx.t("topics.kind.distributor");
121    }
122    let mut text = tx.t("topics.kind.queue");
123    if topic.fifo {
124        text.push_str(" fifo");
125    }
126    if topic.volatile {
127        text.push_str(" vol");
128    }
129    text
130}
131
132fn rate(m: &Model, value: Option<f64>) -> String {
133    value.map_or_else(|| "—".to_owned(), |v| i18n::decimal(m.lang, v, 1))
134}
135
136fn age(value: Option<f64>) -> String {
137    value.map_or_else(|| "—".to_owned(), |secs| i18n::duration(secs as u64))
138}
139
140fn draw_table(f: &mut Frame, area: Rect, m: &Model, topics: &[Topic]) {
141    let tx = m.tx();
142    let th = m.theme;
143    let selected = selected_index(m, topics);
144    // The table shows a window of the rows that always holds the selected one.
145    let capacity = usize::from(area.height.saturating_sub(3)).max(1);
146    let start = selected.map_or(0, |s| s.saturating_sub(capacity - 1));
147    let wide = area.width >= 100;
148    let headers = [
149        tx.t("col.name"),
150        tx.t("col.type"),
151        tx.t("col.depth"),
152        tx.t("col.publish"),
153        tx.t("col.consume"),
154        tx.t("col.age"),
155        tx.t("col.state"),
156    ];
157    let widths = if wide {
158        [10, 14, 9, 9, 9, 10, 18]
159    } else {
160        [0, 12, 8, 6, 6, 7, 12]
161    };
162    let widths: Vec<Constraint> = widths
163        .iter()
164        .enumerate()
165        .map(|(i, w)| {
166            if i == 0 {
167                Constraint::Fill(1)
168            } else {
169                Constraint::Length(*w)
170            }
171        })
172        .collect();
173    let right = |text: String| Cell::from(Line::from(text).alignment(Alignment::Right));
174    let rows: Vec<TableRow> = topics
175        .iter()
176        .enumerate()
177        .skip(start)
178        .take(capacity)
179        .map(|(i, topic)| {
180            let marker = if selected == Some(i) {
181                th.selector()
182            } else {
183                "  "
184            };
185            let n = numbers(m, &topic.name);
186            let (state, state_style) = match n {
187                Some(n) if n.worst == "ok" => (
188                    format!("{} {}", th.icon(Severity::Ok), tx.t("topics.ok")),
189                    Style::default(),
190                ),
191                Some(n) => (
192                    format!("{} {}", th.icon(n.severity), n.worst),
193                    th.severity(n.severity),
194                ),
195                None => ("—".to_owned(), th.dim()),
196            };
197            let cells = vec![
198                Cell::from(format!("{marker}{}", topic.name)),
199                Cell::from(kind_text(m, topic)),
200                right(
201                    n.and_then(|n| n.depth)
202                        .map_or_else(|| "—".to_owned(), |d| i18n::group(m.lang, d)),
203                ),
204                right(rate(m, n.and_then(|n| n.publish_rate))),
205                right(rate(m, n.and_then(|n| n.consume_rate))),
206                right(age(n.and_then(|n| n.oldest_age))),
207                Cell::from(state).style(state_style),
208            ];
209            let row = TableRow::new(cells);
210            if selected == Some(i) {
211                row.style(th.selected())
212            } else if topic.dead_letter {
213                // A dead-letter queue is dim: it follows its source queue (TUI-TOP-04).
214                row.style(th.dim())
215            } else {
216                row
217            }
218        })
219        .collect();
220    let table = Table::new(rows, widths)
221        .header(TableRow::new(headers).style(th.title()))
222        .block(Block::bordered().border_style(th.accent()));
223    f.render_widget(table, area);
224}
225
226fn draw_detail(f: &mut Frame, area: Rect, m: &Model, topics: &[Topic]) {
227    let tx = m.tx();
228    let th = m.theme;
229    let block = Block::bordered().title(format!(" {} ", tx.t("topics.detail")));
230    let Some(topic) = selected_index(m, topics).map(|i| &topics[i]) else {
231        let key = if m.topics.filter.is_empty() {
232            "topics.none"
233        } else {
234            "topics.none-filtered"
235        };
236        f.render_widget(Paragraph::new(tx.t(key)).style(th.dim()).block(block), area);
237        return;
238    };
239    let lines = summary_lines(m, topic);
240    f.render_widget(
241        Paragraph::new(lines)
242            .block(block)
243            .wrap(Wrap { trim: false }),
244        area,
245    );
246}
247
248/// The summary of a topic: its name and kind, id, creation, tags and limits (or prefixes).
249pub fn summary_lines(m: &Model, topic: &Topic) -> Vec<Line<'static>> {
250    let tx = m.tx();
251    let th = m.theme;
252    let width = [
253        "col.name",
254        "col.id",
255        "topics.created",
256        "topics.tags",
257        "topics.prefixes",
258        "topics.limits",
259    ]
260    .iter()
261    .map(|key| tx.t(key).chars().count())
262    .max()
263    .unwrap_or(14);
264    let line = |label: &str, value: String, style: Style| {
265        Line::from(vec![
266            Span::styled(format!("{label:<width$} "), th.dim()),
267            Span::styled(value, style),
268        ])
269    };
270    let none = || "—".to_owned();
271    let created = topic
272        .created_at
273        .as_deref()
274        .and_then(i18n::parse_instant)
275        .map_or_else(none, |at| i18n::date_time(m.lang, at));
276    let tags = if topic.tags.is_empty() {
277        none()
278    } else {
279        topic
280            .tags
281            .iter()
282            .map(|(k, v)| format!("{k}={v}"))
283            .collect::<Vec<_>>()
284            .join(", ")
285    };
286    let mut lines = vec![
287        line(
288            &tx.t("col.name"),
289            format!("{}  ({})", topic.name, kind_text(m, topic)),
290            th.title(),
291        ),
292        line(&tx.t("col.id"), topic.id.clone(), Style::default()),
293        line(&tx.t("topics.created"), created, Style::default()),
294        line(&tx.t("topics.tags"), tags, Style::default()),
295    ];
296    if topic.is_distributor() {
297        let prefixes = if topic.prefixes.is_empty() {
298            none()
299        } else {
300            topic.prefixes.join(", ")
301        };
302        lines.push(line(&tx.t("topics.prefixes"), prefixes, Style::default()));
303    } else if !topic.dead_letter {
304        let unset = |own: Option<u64>, shown: String| {
305            if own.is_some() {
306                shown
307            } else {
308                format!("{shown} {}", tx.t("topics.default"))
309            }
310        };
311        let or_dash =
312            |value: Option<u64>, show: &dyn Fn(u64) -> String| value.map_or_else(none, show);
313        let limits = tx.tw(
314            "topics.limits-value",
315            &[
316                (
317                    "bytes",
318                    &unset(
319                        topic.max_message_bytes,
320                        or_dash(topic.effective_max_message_bytes, &|n| {
321                            i18n::bytes(m.lang, n)
322                        }),
323                    ),
324                ),
325                (
326                    "ttl",
327                    &unset(topic.ttl, or_dash(topic.effective_ttl, &i18n::duration)),
328                ),
329                (
330                    "deliveries",
331                    &unset(
332                        topic.max_deliveries,
333                        or_dash(topic.effective_max_deliveries, &|n| n.to_string()),
334                    ),
335                ),
336            ],
337        );
338        lines.push(line(&tx.t("topics.limits"), limits, Style::default()));
339        if let Some(idle) = topic.idle_timeout.filter(|_| topic.volatile) {
340            lines.push(line(
341                &tx.t("topics.idle"),
342                i18n::duration(idle),
343                Style::default(),
344            ));
345        }
346    }
347    lines.push(Line::default());
348    lines.push(Line::from(Span::styled(
349        match &topic.source {
350            Some(source) if topic.dead_letter => {
351                tx.tw("topics.dead-letter-note", &[("source", source)])
352            }
353            _ => tx.t("topics.immutable"),
354        },
355        th.dim(),
356    )));
357    lines
358}