1use 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
100fn 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
113fn 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 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 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
248pub 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}