Skip to main content

adguardian/widgets/
chart.rs

1use tui::{
2  buffer::Buffer,
3  layout::Rect,
4  style::{Color, Modifier, Style},
5  symbols,
6  text::Span,
7  widgets::{Axis, Block, Borders, Chart, Dataset, Widget},
8};
9
10use crate::fetch::fetch_stats::StatsResponse;
11
12pub fn make_history_chart(stats: &StatsResponse) -> impl Widget + '_ {
13  // Convert datasets into vector that can be consumed by chart
14  let datasets = make_history_datasets(stats);
15  // Find uppermost x and y-axis bounds for chart
16  let (x_bound, y_bound) = find_bounds(stats);
17  // AdGuard sends hourly buckets when stats retention is 7 days or less
18  let (x_title, now) = if stats.time_units == "hours" {
19    ("Time (Hours ago)", "Now")
20  } else {
21    ("Time (Days ago)", "Today")
22  };
23  // Generate incremental labels from data's values, to render on axis
24  let x_labels = generate_x_labels(stats.dns_queries.len() as i32, 5, now);
25  let y_labels = generate_y_labels(y_bound as i32, 5);
26  let chart = Chart::new(datasets)
27    .block(
28      Block::default()
29        .title(Span::styled(
30          "History",
31          Style::default().add_modifier(Modifier::BOLD),
32        ))
33        .borders(Borders::ALL),
34    )
35    .x_axis(
36      Axis::default()
37        .title(x_title)
38        .bounds([0.0, x_bound])
39        .labels(x_labels),
40    )
41    .y_axis(Axis::default().labels(y_labels).bounds([0.0, y_bound]));
42
43  HistoryChart { chart }
44}
45
46const QUERY_COUNT_TITLE: &str = "Query Count";
47
48// ratatui 0.20's Axis::title restyles the whole top row, so the label is drawn after the chart
49struct HistoryChart<'a> {
50  chart: Chart<'a>,
51}
52
53impl Widget for HistoryChart<'_> {
54  fn render(self, area: Rect, buf: &mut Buffer) {
55    self.chart.render(area, buf);
56    let y = area.y.saturating_add(1);
57    // ratatui 0.20 hides the title on graphs under 3 rows tall
58    if area.height < 7 {
59      return;
60    }
61    // Skip the left border. The next vertical bar on this row is the y-axis.
62    let Some(axis_x) = (area.x + 1..area.right().saturating_sub(1))
63      .find(|&x| buf.get(x, y).symbol == symbols::line::VERTICAL)
64    else {
65      return;
66    };
67    let x = axis_x + 1;
68    let room = area.right().saturating_sub(x + 1) as usize;
69    if QUERY_COUNT_TITLE.len() + 1 < room {
70      buf.set_string(x, y, QUERY_COUNT_TITLE, Style::default().fg(Color::Reset));
71    }
72  }
73}
74
75// Returns a dataset that's consumable by the chart widget
76fn make_history_datasets(stats: &StatsResponse) -> Vec<Dataset<'_>> {
77  let dns_queries_dataset = Dataset::default()
78    .name("DNS Queries")
79    .marker(symbols::Marker::Braille)
80    .style(Style::default().fg(Color::Green))
81    .data(&stats.dns_queries_chart);
82
83  let blocked_filtering_dataset = Dataset::default()
84    .name("Blocked Filtering")
85    .marker(symbols::Marker::Braille)
86    .style(Style::default().fg(Color::Red))
87    .data(&stats.blocked_filtering_chart);
88
89  let datasets = vec![dns_queries_dataset, blocked_filtering_dataset];
90
91  datasets
92}
93
94// Determine the uppermost bounds for the x and y axis
95fn find_bounds(stats: &StatsResponse) -> (f64, f64) {
96  let mut max_length = 0;
97  // Floor at 0 so empty data can't leave this at f64::MIN (which would later
98  // overflow when cast to an axis-label step)
99  let mut max_value = 0.0;
100
101  for dataset in &[&stats.dns_queries_chart, &stats.blocked_filtering_chart] {
102    let length = dataset.len();
103    if length > max_length {
104      max_length = length;
105    }
106
107    let max_in_dataset = dataset.iter().map(|&(_, y)| y).fold(f64::MIN, f64::max);
108    if max_in_dataset > max_value {
109      max_value = max_in_dataset;
110    }
111  }
112  (max_length as f64, max_value)
113}
114
115// Generate periodic labels to render on the y-axis (query count)
116fn generate_y_labels(max: i32, count: usize) -> Vec<Span<'static>> {
117  let step = max / (count - 1) as i32;
118
119  (0..count)
120    .map(|x| Span::raw(format!("{}", x * step as usize)))
121    .collect::<Vec<Span<'static>>>()
122}
123
124// Generate periodic labels to render on the x-axis (hours or days ago)
125fn generate_x_labels(len: i32, num_labels: i32, now: &'static str) -> Vec<Span<'static>> {
126  // The newest bucket is the current one, so the oldest is `len - 1` units ago
127  let oldest = (len - 1).max(0) as f64;
128  (0..num_labels)
129    .map(|i| {
130      if i == num_labels - 1 {
131        Span::styled(now, Style::default().add_modifier(Modifier::BOLD))
132      } else {
133        let ago = oldest * (num_labels - 1 - i) as f64 / (num_labels - 1) as f64;
134        Span::raw((ago.round() as i32).to_string())
135      }
136    })
137    .collect()
138}
139
140// Formats vector data into a format that can be consumed by the chart widget
141fn convert_to_chart_data(data: Vec<f64>) -> Vec<(f64, f64)> {
142  data
143    .iter()
144    .enumerate()
145    .map(|(i, &v)| (i as f64, v))
146    .collect()
147}
148
149// Interpolates data, adding n number of points, to make the chart look smoother
150fn interpolate(input: &[f64], points_between: usize) -> Vec<f64> {
151  // Nothing to interpolate between - avoids a panic on the `last()` below
152  let Some(&last) = input.last() else {
153    return Vec::new();
154  };
155
156  let mut output = Vec::new();
157  for window in input.windows(2) {
158    let start = window[0];
159    let end = window[1];
160    let step = (end - start) / (points_between as f64 + 1.0);
161
162    output.push(start);
163    for i in 1..=points_between {
164      output.push(start + step * i as f64);
165    }
166  }
167
168  output.push(last);
169  output
170}
171
172// Adds data formatted for the time-series chart to the stats object
173pub fn prepare_chart_data(stats: &mut StatsResponse) {
174  let dns_queries = stats
175    .dns_queries
176    .iter()
177    .map(|&v| v as f64)
178    .collect::<Vec<_>>();
179  let interpolated_dns_queries = interpolate(&dns_queries, 3);
180  stats.dns_queries_chart = convert_to_chart_data(interpolated_dns_queries);
181
182  let blocked_filtering: Vec<f64> = stats
183    .blocked_filtering
184    .iter()
185    .zip(&stats.replaced_safebrowsing)
186    .zip(&stats.replaced_parental)
187    .map(|((&b, &s), &p)| (b + s + p) as f64)
188    .collect();
189
190  let interpolated_blocked_filtering = interpolate(&blocked_filtering, 3);
191  let blocked_filtering_chart: Vec<(f64, f64)> =
192    convert_to_chart_data(interpolated_blocked_filtering);
193
194  stats.blocked_filtering_chart = blocked_filtering_chart;
195}
196
197#[cfg(test)]
198mod tests {
199  use super::*;
200  use tui::{buffer::Buffer, layout::Rect, widgets::Widget};
201
202  #[test]
203  fn x_labels_count_back_from_the_newest_bucket() {
204    let labels = |len| -> Vec<String> {
205      let spans = generate_x_labels(len, 5, "Now");
206      spans.into_iter().map(|s| s.content.into_owned()).collect()
207    };
208    assert_eq!(labels(24), ["23", "17", "12", "6", "Now"]);
209    assert_eq!(labels(30), ["29", "22", "15", "7", "Now"]);
210    assert_eq!(labels(0), ["0", "0", "0", "0", "Now"]);
211  }
212
213  #[test]
214  fn top_row_braille_keeps_the_dataset_color() {
215    for (queries, blocked, color) in [(100u64, 0u64, Color::Green), (100, 100, Color::Red)] {
216      let mut stats = StatsResponse {
217        time_units: "hours".into(),
218        dns_queries: vec![queries; 24],
219        blocked_filtering: vec![blocked; 24],
220        replaced_safebrowsing: vec![0; 24],
221        replaced_parental: vec![0; 24],
222        ..StatsResponse::default()
223      };
224      prepare_chart_data(&mut stats);
225      let area = Rect::new(0, 0, 60, 14);
226      let mut buf = Buffer::empty(area);
227      make_history_chart(&stats).render(area, &mut buf);
228
229      let y = (0..area.height)
230        .find(|y| row_text(&buf, *y).contains(QUERY_COUNT_TITLE))
231        .unwrap();
232      let x = row_text(&buf, y).find(QUERY_COUNT_TITLE).unwrap() as u16;
233      let colors: Vec<_> = ((x + QUERY_COUNT_TITLE.len() as u16)..area.width)
234        .filter_map(|col| {
235          let cell = buf.get(col, y);
236          cell
237            .symbol
238            .chars()
239            .any(|c| ('\u{2800}'..='\u{28FF}').contains(&c))
240            .then_some(cell.fg)
241        })
242        .collect();
243      assert!(!colors.is_empty());
244      assert!(colors.iter().all(|fg| *fg == color));
245    }
246  }
247
248  fn row_text(buf: &Buffer, y: u16) -> String {
249    (0..buf.area.width)
250      .map(|x| buf.get(x, y).symbol.as_str())
251      .collect()
252  }
253}