From 44094433a7cf95c403e62d3c564137c4a1a8936b Mon Sep 17 00:00:00 2001 From: Hayden Stainsby Date: Thu, 13 Apr 2023 20:05:25 +0200 Subject: [PATCH] refac(console): factor out `Durations` widget from task view (#408) There are 2 widgets which display the poll times for a task in the detail view. The poll times percentiles are always displayed and if UTF-8 is available, then a sparkline histogram is also shown to the right. The logic for displaying these two widgets is quite long and is currently interspersed within the `render` function for the task detail view plus helper functions. Additionally, it is not easy to add a second set of widgets showing the time between waking and being polled for a task which is planned for #409. This change factors out that logic into separate widgets. There was already a separate widget `MiniHistogram`. Some of the logic that was previously in the task detail view has been moved here. A new widget `Percentiles` has been added to encapsulate the logic for preparing and displaying the percentiles. A top level `Durations` widget occupies the entire width of the task detail view and control the horizontal layout of the `Percentiles` and `MiniHistogram` widgets. The new widget will also supress the histogram if there isn't at least enough room to display the legend at the bottom --- tokio-console/src/view/durations.rs | 108 +++++++++++ tokio-console/src/view/mini_histogram.rs | 220 ++++++++++++++--------- tokio-console/src/view/mod.rs | 7 +- tokio-console/src/view/percentiles.rs | 83 +++++++++ tokio-console/src/view/task.rs | 159 ++-------------- 5 files changed, 347 insertions(+), 230 deletions(-) create mode 100644 tokio-console/src/view/durations.rs create mode 100644 tokio-console/src/view/percentiles.rs diff --git a/tokio-console/src/view/durations.rs b/tokio-console/src/view/durations.rs new file mode 100644 index 000000000..29303ed36 --- /dev/null +++ b/tokio-console/src/view/durations.rs @@ -0,0 +1,108 @@ +use std::cmp; + +use tui::{ + layout::{self}, + widgets::Widget, +}; + +use crate::{ + state::histogram::DurationHistogram, + view::{self, mini_histogram::MiniHistogram, percentiles::Percentiles}, +}; + +// This is calculated so that a legend like the below generally fits: +// │0647.17µs 909.31µs │ +// This also gives at characters for the sparkline itself. +const MIN_HISTOGRAM_BLOCK_WIDTH: u16 = 22; + +/// This is a tui-rs widget to visualize durations as a list of percentiles +/// and if possible, a mini-histogram too. +/// +/// This widget wraps the [`Percentiles`] and [`MiniHistogram`] widgets which +/// are displayed side by side. The mini-histogram will only be displayed if +/// a) UTF-8 support is enabled via [`Styles`] +/// b) There is at least a minimum width (22 characters to display the full +/// bottom legend) left after drawing the percentiles +/// +/// This +/// +/// [`Styles`]: crate::view::Styles +pub(crate) struct Durations<'a> { + /// Widget style + styles: &'a view::Styles, + /// The histogram data to render + histogram: Option<&'a DurationHistogram>, + /// Title for percentiles block + percentiles_title: &'a str, + /// Title for histogram sparkline block + histogram_title: &'a str, +} + +impl<'a> Widget for Durations<'a> { + fn render(self, area: tui::layout::Rect, buf: &mut tui::buffer::Buffer) { + // Only split the durations area in half if we're also drawing a + // sparkline. We require UTF-8 to draw the sparkline and also enough width. + let (percentiles_area, histogram_area) = if self.styles.utf8 { + let percentiles_width = cmp::max(self.percentiles_title.len() as u16, 13_u16) + 2; + + // If there isn't enough width left after drawing the percentiles + // then we won't draw the sparkline at all. + if area.width < percentiles_width + MIN_HISTOGRAM_BLOCK_WIDTH { + (area, None) + } else { + let areas = layout::Layout::default() + .direction(layout::Direction::Horizontal) + .constraints( + [ + layout::Constraint::Length(percentiles_width), + layout::Constraint::Min(MIN_HISTOGRAM_BLOCK_WIDTH), + ] + .as_ref(), + ) + .split(area); + (areas[0], Some(areas[1])) + } + } else { + (area, None) + }; + + let percentiles_widget = Percentiles::new(self.styles) + .title(self.percentiles_title) + .histogram(self.histogram); + percentiles_widget.render(percentiles_area, buf); + + if let Some(histogram_area) = histogram_area { + let histogram_widget = MiniHistogram::default() + .block(self.styles.border_block().title(self.histogram_title)) + .histogram(self.histogram) + .duration_precision(2); + histogram_widget.render(histogram_area, buf); + } + } +} + +impl<'a> Durations<'a> { + pub(crate) fn new(styles: &'a view::Styles) -> Self { + Self { + styles, + histogram: None, + percentiles_title: "Percentiles", + histogram_title: "Histogram", + } + } + + pub(crate) fn histogram(mut self, histogram: Option<&'a DurationHistogram>) -> Self { + self.histogram = histogram; + self + } + + pub(crate) fn percentiles_title(mut self, title: &'a str) -> Self { + self.percentiles_title = title; + self + } + + pub(crate) fn histogram_title(mut self, title: &'a str) -> Self { + self.histogram_title = title; + self + } +} diff --git a/tokio-console/src/view/mini_histogram.rs b/tokio-console/src/view/mini_histogram.rs index 72ff983ef..1bdfcbd9e 100644 --- a/tokio-console/src/view/mini_histogram.rs +++ b/tokio-console/src/view/mini_histogram.rs @@ -7,6 +7,8 @@ use tui::{ widgets::{Block, Widget}, }; +use crate::state::histogram::DurationHistogram; + /// This is a tui-rs widget to visualize a latency histogram in a small area. /// It is based on the [`Sparkline`] widget, so it draws a mini bar chart with /// some labels for clarity. Unlike Sparkline, it does not omit very small @@ -18,10 +20,8 @@ pub(crate) struct MiniHistogram<'a> { block: Option>, /// Widget style style: Style, - /// Values for the buckets of the histogram - data: &'a [u64], - /// Metadata about the histogram - metadata: HistogramMetadata, + /// The histogram data to render + histogram: Option<&'a DurationHistogram>, /// The maximum value to take to compute the maximum bar height (if nothing is specified, the /// widget uses the max of the dataset) max: Option, @@ -51,8 +51,7 @@ impl<'a> Default for MiniHistogram<'a> { MiniHistogram { block: None, style: Default::default(), - data: &[], - metadata: Default::default(), + histogram: None, max: None, bar_set: symbols::bar::NINE_LEVELS, duration_precision: 4, @@ -75,34 +74,43 @@ impl<'a> Widget for MiniHistogram<'a> { return; } - let max_qty_label = self.metadata.max_bucket.to_string(); - let min_qty_label = self.metadata.min_bucket.to_string(); + let (data, metadata) = match self.histogram { + // Bit of a deadlock: We cannot know the highest bucket value without determining the number of buckets, + // and we cannot determine the number of buckets without knowing the width of the chart area which depends on + // the number of digits in the highest bucket value. + // So just assume here the number of digits in the highest bucket value is 3. + // If we overshoot, there will be empty columns/buckets at the right end of the chart. + // If we undershoot, the rightmost 1-2 columns/buckets will be hidden. + // We could get the max bucket value from the previous render though... + Some(h) => chart_data(h, inner_area.width - 3), + None => return, + }; + + let max_qty_label = metadata.max_bucket.to_string(); + let min_qty_label = metadata.min_bucket.to_string(); let max_record_label = format!( "{:.prec$?}", - Duration::from_nanos(self.metadata.max_value), + Duration::from_nanos(metadata.max_value), prec = self.duration_precision, ); let min_record_label = format!( "{:.prec$?}", - Duration::from_nanos(self.metadata.min_value), + Duration::from_nanos(metadata.min_value), prec = self.duration_precision, ); let y_axis_label_width = max_qty_label.len() as u16; - self.render_legend( + render_legend( inner_area, buf, + &metadata, max_record_label, min_record_label, max_qty_label, min_qty_label, ); - let legend_height = if self.metadata.high_outliers > 0 { - 2 - } else { - 1 - }; + let legend_height = if metadata.high_outliers > 0 { 2 } else { 1 }; // Shrink the bars area by 1 row from the bottom // and `y_axis_label_width` columns from the left. @@ -112,74 +120,23 @@ impl<'a> Widget for MiniHistogram<'a> { width: inner_area.width - y_axis_label_width, height: inner_area.height - legend_height, }; - self.render_bars(bars_area, buf); + self.render_bars(bars_area, buf, data); } } impl<'a> MiniHistogram<'a> { - fn render_legend( + fn render_bars( &mut self, area: tui::layout::Rect, buf: &mut tui::buffer::Buffer, - max_record_label: String, - min_record_label: String, - max_qty_label: String, - min_qty_label: String, + data: Vec, ) { - // If there are outliers, display a note - let labels_pos = if self.metadata.high_outliers > 0 { - let outliers = format!( - "{} outliers (highest: {:?})", - self.metadata.high_outliers, - self.metadata - .highest_outlier - .expect("if there are outliers, the highest should be set") - ); - buf.set_string( - area.right() - outliers.len() as u16, - area.bottom() - 1, - &outliers, - Style::default(), - ); - 2 - } else { - 1 - }; - - // top left: max quantity - buf.set_string(area.left(), area.top(), &max_qty_label, Style::default()); - // bottom left: 0 aligned to right - let zero_label = format!("{:>width$}", &min_qty_label, width = max_qty_label.len()); - buf.set_string( - area.left(), - area.bottom() - labels_pos, - &zero_label, - Style::default(), - ); - // bottom left below the chart: min time - buf.set_string( - area.left() + max_qty_label.len() as u16, - area.bottom() - labels_pos, - &min_record_label, - Style::default(), - ); - // bottom right: max time - buf.set_string( - area.right() - max_record_label.len() as u16, - area.bottom() - labels_pos, - &max_record_label, - Style::default(), - ); - } - - fn render_bars(&mut self, area: tui::layout::Rect, buf: &mut tui::buffer::Buffer) { let max = match self.max { Some(v) => v, - None => *self.data.iter().max().unwrap_or(&1u64), + None => *data.iter().max().unwrap_or(&1u64), }; - let max_index = std::cmp::min(area.width as usize, self.data.len()); - let mut data = self - .data + let max_index = std::cmp::min(area.width as usize, data.len()); + let mut data = data .iter() .take(max_index) .map(|e| { @@ -244,15 +201,11 @@ impl<'a> MiniHistogram<'a> { self } - #[allow(dead_code)] - pub fn data(mut self, data: &'a [u64]) -> MiniHistogram<'a> { - self.data = data; - self - } - - #[allow(dead_code)] - pub fn metadata(mut self, metadata: HistogramMetadata) -> MiniHistogram<'a> { - self.metadata = metadata; + pub(crate) fn histogram( + mut self, + histogram: Option<&'a DurationHistogram>, + ) -> MiniHistogram<'a> { + self.histogram = histogram; self } @@ -268,3 +221,106 @@ impl<'a> MiniHistogram<'a> { self } } + +fn render_legend( + area: tui::layout::Rect, + buf: &mut tui::buffer::Buffer, + metadata: &HistogramMetadata, + max_record_label: String, + min_record_label: String, + max_qty_label: String, + min_qty_label: String, +) { + // If there are outliers, display a note + let labels_pos = if metadata.high_outliers > 0 { + let outliers = format!( + "{} outliers (highest: {:?})", + metadata.high_outliers, + metadata + .highest_outlier + .expect("if there are outliers, the highest should be set") + ); + buf.set_string( + area.right() - outliers.len() as u16, + area.bottom() - 1, + &outliers, + Style::default(), + ); + 2 + } else { + 1 + }; + + // top left: max quantity + buf.set_string(area.left(), area.top(), &max_qty_label, Style::default()); + // bottom left: 0 aligned to right + let zero_label = format!("{:>width$}", &min_qty_label, width = max_qty_label.len()); + buf.set_string( + area.left(), + area.bottom() - labels_pos, + &zero_label, + Style::default(), + ); + // bottom left below the chart: min time + buf.set_string( + area.left() + max_qty_label.len() as u16, + area.bottom() - labels_pos, + &min_record_label, + Style::default(), + ); + // bottom right: max time + buf.set_string( + area.right() - max_record_label.len() as u16, + area.bottom() - labels_pos, + &max_record_label, + Style::default(), + ); +} + +/// From the histogram, build a visual representation by trying to make as +/// many buckets as the width of the render area. +fn chart_data(histogram: &DurationHistogram, width: u16) -> (Vec, HistogramMetadata) { + let &DurationHistogram { + ref histogram, + high_outliers, + highest_outlier, + .. + } = histogram; + + let step_size = ((histogram.max() - histogram.min()) as f64 / width as f64).ceil() as u64 + 1; + // `iter_linear` panics if step_size is 0 + let data = if step_size > 0 { + let mut found_first_nonzero = false; + let data: Vec = histogram + .iter_linear(step_size) + .filter_map(|value| { + let count = value.count_since_last_iteration(); + // Remove the 0s from the leading side of the buckets. + // Because HdrHistogram can return empty buckets depending + // on its internal state, as it approximates values. + if count == 0 && !found_first_nonzero { + None + } else { + found_first_nonzero = true; + Some(count) + } + }) + .collect(); + data + } else { + Vec::new() + }; + let max_bucket = data.iter().max().copied().unwrap_or_default(); + let min_bucket = data.iter().min().copied().unwrap_or_default(); + ( + data, + HistogramMetadata { + max_value: histogram.max(), + min_value: histogram.min(), + max_bucket, + min_bucket, + high_outliers, + highest_outlier, + }, + ) +} diff --git a/tokio-console/src/view/mod.rs b/tokio-console/src/view/mod.rs index 9f50d5da7..3d35350b8 100644 --- a/tokio-console/src/view/mod.rs +++ b/tokio-console/src/view/mod.rs @@ -8,7 +8,9 @@ use tui::{ }; mod async_ops; +mod durations; mod mini_histogram; +mod percentiles; mod resource; mod resources; mod styles; @@ -18,11 +20,14 @@ mod tasks; pub(crate) use self::styles::{Palette, Styles}; pub(crate) use self::table::SortBy; -const DUR_LEN: usize = 6; // This data is only updated every second, so it doesn't make a ton of // sense to have a lot of precision in timestamps (and this makes sure // there's room for the unit!) +const DUR_LEN: usize = 6; +// Precision (after decimal point) for durations displayed in a list +// (detail view) const DUR_LIST_PRECISION: usize = 2; +// Precision (after decimal point) for durations displayed in a table const DUR_TABLE_PRECISION: usize = 0; const TABLE_HIGHLIGHT_SYMBOL: &str = ">> "; diff --git a/tokio-console/src/view/percentiles.rs b/tokio-console/src/view/percentiles.rs new file mode 100644 index 000000000..6f8ba4666 --- /dev/null +++ b/tokio-console/src/view/percentiles.rs @@ -0,0 +1,83 @@ +use std::time::Duration; + +use tui::{ + text::{Spans, Text}, + widgets::{Paragraph, Widget}, +}; + +use crate::{ + state::histogram::DurationHistogram, + view::{self, bold}, +}; + +/// This is a tui-rs widget to display duration percentiles in a list form. +/// It wraps the [`Paragraph`] widget. +pub(crate) struct Percentiles<'a> { + /// Widget style + styles: &'a view::Styles, + /// The histogram data to render + histogram: Option<&'a DurationHistogram>, + /// The title of the paragraph + title: &'a str, +} + +impl<'a> Widget for Percentiles<'a> { + fn render(self, area: tui::layout::Rect, buf: &mut tui::buffer::Buffer) { + let inner = Paragraph::new(self.make_percentiles_inner()) + .block(self.styles.border_block().title(self.title)); + + inner.render(area, buf) + } +} + +impl<'a> Percentiles<'a> { + pub(crate) fn new(styles: &'a view::Styles) -> Self { + Self { + styles, + histogram: None, + title: "Percentiles", + } + } + + pub(crate) fn make_percentiles_inner(&self) -> Text<'static> { + let mut text = Text::default(); + let histogram = match self.histogram { + Some(DurationHistogram { histogram, .. }) => histogram, + _ => return text, + }; + + // Get the important percentile values from the histogram + let pairs = [10f64, 25f64, 50f64, 75f64, 90f64, 95f64, 99f64] + .iter() + .map(move |i| (*i, histogram.value_at_percentile(*i))); + let percentiles = pairs.map(|pair| { + Spans::from(vec![ + bold(format!("p{:>2}: ", pair.0)), + self.styles.time_units( + Duration::from_nanos(pair.1), + view::DUR_LIST_PRECISION, + None, + ), + ]) + }); + + text.extend(percentiles); + text + } + + #[allow(dead_code)] + pub(crate) fn styles(mut self, styles: &'a view::Styles) -> Percentiles<'a> { + self.styles = styles; + self + } + + pub(crate) fn histogram(mut self, histogram: Option<&'a DurationHistogram>) -> Percentiles<'a> { + self.histogram = histogram; + self + } + + pub(crate) fn title(mut self, title: &'a str) -> Percentiles<'a> { + self.title = title; + self + } +} diff --git a/tokio-console/src/view/task.rs b/tokio-console/src/view/task.rs index b8febbcab..93edd13b7 100644 --- a/tokio-console/src/view/task.rs +++ b/tokio-console/src/view/task.rs @@ -1,16 +1,8 @@ use crate::{ input, - state::{ - histogram::DurationHistogram, - tasks::{Details, Task}, - DetailsRef, - }, + state::{tasks::Task, DetailsRef}, util::Percentage, - view::{ - self, bold, - mini_histogram::{HistogramMetadata, MiniHistogram}, - DUR_LIST_PRECISION, - }, + view::{self, bold, durations::Durations}, }; use std::{ cell::RefCell, @@ -121,26 +113,6 @@ impl TaskView { ) .split(stats_area); - // Only split the histogram area in half if we're also drawing a - // sparkline (which requires UTF-8 characters). - let poll_dur_area = if styles.utf8 { - Layout::default() - .direction(layout::Direction::Horizontal) - .constraints( - [ - // 24 chars is long enough for the title "Poll Times Percentiles" - layout::Constraint::Length(24), - layout::Constraint::Min(50), - ] - .as_ref(), - ) - .split(poll_dur_area) - } else { - vec![poll_dur_area] - }; - - let percentiles_area = poll_dur_area[0]; - let controls = Spans::from(vec![ Span::raw("controls: "), bold(styles.if_utf8("\u{238B} esc", "esc")), @@ -177,12 +149,15 @@ impl TaskView { let percent = amt.as_secs_f64().percent_of(total.as_secs_f64()); Spans::from(vec![ bold(name), - dur(styles, amt), + styles.time_units(amt, view::DUR_LIST_PRECISION, None), Span::from(format!(" ({:.2}%)", percent)), ]) }; - overview.push(Spans::from(vec![bold("Total Time: "), dur(styles, total)])); + overview.push(Spans::from(vec![ + bold("Total Time: "), + styles.time_units(total, view::DUR_LIST_PRECISION, None), + ])); overview.push(dur_percent("Busy: ", task.busy(now))); overview.push(dur_percent("Idle: ", task.idle(now))); @@ -224,30 +199,6 @@ impl TaskView { let mut fields = Text::default(); fields.extend(task.formatted_fields().iter().cloned().map(Spans::from)); - // If UTF-8 is disabled we can't draw the histogram sparklne. - if styles.utf8 { - let sparkline_area = poll_dur_area[1]; - - // Bit of a deadlock: We cannot know the highest bucket value without determining the number of buckets, - // and we cannot determine the number of buckets without knowing the width of the chart area which depends on - // the number of digits in the highest bucket value. - // So just assume here the number of digits in the highest bucket value is 3. - // If we overshoot, there will be empty columns/buckets at the right end of the chart. - // If we undershoot, the rightmost 1-2 columns/buckets will be hidden. - // We could get the max bucket value from the previous render though... - let (chart_data, metadata) = details - .map(|d| d.make_chart_data(sparkline_area.width - 3)) - .unwrap_or_default(); - - let histogram_sparkline = MiniHistogram::default() - .block(styles.border_block().title("Poll Times Histogram")) - .data(&chart_data) - .metadata(metadata) - .duration_precision(2); - - frame.render_widget(histogram_sparkline, sparkline_area); - } - if let Some(warnings_area) = warnings_area { let warnings = List::new(warnings).block(styles.border_block().title("Warnings")); frame.render_widget(warnings, warnings_area); @@ -255,102 +206,16 @@ impl TaskView { let task_widget = Paragraph::new(overview).block(styles.border_block().title("Task")); let wakers_widget = Paragraph::new(waker_stats).block(styles.border_block().title("Waker")); + let poll_durations_widget = Durations::new(styles) + .histogram(details.and_then(|d| d.poll_times_histogram())) + .percentiles_title("Poll Times Percentiles") + .histogram_title("Poll Times Histogram"); let fields_widget = Paragraph::new(fields).block(styles.border_block().title("Fields")); - let percentiles_widget = Paragraph::new( - details - .map(|details| details.make_percentiles_widget(styles)) - .unwrap_or_default(), - ) - .block(styles.border_block().title("Poll Times Percentiles")); frame.render_widget(Block::default().title(controls), controls_area); frame.render_widget(task_widget, stats_area[0]); frame.render_widget(wakers_widget, stats_area[1]); + frame.render_widget(poll_durations_widget, poll_dur_area); frame.render_widget(fields_widget, fields_area); - frame.render_widget(percentiles_widget, percentiles_area); } } - -impl Details { - /// From the histogram, build a visual representation by trying to make as - // many buckets as the width of the render area. - fn make_chart_data(&self, width: u16) -> (Vec, HistogramMetadata) { - self.poll_times_histogram() - .map( - |&DurationHistogram { - ref histogram, - high_outliers, - highest_outlier, - .. - }| { - let step_size = ((histogram.max() - histogram.min()) as f64 / width as f64) - .ceil() as u64 - + 1; - // `iter_linear` panics if step_size is 0 - let data = if step_size > 0 { - let mut found_first_nonzero = false; - let data: Vec = histogram - .iter_linear(step_size) - .filter_map(|value| { - let count = value.count_since_last_iteration(); - // Remove the 0s from the leading side of the buckets. - // Because HdrHistogram can return empty buckets depending - // on its internal state, as it approximates values. - if count == 0 && !found_first_nonzero { - None - } else { - found_first_nonzero = true; - Some(count) - } - }) - .collect(); - data - } else { - Vec::new() - }; - let max_bucket = data.iter().max().copied().unwrap_or_default(); - let min_bucket = data.iter().min().copied().unwrap_or_default(); - ( - data, - HistogramMetadata { - max_value: histogram.max(), - min_value: histogram.min(), - max_bucket, - min_bucket, - high_outliers, - highest_outlier, - }, - ) - }, - ) - .unwrap_or_default() - } - - /// Get the important percentile values from the histogram - fn make_percentiles_widget(&self, styles: &view::Styles) -> Text<'static> { - let mut text = Text::default(); - let histogram = self.poll_times_histogram(); - let percentiles = histogram - .iter() - .flat_map(|&DurationHistogram { histogram, .. }| { - let pairs = [10f64, 25f64, 50f64, 75f64, 90f64, 95f64, 99f64] - .iter() - .map(move |i| (*i, histogram.value_at_percentile(*i))); - pairs.map(|pair| { - Spans::from(vec![ - bold(format!("p{:>2}: ", pair.0)), - dur(styles, Duration::from_nanos(pair.1)), - ]) - }) - }); - text.extend(percentiles); - text - } -} - -fn dur(styles: &view::Styles, dur: std::time::Duration) -> Span<'static> { - // TODO(eliza): can we not have to use `format!` to make a string here? is - // there a way to just give TUI a `fmt::Debug` implementation, or does it - // have to be given a string in order to do layout stuff? - styles.time_units(dur, DUR_LIST_PRECISION, None) -}