diff --git a/.github/actions/spelling/allow.txt b/.github/actions/spelling/allow.txt index 85ca0f9f2981f..6c503837edce3 100644 --- a/.github/actions/spelling/allow.txt +++ b/.github/actions/spelling/allow.txt @@ -276,6 +276,7 @@ KDL keepappkey keephq kenton +keybinds Kingcom Kolkata konqueror diff --git a/Cargo.lock b/Cargo.lock index ff9e8907b9c2f..c848bdb60d212 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12974,6 +12974,7 @@ dependencies = [ "indoc", "num-format", "ratatui", + "regex", "tokio", "tokio-stream", "unit-prefix", diff --git a/changelog.d/24355_vector_top_controls.feature.md b/changelog.d/24355_vector_top_controls.feature.md new file mode 100644 index 0000000000000..7216b58dbf9fb --- /dev/null +++ b/changelog.d/24355_vector_top_controls.feature.md @@ -0,0 +1,3 @@ +Added new keybinds to `vector top` for scrolling, sorting and filtering the table. + +authors: esensar Quad9DNS diff --git a/lib/vector-top/Cargo.toml b/lib/vector-top/Cargo.toml index 815c0a4f858a1..bdfe87bf8f8ec 100644 --- a/lib/vector-top/Cargo.toml +++ b/lib/vector-top/Cargo.toml @@ -21,6 +21,7 @@ crossterm = { version = "0.29.0", default-features = false, features = ["event-s unit-prefix = { version = "0.5.2", default-features = false, features = ["std"] } num-format = { version = "0.4.4", default-features = false, features = ["with-num-bigint"] } ratatui = { version = "0.29.0", default-features = false, features = ["crossterm"] } +regex.workspace = true vector-common = { path = "../vector-common" } vector-api-client = { path = "../vector-api-client" } diff --git a/lib/vector-top/src/dashboard.rs b/lib/vector-top/src/dashboard.rs index 418ee0831d7a8..648f4cfe721d6 100644 --- a/lib/vector-top/src/dashboard.rs +++ b/lib/vector-top/src/dashboard.rs @@ -3,7 +3,7 @@ use std::{io::stdout, time::Duration}; use crossterm::{ ExecutableCommand, cursor::Show, - event::{DisableMouseCapture, EnableMouseCapture, KeyCode}, + event::{DisableMouseCapture, EnableMouseCapture}, execute, terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, tty::IsTty, @@ -12,14 +12,22 @@ use num_format::{Locale, ToFormattedString}; use ratatui::{ Frame, Terminal, backend::CrosstermBackend, - layout::{Alignment, Constraint, Layout, Rect}, - style::{Color, Modifier, Style}, + layout::{Alignment, Constraint, Flex, Layout, Position, Rect}, + style::{Color, Modifier, Style, Stylize}, text::{Line, Span}, - widgets::{Block, Borders, Cell, Paragraph, Row, Table, Wrap}, + widgets::{ + Block, Borders, Cell, Clear, List, ListItem, ListState, Padding, Paragraph, Row, Scrollbar, + ScrollbarOrientation, ScrollbarState, Table, TableState, Wrap, + }, }; use tokio::sync::oneshot; use unit_prefix::NumberPrefix; +use crate::{ + input::{InputMode, handle_input}, + state::{ComponentRow, FilterColumn, FilterMenuState, SortColumn}, +}; + use super::{ events::capture_key_press, state::{self, ConnectionStatus}, @@ -210,12 +218,90 @@ impl<'a> Widgets<'a> { // Header columns let header = HEADER .iter() - .map(|s| Cell::from(*s).style(Style::default().add_modifier(Modifier::BOLD))) + .map(|s| { + let mut c = Cell::from(*s).style(Style::default().add_modifier(Modifier::BOLD)); + if state + .sort_state + .column + .map(|c| c.matches_header(s)) + .unwrap_or_default() + { + c = c.add_modifier(Modifier::REVERSED); + } + c + }) .collect::>(); // Data columns let mut items = Vec::new(); - for (_, r) in state.components.iter() { + let mut sorted = state.components.iter().collect::>(); + if let Some(column) = state.sort_state.column { + let sort_fn = match column { + SortColumn::Id => |l: &ComponentRow, r: &ComponentRow| l.key.cmp(&r.key), + SortColumn::Kind => |l: &ComponentRow, r: &ComponentRow| l.kind.cmp(&r.kind), + SortColumn::Type => { + |l: &ComponentRow, r: &ComponentRow| l.component_type.cmp(&r.component_type) + } + SortColumn::EventsIn => |l: &ComponentRow, r: &ComponentRow| { + l.received_events_throughput_sec + .cmp(&r.received_events_throughput_sec) + }, + SortColumn::EventsInTotal => |l: &ComponentRow, r: &ComponentRow| { + l.received_events_total.cmp(&r.received_events_total) + }, + SortColumn::BytesIn => |l: &ComponentRow, r: &ComponentRow| { + l.received_bytes_throughput_sec + .cmp(&r.received_bytes_throughput_sec) + }, + SortColumn::BytesInTotal => |l: &ComponentRow, r: &ComponentRow| { + l.received_bytes_total.cmp(&r.received_bytes_total) + }, + SortColumn::EventsOut => |l: &ComponentRow, r: &ComponentRow| { + l.sent_events_throughput_sec + .cmp(&r.sent_events_throughput_sec) + }, + SortColumn::EventsOutTotal => |l: &ComponentRow, r: &ComponentRow| { + l.sent_events_total.cmp(&r.sent_events_total) + }, + SortColumn::BytesOut => |l: &ComponentRow, r: &ComponentRow| { + l.sent_bytes_throughput_sec + .cmp(&r.sent_bytes_throughput_sec) + }, + SortColumn::BytesOutTotal => { + |l: &ComponentRow, r: &ComponentRow| l.sent_bytes_total.cmp(&r.sent_bytes_total) + } + SortColumn::Errors => |l: &ComponentRow, r: &ComponentRow| l.errors.cmp(&r.errors), + #[cfg(feature = "allocation-tracing")] + SortColumn::MemoryUsed => { + |l: &ComponentRow, r: &ComponentRow| l.allocated_bytes.cmp(&r.allocated_bytes) + } + }; + if state.sort_state.reverse { + sorted.sort_by(|a, b| sort_fn(a.1, b.1).reverse()) + } else { + sorted.sort_by(|a, b| sort_fn(a.1, b.1)); + } + } + + for (_, r) in sorted.into_iter().filter(|(_, r)| { + let column = state.filter_state.column; + if let Some(regex) = &state.filter_state.pattern { + match column { + FilterColumn::Id => { + regex.is_match(r.key.id()) || r.key.id().contains(regex.as_str()) + } + FilterColumn::Kind => { + regex.is_match(&r.kind) || r.kind.contains(regex.as_str()) + } + FilterColumn::Type => { + regex.is_match(&r.component_type) + || r.component_type.contains(regex.as_str()) + } + } + } else { + true + } + }) { let mut data = vec![ r.key.id().to_string(), if !r.has_displayable_outputs() { @@ -310,7 +396,34 @@ impl<'a> Widgets<'a> { .header(Row::new(header).bottom_margin(1)) .block(Block::default().borders(Borders::ALL).title("Components")) .column_spacing(2); - f.render_widget(w, area); + f.render_stateful_widget( + w, + area, + // We don't need selection, so just create a table state for the scroll + &mut TableState::new().with_offset(state.ui.scroll), + ); + // Skip the border + header row + 1 row of padding as well as the bottom border + let scrollbar_area = Rect::new(area.x, area.y + 3, area.width, area.height - 4); + f.render_stateful_widget( + Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(Some("↑")) + .end_symbol(Some("↓")), + scrollbar_area, + &mut ScrollbarState::new( + // Maximum allowed scroll value + // We calculate it like this, because scrollbar usually accounts for full + // overscroll, but we want scrolling to stop when last available item is visible and + // at the bottom of the table. + state + .components + .len() + .saturating_sub(scrollbar_area.height.into()) + // 1 is also added, because ScrollBar removes 1, to ensure last item is visible + // when overscrolling - we avoid overscroll, so this is useless to us. + .saturating_add(1), + ) + .position(state.ui.scroll), + ); } /// Alerts the user to resize the window to view columns @@ -323,9 +436,109 @@ impl<'a> Widgets<'a> { f.render_widget(w, area); } + /// Renders a box showing instructions on how to use `vector top`. + fn help_box(&self, f: &mut Frame, area: Rect) { + let text = vec![ + Line::from("General").bold(), + Line::from("ESC, q => quit (or close window)"), + Line::from("↓, j => scroll down by 1 row"), + Line::from("↑, k => scroll up by 1 row"), + Line::from("→, PageDown, CTRL+f => scroll down by 1 page"), + Line::from("←, PageUp, CTRL+b => scroll up by 1 page"), + Line::from("End, G => scroll to bottom"), + Line::from("Home, g => scroll to top"), + Line::from("F1, ? => toggle this help window"), + Line::from("1-9 => sort by column"), + Line::from("F6, s => toggle sort menu"), + Line::from("F7, r => toggle ascending/descending sort"), + Line::from("F4, f, / => toggle filter menu"), + Line::default(), + Line::from("Sort menu").bold(), + Line::from("↑, Shift+Tab, k => move sort column selection up"), + Line::from("↓, Tab, j => move sort column selection down"), + Line::from("Enter => confirm sort selection"), + Line::from("F6, s => toggle sort menu"), + Line::default(), + Line::from("Filter menu").bold(), + Line::from("↑, Shift+Tab => move filter column selection up"), + Line::from("↓, Tab => move filter column selection down"), + Line::from("Enter => confirm filter selection"), + Line::from("F4 => toggle sort menu"), + ]; + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default()) + .padding(Padding::proportional(2)) + .title("Help"); + let w = Paragraph::new(text) + .block(block) + .style(Style::default().fg(Color::Gray)) + .alignment(Alignment::Left); + + f.render_widget(Clear, area); + f.render_widget(w, area); + } + + /// Renders a box with sorting options. + fn sort_box(&self, f: &mut Frame, area: Rect, mut list_state: ListState) { + f.render_widget(Clear, area); + let w = List::new( + SortColumn::items() + .into_iter() + .map(|h| ListItem::new(Line::from(h))), + ) + .block( + Block::default() + .padding(Padding::proportional(2)) + .borders(Borders::ALL) + .title("Sort by"), + ) + .highlight_style(Style::new().reversed()); + f.render_stateful_widget(w, area, &mut list_state); + } + + /// Renders a box with filtering options. + fn filter_box(&self, f: &mut Frame, area: Rect, filter_menu_state: &FilterMenuState) { + f.render_widget(Clear, area); + let w = List::new( + FilterColumn::items() + .into_iter() + .map(|h| ListItem::new(Line::from(h))), + ) + .block(Block::default().borders(Borders::ALL).title("Filter by")) + .highlight_style(Style::new().reversed()); + let (top, bottom) = { + ( + Rect::new(area.x, area.y, area.width, area.height / 2), + Rect::new( + area.x, + area.y + area.height / 2, + area.width, + area.height / 2, + ), + ) + }; + f.render_stateful_widget(w, top, &mut filter_menu_state.column_selection.clone()); + f.render_widget( + Paragraph::new(filter_menu_state.input.clone()).block( + Block::default() + .borders(Borders::ALL) + .title("Filter pattern"), + ), + bottom, + ); + f.set_cursor_position(Position::new( + bottom.x + 1 + filter_menu_state.input.len() as u16, + bottom.y + 1, + )); + } + /// Renders a box showing instructions on how to exit from `vector top`. fn quit_box(&self, f: &mut Frame, area: Rect) { - let text = vec![Line::from("To quit, press ESC or 'q'")]; + let text = vec![Line::from( + "To quit, press ESC or 'q'; Press F1 or '?' for help", + )]; let block = Block::default() .borders(Borders::ALL) @@ -355,6 +568,37 @@ impl<'a> Widgets<'a> { } self.quit_box(f, rects[2]); + + // Render help, sort and filter over other items + if state.ui.help_visible { + let [area] = Layout::horizontal([Constraint::Length(64)]) + .flex(Flex::Center) + .areas(size); + let [area] = Layout::vertical([Constraint::Length(32)]) + .flex(Flex::Center) + .areas(area); + self.help_box(f, area); + } + + if state.ui.sort_visible { + let [area] = Layout::horizontal([Constraint::Length(64)]) + .flex(Flex::Center) + .areas(size); + let [area] = Layout::vertical([Constraint::Length(32)]) + .flex(Flex::Center) + .areas(area); + self.sort_box(f, area, state.ui.sort_menu_state); + } + + if state.ui.filter_visible { + let [area] = Layout::horizontal([Constraint::Length(64)]) + .flex(Flex::Center) + .areas(size); + let [area] = Layout::vertical([Constraint::Length(12)]) + .flex(Flex::Center) + .areas(area); + self.filter_box(f, area, &state.ui.filter_menu_state); + } } } @@ -372,6 +616,7 @@ pub async fn init_dashboard<'a>( url: &'a str, interval: u32, human_metrics: bool, + event_tx: state::EventTx, mut state_rx: state::StateRx, mut shutdown_rx: oneshot::Receiver<()>, ) -> Result<(), Box> { @@ -395,16 +640,27 @@ pub async fn init_dashboard<'a>( terminal.clear()?; let widgets = Widgets::new(title, url, interval, human_metrics); + let mut input_mode = InputMode::Top; loop { tokio::select! { Some(state) = state_rx.recv() => { + if state.ui.filter_visible { + input_mode = InputMode::FilterInput; + } else if state.ui.sort_visible { + input_mode = InputMode::SortMenu; + } else if state.ui.help_visible { + input_mode = InputMode::HelpMenu; + } else { + input_mode = InputMode::Top; + } terminal.draw(|f| widgets.draw(f, state))?; }, k = key_press_rx.recv() => { - if let KeyCode::Esc | KeyCode::Char('q') = k.unwrap() { + let k = k.unwrap(); + if handle_input(input_mode, k, &event_tx, &terminal).await { _ = key_press_kill_tx.send(()); - break + break; } } _ = &mut shutdown_rx => { diff --git a/lib/vector-top/src/events.rs b/lib/vector-top/src/events.rs index 5696e696c2910..6864966f6d664 100644 --- a/lib/vector-top/src/events.rs +++ b/lib/vector-top/src/events.rs @@ -1,10 +1,10 @@ -use crossterm::event::{Event, EventStream, KeyCode}; +use crossterm::event::{Event, EventStream, KeyEvent}; use futures::StreamExt; use tokio::sync::{mpsc, oneshot}; /// Capture keyboard input, and send it upstream via a channel. This is used for interaction /// with the dashboard, and exiting from `vector top`. -pub fn capture_key_press() -> (mpsc::UnboundedReceiver, oneshot::Sender<()>) { +pub fn capture_key_press() -> (mpsc::UnboundedReceiver, oneshot::Sender<()>) { let (tx, rx) = mpsc::unbounded_channel(); let (kill_tx, mut kill_rx) = oneshot::channel(); @@ -17,7 +17,7 @@ pub fn capture_key_press() -> (mpsc::UnboundedReceiver, oneshot::Sender _ = &mut kill_rx => return, Some(Ok(event)) = events.next() => { if let Event::Key(k) = event { - _ = tx.clone().send(k.code); + _ = tx.clone().send(k); }; } } diff --git a/lib/vector-top/src/input.rs b/lib/vector-top/src/input.rs new file mode 100644 index 0000000000000..874a0f5be78aa --- /dev/null +++ b/lib/vector-top/src/input.rs @@ -0,0 +1,229 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::{Terminal, prelude::Backend}; + +use crate::state::{self, EventType, SortColumn, UiEventType}; + +#[derive(Debug, Clone, Copy)] +pub(crate) enum InputMode { + Top, + HelpMenu, + FilterInput, + SortMenu, +} + +/// Handles keyboard input for top +/// +/// Returns true if input handling is done (quit is requested) +pub(crate) async fn handle_input( + mode: InputMode, + key_event: KeyEvent, + event_tx: &state::EventTx, + terminal: &Terminal, +) -> bool { + match mode { + InputMode::Top => handle_top_input(key_event, event_tx, terminal).await, + InputMode::HelpMenu => handle_help_input(key_event, event_tx, terminal).await, + InputMode::FilterInput => handle_filter_input(key_event, event_tx, terminal).await, + InputMode::SortMenu => handle_sort_input(key_event, event_tx, terminal).await, + } +} + +async fn handle_top_input( + key_event: KeyEvent, + event_tx: &state::EventTx, + terminal: &Terminal, +) -> bool { + match key_event.code { + KeyCode::Esc | KeyCode::Char('q') => { + return true; + } + KeyCode::Up | KeyCode::Char('k') => { + let _ = event_tx + .send(EventType::Ui(UiEventType::Scroll( + -1, + terminal.size().unwrap_or_default(), + ))) + .await; + } + KeyCode::Down | KeyCode::Char('j') => { + let _ = event_tx + .send(EventType::Ui(UiEventType::Scroll( + 1, + terminal.size().unwrap_or_default(), + ))) + .await; + } + KeyCode::End | KeyCode::Char('G') => { + let _ = event_tx + .send(EventType::Ui(UiEventType::Scroll( + isize::MAX, + terminal.size().unwrap_or_default(), + ))) + .await; + } + KeyCode::Home | KeyCode::Char('g') => { + let _ = event_tx + .send(EventType::Ui(UiEventType::Scroll( + isize::MIN, + terminal.size().unwrap_or_default(), + ))) + .await; + } + KeyCode::Left | KeyCode::PageUp => { + let _ = event_tx + .send(EventType::Ui(UiEventType::ScrollPage( + -1, + terminal.size().unwrap_or_default(), + ))) + .await; + } + KeyCode::Char('b') if key_event.modifiers.intersects(KeyModifiers::CONTROL) => { + let _ = event_tx + .send(EventType::Ui(UiEventType::ScrollPage( + -1, + terminal.size().unwrap_or_default(), + ))) + .await; + } + KeyCode::Right | KeyCode::PageDown => { + let _ = event_tx + .send(EventType::Ui(UiEventType::ScrollPage( + 1, + terminal.size().unwrap_or_default(), + ))) + .await; + } + KeyCode::Char('f') if key_event.modifiers.intersects(KeyModifiers::CONTROL) => { + let _ = event_tx + .send(EventType::Ui(UiEventType::ScrollPage( + 1, + terminal.size().unwrap_or_default(), + ))) + .await; + } + KeyCode::Char('?') | KeyCode::F(1) => { + let _ = event_tx.send(EventType::Ui(UiEventType::ToggleHelp)).await; + } + KeyCode::Char('s') | KeyCode::F(6) => { + let _ = event_tx + .send(EventType::Ui(UiEventType::ToggleSortMenu)) + .await; + } + KeyCode::Char('r') | KeyCode::F(7) => { + let _ = event_tx + .send(EventType::Ui(UiEventType::ToggleSortDirection)) + .await; + } + KeyCode::Char(d) if d.is_ascii_digit() => { + let col = match d { + '1' => SortColumn::Id, + '3' => SortColumn::Kind, + '4' => SortColumn::Type, + '5' => SortColumn::EventsInTotal, + '6' => SortColumn::BytesInTotal, + '7' => SortColumn::EventsOutTotal, + '8' => SortColumn::BytesOutTotal, + '9' => SortColumn::Errors, + #[cfg(feature = "allocation-tracing")] + '0' => SortColumn::MemoryUsed, + _ => return false, + }; + let _ = event_tx + .send(EventType::Ui(UiEventType::SortByColumn(col))) + .await; + } + KeyCode::F(4) | KeyCode::Char('f') | KeyCode::Char('/') => { + let _ = event_tx + .send(EventType::Ui(UiEventType::ToggleFilterMenu)) + .await; + } + _ => (), + } + false +} + +async fn handle_help_input( + key_event: KeyEvent, + event_tx: &state::EventTx, + terminal: &Terminal, +) -> bool { + match key_event.code { + KeyCode::Esc => { + let _ = event_tx.send(EventType::Ui(UiEventType::ToggleHelp)).await; + } + _ => return handle_top_input(key_event, event_tx, terminal).await, + } + false +} + +async fn handle_sort_input( + key_event: KeyEvent, + event_tx: &state::EventTx, + terminal: &Terminal, +) -> bool { + match key_event.code { + KeyCode::Esc => { + let _ = event_tx + .send(EventType::Ui(UiEventType::ToggleSortMenu)) + .await; + } + KeyCode::Up | KeyCode::BackTab | KeyCode::Char('k') => { + let _ = event_tx + .send(EventType::Ui(UiEventType::SortSelection(-1))) + .await; + } + KeyCode::Down | KeyCode::Tab | KeyCode::Char('j') => { + let _ = event_tx + .send(EventType::Ui(UiEventType::SortSelection(1))) + .await; + } + KeyCode::Enter => { + let _ = event_tx + .send(EventType::Ui(UiEventType::SortConfirmation)) + .await; + } + _ => return handle_top_input(key_event, event_tx, terminal).await, + } + false +} + +async fn handle_filter_input( + key_event: KeyEvent, + event_tx: &state::EventTx, + terminal: &Terminal, +) -> bool { + match key_event.code { + KeyCode::Esc => { + let _ = event_tx + .send(EventType::Ui(UiEventType::ToggleFilterMenu)) + .await; + } + KeyCode::BackTab | KeyCode::Up => { + let _ = event_tx + .send(EventType::Ui(UiEventType::FilterColumnSelection(-1))) + .await; + } + KeyCode::Tab | KeyCode::Down => { + let _ = event_tx + .send(EventType::Ui(UiEventType::FilterColumnSelection(1))) + .await; + } + KeyCode::Backspace => { + let _ = event_tx + .send(EventType::Ui(UiEventType::FilterBackspace)) + .await; + } + KeyCode::Enter => { + let _ = event_tx + .send(EventType::Ui(UiEventType::FilterConfirmation)) + .await; + } + KeyCode::Char(any) => { + let _ = event_tx + .send(EventType::Ui(UiEventType::FilterInput(any))) + .await; + } + _ => return handle_top_input(key_event, event_tx, terminal).await, + } + false +} diff --git a/lib/vector-top/src/lib.rs b/lib/vector-top/src/lib.rs index c3580a46402ff..7f4f9b8a8e48a 100644 --- a/lib/vector-top/src/lib.rs +++ b/lib/vector-top/src/lib.rs @@ -1,5 +1,6 @@ //! Top subcommand pub mod dashboard; pub mod events; +mod input; pub mod metrics; pub mod state; diff --git a/lib/vector-top/src/state.rs b/lib/vector-top/src/state.rs index 4bf3ffb8e64ce..c99853a606c9d 100644 --- a/lib/vector-top/src/state.rs +++ b/lib/vector-top/src/state.rs @@ -1,13 +1,17 @@ use std::{ collections::{BTreeMap, HashMap}, + str::FromStr, time::Duration, }; use chrono::{DateTime, Local}; use ratatui::{ + layout::Size, style::{Color, Style}, text::Span, + widgets::ListState, }; +use regex::Regex; use tokio::sync::mpsc; use vector_common::internal_event::DEFAULT_OUTPUT; @@ -45,6 +49,37 @@ pub enum EventType { ComponentAdded(ComponentRow), ComponentRemoved(ComponentKey), ConnectionUpdated(ConnectionStatus), + Ui(UiEventType), +} + +#[derive(Debug)] +pub enum UiEventType { + // Scroll up (-) or down (+). Also passes the window size for correct max scroll calculation. + Scroll(isize, Size), + // Scroll up (-) or down (+) by a whole page. Also passes the window size for page size and max scroll calculation. + ScrollPage(isize, Size), + // Toggles help window. Also closes other windows. + ToggleHelp, + // Toggles sort menu. Also closes other windows. + ToggleSortMenu, + // Toggles sort direction. + ToggleSortDirection, + // Change sort selection up (-) or down (+). + SortSelection(isize), + // Change sort selection to a specific column. + SortByColumn(SortColumn), + // Confirms current sort selection. + SortConfirmation, + // Toggles filter menu. Also closes other windows. + ToggleFilterMenu, + // Change filter column selection left (-) or right (+). + FilterColumnSelection(isize), + // Adds input to filter string. + FilterInput(char), + // Removes a character from the end of the filter string. + FilterBackspace, + // Confirms current filter selection. + FilterConfirmation, } #[derive(Debug, Copy, Clone)] @@ -82,14 +117,242 @@ pub struct State { pub connection_status: ConnectionStatus, pub uptime: Duration, pub components: BTreeMap, + pub sort_state: SortState, + pub filter_state: FilterState, + pub ui: UiState, +} + +impl State { + /// Syncs current state to the UI state, so that all the menus match the current state + /// (sorting, filters). + pub fn sync_to_ui_state(&mut self) { + self.ui + .sort_menu_state + .select(self.sort_state.column.map(|c| c as usize)); + self.ui + .filter_menu_state + .column_selection + .select(Some(self.filter_state.column as usize)); + self.ui.filter_menu_state.input = self + .filter_state + .pattern + .as_ref() + .map(|r| r.as_str().to_string()) + .unwrap_or("".to_string()); + } +} + +#[derive(Debug, Clone, Copy)] +pub enum SortColumn { + Id = 0, + Kind = 1, + Type = 2, + EventsIn = 3, + EventsInTotal = 4, + BytesIn = 5, + BytesInTotal = 6, + EventsOut = 7, + EventsOutTotal = 8, + BytesOut = 9, + BytesOutTotal = 10, + Errors = 11, + #[cfg(feature = "allocation-tracing")] + MemoryUsed = 12, +} + +#[derive(Debug, Default, Clone, Copy)] +pub enum FilterColumn { + #[default] + Id = 0, + Kind = 1, + Type = 2, +} + +impl SortColumn { + pub fn matches_header(&self, header: &str) -> bool { + match self { + SortColumn::Id => header == "ID", + SortColumn::Kind => header == "Kind", + SortColumn::Type => header == "Type", + SortColumn::EventsIn | SortColumn::EventsInTotal => header == "Events In", + SortColumn::BytesIn | SortColumn::BytesInTotal => header == "Bytes In", + SortColumn::EventsOut | SortColumn::EventsOutTotal => header == "Events Out", + SortColumn::BytesOut | SortColumn::BytesOutTotal => header == "Bytes Out", + SortColumn::Errors => header == "Errors", + #[cfg(feature = "allocation-tracing")] + SortColumn::MemoryUsed => header == "Memory Used", + } + } + + pub fn items() -> Vec<&'static str> { + vec![ + "ID", + "Kind", + "Type", + "Events In", + "Events In Total", + "Bytes In", + "Bytes In Total", + "Events Out", + "Events Out Total", + "Bytes Out", + "Bytes Out Total", + "Errors", + #[cfg(feature = "allocation-tracing")] + "Memory Used", + ] + } +} + +impl FilterColumn { + pub fn items() -> Vec<&'static str> { + vec!["ID", "Kind", "Type"] + } +} + +impl From for SortColumn { + fn from(value: usize) -> Self { + match value { + 1 => SortColumn::Kind, + 2 => SortColumn::Type, + 3 => SortColumn::EventsIn, + 4 => SortColumn::EventsInTotal, + 5 => SortColumn::BytesIn, + 6 => SortColumn::BytesInTotal, + 7 => SortColumn::EventsOut, + 8 => SortColumn::EventsOutTotal, + 9 => SortColumn::BytesOut, + 10 => SortColumn::BytesOutTotal, + 11 => SortColumn::Errors, + #[cfg(feature = "allocation-tracing")] + 12 => SortColumn::MemoryUsed, + _ => SortColumn::Id, + } + } +} + +impl From for FilterColumn { + fn from(value: usize) -> Self { + match value { + 1 => FilterColumn::Kind, + 2 => FilterColumn::Type, + _ => FilterColumn::Id, + } + } +} + +impl FromStr for SortColumn { + type Err = String; + + fn from_str(s: &str) -> Result { + if let Some((index, _)) = Self::items() + .iter() + .enumerate() + .find(|(_, item)| item.eq_ignore_ascii_case(s)) + { + Ok(index.into()) + } else { + Err("Unknown sort field".to_string()) + } + } +} + +impl FromStr for FilterColumn { + type Err = String; + + fn from_str(s: &str) -> Result { + if let Some((index, _)) = Self::items() + .iter() + .enumerate() + .find(|(_, item)| item.eq_ignore_ascii_case(s)) + { + Ok(index.into()) + } else { + Err("Unknown filter field".to_string()) + } + } +} + +#[derive(Debug, Default, Clone)] +pub struct SortState { + pub column: Option, + pub reverse: bool, +} + +#[derive(Debug, Default, Clone)] +pub struct FilterState { + pub column: FilterColumn, + pub pattern: Option, +} + +#[derive(Debug, Default, Clone)] +pub struct UiState { + pub scroll: usize, + pub help_visible: bool, + pub sort_visible: bool, + pub sort_menu_state: ListState, + pub filter_visible: bool, + pub filter_menu_state: FilterMenuState, +} + +#[derive(Debug, Clone)] +pub struct FilterMenuState { + pub input: String, + pub column_selection: ListState, +} + +impl Default for FilterMenuState { + fn default() -> Self { + Self { + input: Default::default(), + column_selection: ListState::default().with_selected(Some(0)), + } + } +} + +impl UiState { + /// Returns the height of components display box in rows, based on provided [`Size`]. + /// Calculates by deducting rows used for header and footer. + pub fn components_box_height(area: Size) -> u16 { + // Currently hardcoded (10 is the number of rows the header and footer take up) + area.height.saturating_sub(10) + } + + /// Returns the maximum scroll value + pub fn max_scroll(area: Size, components_count: usize) -> usize { + components_count.saturating_sub(Self::components_box_height(area).into()) + } + + /// Changes current scroll by provided diff in rows. Uses [`Size`] to limit scroll, + /// so that scrolling down is possible until the last component is visible. + pub fn scroll(&mut self, diff: isize, area: Size, components_count: usize) { + let max_scroll = Self::max_scroll(area, components_count); + self.scroll = self.scroll.saturating_add_signed(diff); + if self.scroll > max_scroll { + self.scroll = max_scroll; + } + } + + /// Changes current scroll by provided diff in pages. Uses [`Size`] to limit scroll, + /// and to calculate number of rows a page contains. + pub fn scroll_page(&mut self, diff: isize, area: Size, components_count: usize) { + self.scroll( + diff * (Self::components_box_height(area) as isize), + area, + components_count, + ); + } } impl State { - pub const fn new(components: BTreeMap) -> Self { + pub fn new(components: BTreeMap) -> Self { Self { connection_status: ConnectionStatus::Pending, uptime: Duration::from_secs(0), components, + ui: UiState::default(), + sort_state: SortState::default(), + filter_state: FilterState::default(), } } } @@ -143,15 +406,19 @@ impl ComponentRow { /// Takes the receiver `EventRx` channel, and returns a `StateRx` state receiver. This /// represents the single destination for handling subscriptions and returning 'immutable' state /// for re-rendering the dashboard. This approach uses channels vs. mutexes. -pub async fn updater(mut event_rx: EventRx) -> StateRx { +pub async fn updater(mut event_rx: EventRx, mut state: State) -> StateRx { let (tx, rx) = mpsc::channel(20); - let mut state = State::new(BTreeMap::new()); tokio::spawn(async move { while let Some(event_type) = event_rx.recv().await { match event_type { EventType::InitializeState(new_state) => { + let old_state = state; state = new_state; + // Keep filters, sort and UI states + state.filter_state = old_state.filter_state; + state.sort_state = old_state.sort_state; + state.ui = old_state.ui; } EventType::ReceivedBytesTotals(rows) => { for (key, v) in rows { @@ -253,6 +520,7 @@ pub async fn updater(mut event_rx: EventRx) -> StateRx { EventType::UptimeChanged(uptime) => { state.uptime = Duration::from_secs_f64(uptime); } + EventType::Ui(ui_event_type) => handle_ui_event(ui_event_type, &mut state), } // Send updated map to listeners @@ -262,3 +530,88 @@ pub async fn updater(mut event_rx: EventRx) -> StateRx { rx } + +fn handle_ui_event(event: UiEventType, state: &mut State) { + match event { + UiEventType::Scroll(diff, area) => { + state.ui.scroll(diff, area, state.components.len()); + } + UiEventType::ScrollPage(diff, area) => { + state.ui.scroll_page(diff, area, state.components.len()); + } + UiEventType::ToggleHelp => { + state.ui.help_visible = !state.ui.help_visible; + if state.ui.help_visible { + state.ui.sort_visible = false; + state.ui.filter_visible = false; + } + } + UiEventType::ToggleSortMenu => { + state.ui.sort_visible = !state.ui.sort_visible; + state + .ui + .sort_menu_state + .select(state.sort_state.column.map(|c| c as usize)); + if state.ui.sort_visible { + state.ui.help_visible = false; + state.ui.filter_visible = false; + } + } + UiEventType::ToggleSortDirection => state.sort_state.reverse = !state.sort_state.reverse, + UiEventType::SortSelection(diff) => { + let next = state.ui.sort_menu_state.selected().map_or(0, |s| { + s.saturating_add_signed(diff) + .min(SortColumn::items().len() - 1) + }); + state.ui.sort_menu_state.select(Some(next)); + } + UiEventType::SortByColumn(col) => state.sort_state.column = Some(col), + UiEventType::SortConfirmation => { + if let Some(selected) = state.ui.sort_menu_state.selected() { + state.sort_state.column = Some(selected.into()) + } + state.ui.sort_visible = false; + } + UiEventType::ToggleFilterMenu => { + state.ui.filter_visible = !state.ui.filter_visible; + if state.ui.filter_visible { + state.ui.help_visible = false; + state.ui.sort_visible = false; + } + } + UiEventType::FilterColumnSelection(diff) => { + let next = state + .ui + .filter_menu_state + .column_selection + .selected() + .map_or(0, |s| { + s.saturating_add_signed(diff) + .min(FilterColumn::items().len() - 1) + }); + state + .ui + .filter_menu_state + .column_selection + .select(Some(next)); + } + UiEventType::FilterInput(c) => { + state.ui.filter_menu_state.input.push(c); + } + UiEventType::FilterBackspace => { + let _ = state.ui.filter_menu_state.input.pop(); + } + UiEventType::FilterConfirmation => { + if state.ui.filter_menu_state.input.is_empty() { + state.filter_state.pattern = None; + } else { + // display errors? + state.filter_state.pattern = Regex::new(&state.ui.filter_menu_state.input).ok(); + } + if let Some(selected) = state.ui.filter_menu_state.column_selection.selected() { + state.filter_state.column = selected.into() + } + state.ui.filter_visible = false; + } + } +} diff --git a/src/top/cmd.rs b/src/top/cmd.rs index f63b2e313a446..87d91bc393727 100644 --- a/src/top/cmd.rs +++ b/src/top/cmd.rs @@ -1,14 +1,16 @@ +use std::collections::BTreeMap; use std::time::Duration; use chrono::Local; use futures_util::future::join_all; +use regex::Regex; use tokio::sync::{mpsc, oneshot}; use vector_lib::api_client::{Client, connect_subscription_client}; use vector_lib::top::{ dashboard::{init_dashboard, is_tty}, metrics, - state::{self, ConnectionStatus, EventType}, + state::{self, ConnectionStatus, EventType, State}, }; /// Delay (in milliseconds) before attempting to reconnect to the Vector API @@ -53,11 +55,23 @@ pub async fn cmd(opts: &super::Opts) -> exitcode::ExitCode { pub async fn top(opts: &super::Opts, client: Client, dashboard_title: &str) -> exitcode::ExitCode { // Channel for updating state via event messages let (tx, rx) = tokio::sync::mpsc::channel(20); - let state_rx = state::updater(rx).await; + let mut starting_state = State::new(BTreeMap::new()); + starting_state.sort_state.column = opts.sort_field; + starting_state.sort_state.reverse = opts.sort_desc; + starting_state.filter_state.column = opts.filter_field; + starting_state.filter_state.pattern = opts + .filter_value + .as_deref() + .map(Regex::new) + .and_then(Result::ok); + // Apply changes to sort and filter state to the UI state too, so that filter and sort menus are + // prepopulated with selected values + starting_state.sync_to_ui_state(); + let state_rx = state::updater(rx, starting_state).await; // Channel for shutdown signal let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); - let connection = tokio::spawn(subscription(opts.clone(), client, tx, shutdown_tx)); + let connection = tokio::spawn(subscription(opts.clone(), client, tx.clone(), shutdown_tx)); // Initialize the dashboard match init_dashboard( @@ -65,6 +79,7 @@ pub async fn top(opts: &super::Opts, client: Client, dashboard_title: &str) -> e opts.url().as_str(), opts.interval, opts.human_metrics, + tx, state_rx, shutdown_rx, ) diff --git a/src/top/mod.rs b/src/top/mod.rs index 025596718c852..5fc101f7b13c6 100644 --- a/src/top/mod.rs +++ b/src/top/mod.rs @@ -6,6 +6,7 @@ use glob::Pattern; pub use cmd::{cmd, top}; use url::Url; +use vector_lib::top::state::{FilterColumn, SortColumn}; use crate::config::api::default_graphql_url; @@ -34,6 +35,24 @@ pub struct Opts { /// Components IDs to observe (comma-separated; accepts glob patterns) #[arg(default_value = "*", value_delimiter(','), short = 'c', long)] components: Vec, + + /// Field to sort values to by default (can be changed while running). + #[arg(short = 's', long)] + sort_field: Option, + + /// Sort descending instead of ascending. + #[arg(long, default_value_t = false)] + sort_desc: bool, + + /// Field to filter values by default (can be changed while running). + #[arg(default_value = "id", long)] + filter_field: FilterColumn, + + /// Filter to apply to the chosen field (ID by default). + /// + /// This accepts Regex patterns. + #[arg(short = 'f', long)] + filter_value: Option, } impl Opts {