feat(tui): expand multi-target graph metrics and enhance visualization
Enable switching between different RTT metrics in multi-target mode and add color-coded labels to the target table for better correlation with graph series.
This commit is contained in:
1 parent
e987d31f2f
commit
baa2a862f5
1 file changed
+207
-41
@@ -549,6 +549,60 @@ impl GraphMode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum MultiTargetGraphMetric {
|
||||||
|
EffectiveRtt,
|
||||||
|
RawRtt,
|
||||||
|
AdjustedRtt,
|
||||||
|
ServerProcessing,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MultiTargetGraphMetric {
|
||||||
|
fn from_graph_mode(mode: GraphMode) -> Self {
|
||||||
|
match mode {
|
||||||
|
GraphMode::Rtt => Self::EffectiveRtt,
|
||||||
|
GraphMode::OneWay => Self::RawRtt,
|
||||||
|
GraphMode::Combined => Self::AdjustedRtt,
|
||||||
|
GraphMode::Split => Self::ServerProcessing,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::EffectiveRtt => "effective RTT per target",
|
||||||
|
Self::RawRtt => "raw RTT per target",
|
||||||
|
Self::AdjustedRtt => "adjusted RTT per target",
|
||||||
|
Self::ServerProcessing => "server processing per target",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn title(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::EffectiveRtt => "RTT history - effective per target",
|
||||||
|
Self::RawRtt => "RTT history - raw per target",
|
||||||
|
Self::AdjustedRtt => "RTT history - adjusted per target",
|
||||||
|
Self::ServerProcessing => "server processing - per target",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn empty_message(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::EffectiveRtt | Self::RawRtt => "waiting for primary replies",
|
||||||
|
Self::AdjustedRtt => "waiting for adjusted RTT samples",
|
||||||
|
Self::ServerProcessing => "waiting for server processing samples",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value_ns(self, sample: &GraphSample) -> Option<i128> {
|
||||||
|
match self {
|
||||||
|
Self::EffectiveRtt => Some(sample.effective_ns),
|
||||||
|
Self::RawRtt => Some(sample.raw_ns),
|
||||||
|
Self::AdjustedRtt => sample.adjusted_ns,
|
||||||
|
Self::ServerProcessing => sample.server_processing_ns,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub(super) struct GraphSample {
|
pub(super) struct GraphSample {
|
||||||
seq: u32,
|
seq: u32,
|
||||||
@@ -853,22 +907,24 @@ fn target_table_panel(state: &TuiState, panel_height: u16) -> Paragraph<'static>
|
|||||||
"target", "status", "last", "loss", "dup", "late", "warn"
|
"target", "status", "last", "loss", "dup", "late", "warn"
|
||||||
))];
|
))];
|
||||||
|
|
||||||
for target in state.targets.iter().take(visible) {
|
for (idx, target) in state.targets.iter().enumerate().take(visible) {
|
||||||
let snapshot = target.stats.snapshot();
|
let snapshot = target.stats.snapshot();
|
||||||
let last = target
|
let last = target
|
||||||
.last_sample
|
.last_sample
|
||||||
.map(|sample| format_ns_i128(Some(sample.effective_ns)))
|
.map(|sample| format_ns_i128(Some(sample.effective_ns)))
|
||||||
.unwrap_or_else(|| "-".to_owned());
|
.unwrap_or_else(|| "-".to_owned());
|
||||||
lines.push(Line::from(format!(
|
lines.push(Line::from(vec![
|
||||||
"{:<16} {:<8} {:>9} {:>5} {:>4} {:>4} {:>4}",
|
target_label_span(&target.label, idx, 16),
|
||||||
truncate(&target.label, 16),
|
Span::raw(format!(
|
||||||
|
" {:<8} {:>9} {:>5} {:>4} {:>4} {:>4}",
|
||||||
target.status.label(),
|
target.status.label(),
|
||||||
last,
|
last,
|
||||||
format_count(snapshot.loss.lost_packets),
|
format_count(snapshot.loss.lost_packets),
|
||||||
format_count(snapshot.packets.duplicates),
|
format_count(snapshot.packets.duplicates),
|
||||||
format_count(snapshot.packets.late_packets),
|
format_count(snapshot.packets.late_packets),
|
||||||
format_count(snapshot.events.warning_events)
|
format_count(snapshot.events.warning_events)
|
||||||
)));
|
)),
|
||||||
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
Paragraph::new(lines)
|
Paragraph::new(lines)
|
||||||
@@ -876,6 +932,13 @@ fn target_table_panel(state: &TuiState, panel_height: u16) -> Paragraph<'static>
|
|||||||
.wrap(Wrap { trim: false })
|
.wrap(Wrap { trim: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn target_label_span(label: &str, target_idx: usize, width: usize) -> Span<'static> {
|
||||||
|
Span::styled(
|
||||||
|
format!("{:<width$}", truncate(label, width), width = width),
|
||||||
|
target_style(target_idx).add_modifier(Modifier::BOLD),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn timing_panel(state: &TuiState, snapshot: &Snapshot) -> Paragraph<'static> {
|
fn timing_panel(state: &TuiState, snapshot: &Snapshot) -> Paragraph<'static> {
|
||||||
let mut lines = vec![Line::from(format!(
|
let mut lines = vec![Line::from(format!(
|
||||||
"{:<18} {:>5} {:>9} {:>9} {:>9} {:>9}",
|
"{:<18} {:>5} {:>9} {:>9} {:>9} {:>9}",
|
||||||
@@ -958,21 +1021,18 @@ fn render_split_graph(frame: &mut Frame<'_>, area: Rect, state: &TuiState) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn render_multi_target_graph(frame: &mut Frame<'_>, area: Rect, state: &TuiState) {
|
fn render_multi_target_graph(frame: &mut Frame<'_>, area: Rect, state: &TuiState) {
|
||||||
|
let metric = MultiTargetGraphMetric::from_graph_mode(state.graph_mode);
|
||||||
let capacity = visible_sample_capacity(area.width);
|
let capacity = visible_sample_capacity(area.width);
|
||||||
let series = state
|
let series = state
|
||||||
.targets
|
.targets
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.filter_map(|(idx, target)| target_rtt_series(target, idx, capacity))
|
.filter_map(|(idx, target)| target_metric_series(target, idx, capacity, metric))
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
if series.is_empty() {
|
if series.is_empty() {
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new("waiting for primary replies")
|
Paragraph::new(metric.empty_message())
|
||||||
.block(
|
.block(Block::default().title(metric.title()).borders(Borders::ALL))
|
||||||
Block::default()
|
|
||||||
.title("RTT history - per target")
|
|
||||||
.borders(Borders::ALL),
|
|
||||||
)
|
|
||||||
.wrap(Wrap { trim: true }),
|
.wrap(Wrap { trim: true }),
|
||||||
area,
|
area,
|
||||||
);
|
);
|
||||||
@@ -981,18 +1041,12 @@ fn render_multi_target_graph(frame: &mut Frame<'_>, area: Rect, state: &TuiState
|
|||||||
|
|
||||||
let x_max = series
|
let x_max = series
|
||||||
.iter()
|
.iter()
|
||||||
.map(|series| series.data.len().saturating_sub(1))
|
.flat_map(|series| series.data.iter().map(|(x, _)| *x))
|
||||||
.max()
|
.fold(1.0, f64::max);
|
||||||
.unwrap_or(1)
|
|
||||||
.max(1) as f64;
|
|
||||||
let (min_y, max_y) = chart_y_bounds(&series);
|
let (min_y, max_y) = chart_y_bounds(&series);
|
||||||
let datasets = chart_datasets(&series);
|
let datasets = chart_datasets(&series);
|
||||||
let chart = Chart::new(datasets)
|
let chart = Chart::new(datasets)
|
||||||
.block(
|
.block(Block::default().title(metric.title()).borders(Borders::ALL))
|
||||||
Block::default()
|
|
||||||
.title("RTT history - per target")
|
|
||||||
.borders(Borders::ALL),
|
|
||||||
)
|
|
||||||
.x_axis(
|
.x_axis(
|
||||||
Axis::default()
|
Axis::default()
|
||||||
.bounds([0.0, x_max])
|
.bounds([0.0, x_max])
|
||||||
@@ -1155,10 +1209,11 @@ fn chart_series(value: GraphValue, visible: &[&GraphSample]) -> Option<ChartSeri
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn target_rtt_series(
|
fn target_metric_series(
|
||||||
target: &TuiTargetState,
|
target: &TuiTargetState,
|
||||||
target_idx: usize,
|
target_idx: usize,
|
||||||
capacity: usize,
|
capacity: usize,
|
||||||
|
metric: MultiTargetGraphMetric,
|
||||||
) -> Option<ChartSeries> {
|
) -> Option<ChartSeries> {
|
||||||
let start = target.graph_history.len().saturating_sub(capacity);
|
let start = target.graph_history.len().saturating_sub(capacity);
|
||||||
let data = target
|
let data = target
|
||||||
@@ -1166,7 +1221,11 @@ fn target_rtt_series(
|
|||||||
.iter()
|
.iter()
|
||||||
.skip(start)
|
.skip(start)
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(idx, sample)| (idx as f64, sample.effective_ns as f64 / 1_000_000.0))
|
.filter_map(|(idx, sample)| {
|
||||||
|
metric
|
||||||
|
.value_ns(sample)
|
||||||
|
.map(|ns| (idx as f64, ns as f64 / 1_000_000.0))
|
||||||
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
(!data.is_empty()).then_some(ChartSeries {
|
(!data.is_empty()).then_some(ChartSeries {
|
||||||
@@ -1342,22 +1401,19 @@ fn status_line(state: &TuiState) -> Paragraph<'_> {
|
|||||||
} else {
|
} else {
|
||||||
""
|
""
|
||||||
};
|
};
|
||||||
let legend = if state.is_multi_target() {
|
let graph_label = if state.is_multi_target() {
|
||||||
Some(Line::from(format!("legend: {}", legend_text(state))))
|
MultiTargetGraphMetric::from_graph_mode(state.graph_mode).label()
|
||||||
} else {
|
} else {
|
||||||
None
|
state.graph_mode.label()
|
||||||
};
|
};
|
||||||
let mut lines = vec![Line::from(format!(
|
let lines = vec![Line::from(format!(
|
||||||
"{}{}{} | graph {}{} | q quit | Ctrl-C quit | r reset | p pause | g graph | f full",
|
"{}{}{} | graph {}{} | q quit | Ctrl-C quit | r reset | p pause | g graph | f full",
|
||||||
state.status.label(),
|
state.status.label(),
|
||||||
paused,
|
paused,
|
||||||
quitting,
|
quitting,
|
||||||
state.graph_mode.label(),
|
graph_label,
|
||||||
if state.full_graph { " full" } else { "" }
|
if state.full_graph { " full" } else { "" }
|
||||||
))];
|
))];
|
||||||
if let Some(legend) = legend {
|
|
||||||
lines.push(legend);
|
|
||||||
}
|
|
||||||
Paragraph::new(lines).block(Block::default().borders(Borders::ALL))
|
Paragraph::new(lines).block(Block::default().borders(Borders::ALL))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1489,16 +1545,6 @@ fn truncate(value: &str, max_chars: usize) -> String {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn legend_text(state: &TuiState) -> String {
|
|
||||||
state
|
|
||||||
.targets
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(idx, target)| format!("{} {}", idx + 1, target.label))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(" ")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_seq(value: u32) -> String {
|
fn format_seq(value: u32) -> String {
|
||||||
value.to_string()
|
value.to_string()
|
||||||
}
|
}
|
||||||
@@ -1993,6 +2039,126 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multi_target_metric_series_uses_selected_sample_field() {
|
||||||
|
let mut target = TuiTargetState::new("alpha".to_owned(), stats_config(true));
|
||||||
|
target.push_graph_sample(GraphSample {
|
||||||
|
seq: 1,
|
||||||
|
effective_ns: 1_000_000,
|
||||||
|
raw_ns: 2_000_000,
|
||||||
|
adjusted_ns: Some(3_000_000),
|
||||||
|
client_to_server_ns: None,
|
||||||
|
server_to_client_ns: None,
|
||||||
|
server_processing_ns: Some(4_000_000),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
target_metric_series(&target, 0, 10, MultiTargetGraphMetric::EffectiveRtt)
|
||||||
|
.unwrap()
|
||||||
|
.data,
|
||||||
|
vec![(0.0, 1.0)]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
target_metric_series(&target, 0, 10, MultiTargetGraphMetric::RawRtt)
|
||||||
|
.unwrap()
|
||||||
|
.data,
|
||||||
|
vec![(0.0, 2.0)]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
target_metric_series(&target, 0, 10, MultiTargetGraphMetric::AdjustedRtt)
|
||||||
|
.unwrap()
|
||||||
|
.data,
|
||||||
|
vec![(0.0, 3.0)]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
target_metric_series(&target, 0, 10, MultiTargetGraphMetric::ServerProcessing)
|
||||||
|
.unwrap()
|
||||||
|
.data,
|
||||||
|
vec![(0.0, 4.0)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multi_target_metric_series_skips_missing_optional_samples() {
|
||||||
|
let mut target = TuiTargetState::new("alpha".to_owned(), stats_config(true));
|
||||||
|
target.push_graph_sample(GraphSample {
|
||||||
|
seq: 1,
|
||||||
|
effective_ns: 1_000_000,
|
||||||
|
raw_ns: 2_000_000,
|
||||||
|
adjusted_ns: None,
|
||||||
|
client_to_server_ns: None,
|
||||||
|
server_to_client_ns: None,
|
||||||
|
server_processing_ns: None,
|
||||||
|
});
|
||||||
|
target.push_graph_sample(GraphSample {
|
||||||
|
seq: 2,
|
||||||
|
effective_ns: 2_000_000,
|
||||||
|
raw_ns: 3_000_000,
|
||||||
|
adjusted_ns: Some(4_000_000),
|
||||||
|
client_to_server_ns: None,
|
||||||
|
server_to_client_ns: None,
|
||||||
|
server_processing_ns: Some(5_000_000),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
target_metric_series(&target, 0, 10, MultiTargetGraphMetric::AdjustedRtt)
|
||||||
|
.unwrap()
|
||||||
|
.data,
|
||||||
|
vec![(1.0, 4.0)]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
target_metric_series(&target, 0, 10, MultiTargetGraphMetric::ServerProcessing)
|
||||||
|
.unwrap()
|
||||||
|
.data,
|
||||||
|
vec![(1.0, 5.0)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multi_target_graph_mode_mapping_has_readable_labels_and_titles() {
|
||||||
|
let cases = [
|
||||||
|
(
|
||||||
|
GraphMode::Rtt,
|
||||||
|
MultiTargetGraphMetric::EffectiveRtt,
|
||||||
|
"effective RTT per target",
|
||||||
|
"RTT history - effective per target",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
GraphMode::OneWay,
|
||||||
|
MultiTargetGraphMetric::RawRtt,
|
||||||
|
"raw RTT per target",
|
||||||
|
"RTT history - raw per target",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
GraphMode::Combined,
|
||||||
|
MultiTargetGraphMetric::AdjustedRtt,
|
||||||
|
"adjusted RTT per target",
|
||||||
|
"RTT history - adjusted per target",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
GraphMode::Split,
|
||||||
|
MultiTargetGraphMetric::ServerProcessing,
|
||||||
|
"server processing per target",
|
||||||
|
"server processing - per target",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (graph_mode, metric, label, title) in cases {
|
||||||
|
let mapped = MultiTargetGraphMetric::from_graph_mode(graph_mode);
|
||||||
|
assert_eq!(mapped, metric);
|
||||||
|
assert_eq!(mapped.label(), label);
|
||||||
|
assert_eq!(mapped.title(), title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn target_label_span_uses_graph_series_style() {
|
||||||
|
let span = target_label_span("alpha-target", 3, 16);
|
||||||
|
|
||||||
|
assert_eq!(span.style, target_style(3).add_modifier(Modifier::BOLD));
|
||||||
|
assert_eq!(span.content.as_ref(), "alpha-target ");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn graph_mode_cycling_walks_all_modes() {
|
fn graph_mode_cycling_walks_all_modes() {
|
||||||
let mut state = TuiState::default();
|
let mut state = TuiState::default();
|
||||||
|
|||||||
Reference in new issue
Block a user