vector/top/
dashboard.rs

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