use std::collections::HashMap;
use crate::tui::api::TopicSample;
use crate::tui::effect::Effect;
use crate::tui::history::{Point, Ring};
use crate::tui::i18n;
use crate::tui::keymap::Action;
use crate::tui::model::{Input, InputPurpose, Modal, Model, TopicMonitor, TopicTab, WINDOWS_MIN};
use crate::tui::screens::cluster::{allowed_windows, moved, Unit};
use super::{messages, refuse, selected};
pub mod view;
pub const MAX_GROUPS: usize = 3;
pub struct ChartDef {
pub key: &'static str,
pub title: &'static str,
pub unit: Unit,
pub lines: &'static [(&'static str, &'static str)],
pub groups: bool,
}
pub const CHARTS: &[ChartDef] = &[
ChartDef {
key: "messages",
title: "topic-chart.messages",
unit: Unit::Count,
lines: &[
("visible", "topic-chart.visible"),
("invisible", "topic-chart.invisible"),
],
groups: false,
},
ChartDef {
key: "flow",
title: "topic-chart.flow",
unit: Unit::PerSecond,
lines: &[
("published", "topic-chart.published"),
("consumed", "topic-chart.consumed"),
("acked", "topic-chart.acked"),
],
groups: false,
},
ChartDef {
key: "age",
title: "topic-chart.age",
unit: Unit::Seconds,
lines: &[("oldest", "topic-chart.oldest")],
groups: true,
},
];
pub struct LineDef {
pub key: String,
pub label: String,
}
pub fn lines_of(m: &Model, mon: &TopicMonitor, def: &ChartDef) -> Vec<LineDef> {
let tx = m.tx();
let mut lines: Vec<LineDef> = def
.lines
.iter()
.map(|(key, label)| LineDef {
key: (*key).to_owned(),
label: tx.t(label),
})
.collect();
if def.groups {
for group in &mon.groups {
lines.push(LineDef {
key: format!("g:{group}"),
label: tx.tw("topic-chart.group", &[("group", &group)]),
});
}
}
lines
}
pub fn open(m: &mut Model, tab: TopicTab) -> Vec<Effect> {
let Some(topic) = selected(m) else {
return refuse(m, "gate.no-topic");
};
let distributor = topic.is_distributor();
if distributor && tab == TopicTab::Charts {
return refuse(m, "gate.topic-no-charts");
}
let mut mon = TopicMonitor::new(topic.name, tab, distributor, topic.dead_letter);
if let Some(seen) = m.topics.previews.iter().find(|p| p.name == mon.name) {
mon.series = seen.series.clone();
mon.groups = seen.groups.clone();
}
m.topics.monitor = Some(mon);
Vec::new()
}
pub fn window_ms(m: &Model, mon: &TopicMonitor) -> u64 {
let index = mon
.monitor
.window
.min(allowed_windows(m.history.retention_ms()) - 1);
WINDOWS_MIN[index] * 60_000
}
pub fn line_points(m: &Model, mon: &TopicMonitor, key: &str) -> Vec<Point> {
let at = mon.monitor.frozen.unwrap_or(m.now);
mon.series
.get(key)
.map(|ring| ring.window(at, window_ms(m, mon)))
.unwrap_or_default()
}
fn put(series: &mut HashMap<String, Ring>, cap: usize, key: &str, point: Point) {
series
.entry(key.to_owned())
.or_insert_with(|| Ring::new(cap))
.push(point);
}
pub fn record(mon: &mut TopicMonitor, cap: usize, now: u64, sample: &TopicSample) {
for (group, _) in &sample.groups {
if mon.groups.len() < MAX_GROUPS && !mon.groups.contains(group) {
mon.groups.push(group.clone());
}
}
let at = |value: f64| Point {
at: now,
value,
partial: sample.partial,
};
let series = &mut mon.series;
put(series, cap, "visible", at(sample.visible as f64));
put(series, cap, "invisible", at(sample.invisible as f64));
put(series, cap, "published", at(sample.publish_rate));
put(series, cap, "consumed", at(sample.consume_rate));
put(series, cap, "acked", at(sample.ack_rate));
put(series, cap, "oldest", at(sample.oldest_age.unwrap_or(0.0)));
for (group, age) in &sample.groups {
if mon.groups.contains(group) {
put(&mut mon.series, cap, &format!("g:{group}"), at(*age));
}
}
}
pub fn on_action(m: &mut Model, action: Action) -> Vec<Effect> {
let Some(shown) = m.topics.monitor.as_ref().map(|mon| mon.tab) else {
return Vec::new();
};
if action == Action::Esc {
match &mut m.topics.monitor {
Some(mon) if shown == TopicTab::Charts && mon.monitor.full => mon.monitor.full = false,
_ => m.topics.monitor = None,
}
return Vec::new();
}
if shown == TopicTab::Messages {
return messages::on_action(m, action);
}
charts_action(m, action)
}
fn charts_action(m: &mut Model, action: Action) -> Vec<Effect> {
let now = m.now;
let allowed = allowed_windows(m.history.retention_ms());
let Some(mon) = &mut m.topics.monitor else {
return Vec::new();
};
match action {
Action::Up => mon.monitor.chart = moved(mon.monitor.chart, -1, CHARTS.len()),
Action::Down => mon.monitor.chart = moved(mon.monitor.chart, 1, CHARTS.len()),
Action::First => mon.monitor.chart = 0,
Action::Last => mon.monitor.chart = CHARTS.len() - 1,
Action::ChartFull => mon.monitor.full = !mon.monitor.full,
Action::WindowWider => mon.monitor.window = (mon.monitor.window + 1).min(allowed - 1),
Action::WindowNarrower => mon.monitor.window = mon.monitor.window.saturating_sub(1),
Action::PauseChart => {
mon.monitor.frozen = match mon.monitor.frozen {
Some(_) => None,
None => Some(now),
}
}
Action::Scale => mon.monitor.fixed_scale = !mon.monitor.fixed_scale,
Action::Export => {
let value = format!("taba-{}-charts.csv", crate::tui::model::file_tag(&mon.name));
m.modal = Some(Modal::Input(Input {
purpose: InputPurpose::ExportTopicCsv,
value,
}));
}
_ => {}
}
Vec::new()
}
pub fn export_csv(m: &Model) -> String {
let Some(mon) = &m.topics.monitor else {
return String::new();
};
let lines: Vec<(LineDef, Vec<Point>)> = CHARTS
.iter()
.flat_map(|def| lines_of(m, mon, def))
.map(|line| {
let points = line_points(m, mon, &line.key);
(line, points)
})
.collect();
let mut out = String::from("time");
for (line, _) in &lines {
out.push(',');
out.push_str(&line.label.replace(',', " "));
}
out.push('\n');
let mut instants: Vec<u64> = lines
.iter()
.flat_map(|(_, points)| points.iter().map(|p| p.at))
.collect();
instants.sort_unstable();
instants.dedup();
let offset = m.wall_ms - i64::try_from(m.now).unwrap_or(0);
for at in instants {
let c = i18n::civil_from_ms(offset + i64::try_from(at).unwrap_or(0));
out.push_str(&format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
c.year, c.month, c.day, c.hour, c.minute, c.second
));
for (_, points) in &lines {
out.push(',');
if let Some(p) = points.iter().find(|p| p.at == at) {
out.push_str(&p.value.to_string());
}
}
out.push('\n');
}
out
}
#[cfg(test)]
mod tests;