use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Cell, Paragraph, Row as TableRow, Table, Wrap};
use ratatui::Frame;
use crate::tui::api::{Severity, Topic};
use crate::tui::i18n;
use crate::tui::model::Model;
use crate::tui::screens::cluster::view::error_text;
use crate::tui::screens::topics::monitor::view::draw_compact_chart;
use crate::tui::screens::topics::monitor::CHARTS;
use crate::tui::screens::topics::{numbers, selected_index, visible};
use crate::tui::view::widgets::partial_seal;
pub fn draw(f: &mut Frame, area: Rect, m: &Model) {
let tx = m.tx();
let th = m.theme;
let Some(list) = &m.topics.list.data else {
let (text, style) = match &m.topics.list.error {
Some(fail) => (error_text(m, fail), th.warning()),
None => (tx.t("state.loading"), th.dim()),
};
f.render_widget(
Paragraph::new(text).style(style).wrap(Wrap { trim: false }),
area,
);
return;
};
let topics = visible(m);
let [head, table, detail] = Layout::vertical([
Constraint::Length(1),
Constraint::Min(5),
Constraint::Length(10),
])
.areas(area);
let hidden = if m.topics.show_dead {
0
} else {
list.iter().filter(|t| t.dead_letter).count()
};
let mut spans = vec![
Span::styled(
tx.tn("topics.count", (list.len() - hidden) as u64),
th.title(),
),
Span::styled(
format!(
" {}",
tx.tw("tokens.sorted", &[("by", &tx.t(m.topics.sort.key()))])
),
th.dim(),
),
];
if !m.topics.filter.is_empty() {
spans.push(Span::styled(
format!(
" {}",
tx.tw(
"tokens.filtered",
&[("text", &m.topics.filter), ("shown", &topics.len())]
)
),
th.accent(),
));
}
if hidden > 0 {
spans.push(Span::styled(
format!(" {}", tx.tw("topics.dead-hidden", &[("n", &hidden)])),
th.dim(),
));
}
if m.topics.list.is_stale() {
spans.push(Span::styled(
format!(" {} {}", th.icon(Severity::Warning), tx.t("state.stale")),
th.warning(),
));
}
if m.topics.stats.error.is_some() && m.topics.stats.data.is_none() {
spans.push(Span::styled(
format!(
" {} {}",
th.icon(Severity::Warning),
tx.t("topics.no-numbers")
),
th.warning(),
));
} else if let Some(stats) = m.topics.stats.data.as_ref().filter(|s| s.partial) {
spans.push(Span::raw(" "));
let seal = partial_seal(m, th, &stats.missing);
spans.extend(seal.spans);
}
f.render_widget(Paragraph::new(Line::from(spans)), head);
draw_table(f, table, m, &topics);
draw_strip(f, detail, m, &topics);
}
fn draw_strip(f: &mut Frame, area: Rect, m: &Model, topics: &[Topic]) {
let Some(mon) = crate::tui::screens::topics::preview::current(m) else {
return draw_detail(f, area, m, topics);
};
let cells =
Layout::horizontal([Constraint::Ratio(1, CHARTS.len() as u32); CHARTS.len()]).split(area);
for (def, cell) in CHARTS.iter().zip(cells.iter()) {
draw_compact_chart(f, *cell, m, mon, def);
}
}
fn kind_text(m: &Model, topic: &Topic) -> String {
let tx = m.tx();
if topic.dead_letter {
return tx.t("topics.kind.dead-letter");
}
if topic.is_distributor() {
return tx.t("topics.kind.distributor");
}
let mut text = tx.t("topics.kind.queue");
if topic.fifo {
text.push_str(" fifo");
}
if topic.volatile {
text.push_str(" vol");
}
text
}
fn rate(m: &Model, value: Option<f64>) -> String {
value.map_or_else(|| "—".to_owned(), |v| i18n::decimal(m.lang, v, 1))
}
fn age(value: Option<f64>) -> String {
value.map_or_else(|| "—".to_owned(), |secs| i18n::duration(secs as u64))
}
fn draw_table(f: &mut Frame, area: Rect, m: &Model, topics: &[Topic]) {
let tx = m.tx();
let th = m.theme;
let selected = selected_index(m, topics);
let capacity = usize::from(area.height.saturating_sub(3)).max(1);
let start = selected.map_or(0, |s| s.saturating_sub(capacity - 1));
let wide = area.width >= 100;
let headers = [
tx.t("col.name"),
tx.t("col.type"),
tx.t("col.depth"),
tx.t("col.publish"),
tx.t("col.consume"),
tx.t("col.age"),
tx.t("col.state"),
];
let widths = if wide {
[10, 14, 9, 9, 9, 10, 18]
} else {
[0, 12, 8, 6, 6, 7, 12]
};
let widths: Vec<Constraint> = widths
.iter()
.enumerate()
.map(|(i, w)| {
if i == 0 {
Constraint::Fill(1)
} else {
Constraint::Length(*w)
}
})
.collect();
let right = |text: String| Cell::from(Line::from(text).alignment(Alignment::Right));
let rows: Vec<TableRow> = topics
.iter()
.enumerate()
.skip(start)
.take(capacity)
.map(|(i, topic)| {
let marker = if selected == Some(i) {
th.selector()
} else {
" "
};
let n = numbers(m, &topic.name);
let (state, state_style) = match n {
Some(n) if n.worst == "ok" => (
format!("{} {}", th.icon(Severity::Ok), tx.t("topics.ok")),
Style::default(),
),
Some(n) => (
format!("{} {}", th.icon(n.severity), n.worst),
th.severity(n.severity),
),
None => ("—".to_owned(), th.dim()),
};
let cells = vec![
Cell::from(format!("{marker}{}", topic.name)),
Cell::from(kind_text(m, topic)),
right(
n.and_then(|n| n.depth)
.map_or_else(|| "—".to_owned(), |d| i18n::group(m.lang, d)),
),
right(rate(m, n.and_then(|n| n.publish_rate))),
right(rate(m, n.and_then(|n| n.consume_rate))),
right(age(n.and_then(|n| n.oldest_age))),
Cell::from(state).style(state_style),
];
let row = TableRow::new(cells);
if selected == Some(i) {
row.style(th.selected())
} else if topic.dead_letter {
row.style(th.dim())
} else {
row
}
})
.collect();
let table = Table::new(rows, widths)
.header(TableRow::new(headers).style(th.title()))
.block(Block::bordered().border_style(th.accent()));
f.render_widget(table, area);
}
fn draw_detail(f: &mut Frame, area: Rect, m: &Model, topics: &[Topic]) {
let tx = m.tx();
let th = m.theme;
let block = Block::bordered().title(format!(" {} ", tx.t("topics.detail")));
let Some(topic) = selected_index(m, topics).map(|i| &topics[i]) else {
let key = if m.topics.filter.is_empty() {
"topics.none"
} else {
"topics.none-filtered"
};
f.render_widget(Paragraph::new(tx.t(key)).style(th.dim()).block(block), area);
return;
};
let lines = summary_lines(m, topic);
f.render_widget(
Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: false }),
area,
);
}
pub fn summary_lines(m: &Model, topic: &Topic) -> Vec<Line<'static>> {
let tx = m.tx();
let th = m.theme;
let width = [
"col.name",
"col.id",
"topics.created",
"topics.tags",
"topics.prefixes",
"topics.limits",
]
.iter()
.map(|key| tx.t(key).chars().count())
.max()
.unwrap_or(14);
let line = |label: &str, value: String, style: Style| {
Line::from(vec![
Span::styled(format!("{label:<width$} "), th.dim()),
Span::styled(value, style),
])
};
let none = || "—".to_owned();
let created = topic
.created_at
.as_deref()
.and_then(i18n::parse_instant)
.map_or_else(none, |at| i18n::date_time(m.lang, at));
let tags = if topic.tags.is_empty() {
none()
} else {
topic
.tags
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join(", ")
};
let mut lines = vec![
line(
&tx.t("col.name"),
format!("{} ({})", topic.name, kind_text(m, topic)),
th.title(),
),
line(&tx.t("col.id"), topic.id.clone(), Style::default()),
line(&tx.t("topics.created"), created, Style::default()),
line(&tx.t("topics.tags"), tags, Style::default()),
];
if topic.is_distributor() {
let prefixes = if topic.prefixes.is_empty() {
none()
} else {
topic.prefixes.join(", ")
};
lines.push(line(&tx.t("topics.prefixes"), prefixes, Style::default()));
} else if !topic.dead_letter {
let unset = |own: Option<u64>, shown: String| {
if own.is_some() {
shown
} else {
format!("{shown} {}", tx.t("topics.default"))
}
};
let or_dash =
|value: Option<u64>, show: &dyn Fn(u64) -> String| value.map_or_else(none, show);
let limits = tx.tw(
"topics.limits-value",
&[
(
"bytes",
&unset(
topic.max_message_bytes,
or_dash(topic.effective_max_message_bytes, &|n| {
i18n::bytes(m.lang, n)
}),
),
),
(
"ttl",
&unset(topic.ttl, or_dash(topic.effective_ttl, &i18n::duration)),
),
(
"deliveries",
&unset(
topic.max_deliveries,
or_dash(topic.effective_max_deliveries, &|n| n.to_string()),
),
),
],
);
lines.push(line(&tx.t("topics.limits"), limits, Style::default()));
if let Some(idle) = topic.idle_timeout.filter(|_| topic.volatile) {
lines.push(line(
&tx.t("topics.idle"),
i18n::duration(idle),
Style::default(),
));
}
}
lines.push(Line::default());
lines.push(Line::from(Span::styled(
match &topic.source {
Some(source) if topic.dead_letter => {
tx.tw("topics.dead-letter-note", &[("source", source)])
}
_ => tx.t("topics.immutable"),
},
th.dim(),
)));
lines
}