Skip to main content

adguardian/widgets/
table.rs

1use chrono::{DateTime, Utc};
2use tui::{
3  layout::Constraint,
4  style::{Color, Modifier, Style},
5  text::Span,
6  widgets::{Block, Borders, Cell, Row, Table},
7};
8
9use crate::fetch::fetch_query_log::{Query, Question};
10pub fn make_query_table(data: &[Query], width: u16) -> Table<'_> {
11  let rows = data
12    .iter()
13    .map(|query| {
14      let time = Cell::from(time_ago(query.time.as_str()).unwrap_or("unknown".to_string()))
15        .style(Style::default().fg(Color::Gray));
16
17      let question = Cell::from(make_request_cell(&query.question))
18        .style(Style::default().add_modifier(Modifier::BOLD));
19
20      let client = Cell::from(client_label(query)).style(Style::default().fg(Color::Blue));
21
22      let (time_taken, elapsed_color) = make_time_taken_and_color(&query.elapsed_ms)
23        .unwrap_or_else(|_| ("? ms".to_string(), Color::Gray));
24      let elapsed_ms = Cell::from(time_taken).style(Style::default().fg(elapsed_color));
25
26      let (status_txt, status_color) = block_status_text(&query.reason, query.cached);
27      let status = Cell::from(status_txt).style(Style::default().fg(status_color));
28
29      let upstream = Cell::from(query.upstream.as_str()).style(Style::default().fg(Color::Blue));
30
31      let color = make_row_color(&query.reason);
32      Row::new(vec![time, question, status, elapsed_ms, client, upstream])
33        .style(Style::default().fg(color))
34    })
35    .collect::<Vec<Row>>();
36
37  let title = Span::styled("Query Log", Style::default().add_modifier(Modifier::BOLD));
38
39  let block = Block::default().title(title).borders(Borders::ALL);
40
41  let mut headers = vec![
42    Cell::from(Span::raw("Time")),
43    Cell::from(Span::raw("Request")),
44    Cell::from(Span::raw("Status")),
45    Cell::from(Span::raw("Time Taken")),
46  ];
47
48  if width > 120 {
49    headers.extend(vec![
50      Cell::from(Span::raw("Client")),
51      Cell::from(Span::raw("Upstream DNS")),
52    ]);
53
54    let widths = &[
55      Constraint::Percentage(15),
56      Constraint::Percentage(35),
57      Constraint::Percentage(10),
58      Constraint::Percentage(10),
59      Constraint::Percentage(15),
60      Constraint::Percentage(15),
61    ];
62
63    Table::new(rows)
64      .header(Row::new(headers))
65      .widths(widths)
66      .block(block)
67  } else {
68    let widths = &[
69      Constraint::Percentage(20),
70      Constraint::Percentage(40),
71      Constraint::Percentage(20),
72      Constraint::Percentage(20),
73    ];
74
75    Table::new(rows)
76      .header(Row::new(headers))
77      .widths(widths)
78      .block(block)
79  }
80}
81
82// Given a timestamp, return a string representing how long ago that was
83fn time_ago(timestamp: &str) -> Result<String, anyhow::Error> {
84  let datetime = DateTime::parse_from_rfc3339(timestamp)?;
85  let datetime_utc = datetime.with_timezone(&Utc);
86  let now = Utc::now();
87
88  let duration = now - datetime_utc;
89
90  if duration.num_minutes() < 1 {
91    Ok(format!("{} sec ago", duration.num_seconds()))
92  } else {
93    Ok(format!("{} min ago", duration.num_minutes()))
94  }
95}
96
97// Return cell showing info about the request made in a given query
98fn make_request_cell(q: &Question) -> String {
99  format!("[{}] {} - {}", q.class, q.question_type, q.name)
100}
101
102// Return the client's name if AdGuard knows it, otherwise its IP
103fn client_label(q: &Query) -> &str {
104  match &q.client_info {
105    Some(info) if !info.name.is_empty() => &info.name,
106    _ => &q.client,
107  }
108}
109
110// Return a cell showing the time taken for a query, and a color based on time
111fn make_time_taken_and_color(elapsed: &str) -> Result<(String, Color), anyhow::Error> {
112  let elapsed_f64 = elapsed.parse::<f64>()?;
113  let rounded_elapsed = (elapsed_f64 * 100.0).round() / 100.0;
114  let time_taken = format!("{:.2} ms", rounded_elapsed);
115  let color = if elapsed_f64 < 1.0 {
116    Color::Green
117  } else if (1.0..=20.0).contains(&elapsed_f64) {
118    Color::Yellow
119  } else {
120    Color::Red
121  };
122  Ok((time_taken, color))
123}
124
125// Return color for a row, based on the allow/block reason
126fn make_row_color(reason: &str) -> Color {
127  if reason == "NotFilteredNotFound" {
128    Color::Green
129  } else if reason == "FilteredBlackList" {
130    Color::Red
131  } else {
132    Color::Yellow
133  }
134}
135
136// Return text and color for the status cell based on allow/ block reason
137fn block_status_text(reason: &str, cached: bool) -> (String, Color) {
138  let (text, color) = if reason == "FilteredBlackList" {
139    ("Blacklisted".to_string(), Color::Red)
140  } else if cached {
141    ("Cached".to_string(), Color::Cyan)
142  } else if reason == "NotFilteredNotFound" {
143    ("Allowed".to_string(), Color::Green)
144  } else {
145    ("Other Block".to_string(), Color::Yellow)
146  };
147  (text, color)
148}
149
150#[cfg(test)]
151mod tests {
152  use super::*;
153
154  #[test]
155  fn client_label_prefers_name_and_falls_back_to_ip() {
156    let label = |json: &str| client_label(&serde_json::from_str(json).unwrap()).to_string();
157    let named = r#"{"client":"1.2.3.4","client_info":{"name":"laptop"}}"#;
158    assert_eq!(label(named), "laptop");
159    for unnamed in [
160      r#"{"client":"1.2.3.4","client_info":{"name":""}}"#,
161      r#"{"client":"1.2.3.4","client_info":null}"#,
162      r#"{"client":"1.2.3.4"}"#,
163    ] {
164      assert_eq!(label(unnamed), "1.2.3.4");
165    }
166  }
167}