vector/top/
dashboard.rs

1use std::{io::stdout, time::Duration};
2
3use crossterm::{
4    ExecutableCommand,
5    cursor::Show,
6    event::{DisableMouseCapture, EnableMouseCapture, KeyCode},
7    execute,
8    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
9    tty::IsTty,
10};
11use num_format::{Locale, ToFormattedString};
12use number_prefix::NumberPrefix;
13use ratatui::{
14    Frame, Terminal,
15    backend::CrosstermBackend,
16    layout::{Alignment, Constraint, Layout, Rect},
17    style::{Color, Modifier, Style},
18    text::{Line, Span},
19    widgets::{Block, Borders, Cell, Paragraph, Row, Table, Wrap},
20};
21use tokio::sync::oneshot;
22
23use super::{
24    events::capture_key_press,
25    state::{self, ConnectionStatus},
26};
27use crate::internal_telemetry::is_allocation_tracking_enabled;
28
29/// Format metrics, with thousands separation
30trait ThousandsFormatter {
31    fn thousands_format(&self) -> String;
32}
33
34impl ThousandsFormatter for u32 {
35    fn thousands_format(&self) -> String {
36        match self {
37            0 => "--".into(),
38            _ => self.to_formatted_string(&Locale::en),
39        }
40    }
41}
42
43impl ThousandsFormatter for u64 {
44    fn thousands_format(&self) -> String {
45        match self {
46            0 => "--".into(),
47            _ => self.to_formatted_string(&Locale::en),
48        }
49    }
50}
51
52impl ThousandsFormatter for i64 {
53    fn thousands_format(&self) -> String {
54        match self {
55            0 => "--".into(),
56            _ => self.to_formatted_string(&Locale::en),
57        }
58    }
59}
60
61/// Format metrics, using the 'humanized' format, abbreviating with suffixes
62trait HumanFormatter {
63    fn human_format(&self) -> String;
64    fn human_format_bytes(&self) -> String;
65}
66
67impl HumanFormatter for i64 {
68    /// Format an i64 as a string, returning `--` if zero, the value as a string if < 1000, or
69    /// the value and the recognised abbreviation
70    fn human_format(&self) -> String {
71        match self {
72            0 => "--".into(),
73            n => match NumberPrefix::decimal(*n as f64) {
74                NumberPrefix::Standalone(n) => n.to_string(),
75                NumberPrefix::Prefixed(p, n) => format!("{n:.2} {p}"),
76            },
77        }
78    }
79
80    /// Format an i64 as a string in the same way as `human_format`, but using a 1024 base
81    /// for binary, and appended with a "B" to represent byte values
82    fn human_format_bytes(&self) -> String {
83        match self {
84            0 => "--".into(),
85            n => match NumberPrefix::binary(*n as f64) {
86                NumberPrefix::Standalone(n) => n.to_string(),
87                NumberPrefix::Prefixed(p, n) => format!("{n:.2} {p}B"),
88            },
89        }
90    }
91}
92
93fn format_metric(total: i64, throughput: i64, human_metrics: bool) -> String {
94    match total {
95        0 => "N/A".to_string(),
96        v => format!(
97            "{} ({}/s)",
98            if human_metrics {
99                v.human_format()
100            } else {
101                v.thousands_format()
102            },
103            throughput.human_format()
104        ),
105    }
106}
107
108fn format_metric_bytes(total: i64, throughput: i64, human_metrics: bool) -> String {
109    match total {
110        0 => "N/A".to_string(),
111        v => format!(
112            "{} ({}/s)",
113            if human_metrics {
114                v.human_format_bytes()
115            } else {
116                v.thousands_format()
117            },
118            throughput.human_format_bytes()
119        ),
120    }
121}
122
123const NUM_COLUMNS: usize = if is_allocation_tracking_enabled() {
124    10
125} else {
126    9
127};
128
129static HEADER: [&str; NUM_COLUMNS] = [
130    "ID",
131    "Output",
132    "Kind",
133    "Type",
134    "Events In",
135    "Bytes In",
136    "Events Out",
137    "Bytes Out",
138    "Errors",
139    #[cfg(feature = "allocation-tracing")]
140    "Memory Used",
141];
142
143struct Widgets<'a> {
144    constraints: Vec<Constraint>,
145    url_string: &'a str,
146    opts: &'a super::Opts,
147    title: &'a str,
148}
149
150impl<'a> Widgets<'a> {
151    /// Creates a new Widgets, containing constraints to re-use across renders.
152    pub fn new(title: &'a str, url_string: &'a str, opts: &'a super::Opts) -> Self {
153        let constraints = vec![
154            Constraint::Length(3),
155            Constraint::Max(90),
156            Constraint::Length(3),
157        ];
158
159        Self {
160            constraints,
161            url_string,
162            opts,
163            title,
164        }
165    }
166
167    /// Renders a title and the URL the dashboard is currently connected to.
168    fn title(
169        &'a self,
170        f: &mut Frame,
171        area: Rect,
172        connection_status: &ConnectionStatus,
173        uptime: Duration,
174    ) {
175        let mut text = vec![
176            Span::from(self.url_string),
177            Span::styled(
178                format!(" | Sampling @ {}ms", self.opts.interval.thousands_format()),
179                Style::default().fg(Color::Gray),
180            ),
181            Span::from(" | "),
182        ];
183        text.extend(connection_status.as_ui_spans());
184        text.extend(vec![Span::from(format!(
185            " | Uptime: {}",
186            humantime::format_duration(uptime)
187        ))]);
188
189        let text = vec![Line::from(text)];
190
191        let block = Block::default().borders(Borders::ALL).title(Span::styled(
192            self.title,
193            Style::default()
194                .fg(Color::Green)
195                .add_modifier(Modifier::BOLD),
196        ));
197        let w = Paragraph::new(text).block(block).wrap(Wrap { trim: true });
198
199        f.render_widget(w, area);
200    }
201
202    /// Renders a components table, showing sources, transforms and sinks in tabular form, with
203    /// statistics pulled from `ComponentsState`,
204    fn components_table(&self, f: &mut Frame, state: &state::State, area: Rect) {
205        // Header columns
206        let header = HEADER
207            .iter()
208            .map(|s| Cell::from(*s).style(Style::default().add_modifier(Modifier::BOLD)))
209            .collect::<Vec<_>>();
210
211        // Data columns
212        let mut items = Vec::new();
213        for (_, r) in state.components.iter() {
214            let mut data = vec![
215                r.key.id().to_string(),
216                if !r.has_displayable_outputs() {
217                    "--"
218                } else {
219                    Default::default()
220                }
221                .to_string(),
222                r.kind.clone(),
223                r.component_type.clone(),
224            ];
225
226            let formatted_metrics = [
227                format_metric(
228                    r.received_events_total,
229                    r.received_events_throughput_sec,
230                    self.opts.human_metrics,
231                ),
232                format_metric_bytes(
233                    r.received_bytes_total,
234                    r.received_bytes_throughput_sec,
235                    self.opts.human_metrics,
236                ),
237                format_metric(
238                    r.sent_events_total,
239                    r.sent_events_throughput_sec,
240                    self.opts.human_metrics,
241                ),
242                format_metric_bytes(
243                    r.sent_bytes_total,
244                    r.sent_bytes_throughput_sec,
245                    self.opts.human_metrics,
246                ),
247                if self.opts.human_metrics {
248                    r.errors.human_format()
249                } else {
250                    r.errors.thousands_format()
251                },
252                #[cfg(feature = "allocation-tracing")]
253                r.allocated_bytes.human_format_bytes(),
254            ];
255
256            data.extend_from_slice(&formatted_metrics);
257            items.push(Row::new(data).style(Style::default()));
258
259            // Add output rows
260            if r.has_displayable_outputs() {
261                for (id, output) in r.outputs.iter() {
262                    let sent_events_metric = format_metric(
263                        output.sent_events_total,
264                        output.sent_events_throughput_sec,
265                        self.opts.human_metrics,
266                    );
267                    let mut data = [""; NUM_COLUMNS]
268                        .into_iter()
269                        .map(Cell::from)
270                        .collect::<Vec<_>>();
271                    data[1] = Cell::from(id.as_str());
272                    data[5] = Cell::from(sent_events_metric);
273                    items.push(Row::new(data).style(Style::default()));
274                }
275            }
276        }
277
278        let widths: &[Constraint] = if is_allocation_tracking_enabled() {
279            &[
280                Constraint::Percentage(13), // ID
281                Constraint::Percentage(8),  // Output
282                Constraint::Percentage(4),  // Kind
283                Constraint::Percentage(9),  // Type
284                Constraint::Percentage(10), // Events In
285                Constraint::Percentage(12), // Bytes In
286                Constraint::Percentage(10), // Events Out
287                Constraint::Percentage(12), // Bytes Out
288                Constraint::Percentage(8),  // Errors
289                Constraint::Percentage(14), // Allocated Bytes
290            ]
291        } else {
292            &[
293                Constraint::Percentage(13), // ID
294                Constraint::Percentage(12), // Output
295                Constraint::Percentage(9),  // Kind
296                Constraint::Percentage(6),  // Type
297                Constraint::Percentage(12), // Events In
298                Constraint::Percentage(14), // Bytes In
299                Constraint::Percentage(12), // Events Out
300                Constraint::Percentage(14), // Bytes Out
301                Constraint::Percentage(8),  // Errors
302            ]
303        };
304        let w = Table::new(items, widths)
305            .header(Row::new(header).bottom_margin(1))
306            .block(Block::default().borders(Borders::ALL).title("Components"))
307            .column_spacing(2);
308        f.render_widget(w, area);
309    }
310
311    /// Alerts the user to resize the window to view columns
312    fn components_resize_window(&self, f: &mut Frame, area: Rect) {
313        let block = Block::default().borders(Borders::ALL).title("Components");
314        let w = Paragraph::new("Expand the window to > 80 chars to view metrics")
315            .block(block)
316            .wrap(Wrap { trim: true });
317
318        f.render_widget(w, area);
319    }
320
321    /// Renders a box showing instructions on how to exit from `vector top`.
322    fn quit_box(&self, f: &mut Frame, area: Rect) {
323        let text = vec![Line::from("To quit, press ESC or 'q'")];
324
325        let block = Block::default()
326            .borders(Borders::ALL)
327            .border_style(Style::default().fg(Color::Gray));
328        let w = Paragraph::new(text)
329            .block(block)
330            .style(Style::default().fg(Color::Gray))
331            .alignment(Alignment::Left);
332
333        f.render_widget(w, area);
334    }
335
336    /// Draw a single frame. Creates a layout and renders widgets into it.
337    fn draw(&self, f: &mut Frame, state: state::State) {
338        let size = f.area();
339        let rects = Layout::default()
340            .constraints(self.constraints.clone())
341            .split(size);
342
343        self.title(f, rects[0], &state.connection_status, state.uptime);
344
345        // Require a minimum of 80 chars of line width to display the table
346        if size.width >= 80 {
347            self.components_table(f, &state, rects[1]);
348        } else {
349            self.components_resize_window(f, rects[1]);
350        }
351
352        self.quit_box(f, rects[2]);
353    }
354}
355
356/// Determine if the terminal is a TTY
357pub fn is_tty() -> bool {
358    stdout().is_tty()
359}
360
361/// Initialize the dashboard. A new terminal drawing session will be created, targeting
362/// stdout. We're using 'direct' drawing mode to control the full output of the dashboard,
363/// as well as entering an 'alternate screen' to overlay the console. This ensures that when
364/// the dashboard is exited, the user's previous terminal session can commence, unaffected.
365pub async fn init_dashboard<'a>(
366    title: &'a str,
367    url: &'a str,
368    opts: &'a super::Opts,
369    mut state_rx: state::StateRx,
370    mut shutdown_rx: oneshot::Receiver<()>,
371) -> Result<(), Box<dyn std::error::Error>> {
372    // Capture key presses, to determine when to quit
373    let (mut key_press_rx, key_press_kill_tx) = capture_key_press();
374
375    // Write to stdout, and enter an alternate screen, to avoid overwriting existing
376    // terminal output
377    let mut stdout = stdout();
378
379    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
380
381    // Drop into 'raw' mode, to enable direct drawing to the terminal
382    enable_raw_mode()?;
383
384    // Build terminal. We're using crossterm for *nix + Windows support
385    let backend = CrosstermBackend::new(stdout);
386    let mut terminal = Terminal::new(backend)?;
387
388    // Clear the screen, readying it for output
389    terminal.clear()?;
390
391    let widgets = Widgets::new(title, url, opts);
392
393    loop {
394        tokio::select! {
395            Some(state) = state_rx.recv() => {
396                terminal.draw(|f| widgets.draw(f, state))?;
397            },
398            k = key_press_rx.recv() => {
399                if let KeyCode::Esc | KeyCode::Char('q') = k.unwrap() {
400                    _ = key_press_kill_tx.send(());
401                    break
402                }
403            }
404            _ = &mut shutdown_rx => {
405                _ = key_press_kill_tx.send(());
406                break
407            }
408        }
409    }
410
411    // Clean-up terminal
412    terminal.backend_mut().execute(DisableMouseCapture)?;
413    terminal.backend_mut().execute(LeaveAlternateScreen)?;
414    terminal.backend_mut().execute(Show)?;
415
416    disable_raw_mode()?;
417
418    Ok(())
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424
425    #[test]
426    /// Zero should be formatted as "--" in all cases
427    fn format_zero() {
428        const N: i64 = 0;
429
430        assert_eq!(N.thousands_format(), "--");
431        assert_eq!(N.human_format(), "--");
432    }
433
434    #[test]
435    /// < 1000 should always be as-is
436    fn format_hundred() {
437        const N: i64 = 100;
438
439        assert_eq!(N.thousands_format(), "100");
440        assert_eq!(N.human_format(), "100");
441    }
442
443    #[test]
444    /// 1,000+ starts to make a difference...
445    fn format_thousands() {
446        const N: i64 = 1_000;
447
448        assert_eq!(N.thousands_format(), "1,000");
449        assert_eq!(N.human_format(), "1.00 k");
450    }
451
452    #[test]
453    /// Shouldn't round down
454    fn format_thousands_no_rounding() {
455        const N: i64 = 1_500;
456
457        assert_eq!(N.thousands_format(), "1,500");
458        assert_eq!(N.human_format(), "1.50 k");
459    }
460
461    #[test]
462    /// Should round down when human formatted
463    fn format_thousands_round_down() {
464        const N: i64 = 1_514;
465
466        assert_eq!(N.thousands_format(), "1,514");
467        assert_eq!(N.human_format(), "1.51 k");
468    }
469
470    #[test]
471    /// Should round up when human formatted
472    fn format_thousands_round_up() {
473        const N: i64 = 1_999;
474
475        assert_eq!(N.thousands_format(), "1,999");
476        assert_eq!(N.human_format(), "2.00 k");
477    }
478
479    #[test]
480    /// Should format millions
481    fn format_millions() {
482        const N: i64 = 1_000_000;
483
484        assert_eq!(N.thousands_format(), "1,000,000");
485        assert_eq!(N.human_format(), "1.00 M");
486    }
487
488    #[test]
489    /// Should format billions
490    fn format_billions() {
491        const N: i64 = 1_000_000_000;
492
493        assert_eq!(N.thousands_format(), "1,000,000,000");
494        assert_eq!(N.human_format(), "1.00 G");
495    }
496
497    #[test]
498    /// Should format trillions
499    fn format_trillions() {
500        const N: i64 = 1_100_000_000_000;
501
502        assert_eq!(N.thousands_format(), "1,100,000,000,000");
503        assert_eq!(N.human_format(), "1.10 T");
504    }
505
506    #[test]
507    /// Should format bytes
508    fn format_bytes() {
509        const N: i64 = 1024;
510
511        assert_eq!(N.human_format_bytes(), "1.00 KiB");
512        assert_eq!((N * N).human_format_bytes(), "1.00 MiB");
513        assert_eq!((N * (N * N)).human_format_bytes(), "1.00 GiB");
514        assert_eq!((N * (N * (N * N))).human_format_bytes(), "1.00 TiB");
515        assert_eq!((N * (N * (N * (N * N)))).human_format_bytes(), "1.00 PiB");
516    }
517}