feat(tui): add time-based graph navigation and zooming
Implement a scrollable and zoomable viewport for the TUI graphs, allowing users to pan through historical data using arrow keys and adjust the time window with zoom controls. This transitions graph rendering from sample-count based to timestamp-based for more accurate temporal visualization.
This commit is contained in:
1 parent
b9f5367ed1
commit
f9c6728855
2 files changed
+451
-86
No files matched your search
@@ -318,7 +318,7 @@ fn handle_input(state: &mut TuiState, shutdown_requested: &AtomicBool) -> io::Re
|
|||||||
state.toggle_pause();
|
state.toggle_pause();
|
||||||
force_render = true;
|
force_render = true;
|
||||||
}
|
}
|
||||||
KeyCode::Char('g') => {
|
KeyCode::Char('g') | KeyCode::Char('m') => {
|
||||||
state.cycle_graph_mode();
|
state.cycle_graph_mode();
|
||||||
force_render = true;
|
force_render = true;
|
||||||
}
|
}
|
||||||
@@ -330,6 +330,42 @@ fn handle_input(state: &mut TuiState, shutdown_requested: &AtomicBool) -> io::Re
|
|||||||
state.toggle_full_graph();
|
state.toggle_full_graph();
|
||||||
force_render = true;
|
force_render = true;
|
||||||
}
|
}
|
||||||
|
KeyCode::Left => {
|
||||||
|
state.pan_graph_left();
|
||||||
|
force_render = true;
|
||||||
|
}
|
||||||
|
KeyCode::Right => {
|
||||||
|
state.pan_graph_right();
|
||||||
|
force_render = true;
|
||||||
|
}
|
||||||
|
KeyCode::PageUp => {
|
||||||
|
state.pan_graph_page_left();
|
||||||
|
force_render = true;
|
||||||
|
}
|
||||||
|
KeyCode::PageDown => {
|
||||||
|
state.pan_graph_page_right();
|
||||||
|
force_render = true;
|
||||||
|
}
|
||||||
|
KeyCode::Home => {
|
||||||
|
state.jump_graph_oldest();
|
||||||
|
force_render = true;
|
||||||
|
}
|
||||||
|
KeyCode::End => {
|
||||||
|
state.jump_graph_live();
|
||||||
|
force_render = true;
|
||||||
|
}
|
||||||
|
KeyCode::Char('+') | KeyCode::Char('=') => {
|
||||||
|
state.zoom_graph_in();
|
||||||
|
force_render = true;
|
||||||
|
}
|
||||||
|
KeyCode::Char('-') => {
|
||||||
|
state.zoom_graph_out();
|
||||||
|
force_render = true;
|
||||||
|
}
|
||||||
|
KeyCode::Char('0') => {
|
||||||
|
state.reset_graph_window();
|
||||||
|
force_render = true;
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,11 +26,15 @@ use crate::{
|
|||||||
shared::client::{expected_probe_count, GroupPacingArg},
|
shared::client::{expected_probe_count, GroupPacingArg},
|
||||||
};
|
};
|
||||||
|
|
||||||
const HISTORY_LIMIT: usize = 240;
|
const HISTORY_LIMIT: usize = 100_000;
|
||||||
const RECENT_EVENT_LIMIT: usize = 80;
|
const RECENT_EVENT_LIMIT: usize = 80;
|
||||||
const MIN_CHART_POINTS: usize = 12;
|
|
||||||
const MIN_WIDTH: u16 = 56;
|
const MIN_WIDTH: u16 = 56;
|
||||||
const MIN_HEIGHT: u16 = 18;
|
const MIN_HEIGHT: u16 = 18;
|
||||||
|
const DEFAULT_GRAPH_WINDOW: Duration = Duration::from_secs(60);
|
||||||
|
const MIN_GRAPH_WINDOW: Duration = Duration::from_secs(5);
|
||||||
|
const MAX_GRAPH_WINDOW: Duration = Duration::from_secs(60 * 60);
|
||||||
|
const PAN_STEP_NUMERATOR: u32 = 1;
|
||||||
|
const PAN_STEP_DENOMINATOR: u32 = 4;
|
||||||
|
|
||||||
pub(super) struct TuiTerminal {
|
pub(super) struct TuiTerminal {
|
||||||
terminal: Terminal<CrosstermBackend<Stdout>>,
|
terminal: Terminal<CrosstermBackend<Stdout>>,
|
||||||
@@ -91,6 +95,7 @@ pub(super) struct TuiState {
|
|||||||
last_warning: Option<String>,
|
last_warning: Option<String>,
|
||||||
graph_mode: GraphMode,
|
graph_mode: GraphMode,
|
||||||
graph_scale: GraphScale,
|
graph_scale: GraphScale,
|
||||||
|
graph_viewport: GraphViewport,
|
||||||
full_graph: bool,
|
full_graph: bool,
|
||||||
pub(super) paused: bool,
|
pub(super) paused: bool,
|
||||||
pub(super) quit_requested: bool,
|
pub(super) quit_requested: bool,
|
||||||
@@ -125,6 +130,7 @@ impl TuiState {
|
|||||||
last_warning: None,
|
last_warning: None,
|
||||||
graph_mode: GraphMode::Rtt,
|
graph_mode: GraphMode::Rtt,
|
||||||
graph_scale: GraphScale::Auto,
|
graph_scale: GraphScale::Auto,
|
||||||
|
graph_viewport: GraphViewport::default(),
|
||||||
full_graph: false,
|
full_graph: false,
|
||||||
paused: false,
|
paused: false,
|
||||||
quit_requested: false,
|
quit_requested: false,
|
||||||
@@ -202,6 +208,7 @@ impl TuiState {
|
|||||||
}
|
}
|
||||||
ClientEvent::EchoReply {
|
ClientEvent::EchoReply {
|
||||||
seq,
|
seq,
|
||||||
|
received_at,
|
||||||
rtt,
|
rtt,
|
||||||
one_way,
|
one_way,
|
||||||
server_timing,
|
server_timing,
|
||||||
@@ -217,6 +224,7 @@ impl TuiState {
|
|||||||
.and_then(|timing| timing.processing)
|
.and_then(|timing| timing.processing)
|
||||||
.map(duration_ns);
|
.map(duration_ns);
|
||||||
target.push_graph_sample(GraphSample {
|
target.push_graph_sample(GraphSample {
|
||||||
|
timestamp: received_at.mono,
|
||||||
seq: *seq,
|
seq: *seq,
|
||||||
effective_ns: rtt.effective.as_nanos(),
|
effective_ns: rtt.effective.as_nanos(),
|
||||||
raw_ns: duration_ns(rtt.raw),
|
raw_ns: duration_ns(rtt.raw),
|
||||||
@@ -315,6 +323,7 @@ impl TuiState {
|
|||||||
for target in &mut self.targets {
|
for target in &mut self.targets {
|
||||||
target.graph_history.clear();
|
target.graph_history.clear();
|
||||||
}
|
}
|
||||||
|
self.graph_viewport.follow_live();
|
||||||
self.push_event("visible graph history reset".to_owned());
|
self.push_event("visible graph history reset".to_owned());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,6 +343,52 @@ impl TuiState {
|
|||||||
self.full_graph = !self.full_graph;
|
self.full_graph = !self.full_graph;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(super) fn pan_graph_left(&mut self) {
|
||||||
|
let oldest = self.oldest_graph_sample_time();
|
||||||
|
let newest = self.newest_graph_sample_time();
|
||||||
|
self.graph_viewport.pan_backward(oldest, newest);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn pan_graph_right(&mut self) {
|
||||||
|
let newest = self.newest_graph_sample_time();
|
||||||
|
self.graph_viewport.pan_forward(newest);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn pan_graph_page_left(&mut self) {
|
||||||
|
let oldest = self.oldest_graph_sample_time();
|
||||||
|
let newest = self.newest_graph_sample_time();
|
||||||
|
self.graph_viewport.page_backward(oldest, newest);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn pan_graph_page_right(&mut self) {
|
||||||
|
let newest = self.newest_graph_sample_time();
|
||||||
|
self.graph_viewport.page_forward(newest);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn jump_graph_oldest(&mut self) {
|
||||||
|
let oldest = self.oldest_graph_sample_time();
|
||||||
|
let newest = self.newest_graph_sample_time();
|
||||||
|
self.graph_viewport.jump_oldest(oldest, newest);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn jump_graph_live(&mut self) {
|
||||||
|
self.graph_viewport.follow_live();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn zoom_graph_in(&mut self) {
|
||||||
|
self.graph_viewport.zoom_in(self.newest_graph_sample_time());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn zoom_graph_out(&mut self) {
|
||||||
|
self.graph_viewport
|
||||||
|
.zoom_out(self.newest_graph_sample_time());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn reset_graph_window(&mut self) {
|
||||||
|
self.graph_viewport
|
||||||
|
.reset_window(self.newest_graph_sample_time());
|
||||||
|
}
|
||||||
|
|
||||||
fn push_event(&mut self, event: String) {
|
fn push_event(&mut self, event: String) {
|
||||||
push_bounded(&mut self.recent_events, event, RECENT_EVENT_LIMIT);
|
push_bounded(&mut self.recent_events, event, RECENT_EVENT_LIMIT);
|
||||||
}
|
}
|
||||||
@@ -351,6 +406,20 @@ impl TuiState {
|
|||||||
fn is_multi_target(&self) -> bool {
|
fn is_multi_target(&self) -> bool {
|
||||||
self.targets.len() > 1
|
self.targets.len() > 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn oldest_graph_sample_time(&self) -> Option<Instant> {
|
||||||
|
self.targets
|
||||||
|
.iter()
|
||||||
|
.filter_map(|target| target.graph_history.front().map(|sample| sample.timestamp))
|
||||||
|
.min()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn newest_graph_sample_time(&self) -> Option<Instant> {
|
||||||
|
self.targets
|
||||||
|
.iter()
|
||||||
|
.filter_map(|target| target.graph_history.back().map(|sample| sample.timestamp))
|
||||||
|
.max()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -600,6 +669,178 @@ impl GraphScale {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
struct GraphViewport {
|
||||||
|
mode: GraphViewportMode,
|
||||||
|
window: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for GraphViewport {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
mode: GraphViewportMode::Follow,
|
||||||
|
window: DEFAULT_GRAPH_WINDOW,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GraphViewport {
|
||||||
|
fn range(self, now: Instant, newest_sample: Option<Instant>) -> GraphViewportRange {
|
||||||
|
let end = match self.mode {
|
||||||
|
GraphViewportMode::Follow => newest_sample.unwrap_or(now).max(now),
|
||||||
|
GraphViewportMode::Historical { end } => end,
|
||||||
|
};
|
||||||
|
GraphViewportRange {
|
||||||
|
start: end.checked_sub(self.window).unwrap_or(end),
|
||||||
|
end,
|
||||||
|
window: self.window,
|
||||||
|
is_live: matches!(self.mode, GraphViewportMode::Follow),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn follow_live(&mut self) {
|
||||||
|
self.mode = GraphViewportMode::Follow;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pan_backward(&mut self, oldest: Option<Instant>, newest: Option<Instant>) {
|
||||||
|
self.pan_by(
|
||||||
|
-duration_fraction(self.window, PAN_STEP_NUMERATOR, PAN_STEP_DENOMINATOR),
|
||||||
|
oldest,
|
||||||
|
newest,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pan_forward(&mut self, newest: Option<Instant>) {
|
||||||
|
self.pan_by(
|
||||||
|
duration_fraction(self.window, PAN_STEP_NUMERATOR, PAN_STEP_DENOMINATOR),
|
||||||
|
None,
|
||||||
|
newest,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn page_backward(&mut self, oldest: Option<Instant>, newest: Option<Instant>) {
|
||||||
|
self.pan_by(-signed_duration(self.window), oldest, newest);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn page_forward(&mut self, newest: Option<Instant>) {
|
||||||
|
self.pan_by(signed_duration(self.window), None, newest);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn jump_oldest(&mut self, oldest: Option<Instant>, newest: Option<Instant>) {
|
||||||
|
let Some(oldest) = oldest else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let live_end = newest.unwrap_or(oldest);
|
||||||
|
self.mode = GraphViewportMode::Historical {
|
||||||
|
end: (oldest + self.window).min(live_end),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn zoom_in(&mut self, newest: Option<Instant>) {
|
||||||
|
self.set_window(duration_fraction(self.window, 2, 3).duration, newest);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn zoom_out(&mut self, newest: Option<Instant>) {
|
||||||
|
self.set_window(duration_fraction(self.window, 3, 2).duration, newest);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset_window(&mut self, newest: Option<Instant>) {
|
||||||
|
self.set_window(DEFAULT_GRAPH_WINDOW, newest);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_window(&mut self, window: Duration, newest: Option<Instant>) {
|
||||||
|
self.window = window.clamp(MIN_GRAPH_WINDOW, MAX_GRAPH_WINDOW);
|
||||||
|
if let GraphViewportMode::Historical { end } = &mut self.mode {
|
||||||
|
if let Some(newest) = newest {
|
||||||
|
*end = (*end).min(newest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pan_by(
|
||||||
|
&mut self,
|
||||||
|
delta: SignedViewportDuration,
|
||||||
|
oldest: Option<Instant>,
|
||||||
|
newest: Option<Instant>,
|
||||||
|
) {
|
||||||
|
let Some(newest) = newest else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let current_end = match self.mode {
|
||||||
|
GraphViewportMode::Follow => newest,
|
||||||
|
GraphViewportMode::Historical { end } => end,
|
||||||
|
};
|
||||||
|
let mut end = delta.apply(current_end);
|
||||||
|
if let Some(oldest) = oldest {
|
||||||
|
end = end.max(oldest + self.window);
|
||||||
|
}
|
||||||
|
end = end.min(newest);
|
||||||
|
self.mode = GraphViewportMode::Historical { end };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum GraphViewportMode {
|
||||||
|
Follow,
|
||||||
|
Historical { end: Instant },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
struct GraphViewportRange {
|
||||||
|
start: Instant,
|
||||||
|
end: Instant,
|
||||||
|
window: Duration,
|
||||||
|
is_live: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
struct SignedViewportDuration {
|
||||||
|
duration: Duration,
|
||||||
|
negative: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SignedViewportDuration {
|
||||||
|
fn apply(self, instant: Instant) -> Instant {
|
||||||
|
if self.negative {
|
||||||
|
instant.checked_sub(self.duration).unwrap_or(instant)
|
||||||
|
} else {
|
||||||
|
instant + self.duration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::ops::Neg for SignedViewportDuration {
|
||||||
|
type Output = Self;
|
||||||
|
|
||||||
|
fn neg(self) -> Self::Output {
|
||||||
|
Self {
|
||||||
|
duration: self.duration,
|
||||||
|
negative: !self.negative,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signed_duration(duration: Duration) -> SignedViewportDuration {
|
||||||
|
SignedViewportDuration {
|
||||||
|
duration,
|
||||||
|
negative: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn duration_fraction(
|
||||||
|
duration: Duration,
|
||||||
|
numerator: u32,
|
||||||
|
denominator: u32,
|
||||||
|
) -> SignedViewportDuration {
|
||||||
|
let nanos =
|
||||||
|
duration.as_nanos().saturating_mul(u128::from(numerator)) / u128::from(denominator.max(1));
|
||||||
|
let nanos = nanos.max(1).min(u128::from(u64::MAX));
|
||||||
|
SignedViewportDuration {
|
||||||
|
duration: Duration::from_nanos(nanos as u64),
|
||||||
|
negative: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
enum MultiTargetGraphMetric {
|
enum MultiTargetGraphMetric {
|
||||||
EffectiveRtt,
|
EffectiveRtt,
|
||||||
@@ -664,6 +905,7 @@ impl MultiTargetGraphMetric {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub(super) struct GraphSample {
|
pub(super) struct GraphSample {
|
||||||
|
timestamp: Instant,
|
||||||
seq: u32,
|
seq: u32,
|
||||||
effective_ns: i128,
|
effective_ns: i128,
|
||||||
raw_ns: i128,
|
raw_ns: i128,
|
||||||
@@ -1037,8 +1279,11 @@ fn timing_panel(state: &TuiState, snapshot: &Snapshot) -> Paragraph<'static> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn render_graph_area(frame: &mut Frame<'_>, area: Rect, state: &TuiState) {
|
fn render_graph_area(frame: &mut Frame<'_>, area: Rect, state: &TuiState) {
|
||||||
|
let viewport = state
|
||||||
|
.graph_viewport
|
||||||
|
.range(Instant::now(), state.newest_graph_sample_time());
|
||||||
if state.is_multi_target() {
|
if state.is_multi_target() {
|
||||||
render_multi_target_graph(frame, area, state);
|
render_multi_target_graph(frame, area, state, viewport);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1049,22 +1294,28 @@ fn render_graph_area(frame: &mut Frame<'_>, area: Rect, state: &TuiState) {
|
|||||||
.selected_target()
|
.selected_target()
|
||||||
.map(|target| &target.graph_history)
|
.map(|target| &target.graph_history)
|
||||||
.expect("TuiState always has at least one target");
|
.expect("TuiState always has at least one target");
|
||||||
let visible = visible_history_window(history, area.width);
|
let visible = visible_history_window(history, viewport);
|
||||||
let series = graph_series(mode, &visible);
|
let series = graph_series(mode, &visible, viewport);
|
||||||
render_chart(
|
render_chart(
|
||||||
frame,
|
frame,
|
||||||
area,
|
area,
|
||||||
mode.title(),
|
|
||||||
&visible,
|
&visible,
|
||||||
&series,
|
&series,
|
||||||
mode.axis_kind(),
|
ChartRenderConfig {
|
||||||
state.graph_scale,
|
title: mode.title(),
|
||||||
|
axis_kind: mode.axis_kind(),
|
||||||
|
scale: state.graph_scale,
|
||||||
|
viewport,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_split_graph(frame: &mut Frame<'_>, area: Rect, state: &TuiState) {
|
fn render_split_graph(frame: &mut Frame<'_>, area: Rect, state: &TuiState) {
|
||||||
|
let viewport = state
|
||||||
|
.graph_viewport
|
||||||
|
.range(Instant::now(), state.newest_graph_sample_time());
|
||||||
let rows = Layout::default()
|
let rows = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||||
@@ -1073,38 +1324,48 @@ fn render_split_graph(frame: &mut Frame<'_>, area: Rect, state: &TuiState) {
|
|||||||
.selected_target()
|
.selected_target()
|
||||||
.map(|target| &target.graph_history)
|
.map(|target| &target.graph_history)
|
||||||
.expect("TuiState always has at least one target");
|
.expect("TuiState always has at least one target");
|
||||||
let visible = visible_history_window(history, area.width);
|
let visible = visible_history_window(history, viewport);
|
||||||
let rtt = graph_series(GraphMode::Rtt, &visible);
|
let rtt = graph_series(GraphMode::Rtt, &visible, viewport);
|
||||||
let one_way = graph_series(GraphMode::OneWay, &visible);
|
let one_way = graph_series(GraphMode::OneWay, &visible, viewport);
|
||||||
|
|
||||||
render_chart(
|
render_chart(
|
||||||
frame,
|
frame,
|
||||||
rows[0],
|
rows[0],
|
||||||
GraphMode::Rtt.title(),
|
|
||||||
&visible,
|
&visible,
|
||||||
&rtt,
|
&rtt,
|
||||||
GraphMode::Rtt.axis_kind(),
|
ChartRenderConfig {
|
||||||
state.graph_scale,
|
title: GraphMode::Rtt.title(),
|
||||||
|
axis_kind: GraphMode::Rtt.axis_kind(),
|
||||||
|
scale: state.graph_scale,
|
||||||
|
viewport,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
render_chart(
|
render_chart(
|
||||||
frame,
|
frame,
|
||||||
rows[1],
|
rows[1],
|
||||||
GraphMode::OneWay.title(),
|
|
||||||
&visible,
|
&visible,
|
||||||
&one_way,
|
&one_way,
|
||||||
GraphMode::OneWay.axis_kind(),
|
ChartRenderConfig {
|
||||||
state.graph_scale,
|
title: GraphMode::OneWay.title(),
|
||||||
|
axis_kind: GraphMode::OneWay.axis_kind(),
|
||||||
|
scale: state.graph_scale,
|
||||||
|
viewport,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_multi_target_graph(frame: &mut Frame<'_>, area: Rect, state: &TuiState) {
|
fn render_multi_target_graph(
|
||||||
|
frame: &mut Frame<'_>,
|
||||||
|
area: Rect,
|
||||||
|
state: &TuiState,
|
||||||
|
viewport: GraphViewportRange,
|
||||||
|
) {
|
||||||
let metric = MultiTargetGraphMetric::from_graph_mode(state.graph_mode);
|
let metric = MultiTargetGraphMetric::from_graph_mode(state.graph_mode);
|
||||||
let capacity = visible_sample_capacity(area.width);
|
|
||||||
let series = state
|
let series = state
|
||||||
.targets
|
.targets
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.filter_map(|(idx, target)| target_metric_series(target, idx, capacity, metric))
|
.filter_map(|(idx, target)| target_metric_series(target, idx, viewport, metric))
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
if series.is_empty() {
|
if series.is_empty() {
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
@@ -1116,10 +1377,6 @@ fn render_multi_target_graph(frame: &mut Frame<'_>, area: Rect, state: &TuiState
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let x_max = series
|
|
||||||
.iter()
|
|
||||||
.flat_map(|series| series.data.iter().map(|(x, _)| *x))
|
|
||||||
.fold(1.0, f64::max);
|
|
||||||
let (min_y, max_y) = chart_y_bounds(&series, metric.axis_kind(), state.graph_scale);
|
let (min_y, max_y) = chart_y_bounds(&series, metric.axis_kind(), state.graph_scale);
|
||||||
let datasets = chart_datasets(&series);
|
let datasets = chart_datasets(&series);
|
||||||
let chart = Chart::new(datasets)
|
let chart = Chart::new(datasets)
|
||||||
@@ -1134,8 +1391,8 @@ fn render_multi_target_graph(frame: &mut Frame<'_>, area: Rect, state: &TuiState
|
|||||||
)
|
)
|
||||||
.x_axis(
|
.x_axis(
|
||||||
Axis::default()
|
Axis::default()
|
||||||
.bounds([0.0, x_max])
|
.bounds(viewport_x_bounds(viewport))
|
||||||
.labels(vec![Span::raw("old"), Span::raw("new")])
|
.labels(viewport_x_axis_labels(viewport))
|
||||||
.style(Style::default().fg(Color::Gray)),
|
.style(Style::default().fg(Color::Gray)),
|
||||||
)
|
)
|
||||||
.y_axis(
|
.y_axis(
|
||||||
@@ -1153,11 +1410,9 @@ fn render_multi_target_graph(frame: &mut Frame<'_>, area: Rect, state: &TuiState
|
|||||||
fn render_chart(
|
fn render_chart(
|
||||||
frame: &mut Frame<'_>,
|
frame: &mut Frame<'_>,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
title: &'static str,
|
|
||||||
visible: &[&GraphSample],
|
visible: &[&GraphSample],
|
||||||
series: &[ChartSeries],
|
series: &[ChartSeries],
|
||||||
axis_kind: ChartAxisKind,
|
config: ChartRenderConfig,
|
||||||
scale: GraphScale,
|
|
||||||
) {
|
) {
|
||||||
if series.is_empty() {
|
if series.is_empty() {
|
||||||
let note = if visible.is_empty() {
|
let note = if visible.is_empty() {
|
||||||
@@ -1167,26 +1422,25 @@ fn render_chart(
|
|||||||
};
|
};
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(note)
|
Paragraph::new(note)
|
||||||
.block(Block::default().title(title).borders(Borders::ALL))
|
.block(Block::default().title(config.title).borders(Borders::ALL))
|
||||||
.wrap(Wrap { trim: true }),
|
.wrap(Wrap { trim: true }),
|
||||||
area,
|
area,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let (min_y, max_y) = chart_y_bounds(series, axis_kind, scale);
|
let (min_y, max_y) = chart_y_bounds(series, config.axis_kind, config.scale);
|
||||||
let x_max = visible.len().saturating_sub(1).max(1) as f64;
|
|
||||||
let datasets = chart_datasets(series);
|
let datasets = chart_datasets(series);
|
||||||
let chart = Chart::new(datasets)
|
let chart = Chart::new(datasets)
|
||||||
.block(
|
.block(
|
||||||
Block::default()
|
Block::default()
|
||||||
.title(format!("{title} ({})", scale.label()))
|
.title(format!("{} ({})", config.title, config.scale.label()))
|
||||||
.borders(Borders::ALL),
|
.borders(Borders::ALL),
|
||||||
)
|
)
|
||||||
.x_axis(
|
.x_axis(
|
||||||
Axis::default()
|
Axis::default()
|
||||||
.bounds([0.0, x_max])
|
.bounds(viewport_x_bounds(config.viewport))
|
||||||
.labels(x_axis_labels(visible))
|
.labels(viewport_x_axis_labels(config.viewport))
|
||||||
.style(Style::default().fg(Color::Gray)),
|
.style(Style::default().fg(Color::Gray)),
|
||||||
)
|
)
|
||||||
.y_axis(
|
.y_axis(
|
||||||
@@ -1201,6 +1455,14 @@ fn render_chart(
|
|||||||
frame.render_widget(chart, area);
|
frame.render_widget(chart, area);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
struct ChartRenderConfig {
|
||||||
|
title: &'static str,
|
||||||
|
axis_kind: ChartAxisKind,
|
||||||
|
scale: GraphScale,
|
||||||
|
viewport: GraphViewportRange,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
enum GraphValue {
|
enum GraphValue {
|
||||||
EffectiveRtt,
|
EffectiveRtt,
|
||||||
@@ -1268,18 +1530,21 @@ enum ChartAxisKind {
|
|||||||
Mixed,
|
Mixed,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn visible_sample_capacity(chart_width: u16) -> usize {
|
fn visible_history_window(
|
||||||
let derived = usize::from(chart_width.saturating_sub(10)).max(MIN_CHART_POINTS);
|
history: &VecDeque<GraphSample>,
|
||||||
derived.min(HISTORY_LIMIT)
|
viewport: GraphViewportRange,
|
||||||
|
) -> Vec<&GraphSample> {
|
||||||
|
history
|
||||||
|
.iter()
|
||||||
|
.filter(|sample| sample.timestamp >= viewport.start && sample.timestamp <= viewport.end)
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn visible_history_window(history: &VecDeque<GraphSample>, chart_width: u16) -> Vec<&GraphSample> {
|
fn graph_series(
|
||||||
let capacity = visible_sample_capacity(chart_width);
|
mode: GraphMode,
|
||||||
let start = history.len().saturating_sub(capacity);
|
visible: &[&GraphSample],
|
||||||
history.iter().skip(start).collect()
|
viewport: GraphViewportRange,
|
||||||
}
|
) -> Vec<ChartSeries> {
|
||||||
|
|
||||||
fn graph_series(mode: GraphMode, visible: &[&GraphSample]) -> Vec<ChartSeries> {
|
|
||||||
let values: &[GraphValue] = match mode {
|
let values: &[GraphValue] = match mode {
|
||||||
GraphMode::Rtt => &[GraphValue::EffectiveRtt],
|
GraphMode::Rtt => &[GraphValue::EffectiveRtt],
|
||||||
GraphMode::OneWay => &[GraphValue::ClientToServer, GraphValue::ServerToClient],
|
GraphMode::OneWay => &[GraphValue::ClientToServer, GraphValue::ServerToClient],
|
||||||
@@ -1292,18 +1557,21 @@ fn graph_series(mode: GraphMode, visible: &[&GraphSample]) -> Vec<ChartSeries> {
|
|||||||
};
|
};
|
||||||
values
|
values
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|value| chart_series(*value, visible))
|
.filter_map(|value| chart_series(*value, visible, viewport))
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn chart_series(value: GraphValue, visible: &[&GraphSample]) -> Option<ChartSeries> {
|
fn chart_series(
|
||||||
|
value: GraphValue,
|
||||||
|
visible: &[&GraphSample],
|
||||||
|
viewport: GraphViewportRange,
|
||||||
|
) -> Option<ChartSeries> {
|
||||||
let data: Vec<(f64, f64)> = visible
|
let data: Vec<(f64, f64)> = visible
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.filter_map(|sample| {
|
||||||
.filter_map(|(idx, sample)| {
|
|
||||||
value
|
value
|
||||||
.value_ns(sample)
|
.value_ns(sample)
|
||||||
.map(|ns| (idx as f64, ns as f64 / 1_000_000.0))
|
.map(|ns| (sample_x(sample, viewport), ns as f64 / 1_000_000.0))
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -1318,19 +1586,17 @@ fn chart_series(value: GraphValue, visible: &[&GraphSample]) -> Option<ChartSeri
|
|||||||
fn target_metric_series(
|
fn target_metric_series(
|
||||||
target: &TuiTargetState,
|
target: &TuiTargetState,
|
||||||
target_idx: usize,
|
target_idx: usize,
|
||||||
capacity: usize,
|
viewport: GraphViewportRange,
|
||||||
metric: MultiTargetGraphMetric,
|
metric: MultiTargetGraphMetric,
|
||||||
) -> Option<ChartSeries> {
|
) -> Option<ChartSeries> {
|
||||||
let start = target.graph_history.len().saturating_sub(capacity);
|
|
||||||
let data = target
|
let data = target
|
||||||
.graph_history
|
.graph_history
|
||||||
.iter()
|
.iter()
|
||||||
.skip(start)
|
.filter(|sample| sample.timestamp >= viewport.start && sample.timestamp <= viewport.end)
|
||||||
.enumerate()
|
.filter_map(|sample| {
|
||||||
.filter_map(|(idx, sample)| {
|
|
||||||
metric
|
metric
|
||||||
.value_ns(sample)
|
.value_ns(sample)
|
||||||
.map(|ns| (idx as f64, ns as f64 / 1_000_000.0))
|
.map(|ns| (sample_x(sample, viewport), ns as f64 / 1_000_000.0))
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
@@ -1342,6 +1608,13 @@ fn target_metric_series(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sample_x(sample: &GraphSample, viewport: GraphViewportRange) -> f64 {
|
||||||
|
sample
|
||||||
|
.timestamp
|
||||||
|
.saturating_duration_since(viewport.start)
|
||||||
|
.as_secs_f64()
|
||||||
|
}
|
||||||
|
|
||||||
fn chart_datasets(series: &[ChartSeries]) -> Vec<Dataset<'_>> {
|
fn chart_datasets(series: &[ChartSeries]) -> Vec<Dataset<'_>> {
|
||||||
series
|
series
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1459,14 +1732,14 @@ fn nice_upper_ms(value: f64) -> f64 {
|
|||||||
10.0 * magnitude
|
10.0 * magnitude
|
||||||
}
|
}
|
||||||
|
|
||||||
fn x_axis_labels(visible: &[&GraphSample]) -> Vec<Span<'static>> {
|
fn viewport_x_bounds(viewport: GraphViewportRange) -> [f64; 2] {
|
||||||
let Some(first) = visible.first() else {
|
[0.0, viewport.window.as_secs_f64().max(1.0)]
|
||||||
return Vec::new();
|
}
|
||||||
};
|
|
||||||
let last = visible.last().unwrap_or(first);
|
fn viewport_x_axis_labels(viewport: GraphViewportRange) -> Vec<Span<'static>> {
|
||||||
vec![
|
vec![
|
||||||
Span::raw(format!("#{}", first.seq)),
|
Span::raw(format!("-{}", format_duration(viewport.window))),
|
||||||
Span::raw(format!("#{}", last.seq)),
|
Span::raw(if viewport.is_live { "live" } else { "end" }),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1568,18 +1841,38 @@ fn status_line(state: &TuiState) -> Paragraph<'_> {
|
|||||||
} else {
|
} else {
|
||||||
state.graph_mode.label()
|
state.graph_mode.label()
|
||||||
};
|
};
|
||||||
|
let mode = graph_viewport_status(state);
|
||||||
|
let layout = if state.full_graph { " | full" } else { "" };
|
||||||
|
let controls = if state.graph_viewport.mode == GraphViewportMode::Follow {
|
||||||
|
" | q quit | r reset | p pause | m/g metric | s scale | f full | arrows pan | +/- zoom"
|
||||||
|
} else {
|
||||||
|
" | End live | ←/→ pan | PgUp/PgDn page | +/- zoom | q quit"
|
||||||
|
};
|
||||||
let lines = vec![Line::from(format!(
|
let lines = vec![Line::from(format!(
|
||||||
"{}{}{} | graph {}{} scale {} | q quit | Ctrl-C quit | r reset | p pause | g graph | s scale | f full",
|
"{}{}{} | {mode} | window {} | metric {graph_label}{layout} | scale {}{controls}",
|
||||||
state.status.label(),
|
state.status.label(),
|
||||||
paused,
|
paused,
|
||||||
quitting,
|
quitting,
|
||||||
graph_label,
|
format_duration(state.graph_viewport.window),
|
||||||
if state.full_graph { " full" } else { "" },
|
|
||||||
state.graph_scale.label()
|
state.graph_scale.label()
|
||||||
))];
|
))];
|
||||||
Paragraph::new(lines).block(Block::default().borders(Borders::ALL))
|
Paragraph::new(lines).block(Block::default().borders(Borders::ALL))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn graph_viewport_status(state: &TuiState) -> String {
|
||||||
|
match state.graph_viewport.mode {
|
||||||
|
GraphViewportMode::Follow => "live".to_owned(),
|
||||||
|
GraphViewportMode::Historical { end } => {
|
||||||
|
let now = Instant::now();
|
||||||
|
let live_end = state.newest_graph_sample_time().unwrap_or(now).max(now);
|
||||||
|
format!(
|
||||||
|
"history -{}",
|
||||||
|
format_duration(live_end.saturating_duration_since(end))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn push_time_line(lines: &mut Vec<Line<'_>>, label: &str, stats: &TimeStats) {
|
fn push_time_line(lines: &mut Vec<Line<'_>>, label: &str, stats: &TimeStats) {
|
||||||
if stats.count == 0 {
|
if stats.count == 0 {
|
||||||
lines.push(Line::from(format!(
|
lines.push(Line::from(format!(
|
||||||
@@ -1785,6 +2078,7 @@ mod tests {
|
|||||||
|
|
||||||
fn graph_sample(seq: u32, effective_ns: i128) -> GraphSample {
|
fn graph_sample(seq: u32, effective_ns: i128) -> GraphSample {
|
||||||
GraphSample {
|
GraphSample {
|
||||||
|
timestamp: Instant::now() + Duration::from_secs(u64::from(seq)),
|
||||||
seq,
|
seq,
|
||||||
effective_ns,
|
effective_ns,
|
||||||
raw_ns: effective_ns + 1_000,
|
raw_ns: effective_ns + 1_000,
|
||||||
@@ -1797,6 +2091,7 @@ mod tests {
|
|||||||
|
|
||||||
fn graph_sample_with_timing(seq: u32, effective_ns: i128) -> GraphSample {
|
fn graph_sample_with_timing(seq: u32, effective_ns: i128) -> GraphSample {
|
||||||
GraphSample {
|
GraphSample {
|
||||||
|
timestamp: Instant::now() + Duration::from_secs(u64::from(seq)),
|
||||||
seq,
|
seq,
|
||||||
effective_ns,
|
effective_ns,
|
||||||
raw_ns: effective_ns + 1_000,
|
raw_ns: effective_ns + 1_000,
|
||||||
@@ -1816,6 +2111,23 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn viewport(start: Instant, end: Instant) -> GraphViewportRange {
|
||||||
|
GraphViewportRange {
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
window: end
|
||||||
|
.saturating_duration_since(start)
|
||||||
|
.max(Duration::from_secs(1)),
|
||||||
|
is_live: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn viewport_for_visible(visible: &[&GraphSample]) -> GraphViewportRange {
|
||||||
|
let start = visible.first().unwrap().timestamp;
|
||||||
|
let end = visible.last().unwrap().timestamp;
|
||||||
|
viewport(start, end)
|
||||||
|
}
|
||||||
|
|
||||||
fn primary_target(state: &TuiState) -> &TuiTargetState {
|
fn primary_target(state: &TuiState) -> &TuiTargetState {
|
||||||
&state.targets[0]
|
&state.targets[0]
|
||||||
}
|
}
|
||||||
@@ -2110,17 +2422,15 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn visible_window_length_scales_with_chart_width_and_remains_bounded() {
|
fn visible_window_selects_samples_inside_viewport() {
|
||||||
let history: VecDeque<_> = (0..300).map(|seq| graph_sample(seq, seq.into())).collect();
|
let history: VecDeque<_> = (0..300).map(|seq| graph_sample(seq, seq.into())).collect();
|
||||||
|
let start = history[100].timestamp;
|
||||||
|
let end = history[120].timestamp;
|
||||||
|
|
||||||
let narrow = visible_history_window(&history, 20);
|
let visible = visible_history_window(&history, viewport(start, end));
|
||||||
let wide = visible_history_window(&history, 80);
|
|
||||||
let huge = visible_history_window(&history, 1_000);
|
|
||||||
|
|
||||||
assert_eq!(narrow.len(), MIN_CHART_POINTS);
|
assert_eq!(visible.first().unwrap().seq, 100);
|
||||||
assert!(wide.len() > narrow.len(), "wide={}", wide.len());
|
assert_eq!(visible.last().unwrap().seq, 120);
|
||||||
assert_eq!(huge.len(), HISTORY_LIMIT);
|
|
||||||
assert_eq!(huge.first().unwrap().seq, 60);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2225,9 +2535,10 @@ mod tests {
|
|||||||
fn optional_missing_one_way_series_are_omitted_not_zero_filled() {
|
fn optional_missing_one_way_series_are_omitted_not_zero_filled() {
|
||||||
let visible_samples = [graph_sample(1, 2_000_000)];
|
let visible_samples = [graph_sample(1, 2_000_000)];
|
||||||
let visible: Vec<_> = visible_samples.iter().collect();
|
let visible: Vec<_> = visible_samples.iter().collect();
|
||||||
|
let viewport = viewport_for_visible(&visible);
|
||||||
|
|
||||||
let one_way = graph_series(GraphMode::OneWay, &visible);
|
let one_way = graph_series(GraphMode::OneWay, &visible, viewport);
|
||||||
let rtt = graph_series(GraphMode::Rtt, &visible);
|
let rtt = graph_series(GraphMode::Rtt, &visible, viewport);
|
||||||
|
|
||||||
assert!(one_way.is_empty());
|
assert!(one_way.is_empty());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -2242,23 +2553,24 @@ mod tests {
|
|||||||
fn graph_modes_use_readable_default_series() {
|
fn graph_modes_use_readable_default_series() {
|
||||||
let visible_samples = [graph_sample_with_timing(1, 3_000_000)];
|
let visible_samples = [graph_sample_with_timing(1, 3_000_000)];
|
||||||
let visible: Vec<_> = visible_samples.iter().collect();
|
let visible: Vec<_> = visible_samples.iter().collect();
|
||||||
|
let viewport = viewport_for_visible(&visible);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
graph_series(GraphMode::Rtt, &visible)
|
graph_series(GraphMode::Rtt, &visible, viewport)
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|series| series.value)
|
.filter_map(|series| series.value)
|
||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
vec![GraphValue::EffectiveRtt]
|
vec![GraphValue::EffectiveRtt]
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
graph_series(GraphMode::OneWay, &visible)
|
graph_series(GraphMode::OneWay, &visible, viewport)
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|series| series.value)
|
.filter_map(|series| series.value)
|
||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
vec![GraphValue::ClientToServer, GraphValue::ServerToClient]
|
vec![GraphValue::ClientToServer, GraphValue::ServerToClient]
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
graph_series(GraphMode::Combined, &visible)
|
graph_series(GraphMode::Combined, &visible, viewport)
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|series| series.value)
|
.filter_map(|series| series.value)
|
||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
@@ -2273,7 +2585,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn multi_target_metric_series_uses_selected_sample_field() {
|
fn multi_target_metric_series_uses_selected_sample_field() {
|
||||||
let mut target = TuiTargetState::new("alpha".to_owned(), stats_config(true));
|
let mut target = TuiTargetState::new("alpha".to_owned(), stats_config(true));
|
||||||
|
let timestamp = Instant::now();
|
||||||
target.push_graph_sample(GraphSample {
|
target.push_graph_sample(GraphSample {
|
||||||
|
timestamp,
|
||||||
seq: 1,
|
seq: 1,
|
||||||
effective_ns: 1_000_000,
|
effective_ns: 1_000_000,
|
||||||
raw_ns: 2_000_000,
|
raw_ns: 2_000_000,
|
||||||
@@ -2282,27 +2596,33 @@ mod tests {
|
|||||||
server_to_client_ns: None,
|
server_to_client_ns: None,
|
||||||
server_processing_ns: Some(4_000_000),
|
server_processing_ns: Some(4_000_000),
|
||||||
});
|
});
|
||||||
|
let viewport = viewport(timestamp, timestamp + Duration::from_secs(1));
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
target_metric_series(&target, 0, 10, MultiTargetGraphMetric::EffectiveRtt)
|
target_metric_series(&target, 0, viewport, MultiTargetGraphMetric::EffectiveRtt)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.data,
|
.data,
|
||||||
vec![(0.0, 1.0)]
|
vec![(0.0, 1.0)]
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
target_metric_series(&target, 0, 10, MultiTargetGraphMetric::RawRtt)
|
target_metric_series(&target, 0, viewport, MultiTargetGraphMetric::RawRtt)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.data,
|
.data,
|
||||||
vec![(0.0, 2.0)]
|
vec![(0.0, 2.0)]
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
target_metric_series(&target, 0, 10, MultiTargetGraphMetric::AdjustedRtt)
|
target_metric_series(&target, 0, viewport, MultiTargetGraphMetric::AdjustedRtt)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.data,
|
.data,
|
||||||
vec![(0.0, 3.0)]
|
vec![(0.0, 3.0)]
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
target_metric_series(&target, 0, 10, MultiTargetGraphMetric::ServerProcessing)
|
target_metric_series(
|
||||||
|
&target,
|
||||||
|
0,
|
||||||
|
viewport,
|
||||||
|
MultiTargetGraphMetric::ServerProcessing
|
||||||
|
)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.data,
|
.data,
|
||||||
vec![(0.0, 4.0)]
|
vec![(0.0, 4.0)]
|
||||||
@@ -2312,7 +2632,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn multi_target_metric_series_skips_missing_optional_samples() {
|
fn multi_target_metric_series_skips_missing_optional_samples() {
|
||||||
let mut target = TuiTargetState::new("alpha".to_owned(), stats_config(true));
|
let mut target = TuiTargetState::new("alpha".to_owned(), stats_config(true));
|
||||||
|
let start = Instant::now();
|
||||||
target.push_graph_sample(GraphSample {
|
target.push_graph_sample(GraphSample {
|
||||||
|
timestamp: start,
|
||||||
seq: 1,
|
seq: 1,
|
||||||
effective_ns: 1_000_000,
|
effective_ns: 1_000_000,
|
||||||
raw_ns: 2_000_000,
|
raw_ns: 2_000_000,
|
||||||
@@ -2322,6 +2644,7 @@ mod tests {
|
|||||||
server_processing_ns: None,
|
server_processing_ns: None,
|
||||||
});
|
});
|
||||||
target.push_graph_sample(GraphSample {
|
target.push_graph_sample(GraphSample {
|
||||||
|
timestamp: start + Duration::from_secs(1),
|
||||||
seq: 2,
|
seq: 2,
|
||||||
effective_ns: 2_000_000,
|
effective_ns: 2_000_000,
|
||||||
raw_ns: 3_000_000,
|
raw_ns: 3_000_000,
|
||||||
@@ -2330,15 +2653,21 @@ mod tests {
|
|||||||
server_to_client_ns: None,
|
server_to_client_ns: None,
|
||||||
server_processing_ns: Some(5_000_000),
|
server_processing_ns: Some(5_000_000),
|
||||||
});
|
});
|
||||||
|
let viewport = viewport(start, start + Duration::from_secs(2));
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
target_metric_series(&target, 0, 10, MultiTargetGraphMetric::AdjustedRtt)
|
target_metric_series(&target, 0, viewport, MultiTargetGraphMetric::AdjustedRtt)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.data,
|
.data,
|
||||||
vec![(1.0, 4.0)]
|
vec![(1.0, 4.0)]
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
target_metric_series(&target, 0, 10, MultiTargetGraphMetric::ServerProcessing)
|
target_metric_series(
|
||||||
|
&target,
|
||||||
|
0,
|
||||||
|
viewport,
|
||||||
|
MultiTargetGraphMetric::ServerProcessing
|
||||||
|
)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.data,
|
.data,
|
||||||
vec![(1.0, 5.0)]
|
vec![(1.0, 5.0)]
|
||||||
|
|||||||
Reference in new issue
Block a user