Skip to main content

adguardian/widgets/
chart.rs

1use tui::{
2  style::{Color, Modifier, Style},
3  symbols,
4  text::Span,
5  widgets::{Axis, Block, Borders, Chart, Dataset},
6};
7
8use crate::fetch::fetch_stats::StatsResponse;
9
10pub fn make_history_chart(stats: &StatsResponse) -> Chart<'_> {
11  // Convert datasets into vector that can be consumed by chart
12  let datasets = make_history_datasets(stats);
13  // Find uppermost x and y-axis bounds for chart
14  let (x_bound, y_bound) = find_bounds(stats);
15  // AdGuard sends hourly buckets when stats retention is 7 days or less
16  let (x_title, now) = if stats.time_units == "hours" {
17    ("Time (Hours ago)", "Now")
18  } else {
19    ("Time (Days ago)", "Today")
20  };
21  // Generate incremental labels from data's values, to render on axis
22  let x_labels = generate_x_labels(stats.dns_queries.len() as i32, 5, now);
23  let y_labels = generate_y_labels(y_bound as i32, 5);
24  // Create chart
25  let chart = Chart::new(datasets)
26    .block(
27      Block::default()
28        .title(Span::styled(
29          "History",
30          Style::default().add_modifier(Modifier::BOLD),
31        ))
32        .borders(Borders::ALL),
33    )
34    .x_axis(
35      Axis::default()
36        .title(x_title)
37        .bounds([0.0, x_bound])
38        .labels(x_labels),
39    )
40    .y_axis(
41      Axis::default()
42        .title("Query Count")
43        .labels(y_labels)
44        .bounds([0.0, y_bound]),
45    );
46
47  chart
48}
49
50// Returns a dataset that's consumable by the chart widget
51fn make_history_datasets(stats: &StatsResponse) -> Vec<Dataset<'_>> {
52  let dns_queries_dataset = Dataset::default()
53    .name("DNS Queries")
54    .marker(symbols::Marker::Braille)
55    .style(Style::default().fg(Color::Green))
56    .data(&stats.dns_queries_chart);
57
58  let blocked_filtering_dataset = Dataset::default()
59    .name("Blocked Filtering")
60    .marker(symbols::Marker::Braille)
61    .style(Style::default().fg(Color::Red))
62    .data(&stats.blocked_filtering_chart);
63
64  let datasets = vec![dns_queries_dataset, blocked_filtering_dataset];
65
66  datasets
67}
68
69// Determine the uppermost bounds for the x and y axis
70fn find_bounds(stats: &StatsResponse) -> (f64, f64) {
71  let mut max_length = 0;
72  // Floor at 0 so empty data can't leave this at f64::MIN (which would later
73  // overflow when cast to an axis-label step)
74  let mut max_value = 0.0;
75
76  for dataset in &[&stats.dns_queries_chart, &stats.blocked_filtering_chart] {
77    let length = dataset.len();
78    if length > max_length {
79      max_length = length;
80    }
81
82    let max_in_dataset = dataset.iter().map(|&(_, y)| y).fold(f64::MIN, f64::max);
83    if max_in_dataset > max_value {
84      max_value = max_in_dataset;
85    }
86  }
87  (max_length as f64, max_value)
88}
89
90// Generate periodic labels to render on the y-axis (query count)
91fn generate_y_labels(max: i32, count: usize) -> Vec<Span<'static>> {
92  let step = max / (count - 1) as i32;
93
94  (0..count)
95    .map(|x| Span::raw(format!("{}", x * step as usize)))
96    .collect::<Vec<Span<'static>>>()
97}
98
99// Generate periodic labels to render on the x-axis (hours or days ago)
100fn generate_x_labels(len: i32, num_labels: i32, now: &'static str) -> Vec<Span<'static>> {
101  // The newest bucket is the current one, so the oldest is `len - 1` units ago
102  let oldest = (len - 1).max(0) as f64;
103  (0..num_labels)
104    .map(|i| {
105      if i == num_labels - 1 {
106        Span::styled(now, Style::default().add_modifier(Modifier::BOLD))
107      } else {
108        let ago = oldest * (num_labels - 1 - i) as f64 / (num_labels - 1) as f64;
109        Span::raw((ago.round() as i32).to_string())
110      }
111    })
112    .collect()
113}
114
115// Formats vector data into a format that can be consumed by the chart widget
116fn convert_to_chart_data(data: Vec<f64>) -> Vec<(f64, f64)> {
117  data
118    .iter()
119    .enumerate()
120    .map(|(i, &v)| (i as f64, v))
121    .collect()
122}
123
124// Interpolates data, adding n number of points, to make the chart look smoother
125fn interpolate(input: &[f64], points_between: usize) -> Vec<f64> {
126  // Nothing to interpolate between - avoids a panic on the `last()` below
127  let Some(&last) = input.last() else {
128    return Vec::new();
129  };
130
131  let mut output = Vec::new();
132  for window in input.windows(2) {
133    let start = window[0];
134    let end = window[1];
135    let step = (end - start) / (points_between as f64 + 1.0);
136
137    output.push(start);
138    for i in 1..=points_between {
139      output.push(start + step * i as f64);
140    }
141  }
142
143  output.push(last);
144  output
145}
146
147// Adds data formatted for the time-series chart to the stats object
148pub fn prepare_chart_data(stats: &mut StatsResponse) {
149  let dns_queries = stats
150    .dns_queries
151    .iter()
152    .map(|&v| v as f64)
153    .collect::<Vec<_>>();
154  let interpolated_dns_queries = interpolate(&dns_queries, 3);
155  stats.dns_queries_chart = convert_to_chart_data(interpolated_dns_queries);
156
157  let blocked_filtering: Vec<f64> = stats
158    .blocked_filtering
159    .iter()
160    .zip(&stats.replaced_safebrowsing)
161    .zip(&stats.replaced_parental)
162    .map(|((&b, &s), &p)| (b + s + p) as f64)
163    .collect();
164
165  let interpolated_blocked_filtering = interpolate(&blocked_filtering, 3);
166  let blocked_filtering_chart: Vec<(f64, f64)> =
167    convert_to_chart_data(interpolated_blocked_filtering);
168
169  stats.blocked_filtering_chart = blocked_filtering_chart;
170}
171
172#[cfg(test)]
173mod tests {
174  use super::*;
175
176  #[test]
177  fn x_labels_count_back_from_the_newest_bucket() {
178    let labels = |len| -> Vec<String> {
179      let spans = generate_x_labels(len, 5, "Now");
180      spans.into_iter().map(|s| s.content.into_owned()).collect()
181    };
182    assert_eq!(labels(24), ["23", "17", "12", "6", "Now"]);
183    assert_eq!(labels(30), ["29", "22", "15", "7", "Now"]);
184    assert_eq!(labels(0), ["0", "0", "0", "0", "Now"]);
185  }
186}