From ed56c0c184dc3fca76b4e958bc48b1fcdaa273a6 Mon Sep 17 00:00:00 2001 From: Miguel Aranha Baldi Horlle Date: Fri, 19 Apr 2024 10:44:08 -0300 Subject: [PATCH 1/8] Messages page improvements. - Revamp on Messages page, introducing Tabs. Tabs title shows connection name and topic name. Fix #2 - Minor messages controls fixes. --- src/backend/kafka.rs | 9 +- src/backend/worker.rs | 7 +- src/component/app.rs | 11 +- src/component/messages/messages_page.rs | 813 ++--------------------- src/component/messages/messages_tab.rs | 832 ++++++++++++++++++++++++ src/component/messages/mod.rs | 3 +- src/main.rs | 2 +- 7 files changed, 884 insertions(+), 793 deletions(-) create mode 100644 src/component/messages/messages_tab.rs diff --git a/src/backend/kafka.rs b/src/backend/kafka.rs index 4a677e6..5caa934 100644 --- a/src/backend/kafka.rs +++ b/src/backend/kafka.rs @@ -11,7 +11,6 @@ use std::time::{Duration, Instant}; use tracing::{debug, info, trace, warn}; use crate::backend::repository::{KrustConnection, KrustHeader, KrustMessage, Partition}; -use crate::component::messages::messages_page::{MessagesPageMsg, LIVE_MESSAGES_BROKER}; use crate::config::ExternalError; use super::repository::{KrustConnectionSecurityType, KrustTopic, MessagesRepository}; @@ -271,7 +270,7 @@ impl KafkaBackend { &self, topic: &String, total: usize, - ) -> Result { + ) -> Result, ExternalError> { let start_mark = Instant::now(); info!("starting listing messages for topic {}", topic); let topic_name = topic.as_str(); @@ -284,7 +283,7 @@ impl KafkaBackend { consumer .subscribe(&[topic_name]) .expect("Can't subscribe to specified topics"); - + let mut messages: Vec = Vec::with_capacity(total); while counter < total { match consumer.recv().await { Err(e) => warn!("Kafka error: {}", e), @@ -323,7 +322,7 @@ impl KafkaBackend { headers, }; - LIVE_MESSAGES_BROKER.send(MessagesPageMsg::UpdateMessage(message)); + messages.push(message); counter += 1; } }; @@ -333,6 +332,6 @@ impl KafkaBackend { "finished listing messages for topic {}, duration: {:?}", topic, duration ); - Ok(duration) + Ok(messages) } } diff --git a/src/backend/worker.rs b/src/backend/worker.rs index d79c852..046f26b 100644 --- a/src/backend/worker.rs +++ b/src/backend/worker.rs @@ -92,7 +92,7 @@ impl MessagesWorker { _ = token.cancelled() => { info!("request {:?} cancelled", &req); // The token was cancelled - Ok(MessagesResponse { total: 0, messages: Vec::new(), topic: None, page_operation: req.page_operation, page_size: req.page_size}) + Ok(MessagesResponse { total: 0, messages: Vec::new(), topic: Some(req.topic), page_operation: req.page_operation, page_size: req.page_size}) } messages = self.get_messages_by_mode(&req) => { messages @@ -202,11 +202,10 @@ impl MessagesWorker { let topic = &request.topic.name; // Run async background task let total = kafka.topic_message_count(topic).await; - let duration = kafka.list_messages_for_topic(topic, total).await?; - info!("get_messages_live {:?}", duration); + let messages = kafka.list_messages_for_topic(topic, total, ).await?; Ok(MessagesResponse { total, - messages: vec![], + messages: messages, topic: Some(request.topic.clone()), page_operation: request.page_operation, page_size: request.page_size, diff --git a/src/component/app.rs b/src/component/app.rs index 2c755d9..0facc3d 100644 --- a/src/component/app.rs +++ b/src/component/app.rs @@ -13,12 +13,7 @@ use tracing::{error, info, warn}; use crate::{ backend::repository::{KrustConnection, KrustTopic, Repository}, component::{ - connection_list::KrustConnectionOutput, - connection_page::{ConnectionPageModel, ConnectionPageMsg, ConnectionPageOutput}, - messages::messages_page::LIVE_MESSAGES_BROKER, - settings_page::{SettingsPageModel, SettingsPageMsg, SettingsPageOutput}, - status_bar::{StatusBarModel, STATUS_BROKER}, - topics_page::{TopicsPageModel, TopicsPageMsg, TopicsPageOutput}, + connection_list::KrustConnectionOutput, connection_page::{ConnectionPageModel, ConnectionPageMsg, ConnectionPageOutput}, settings_page::{SettingsPageModel, SettingsPageMsg, SettingsPageOutput}, status_bar::{StatusBarModel, STATUS_BROKER}, topics_page::{TopicsPageModel, TopicsPageMsg, TopicsPageOutput} }, config::State, modals::about::AboutDialog, @@ -149,7 +144,7 @@ impl Component for AppModel { add_child = topics_page.widget() -> >k::Box {} -> { set_name: "Topics" }, - add_child = messages_page.widget() -> >k::Paned {} -> { + add_child = messages_page.widget() -> >k::Box {} -> { set_name: "Messages" }, add_child = settings_page.widget() -> >k::Grid {} -> { @@ -209,7 +204,7 @@ impl Component for AppModel { }); let messages_page: Controller = MessagesPageModel::builder() - .launch_with_broker((), &LIVE_MESSAGES_BROKER) + .launch(()) .detach(); let settings_page: Controller = SettingsPageModel::builder() diff --git a/src/component/messages/messages_page.rs b/src/component/messages/messages_page.rs index 17710fd..af639f8 100644 --- a/src/component/messages/messages_page.rs +++ b/src/component/messages/messages_page.rs @@ -1,346 +1,47 @@ #![allow(deprecated)] // See: https://gitlab.gnome.org/GNOME/gtk/-/issues/5644 -use chrono::{TimeZone, Utc}; -use chrono_tz::America; -use gtk::{gdk::DisplayManager, ColumnViewSorter}; -use relm4::{typed_view::column::TypedColumnView, *}; -use relm4_components::simple_combo_box::SimpleComboBox; -use sourceview::prelude::*; -use sourceview5 as sourceview; -use tokio_util::sync::CancellationToken; -use tracing::{debug, info, trace}; - use crate::{ - backend::{ - repository::{KrustConnection, KrustMessage, KrustTopic}, - worker::{ - MessagesCleanupRequest, MessagesMode, MessagesRequest, MessagesResponse, - MessagesWorker, PageOp, - }, - }, - component::{ - messages::lists::{ - HeaderListItem, HeaderNameColumn, HeaderValueColumn, MessageListItem, - MessageOfssetColumn, MessagePartitionColumn, MessageTimestampColumn, - MessageValueColumn, - }, - status_bar::{StatusBarMsg, STATUS_BROKER}, - }, - Repository, DATE_TIME_FORMAT, + backend::repository::{KrustConnection, KrustTopic}, + Repository, }; +use adw::TabPage; +use relm4::{factory::FactoryVecDeque, *}; +use sourceview::prelude::*; +use sourceview5 as sourceview; -pub static LIVE_MESSAGES_BROKER: MessageBroker = MessageBroker::new(); +use super::messages_tab::{MessagesTabInit, MessagesTabModel}; #[derive(Debug)] pub struct MessagesPageModel { - token: CancellationToken, topic: Option, - mode: MessagesMode, connection: Option, - messages_wrapper: TypedColumnView, - headers_wrapper: TypedColumnView, - page_size_combo: Controller>, - page_size: u16, + topics: FactoryVecDeque, } #[derive(Debug)] pub enum MessagesPageMsg { Open(KrustConnection, KrustTopic), - GetMessages, - GetNextMessages, - GetPreviousMessages, - StopGetMessages, - RefreshMessages, - UpdateMessages(MessagesResponse), - UpdateMessage(KrustMessage), - OpenMessage(u32), - Selection(u32), - PageSizeChanged(usize), - ToggleMode(bool), -} - -#[derive(Debug)] -pub enum CommandMsg { - Data(MessagesResponse), + PageAdded(TabPage, i32), } -const AVAILABLE_PAGE_SIZES: [u16; 4] = [50, 100, 500, 1000]; - #[relm4::component(pub)] impl Component for MessagesPageModel { type Init = (); type Input = MessagesPageMsg; type Output = (); - type CommandOutput = CommandMsg; + type CommandOutput = (); view! { #[root] - gtk::Paned { + gtk::Box { set_orientation: gtk::Orientation::Vertical, - //set_resize_start_child: true, - #[wrap(Some)] - set_start_child = >k::Box { - set_orientation: gtk::Orientation::Vertical, - set_hexpand: true, - set_vexpand: true, - gtk::CenterBox { - set_orientation: gtk::Orientation::Horizontal, - set_halign: gtk::Align::Fill, - set_margin_all: 10, - set_hexpand: true, - #[wrap(Some)] - set_start_widget = >k::Box { - set_orientation: gtk::Orientation::Horizontal, - set_halign: gtk::Align::Start, - set_hexpand: true, - #[name(btn_get_messages)] - gtk::Button { - set_icon_name: "media-playback-start-symbolic", - connect_clicked[sender] => move |_| { - sender.input(MessagesPageMsg::GetMessages); - }, - }, - #[name(btn_stop_messages)] - gtk::Button { - set_icon_name: "media-playback-stop-symbolic", - set_margin_start: 5, - connect_clicked[sender] => move |_| { - sender.input(MessagesPageMsg::StopGetMessages); - }, - }, - #[name(btn_cache_refresh)] - gtk::Button { - set_icon_name: "media-playlist-repeat-symbolic", - set_margin_start: 5, - connect_clicked[sender] => move |_| { - sender.input(MessagesPageMsg::RefreshMessages); - }, - }, - #[name(btn_cache_toggle)] - gtk::ToggleButton { - set_margin_start: 5, - set_label: "Cache", - add_css_class: "krust-toggle", - connect_toggled[sender] => move |btn| { - sender.input(MessagesPageMsg::ToggleMode(btn.is_active())); - }, - }, - #[name(cache_timestamp)] - gtk::Label { - set_margin_start: 5, - set_label: "", - set_visible: false, - } - }, - #[wrap(Some)] - set_end_widget = >k::Box { - set_orientation: gtk::Orientation::Horizontal, - set_halign: gtk::Align::Fill, - set_hexpand: true, - #[name(topics_search_entry)] - gtk::SearchEntry { - set_hexpand: true, - set_halign: gtk::Align::Fill, - - }, - gtk::Button { - set_icon_name: "edit-find-symbolic", - set_margin_start: 5, - }, - }, - }, - gtk::ScrolledWindow { - set_vexpand: true, - set_hexpand: true, - set_propagate_natural_width: true, - #[local_ref] - messages_view -> gtk::ColumnView { - set_vexpand: true, - set_hexpand: true, - set_show_row_separators: true, - set_show_column_separators: true, - set_single_click_activate: false, - set_enable_rubberband: true, - } - }, - }, - #[wrap(Some)] - set_end_child = >k::Box { - set_orientation: gtk::Orientation::Vertical, - append = >k::StackSwitcher { - set_orientation: gtk::Orientation::Horizontal, - set_stack: Some(&message_viewer), - }, - append: message_viewer = >k::Stack { - add_child = >k::Box { - set_hexpand: true, - set_vexpand: true, - #[name = "value_container"] - gtk::ScrolledWindow { - add_css_class: "bordered", - set_vexpand: true, - set_hexpand: true, - set_propagate_natural_height: true, - set_overflow: gtk::Overflow::Hidden, - set_valign: gtk::Align::Fill, - #[name = "value_source_view"] - sourceview::View { - add_css_class: "file-preview-source", - set_cursor_visible: true, - set_editable: false, - set_monospace: true, - set_show_line_numbers: true, - set_valign: gtk::Align::Fill, - } - }, - } -> { - set_title: "Value", - set_name: "Value", - }, - add_child = >k::Box { - gtk::ScrolledWindow { - set_vexpand: true, - set_hexpand: true, - set_propagate_natural_width: true, - #[local_ref] - headers_view -> gtk::ColumnView { - set_vexpand: true, - set_hexpand: true, - set_show_row_separators: true, - set_show_column_separators: true, - } - }, - } -> { - set_title: "Header", - set_name: "Header", - }, - }, - gtk::CenterBox { - set_orientation: gtk::Orientation::Horizontal, - set_halign: gtk::Align::Fill, - set_margin_all: 10, - set_hexpand: true, - #[wrap(Some)] - set_start_widget = >k::Box { - set_orientation: gtk::Orientation::Horizontal, - set_halign: gtk::Align::Start, - set_hexpand: true, - gtk::Label { - set_label: "Total" - }, - #[name(pag_total_entry)] - gtk::Entry { - set_editable: false, - set_sensitive: false, - set_margin_start: 5, - set_width_chars: 10, - }, - }, - #[wrap(Some)] - set_center_widget = >k::Box { - set_orientation: gtk::Orientation::Horizontal, - set_halign: gtk::Align::Center, - set_hexpand: true, - #[name(cached_centered_controls)] - gtk::Box { - set_orientation: gtk::Orientation::Horizontal, - set_halign: gtk::Align::Start, - set_hexpand: true, - #[name(pag_current_entry)] - gtk::Entry { - set_editable: false, - set_sensitive: false, - set_margin_start: 5, - set_width_chars: 10, - }, - gtk::Label { - set_label: "of", - set_margin_start: 5, - }, - #[name(pag_last_entry)] - gtk::Entry { - set_editable: false, - set_sensitive: false, - set_margin_start: 5, - set_width_chars: 10, - }, - }, - - }, - #[wrap(Some)] - set_end_widget = >k::Box { - set_orientation: gtk::Orientation::Horizontal, - set_halign: gtk::Align::Start, - set_hexpand: true, - #[name(cached_controls)] - gtk::Box { - set_orientation: gtk::Orientation::Horizontal, - set_halign: gtk::Align::Start, - set_hexpand: true, - #[name(first_offset)] - gtk::Label { - set_label: "", - set_visible: false, - }, - #[name(first_partition)] - gtk::Label { - set_label: "", - set_visible: false, - }, - #[name(last_offset)] - gtk::Label { - set_label: "", - set_visible: false, - }, - #[name(last_partition)] - gtk::Label { - set_label: "", - set_visible: false, - }, - gtk::Label { - set_label: "Page size", - set_margin_start: 5, - }, - model.page_size_combo.widget() -> >k::ComboBoxText { - set_margin_start: 5, - }, - #[name(btn_previous_page)] - gtk::Button { - set_margin_start: 5, - set_icon_name: "go-previous", - connect_clicked[sender] => move |_| { - sender.input(MessagesPageMsg::GetPreviousMessages); - }, - }, - #[name(btn_next_page)] - gtk::Button { - set_margin_start: 5, - set_icon_name: "go-next", - connect_clicked[sender] => move |_| { - sender.input(MessagesPageMsg::GetNextMessages); - }, - }, - }, - #[name(live_controls)] - gtk::Box { - set_orientation: gtk::Orientation::Horizontal, - set_halign: gtk::Align::Start, - set_hexpand: true, - gtk::Label { - set_label: "Max messages", - set_margin_start: 5, - }, - #[name(max_messages)] - gtk::Entry { - set_margin_start: 5, - set_width_chars: 10, - }, - }, - }, - }, + append = &adw::TabBar { + set_autohide: false, + set_view: Some(&topics_viewer), }, + #[local_ref] + topics_viewer -> adw::TabView {} } - } fn init( @@ -348,72 +49,22 @@ impl Component for MessagesPageModel { root: Self::Root, sender: ComponentSender, ) -> ComponentParts { - // Initialize the messages ListView wrapper - let mut messages_wrapper = TypedColumnView::::new(); - messages_wrapper.append_column::(); - messages_wrapper.append_column::(); - messages_wrapper.append_column::(); - messages_wrapper.append_column::(); - // Initialize the headers ListView wrapper - let mut headers_wrapper = TypedColumnView::::new(); - headers_wrapper.append_column::(); - headers_wrapper.append_column::(); - let default_idx = 0; - let page_size_combo = SimpleComboBox::builder() - .launch(SimpleComboBox { - variants: AVAILABLE_PAGE_SIZES.to_vec(), - active_index: Some(default_idx), - }) - .forward(sender.input_sender(), MessagesPageMsg::PageSizeChanged); - page_size_combo.widget().queue_allocate(); - let model = MessagesPageModel { - token: CancellationToken::new(), - mode: MessagesMode::Live, - topic: None, - connection: None, - messages_wrapper, - headers_wrapper, - page_size_combo, - page_size: AVAILABLE_PAGE_SIZES[0], - }; + let topics = FactoryVecDeque::builder() + .launch(adw::TabView::default()) + .detach(); - let messages_view = &model.messages_wrapper.view; - let headers_view = &model.headers_wrapper.view; - let sender_for_selection = sender.clone(); - messages_view - .model() - .unwrap() - .connect_selection_changed(move |selection_model, _, _| { - sender_for_selection.input(MessagesPageMsg::Selection(selection_model.n_items())); - }); - let sender_for_activate = sender.clone(); - messages_view.connect_activate(move |_view, idx| { - sender_for_activate.input(MessagesPageMsg::OpenMessage(idx)); - }); + let topics_viewer: &adw::TabView = topics.widget(); - messages_view.sorter().unwrap().connect_changed(move |sorter, change| { - let order = sorter.order(); - let csorter: &ColumnViewSorter = sorter.downcast_ref().unwrap(); - info!("sort order changed: {:?}:{:?}", change, order); - for i in 0..= csorter.n_sort_columns() { - let (cvc, sort) = csorter.nth_sort_column(i); - info!("column[{:?}]sort[{:?}]", cvc.map(|col| { col.title() }), sort); - } + topics_viewer.connect_page_attached(move |_tab_view, page, n| { + sender.input(MessagesPageMsg::PageAdded(page.clone(), n)); }); - let widgets = view_output!(); - let buffer = widgets - .value_source_view - .buffer() - .downcast::() - .expect("sourceview was not backed by sourceview buffer"); - - if let Some(scheme) = &sourceview::StyleSchemeManager::new().scheme("oblivion") { - buffer.set_style_scheme(Some(scheme)); - } - let language = sourceview::LanguageManager::default().language("json"); - buffer.set_language(language.as_ref()); + let model = MessagesPageModel { + topic: None, + connection: None, + topics: topics, + }; ComponentParts { model, widgets } } @@ -426,76 +77,6 @@ impl Component for MessagesPageModel { _: &Self::Root, ) { match msg { - MessagesPageMsg::ToggleMode(toggle) => { - self.mode = if toggle { - widgets.cached_controls.set_visible(true); - widgets.cached_centered_controls.set_visible(true); - widgets.live_controls.set_visible(false); - MessagesMode::Cached { refresh: false } - } else { - widgets.live_controls.set_visible(true); - widgets.cached_controls.set_visible(false); - widgets.cached_centered_controls.set_visible(false); - widgets.cache_timestamp.set_visible(false); - widgets.cache_timestamp.set_text(""); - let cloned_topic = self.topic.clone().unwrap(); - let topic = KrustTopic { - name: cloned_topic.name.clone(), - connection_id: cloned_topic.connection_id, - cached: None, - partitions: vec![], - }; - let conn = self.connection.clone().unwrap(); - MessagesWorker::new().cleanup_messages(&MessagesCleanupRequest { - connection: conn, - topic: topic.clone(), - }); - self.topic = Some(topic); - MessagesMode::Live - }; - } - MessagesPageMsg::PageSizeChanged(_idx) => { - let page_size = match self.page_size_combo.model().get_active_elem() { - Some(ps) => *ps, - None => AVAILABLE_PAGE_SIZES[0], - }; - self.page_size = page_size; - self.page_size_combo.widget().queue_allocate(); - } - MessagesPageMsg::Selection(size) => { - let mut copy_content = String::from("PARTITION;OFFSET;VALUE;TIMESTAMP"); - let min_length = copy_content.len(); - for i in 0..size { - if self.messages_wrapper.selection_model.is_selected(i) { - let item = self.messages_wrapper.get_visible(i).unwrap(); - let partition = item.borrow().partition; - let offset = item.borrow().offset; - let value = item.borrow().value.clone(); - let clean_value = - match serde_json::from_str::(value.as_str()) { - Ok(json) => json.to_string(), - Err(_) => value.replace('\n', ""), - }; - let timestamp = item.borrow().timestamp; - let copy_text = format!( - "\n{};{};{};{}", - partition, - offset, - clean_value, - timestamp.unwrap_or_default() - ); - copy_content.push_str(copy_text.as_str()); - info!("selected offset[{}]", copy_text); - } - } - if copy_content.len() > min_length { - DisplayManager::get() - .default_display() - .unwrap() - .clipboard() - .set_text(copy_content.as_str()); - } - } MessagesPageMsg::Open(connection, topic) => { let conn_id = &connection.id.unwrap(); let topic_name = &topic.name.clone(); @@ -503,340 +84,24 @@ impl Component for MessagesPageModel { let mut repo = Repository::new(); let maybe_topic = repo.find_topic(*conn_id, topic_name); self.topic = maybe_topic.clone().or(Some(topic)); - let toggled = maybe_topic.is_some(); - let cache_ts = maybe_topic - .and_then(|t| { - t.cached.map(|ts| { - Utc.timestamp_millis_opt(ts) - .unwrap() - .with_timezone(&America::Sao_Paulo) - .format(DATE_TIME_FORMAT) - .to_string() - }) - }) - .unwrap_or_default(); - widgets.cache_timestamp.set_label(&cache_ts); - widgets.cache_timestamp.set_visible(true); - widgets.btn_cache_toggle.set_active(toggled); - widgets.pag_total_entry.set_text(""); - widgets.pag_current_entry.set_text(""); - widgets.pag_last_entry.set_text(""); - self.messages_wrapper.clear(); - self.page_size_combo.widget().queue_allocate(); - sender.input(MessagesPageMsg::ToggleMode(toggled)); - } - MessagesPageMsg::GetMessages => { - STATUS_BROKER.send(StatusBarMsg::Start); - on_loading(widgets, false); - let mode = self.mode; - self.mode = match self.mode { - MessagesMode::Cached { refresh: _ } => MessagesMode::Cached { refresh: false }, - MessagesMode::Live => { - self.messages_wrapper.clear(); - MessagesMode::Live - } + let init = MessagesTabInit { + topic: self.topic.clone().unwrap(), + connection: self.connection.clone().unwrap(), }; - let topic = self.topic.clone().unwrap(); - let conn = self.connection.clone().unwrap(); - if self.token.is_cancelled() { - self.token = CancellationToken::new(); - } - let page_size = self.page_size; - let token = self.token.clone(); - widgets.pag_current_entry.set_text("0"); - sender.oneshot_command(async move { - // Run async background task - let messages_worker = MessagesWorker::new(); - let result = &messages_worker - .get_messages( - token, - &MessagesRequest { - mode, - connection: conn, - topic: topic.clone(), - page_operation: PageOp::Next, - page_size, - offset_partition: (0, 0), - }, - ) - .await - .unwrap(); - let total = result.total; - trace!("selected topic {} with {} messages", topic.name, &total,); - CommandMsg::Data(result.clone()) - }); + let _index = self.topics.guard().push_front(init); } - MessagesPageMsg::GetNextMessages => { - STATUS_BROKER.send(StatusBarMsg::Start); - on_loading(widgets, false); - let mode = self.mode; - let topic = self.topic.clone().unwrap(); - let conn = self.connection.clone().unwrap(); - if self.token.is_cancelled() { - self.token = CancellationToken::new(); - } - let page_size = self.page_size; - let (offset, partition) = ( - widgets - .last_offset - .text() - .to_string() - .parse::() - .unwrap(), - widgets - .last_partition - .text() - .to_string() - .parse::() - .unwrap(), + MessagesPageMsg::PageAdded(page, index) => { + let tab_model = self.topics.get(index.try_into().unwrap()).unwrap(); + let title = format!( + "[{}] {}", + tab_model.connection.clone().unwrap().name, + tab_model.topic.clone().unwrap().name ); - let token = self.token.clone(); - info!( - "getting next messages [page_size={}, last_offset={}, last_partition={}]", - page_size, offset, partition - ); - sender.oneshot_command(async move { - // Run async background task - let messages_worker = MessagesWorker::new(); - let result = &messages_worker - .get_messages( - token, - &MessagesRequest { - mode, - connection: conn, - topic: topic.clone(), - page_operation: PageOp::Next, - page_size, - offset_partition: (offset, partition), - }, - ) - .await - .unwrap(); - let total = result.total; - trace!("selected topic {} with {} messages", topic.name, &total,); - CommandMsg::Data(result.clone()) - }); - } - MessagesPageMsg::GetPreviousMessages => { - STATUS_BROKER.send(StatusBarMsg::Start); - on_loading(widgets, false); - let mode = self.mode; - let topic = self.topic.clone().unwrap(); - let conn = self.connection.clone().unwrap(); - if self.token.is_cancelled() { - self.token = CancellationToken::new(); - } - let page_size = self.page_size; - let (offset, partition) = ( - widgets - .first_offset - .text() - .to_string() - .parse::() - .unwrap(), - widgets - .first_partition - .text() - .to_string() - .parse::() - .unwrap(), - ); - let token = self.token.clone(); - sender.oneshot_command(async move { - // Run async background task - let messages_worker = MessagesWorker::new(); - let result = &messages_worker - .get_messages( - token, - &MessagesRequest { - mode, - connection: conn, - topic: topic.clone(), - page_operation: PageOp::Prev, - page_size, - offset_partition: (offset, partition), - }, - ) - .await - .unwrap(); - let total = result.total; - trace!("selected topic {} with {} messages", topic.name, &total,); - CommandMsg::Data(result.clone()) - }); - } - MessagesPageMsg::RefreshMessages => { - info!("refreshing cached messages"); - self.mode = MessagesMode::Cached { refresh: true }; - sender.input(MessagesPageMsg::GetMessages); - } - MessagesPageMsg::StopGetMessages => { - info!("cancelling get messages..."); - self.token.cancel(); - on_loading(widgets, true); - STATUS_BROKER.send(StatusBarMsg::StopWithInfo { - text: Some("Operation cancelled!".to_string()), - }); - } - MessagesPageMsg::UpdateMessage(message) => { - self.messages_wrapper.append(MessageListItem::new(message)); - } - MessagesPageMsg::UpdateMessages(response) => { - let total = response.total; - self.topic = response.topic.clone(); - match self.mode { - MessagesMode::Live => info!("no need to cleanup list on live mode"), - MessagesMode::Cached { refresh: _ } => self.messages_wrapper.clear(), - } - self.headers_wrapper.clear(); - widgets.value_source_view.buffer().set_text(""); - fill_pagination( - response.page_operation, - widgets, - total, - response.page_size, - response.messages.first(), - response.messages.last(), - ); - self.messages_wrapper.extend_from_iter( - response - .messages - .iter() - .map(|m| MessageListItem::new(m.clone())), - ); - widgets.value_source_view.buffer().set_text(""); - let cache_ts = response - .topic - .and_then(|t| { - t.cached.map(|ts| { - Utc.timestamp_millis_opt(ts) - .unwrap() - .with_timezone(&America::Sao_Paulo) - .format(DATE_TIME_FORMAT) - .to_string() - }) - }) - .unwrap_or(String::default()); - widgets.cache_timestamp.set_label(&cache_ts); - widgets.cache_timestamp.set_visible(true); - on_loading(widgets, true); - STATUS_BROKER.send(StatusBarMsg::StopWithInfo { - text: Some(format!("{} messages loaded!", self.messages_wrapper.len())), - }); - } - MessagesPageMsg::OpenMessage(message_idx) => { - let item = self.messages_wrapper.get_visible(message_idx).unwrap(); - let message_text = item.borrow().value.clone(); - - let buffer = widgets - .value_source_view - .buffer() - .downcast::() - .expect("sourceview was not backed by sourceview buffer"); - - let valid_json: Result = - serde_json::from_str(message_text.as_str()); - let (language, formatted_text) = match valid_json { - Ok(jt) => ( - sourceview::LanguageManager::default().language("json"), - serde_json::to_string_pretty(&jt).unwrap(), - ), - Err(_) => ( - sourceview::LanguageManager::default().language("text"), - message_text, - ), - }; - buffer.set_language(language.as_ref()); - buffer.set_text(formatted_text.as_str()); - - self.headers_wrapper.clear(); - for header in item.borrow().headers.iter() { - self.headers_wrapper - .append(HeaderListItem::new(header.clone())); - } + page.set_title(title.as_str()); + widgets.topics_viewer.set_selected_page(&page); } }; self.update_view(widgets, sender); } - - fn update_cmd( - &mut self, - message: Self::CommandOutput, - sender: ComponentSender, - _: &Self::Root, - ) { - match message { - CommandMsg::Data(messages) => sender.input(MessagesPageMsg::UpdateMessages(messages)), - } - } -} - -fn on_loading(widgets: &mut MessagesPageModelWidgets, enabled: bool,) { - widgets.btn_next_page.set_sensitive(enabled); - widgets.btn_previous_page.set_sensitive(enabled); - widgets.btn_get_messages.set_sensitive(enabled); - widgets.btn_cache_refresh.set_sensitive(enabled); - widgets.btn_cache_toggle.set_sensitive(enabled); -} - -fn fill_pagination( - page_op: PageOp, - widgets: &mut MessagesPageModelWidgets, - total: usize, - page_size: u16, - first: Option<&KrustMessage>, - last: Option<&KrustMessage>, -) { - let current_page: usize = widgets - .pag_current_entry - .text() - .to_string() - .parse::() - .unwrap_or_default(); - let current_page = match page_op { - PageOp::Next => current_page + 1, - PageOp::Prev => current_page - 1, - }; - widgets.pag_total_entry.set_text(total.to_string().as_str()); - widgets - .pag_current_entry - .set_text(current_page.to_string().as_str()); - let pages = ((total as f64) / (page_size as f64)).ceil() as usize; - widgets.pag_last_entry.set_text(pages.to_string().as_str()); - match (first, last) { - (Some(first), Some(last)) => { - let first_offset = first.offset; - let first_partition = first.partition; - let last_offset = last.offset; - let last_partition = last.partition; - widgets - .first_offset - .set_text(first_offset.to_string().as_str()); - widgets - .first_partition - .set_text(first_partition.to_string().as_str()); - widgets - .last_offset - .set_text(last_offset.to_string().as_str()); - widgets - .last_partition - .set_text(last_partition.to_string().as_str()); - } - (_, _) => (), - } - debug!("fill pagination of current page {}", current_page); - match current_page { - 1 => { - widgets.btn_previous_page.set_sensitive(false); - widgets.btn_next_page.set_sensitive(true); - } - n if n >= pages => { - widgets.btn_next_page.set_sensitive(false); - widgets.btn_previous_page.set_sensitive(true); - } - _ => { - widgets.btn_next_page.set_sensitive(true); - widgets.btn_previous_page.set_sensitive(true); - } - } } diff --git a/src/component/messages/messages_tab.rs b/src/component/messages/messages_tab.rs new file mode 100644 index 0000000..04c7fc7 --- /dev/null +++ b/src/component/messages/messages_tab.rs @@ -0,0 +1,832 @@ +#![allow(deprecated)] +// See: https://gitlab.gnome.org/GNOME/gtk/-/issues/5644 +use chrono::{TimeZone, Utc}; +use chrono_tz::America; +use gtk::{gdk::DisplayManager, ColumnViewSorter}; +use relm4::{factory::{DynamicIndex, FactoryComponent}, typed_view::column::TypedColumnView, *}; +use relm4_components::simple_combo_box::SimpleComboBox; +use sourceview::prelude::*; +use sourceview5 as sourceview; +use tokio_util::sync::CancellationToken; +use tracing::{debug, info, trace}; + +use crate::{ + backend::{ + repository::{KrustConnection, KrustMessage, KrustTopic}, + worker::{ + MessagesCleanupRequest, MessagesMode, MessagesRequest, MessagesResponse, + MessagesWorker, PageOp, + }, + }, + component::{ + messages::lists::{ + HeaderListItem, HeaderNameColumn, HeaderValueColumn, MessageListItem, + MessageOfssetColumn, MessagePartitionColumn, MessageTimestampColumn, + MessageValueColumn, + }, + status_bar::{StatusBarMsg, STATUS_BROKER}, + }, + Repository, DATE_TIME_FORMAT, +}; + +#[derive(Debug)] +pub struct MessagesTabModel { + token: CancellationToken, + pub topic: Option, + mode: MessagesMode, + pub connection: Option, + messages_wrapper: TypedColumnView, + headers_wrapper: TypedColumnView, + page_size_combo: Controller>, + page_size: u16, +} + +pub struct MessagesTabInit { + pub topic: KrustTopic, + pub connection: KrustConnection, +} + +#[derive(Debug)] +pub enum MessagesTabMsg { + Open(KrustConnection, KrustTopic), + GetMessages, + GetNextMessages, + GetPreviousMessages, + StopGetMessages, + RefreshMessages, + UpdateMessages(MessagesResponse), + OpenMessage(u32), + Selection(u32), + PageSizeChanged(usize), + ToggleMode(bool), +} + +#[derive(Debug)] +pub enum CommandMsg { + Data(MessagesResponse), +} + +const AVAILABLE_PAGE_SIZES: [u16; 4] = [50, 100, 500, 1000]; + +#[relm4::factory(pub)] +impl FactoryComponent for MessagesTabModel { + type Init = MessagesTabInit; + type Input = MessagesTabMsg; + type Output = (); + type CommandOutput = CommandMsg; + type ParentWidget = adw::TabView; + + view! { + #[root] + gtk::Paned { + set_orientation: gtk::Orientation::Vertical, + //set_resize_start_child: true, + #[wrap(Some)] + set_start_child = >k::Box { + set_orientation: gtk::Orientation::Vertical, + set_hexpand: true, + set_vexpand: true, + gtk::CenterBox { + set_orientation: gtk::Orientation::Horizontal, + set_halign: gtk::Align::Fill, + set_margin_all: 10, + set_hexpand: true, + #[wrap(Some)] + set_start_widget = >k::Box { + set_orientation: gtk::Orientation::Horizontal, + set_halign: gtk::Align::Start, + set_hexpand: true, + #[name(btn_get_messages)] + gtk::Button { + set_icon_name: "media-playback-start-symbolic", + connect_clicked[sender] => move |_| { + sender.input(MessagesTabMsg::GetMessages); + }, + }, + #[name(btn_stop_messages)] + gtk::Button { + set_icon_name: "media-playback-stop-symbolic", + set_margin_start: 5, + connect_clicked[sender] => move |_| { + sender.input(MessagesTabMsg::StopGetMessages); + }, + }, + #[name(btn_cache_refresh)] + gtk::Button { + set_icon_name: "media-playlist-repeat-symbolic", + set_margin_start: 5, + connect_clicked[sender] => move |_| { + sender.input(MessagesTabMsg::RefreshMessages); + }, + }, + #[name(btn_cache_toggle)] + gtk::ToggleButton { + set_margin_start: 5, + set_label: "Cache", + add_css_class: "krust-toggle", + connect_toggled[sender] => move |btn| { + sender.input(MessagesTabMsg::ToggleMode(btn.is_active())); + }, + }, + #[name(cache_timestamp)] + gtk::Label { + set_margin_start: 5, + set_label: "", + set_visible: false, + } + }, + #[wrap(Some)] + set_end_widget = >k::Box { + set_orientation: gtk::Orientation::Horizontal, + set_halign: gtk::Align::Fill, + set_hexpand: true, + #[name(topics_search_entry)] + gtk::SearchEntry { + set_hexpand: true, + set_halign: gtk::Align::Fill, + + }, + gtk::Button { + set_icon_name: "edit-find-symbolic", + set_margin_start: 5, + }, + }, + }, + gtk::ScrolledWindow { + set_vexpand: true, + set_hexpand: true, + set_propagate_natural_width: true, + self.messages_wrapper.view.clone() -> gtk::ColumnView { + set_vexpand: true, + set_hexpand: true, + set_show_row_separators: true, + set_show_column_separators: true, + set_single_click_activate: false, + set_enable_rubberband: true, + } + }, + }, + #[wrap(Some)] + set_end_child = >k::Box { + set_orientation: gtk::Orientation::Vertical, + append = >k::StackSwitcher { + set_orientation: gtk::Orientation::Horizontal, + set_stack: Some(&message_viewer), + }, + append: message_viewer = >k::Stack { + add_child = >k::Box { + set_hexpand: true, + set_vexpand: true, + #[name = "value_container"] + gtk::ScrolledWindow { + add_css_class: "bordered", + set_vexpand: true, + set_hexpand: true, + set_propagate_natural_height: true, + set_overflow: gtk::Overflow::Hidden, + set_valign: gtk::Align::Fill, + #[name = "value_source_view"] + sourceview::View { + add_css_class: "file-preview-source", + set_cursor_visible: true, + set_editable: false, + set_monospace: true, + set_show_line_numbers: true, + set_valign: gtk::Align::Fill, + } + }, + } -> { + set_title: "Value", + set_name: "Value", + }, + add_child = >k::Box { + gtk::ScrolledWindow { + set_vexpand: true, + set_hexpand: true, + set_propagate_natural_width: true, + self.headers_wrapper.view.clone() -> gtk::ColumnView { + set_vexpand: true, + set_hexpand: true, + set_show_row_separators: true, + set_show_column_separators: true, + } + }, + } -> { + set_title: "Header", + set_name: "Header", + }, + }, + gtk::CenterBox { + set_orientation: gtk::Orientation::Horizontal, + set_halign: gtk::Align::Fill, + set_margin_all: 10, + set_hexpand: true, + #[wrap(Some)] + set_start_widget = >k::Box { + set_orientation: gtk::Orientation::Horizontal, + set_halign: gtk::Align::Start, + set_hexpand: true, + gtk::Label { + set_label: "Total" + }, + #[name(pag_total_entry)] + gtk::Entry { + set_editable: false, + set_sensitive: false, + set_margin_start: 5, + set_width_chars: 10, + }, + }, + #[wrap(Some)] + set_center_widget = >k::Box { + set_orientation: gtk::Orientation::Horizontal, + set_halign: gtk::Align::Center, + set_hexpand: true, + #[name(cached_centered_controls)] + gtk::Box { + set_orientation: gtk::Orientation::Horizontal, + set_halign: gtk::Align::Start, + set_hexpand: true, + #[name(pag_current_entry)] + gtk::Entry { + set_editable: false, + set_sensitive: false, + set_margin_start: 5, + set_width_chars: 10, + }, + gtk::Label { + set_label: "of", + set_margin_start: 5, + }, + #[name(pag_last_entry)] + gtk::Entry { + set_editable: false, + set_sensitive: false, + set_margin_start: 5, + set_width_chars: 10, + }, + }, + + }, + #[wrap(Some)] + set_end_widget = >k::Box { + set_orientation: gtk::Orientation::Horizontal, + set_halign: gtk::Align::Start, + set_hexpand: true, + #[name(cached_controls)] + gtk::Box { + set_orientation: gtk::Orientation::Horizontal, + set_halign: gtk::Align::Start, + set_hexpand: true, + #[name(first_offset)] + gtk::Label { + set_label: "", + set_visible: false, + }, + #[name(first_partition)] + gtk::Label { + set_label: "", + set_visible: false, + }, + #[name(last_offset)] + gtk::Label { + set_label: "", + set_visible: false, + }, + #[name(last_partition)] + gtk::Label { + set_label: "", + set_visible: false, + }, + gtk::Label { + set_label: "Page size", + set_margin_start: 5, + }, + self.page_size_combo.widget() -> >k::ComboBoxText { + set_margin_start: 5, + }, + #[name(btn_previous_page)] + gtk::Button { + set_margin_start: 5, + set_icon_name: "go-previous", + connect_clicked[sender] => move |_| { + sender.input(MessagesTabMsg::GetPreviousMessages); + }, + }, + #[name(btn_next_page)] + gtk::Button { + set_margin_start: 5, + set_icon_name: "go-next", + connect_clicked[sender] => move |_| { + sender.input(MessagesTabMsg::GetNextMessages); + }, + }, + }, + #[name(live_controls)] + gtk::Box { + set_orientation: gtk::Orientation::Horizontal, + set_halign: gtk::Align::Start, + set_hexpand: true, + gtk::Label { + set_label: "Max messages", + set_margin_start: 5, + }, + #[name(max_messages)] + gtk::Entry { + set_margin_start: 5, + set_width_chars: 10, + }, + }, + }, + }, + }, + } + + } + + fn init_model(open: Self::Init, _index: &DynamicIndex, sender: FactorySender) -> Self { + // Initialize the messages ListView wrapper + let mut messages_wrapper = TypedColumnView::::new(); + messages_wrapper.append_column::(); + messages_wrapper.append_column::(); + messages_wrapper.append_column::(); + messages_wrapper.append_column::(); + // Initialize the headers ListView wrapper + let mut headers_wrapper = TypedColumnView::::new(); + headers_wrapper.append_column::(); + headers_wrapper.append_column::(); + let default_idx = 0; + let page_size_combo = SimpleComboBox::builder() + .launch(SimpleComboBox { + variants: AVAILABLE_PAGE_SIZES.to_vec(), + active_index: Some(default_idx), + }) + .forward(sender.input_sender(), MessagesTabMsg::PageSizeChanged); + page_size_combo.widget().queue_allocate(); + let model = MessagesTabModel { + token: CancellationToken::new(), + mode: MessagesMode::Live, + topic: Some(open.topic), + connection: Some(open.connection), + messages_wrapper, + headers_wrapper, + page_size_combo, + page_size: AVAILABLE_PAGE_SIZES[0], + }; + let messages_view = &model.messages_wrapper.view; + let _headers_view = &model.headers_wrapper.view; + let sender_for_selection = sender.clone(); + messages_view + .model() + .unwrap() + .connect_selection_changed(move |selection_model, _, _| { + sender_for_selection.input(MessagesTabMsg::Selection(selection_model.n_items())); + }); + let sender_for_activate = sender.clone(); + messages_view.connect_activate(move |_view, idx| { + sender_for_activate.input(MessagesTabMsg::OpenMessage(idx)); + }); + + messages_view.sorter().unwrap().connect_changed(move |sorter, change| { + let order = sorter.order(); + let csorter: &ColumnViewSorter = sorter.downcast_ref().unwrap(); + info!("sort order changed: {:?}:{:?}", change, order); + for i in 0..= csorter.n_sort_columns() { + let (cvc, sort) = csorter.nth_sort_column(i); + info!("column[{:?}]sort[{:?}]", cvc.map(|col| { col.title() }), sort); + } + }); + + sender.input(MessagesTabMsg::Open(model.connection.clone().unwrap(), model.topic.clone().unwrap())); + model + } + + fn post_view(&self, widgets: &mut Self::Widgets) { + let buffer = widgets + .value_source_view + .buffer() + .downcast::() + .expect("sourceview was not backed by sourceview buffer"); + + if let Some(scheme) = &sourceview::StyleSchemeManager::new().scheme("oblivion") { + buffer.set_style_scheme(Some(scheme)); + } + let language = sourceview::LanguageManager::default().language("json"); + buffer.set_language(language.as_ref()); + } + + fn update_with_view( + &mut self, + widgets: &mut Self::Widgets, + msg: MessagesTabMsg, + sender: FactorySender, + ) { + match msg { + MessagesTabMsg::ToggleMode(toggle) => { + self.mode = if toggle { + widgets.cached_controls.set_visible(true); + widgets.cached_centered_controls.set_visible(true); + widgets.live_controls.set_visible(false); + MessagesMode::Cached { refresh: false } + } else { + widgets.live_controls.set_visible(true); + widgets.cached_controls.set_visible(false); + widgets.cached_centered_controls.set_visible(false); + widgets.cache_timestamp.set_visible(false); + widgets.cache_timestamp.set_text(""); + let cloned_topic = self.topic.clone().unwrap(); + let topic = KrustTopic { + name: cloned_topic.name.clone(), + connection_id: cloned_topic.connection_id, + cached: None, + partitions: vec![], + }; + let conn = self.connection.clone().unwrap(); + MessagesWorker::new().cleanup_messages(&MessagesCleanupRequest { + connection: conn, + topic: topic.clone(), + }); + self.topic = Some(topic); + MessagesMode::Live + }; + } + MessagesTabMsg::PageSizeChanged(_idx) => { + let page_size = match self.page_size_combo.model().get_active_elem() { + Some(ps) => *ps, + None => AVAILABLE_PAGE_SIZES[0], + }; + self.page_size = page_size; + self.page_size_combo.widget().queue_allocate(); + } + MessagesTabMsg::Selection(size) => { + let mut copy_content = String::from("PARTITION;OFFSET;VALUE;TIMESTAMP"); + let min_length = copy_content.len(); + for i in 0..size { + if self.messages_wrapper.selection_model.is_selected(i) { + let item = self.messages_wrapper.get_visible(i).unwrap(); + let partition = item.borrow().partition; + let offset = item.borrow().offset; + let value = item.borrow().value.clone(); + let clean_value = + match serde_json::from_str::(value.as_str()) { + Ok(json) => json.to_string(), + Err(_) => value.replace('\n', ""), + }; + let timestamp = item.borrow().timestamp; + let copy_text = format!( + "\n{};{};{};{}", + partition, + offset, + clean_value, + timestamp.unwrap_or_default() + ); + copy_content.push_str(copy_text.as_str()); + info!("selected offset[{}]", copy_text); + } + } + if copy_content.len() > min_length { + DisplayManager::get() + .default_display() + .unwrap() + .clipboard() + .set_text(copy_content.as_str()); + } + } + MessagesTabMsg::Open(connection, topic) => { + let conn_id = &connection.id.unwrap(); + let topic_name = &topic.name.clone(); + self.connection = Some(connection); + let mut repo = Repository::new(); + let maybe_topic = repo.find_topic(*conn_id, topic_name); + self.topic = maybe_topic.clone().or(Some(topic)); + let toggled = maybe_topic.is_some(); + let cache_ts = maybe_topic + .and_then(|t| { + t.cached.map(|ts| { + Utc.timestamp_millis_opt(ts) + .unwrap() + .with_timezone(&America::Sao_Paulo) + .format(DATE_TIME_FORMAT) + .to_string() + }) + }) + .unwrap_or_default(); + widgets.cache_timestamp.set_label(&cache_ts); + widgets.cache_timestamp.set_visible(true); + widgets.btn_cache_toggle.set_active(toggled); + widgets.pag_total_entry.set_text(""); + widgets.pag_current_entry.set_text(""); + widgets.pag_last_entry.set_text(""); + self.messages_wrapper.clear(); + self.page_size_combo.widget().queue_allocate(); + sender.input(MessagesTabMsg::ToggleMode(toggled)); + } + MessagesTabMsg::GetMessages => { + STATUS_BROKER.send(StatusBarMsg::Start); + on_loading(widgets, false); + let mode = self.mode; + self.mode = match self.mode { + MessagesMode::Cached { refresh: _ } => MessagesMode::Cached { refresh: false }, + MessagesMode::Live => { + self.messages_wrapper.clear(); + MessagesMode::Live + } + }; + let topic = self.topic.clone().unwrap(); + let conn = self.connection.clone().unwrap(); + if self.token.is_cancelled() { + self.token = CancellationToken::new(); + } + let page_size = self.page_size; + let token = self.token.clone(); + widgets.pag_current_entry.set_text("0"); + sender.oneshot_command(async move { + // Run async background task + let messages_worker = MessagesWorker::new(); + let result = &messages_worker + .get_messages( + token, + &MessagesRequest { + mode, + connection: conn, + topic: topic.clone(), + page_operation: PageOp::Next, + page_size, + offset_partition: (0, 0), + }, + ) + .await + .unwrap(); + let total = result.total; + trace!("selected topic {} with {} messages", topic.name, &total,); + CommandMsg::Data(result.clone()) + }); + } + MessagesTabMsg::GetNextMessages => { + STATUS_BROKER.send(StatusBarMsg::Start); + on_loading(widgets, false); + let mode = self.mode; + let topic = self.topic.clone().unwrap(); + let conn = self.connection.clone().unwrap(); + if self.token.is_cancelled() { + self.token = CancellationToken::new(); + } + let page_size = self.page_size; + let (offset, partition) = ( + widgets + .last_offset + .text() + .to_string() + .parse::() + .unwrap(), + widgets + .last_partition + .text() + .to_string() + .parse::() + .unwrap(), + ); + let token = self.token.clone(); + info!( + "getting next messages [page_size={}, last_offset={}, last_partition={}]", + page_size, offset, partition + ); + sender.oneshot_command(async move { + // Run async background task + let messages_worker = MessagesWorker::new(); + let result = &messages_worker + .get_messages( + token, + &MessagesRequest { + mode, + connection: conn, + topic: topic.clone(), + page_operation: PageOp::Next, + page_size, + offset_partition: (offset, partition), + }, + ) + .await + .unwrap(); + let total = result.total; + trace!("selected topic {} with {} messages", topic.name, &total,); + CommandMsg::Data(result.clone()) + }); + } + MessagesTabMsg::GetPreviousMessages => { + STATUS_BROKER.send(StatusBarMsg::Start); + on_loading(widgets, false); + let mode = self.mode; + let topic = self.topic.clone().unwrap(); + let conn = self.connection.clone().unwrap(); + if self.token.is_cancelled() { + self.token = CancellationToken::new(); + } + let page_size = self.page_size; + let (offset, partition) = ( + widgets + .first_offset + .text() + .to_string() + .parse::() + .unwrap(), + widgets + .first_partition + .text() + .to_string() + .parse::() + .unwrap(), + ); + let token = self.token.clone(); + sender.oneshot_command(async move { + // Run async background task + let messages_worker = MessagesWorker::new(); + let result = &messages_worker + .get_messages( + token, + &MessagesRequest { + mode, + connection: conn, + topic: topic.clone(), + page_operation: PageOp::Prev, + page_size, + offset_partition: (offset, partition), + }, + ) + .await + .unwrap(); + let total = result.total; + trace!("selected topic {} with {} messages", topic.name, &total,); + CommandMsg::Data(result.clone()) + }); + } + MessagesTabMsg::RefreshMessages => { + info!("refreshing cached messages"); + self.mode = MessagesMode::Cached { refresh: true }; + sender.input(MessagesTabMsg::GetMessages); + } + MessagesTabMsg::StopGetMessages => { + info!("cancelling get messages..."); + self.token.cancel(); + on_loading(widgets, true); + STATUS_BROKER.send(StatusBarMsg::StopWithInfo { + text: Some("Operation cancelled!".to_string()), + }); + } + MessagesTabMsg::UpdateMessages(response) => { + let total = response.total; + self.topic = response.topic.clone(); + match self.mode { + MessagesMode::Live => info!("no need to cleanup list on live mode"), + MessagesMode::Cached { refresh: _ } => self.messages_wrapper.clear(), + } + self.headers_wrapper.clear(); + widgets.value_source_view.buffer().set_text(""); + on_loading(widgets, true); + fill_pagination( + response.page_operation, + widgets, + total, + response.page_size, + response.messages.first(), + response.messages.last(), + ); + self.messages_wrapper.extend_from_iter( + response + .messages + .iter() + .map(|m| MessageListItem::new(m.clone())), + ); + widgets.value_source_view.buffer().set_text(""); + let cache_ts = response + .topic + .and_then(|t| { + t.cached.map(|ts| { + Utc.timestamp_millis_opt(ts) + .unwrap() + .with_timezone(&America::Sao_Paulo) + .format(DATE_TIME_FORMAT) + .to_string() + }) + }) + .unwrap_or(String::default()); + widgets.cache_timestamp.set_label(&cache_ts); + widgets.cache_timestamp.set_visible(true); + STATUS_BROKER.send(StatusBarMsg::StopWithInfo { + text: Some(format!("{} messages loaded!", self.messages_wrapper.len())), + }); + } + MessagesTabMsg::OpenMessage(message_idx) => { + let item = self.messages_wrapper.get_visible(message_idx).unwrap(); + let message_text = item.borrow().value.clone(); + + let buffer = widgets + .value_source_view + .buffer() + .downcast::() + .expect("sourceview was not backed by sourceview buffer"); + + let valid_json: Result = + serde_json::from_str(message_text.as_str()); + let (language, formatted_text) = match valid_json { + Ok(jt) => ( + sourceview::LanguageManager::default().language("json"), + serde_json::to_string_pretty(&jt).unwrap(), + ), + Err(_) => ( + sourceview::LanguageManager::default().language("text"), + message_text, + ), + }; + buffer.set_language(language.as_ref()); + buffer.set_text(formatted_text.as_str()); + + self.headers_wrapper.clear(); + for header in item.borrow().headers.iter() { + self.headers_wrapper + .append(HeaderListItem::new(header.clone())); + } + } + }; + + self.update_view(widgets, sender); + } + + fn update_cmd( + &mut self, + message: Self::CommandOutput, + sender: FactorySender, + ) { + match message { + CommandMsg::Data(messages) => sender.input(MessagesTabMsg::UpdateMessages(messages)), + } + } +} + +fn on_loading(widgets: &mut MessagesTabModelWidgets, enabled: bool,) { + widgets.btn_get_messages.set_sensitive(enabled); + widgets.btn_cache_refresh.set_sensitive(enabled); + widgets.btn_cache_toggle.set_sensitive(enabled); +} + +fn fill_pagination( + page_op: PageOp, + widgets: &mut MessagesTabModelWidgets, + total: usize, + page_size: u16, + first: Option<&KrustMessage>, + last: Option<&KrustMessage>, +) { + let current_page: usize = widgets + .pag_current_entry + .text() + .to_string() + .parse::() + .unwrap_or_default(); + let current_page = match page_op { + PageOp::Next => current_page + 1, + PageOp::Prev => current_page - 1, + }; + widgets.pag_total_entry.set_text(total.to_string().as_str()); + widgets + .pag_current_entry + .set_text(current_page.to_string().as_str()); + let pages = ((total as f64) / (page_size as f64)).ceil() as usize; + widgets.pag_last_entry.set_text(pages.to_string().as_str()); + match (first, last) { + (Some(first), Some(last)) => { + let first_offset = first.offset; + let first_partition = first.partition; + let last_offset = last.offset; + let last_partition = last.partition; + widgets + .first_offset + .set_text(first_offset.to_string().as_str()); + widgets + .first_partition + .set_text(first_partition.to_string().as_str()); + widgets + .last_offset + .set_text(last_offset.to_string().as_str()); + widgets + .last_partition + .set_text(last_partition.to_string().as_str()); + } + (_, _) => (), + } + debug!("fill pagination of current page {}", current_page); + match current_page { + 1 => { + widgets.btn_previous_page.set_sensitive(false); + widgets.btn_next_page.set_sensitive(true); + } + n if n >= pages => { + widgets.btn_next_page.set_sensitive(false); + widgets.btn_previous_page.set_sensitive(true); + } + _ => { + widgets.btn_next_page.set_sensitive(true); + widgets.btn_previous_page.set_sensitive(true); + } + } +} diff --git a/src/component/messages/mod.rs b/src/component/messages/mod.rs index 456d3ed..281b399 100644 --- a/src/component/messages/mod.rs +++ b/src/component/messages/mod.rs @@ -1,2 +1,3 @@ - mod lists; +mod lists; pub(crate) mod messages_page; +pub(crate) mod messages_tab; diff --git a/src/main.rs b/src/main.rs index d19b1b2..45c0253 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,7 +31,7 @@ fn main() -> Result<(), ()> { gtk::init().expect("should initialize GTK"); let filter = filter::Targets::new() // Enable the `INFO` level for anything in `my_crate` - .with_target("relm4", Level::WARN) + .with_target("relm4", Level::INFO) // Enable the `DEBUG` level for a specific module. .with_target("krust", Level::DEBUG); tracing_subscriber::registry() From 276f1da89eff572b7f2e215783944ecd0d2dd0af Mon Sep 17 00:00:00 2001 From: Miguel Aranha Baldi Horlle Date: Fri, 19 Apr 2024 19:09:11 -0300 Subject: [PATCH 2/8] UI navigation improvements. - Minor UI navigation using a `gtk::StackSwitcher`. --- src/component/app.rs | 209 ++++++++++++++++++++++++------------------- 1 file changed, 117 insertions(+), 92 deletions(-) diff --git a/src/component/app.rs b/src/component/app.rs index 0facc3d..2b9c078 100644 --- a/src/component/app.rs +++ b/src/component/app.rs @@ -13,7 +13,11 @@ use tracing::{error, info, warn}; use crate::{ backend::repository::{KrustConnection, KrustTopic, Repository}, component::{ - connection_list::KrustConnectionOutput, connection_page::{ConnectionPageModel, ConnectionPageMsg, ConnectionPageOutput}, settings_page::{SettingsPageModel, SettingsPageMsg, SettingsPageOutput}, status_bar::{StatusBarModel, STATUS_BROKER}, topics_page::{TopicsPageModel, TopicsPageMsg, TopicsPageOutput} + connection_list::KrustConnectionOutput, + connection_page::{ConnectionPageModel, ConnectionPageMsg, ConnectionPageOutput}, + settings_page::{SettingsPageModel, SettingsPageMsg, SettingsPageOutput}, + status_bar::{StatusBarModel, STATUS_BROKER}, + topics_page::{TopicsPageModel, TopicsPageMsg, TopicsPageOutput}, }, config::State, modals::about::AboutDialog, @@ -69,103 +73,121 @@ impl Component for AppModel { type CommandOutput = (); menu! { - primary_menu: { - section! { - "_Settings" => EditSettings, - "_Add connection" => AddConnection, - "_Keyboard" => ShortcutsAction, - "_About" => AboutAction, + primary_menu: { + section! { + "_Settings" => EditSettings, + "_Add connection" => AddConnection, + "_Keyboard" => ShortcutsAction, + "_About" => AboutAction, + } } - } } view! { - main_window = adw::ApplicationWindow::new(&main_application()) { - set_visible: true, - set_title: Some("KRust Kafka Client"), - set_icon_name: Some("krust-icon"), - gtk::Box { - set_orientation: gtk::Orientation::Vertical, - - adw::HeaderBar { - pack_end = >k::MenuButton { - set_icon_name: "open-menu-symbolic", - set_menu_model: Some(&primary_menu), - } - }, - #[name(main_paned)] - gtk::Paned { - set_orientation: gtk::Orientation::Horizontal, - set_resize_start_child: true, - #[wrap(Some)] - set_start_child = >k::ScrolledWindow { - set_min_content_width: 200, - set_hexpand: true, - set_vexpand: true, - set_propagate_natural_width: true, - #[wrap(Some)] - set_child = connections.widget() -> >k::ListBox { - set_selection_mode: gtk::SelectionMode::Single, - set_hexpand: true, - set_vexpand: true, - set_show_separators: true, - add_css_class: "rich-list", - connect_row_activated[sender] => move |list_box, row| { - info!("clicked on connection: {:?} - {:?}", list_box, row.index()); - sender.input(AppMsg::ShowTopicsPageByIndex(row.index())); + main_window = adw::ApplicationWindow::new(&main_application()) { + set_visible: true, + set_title: Some("KRust Kafka Client"), + set_icon_name: Some("krust-icon"), + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + + adw::HeaderBar { + pack_end = >k::MenuButton { + set_icon_name: "open-menu-symbolic", + set_menu_model: Some(&primary_menu), + } }, - }, - }, - #[wrap(Some)] - set_end_child = >k::ScrolledWindow { - set_hexpand: true, - set_vexpand: true, - #[wrap(Some)] - set_child = >k::Box { - #[name(main_stack)] - gtk::Stack { - add_child = >k::Box { - set_halign: gtk::Align::Center, - set_orientation: gtk::Orientation::Vertical, - #[name="support_logo"] - gtk::Picture { + #[name(main_paned)] + gtk::Paned { + set_orientation: gtk::Orientation::Horizontal, + set_resize_start_child: true, + set_wide_handle: true, + #[wrap(Some)] + set_start_child = >k::ScrolledWindow { + set_min_content_width: 200, + set_hexpand: true, set_vexpand: true, + set_propagate_natural_width: true, + #[wrap(Some)] + set_child = >k::Box { + set_orientation: gtk::Orientation::Vertical, + gtk::StackSwitcher { + set_overflow: gtk::Overflow::Hidden, + set_orientation: gtk::Orientation::Horizontal, + set_stack: Some(&main_stack), + set_hexpand: false, + set_vexpand: false, + set_valign: gtk::Align::Baseline, + set_halign: gtk::Align::Center, + }, + gtk::ScrolledWindow { + connections.widget() -> >k::ListBox { + set_selection_mode: gtk::SelectionMode::Single, + set_hexpand: true, + set_vexpand: true, + set_show_separators: true, + add_css_class: "rich-list", + connect_row_activated[sender] => move |list_box, row| { + info!("clicked on connection: {:?} - {:?}", list_box, row.index()); + sender.input(AppMsg::ShowTopicsPageByIndex(row.index())); + }, + }, + }, + }, + }, + #[wrap(Some)] + set_end_child = >k::ScrolledWindow { set_hexpand: true, - set_margin_top: 48, - set_margin_bottom: 48, + set_vexpand: true, + #[wrap(Some)] + set_child = >k::Box { + set_orientation: gtk::Orientation::Vertical, + #[name(main_stack)] + gtk::Stack { + add_child = >k::Box { + set_halign: gtk::Align::Center, + set_orientation: gtk::Orientation::Vertical, + #[name="support_logo"] + gtk::Picture { + set_vexpand: true, + set_hexpand: true, + set_margin_top: 48, + set_margin_bottom: 48, + }, + } -> { + set_title: "Home", + set_name: "Home", + }, + add_child = connection_page.widget() -> >k::Grid {} -> { + set_name: "Connection", + }, + add_child = topics_page.widget() -> >k::Box {} -> { + set_name: "Topics", + set_title: "Topics", + }, + add_child = messages_page.widget() -> >k::Box {} -> { + set_name: "Messages", + set_title: "Messages", + }, + add_child = settings_page.widget() -> >k::Grid {} -> { + set_name: "Settings", + }, + }, + }, }, - } -> { - set_title: "Home", - set_name: "Home", - }, - add_child = connection_page.widget() -> >k::Grid {} -> { - set_name: "Connection" - }, - add_child = topics_page.widget() -> >k::Box {} -> { - set_name: "Topics" - }, - add_child = messages_page.widget() -> >k::Box {} -> { - set_name: "Messages" - }, - add_child = settings_page.widget() -> >k::Grid {} -> { - set_name: "Settings" - }, + }, + gtk::Box { + add_css_class: "status-bar", + status_bar.widget() -> >k::CenterBox {} } - } }, - }, - gtk::Box { - add_css_class: "status-bar", - status_bar.widget() -> >k::CenterBox {} - } - }, - - connect_close_request[sender] => move |_this| { - sender.input(AppMsg::CloseRequest); - gtk::glib::Propagation::Stop - }, - - } + + connect_close_request[sender] => move |_this| { + sender.input(AppMsg::CloseRequest); + gtk::glib::Propagation::Stop + }, + + } } fn init(_params: (), root: Self::Root, sender: ComponentSender) -> ComponentParts { @@ -203,9 +225,8 @@ impl Component for AppModel { } }); - let messages_page: Controller = MessagesPageModel::builder() - .launch(()) - .detach(); + let messages_page: Controller = + MessagesPageModel::builder().launch(()).detach(); let settings_page: Controller = SettingsPageModel::builder() .launch(()) @@ -411,7 +432,11 @@ impl AppModelWidgets { fn save_window_size(&self) -> Result<(), glib::BoolError> { let (width, height) = self.main_window.default_size(); let is_maximized = self.main_window.is_maximized(); - let separator = self.main_paned.position(); + let separator = if self.main_paned.position() < 405 { + 405 + } else { + self.main_paned.position() + }; let new_state = State { width, height, From 6cc7c11c6ba268cfaa58f361d6374b00ae57cc72 Mon Sep 17 00:00:00 2001 From: Miguel Aranha Baldi Horlle Date: Sat, 20 Apr 2024 13:45:22 -0300 Subject: [PATCH 3/8] Messages page improvements. - Messages search: Live and Cached. - Minor layout improvements. --- src/backend/repository.rs | 185 +++++++++++++++++-------- src/backend/worker.rs | 29 +++- src/component/messages/lists.rs | 2 +- src/component/messages/messages_tab.rs | 125 +++++++++++++---- src/component/mod.rs | 8 +- src/component/topics_page.rs | 2 +- src/lib.rs | 1 + src/main.rs | 10 +- 8 files changed, 263 insertions(+), 99 deletions(-) diff --git a/src/backend/repository.rs b/src/backend/repository.rs index 011a679..4bb1fdf 100644 --- a/src/backend/repository.rs +++ b/src/backend/repository.rs @@ -123,15 +123,15 @@ impl MessagesRepository { //let conn = self.get_connection(); let mut stmt_by_id = conn.prepare_cached( "INSERT INTO kr_message(partition, offset, value, timestamp, headers) - VALUES (:p, :o, :v, :t, :h)", + VALUES (:p, :o, :v, :t, :h)", )?; let headers = ron::ser::to_string::>(message.headers.as_ref()).unwrap_or_default(); let maybe_message = stmt_by_id - .execute( - named_params! { ":p": message.partition, ":o": message.offset, ":v": message.value, ":t": message.timestamp, ":h": headers}, - ) - .map_err(ExternalError::DatabaseError); + .execute( + named_params! { ":p": message.partition, ":o": message.offset, ":v": message.value, ":t": message.timestamp, ":h": headers}, + ) + .map_err(ExternalError::DatabaseError); match maybe_message { Ok(_) => Ok(message.to_owned()), @@ -139,12 +139,25 @@ impl MessagesRepository { } } - pub fn count_messages(&mut self) -> Result { + pub fn count_messages(&mut self, search: Option) -> Result { let conn = self.get_connection(); - let mut stmt_by_id = conn.prepare_cached("SELECT COUNT(1) FROM kr_message")?; - - stmt_by_id - .query_row(params![], move |row| row.get(0)) + let mut stmt_count = match search { + Some(_) => { + conn.prepare_cached("SELECT COUNT(1) FROM kr_message WHERE value LIKE :search")? + } + None => conn.prepare_cached("SELECT COUNT(1) FROM kr_message")?, + }; + let params_with_search = + named_params! { ":search": format!("%{}%", search.clone().unwrap_or_default()) }; + stmt_count + .query_row( + if search.is_some() { + params_with_search + } else { + named_params![] + }, + move |row| row.get(0), + ) .map_err(ExternalError::DatabaseError) } @@ -154,12 +167,12 @@ impl MessagesRepository { let conn = self.get_connection(); let mut stmt_by_id = conn.prepare_cached( "SELECT high.partition partition, offset_low, offset_high - FROM (SELECT partition, MAX(offset) offset_high - from kr_message - GROUP BY partition) high - JOIN (SELECT partition, MIN(offset) offset_low - from kr_message - GROUP BY partition) low ON high.partition = low.partition", + FROM (SELECT partition, MAX(offset) offset_high + from kr_message + GROUP BY partition) high + JOIN (SELECT partition, MIN(offset) offset_low + from kr_message + GROUP BY partition) low ON high.partition = low.partition", )?; let row_to_model = move |row: &Row<'_>| { @@ -179,14 +192,27 @@ impl MessagesRepository { Ok(messages) } - pub fn find_messages(&mut self, page_size: u16) -> Result, ExternalError> { + pub fn find_messages( + &mut self, + page_size: u16, + search: Option, + ) -> Result, ExternalError> { let conn = self.get_connection(); - let mut stmt_by_id = conn.prepare_cached( - "SELECT partition, offset, value, timestamp, headers - FROM kr_message - ORDER BY offset, partition - LIMIT :ps", - )?; + let mut stmt_query = match search { + Some(_) => conn.prepare_cached( + "SELECT partition, offset, value, timestamp, headers + FROM kr_message + WHERE value LIKE :search + ORDER BY offset, partition + LIMIT :ps", + )?, + None => conn.prepare_cached( + "SELECT partition, offset, value, timestamp, headers + FROM kr_message + ORDER BY offset, partition + LIMIT :ps", + )?, + }; let string_to_headers = move |sheaders: String| { let headers: Result, rusqlite::Error> = ron::from_str(&sheaders) .map_err(|e| rusqlite::Error::InvalidColumnName(e.to_string())); @@ -203,8 +229,17 @@ impl MessagesRepository { topic: topic_name.clone(), }) }; - let rows = stmt_by_id - .query_map(named_params! {":ps": page_size}, row_to_model) + let params_with_search = named_params! { ":search": format!("%{}%", search.clone().unwrap_or_default()), ":ps": page_size }; + let params = named_params! { ":ps": page_size, }; + let rows = stmt_query + .query_map( + if search.is_some() { + params_with_search + } else { + params + }, + row_to_model, + ) .map_err(ExternalError::DatabaseError)?; let mut messages = Vec::new(); for row in rows { @@ -217,16 +252,27 @@ impl MessagesRepository { &mut self, page_size: u16, last: (usize, usize), + search: Option, ) -> Result, ExternalError> { let (offset, partition) = last; let conn = self.get_connection(); - let mut stmt_by_id = conn.prepare_cached( - "SELECT partition, offset, value, timestamp, headers - FROM kr_message - WHERE (offset, partition) > (:o, :p) - ORDER BY offset, partition - LIMIT :ps", - )?; + let mut stmt_query = match search { + Some(_) => conn.prepare_cached( + "SELECT partition, offset, value, timestamp, headers + FROM kr_message + WHERE (offset, partition) > (:o, :p) + AND value LIKE :search + ORDER BY offset, partition + LIMIT :ps", + )?, + None => conn.prepare_cached( + "SELECT partition, offset, value, timestamp, headers + FROM kr_message + WHERE (offset, partition) > (:o, :p) + ORDER BY offset, partition + LIMIT :ps", + )?, + }; let string_to_headers = move |sheaders: String| { let headers: Result, rusqlite::Error> = ron::from_str(&sheaders) .map_err(|e| rusqlite::Error::InvalidColumnName(e.to_string())); @@ -243,9 +289,15 @@ impl MessagesRepository { topic: topic_name.clone(), }) }; - let rows = stmt_by_id + let params_with_search = named_params! {":ps": page_size, ":o": offset, ":p": partition, ":search": format!("%{}%", search.clone().unwrap_or_default())}; + let params = named_params! {":ps": page_size, ":o": offset, ":p": partition,}; + let rows = stmt_query .query_map( - named_params! {":ps": page_size, ":o": offset, ":p": partition}, + if search.is_some() { + params_with_search + } else { + params + }, row_to_model, ) .map_err(ExternalError::DatabaseError)?; @@ -259,18 +311,31 @@ impl MessagesRepository { &mut self, page_size: u16, first: (usize, usize), + search: Option, ) -> Result, ExternalError> { let (offset, partition) = first; let conn = self.get_connection(); - let mut stmt_by_id = conn.prepare_cached( - "SELECT partition, offset, value, timestamp, headers FROM ( - SELECT partition, offset, value, timestamp, headers - FROM kr_message - WHERE (offset, partition) < (:o, :p) - ORDER BY offset DESC, partition DESC - LIMIT :ps - ) ORDER BY offset ASC, partition ASC", - )?; + let mut stmt_query = match search { + Some(_) => conn.prepare_cached( + "SELECT partition, offset, value, timestamp, headers FROM ( + SELECT partition, offset, value, timestamp, headers + FROM kr_message + WHERE (offset, partition) < (:o, :p) + AND value LIKE :search + ORDER BY offset DESC, partition DESC + LIMIT :ps + ) ORDER BY offset ASC, partition ASC", + )?, + None => conn.prepare_cached( + "SELECT partition, offset, value, timestamp, headers FROM ( + SELECT partition, offset, value, timestamp, headers + FROM kr_message + WHERE (offset, partition) < (:o, :p) + ORDER BY offset DESC, partition DESC + LIMIT :ps + ) ORDER BY offset ASC, partition ASC", + )?, + }; let string_to_headers = move |sheaders: String| { let headers: Result, rusqlite::Error> = ron::from_str(&sheaders) .map_err(|e| rusqlite::Error::InvalidColumnName(e.to_string())); @@ -287,9 +352,15 @@ impl MessagesRepository { topic: topic_name.clone(), }) }; - let rows = stmt_by_id + let params_with_search = named_params! {":ps": page_size, ":o": offset, ":p": partition, ":search": format!("%{}%", search.clone().unwrap_or_default()) }; + let params = named_params! {":ps": page_size, ":o": offset, ":p": partition }; + let rows = stmt_query .query_map( - named_params! {":ps": page_size, ":o": offset, ":p": partition}, + if search.is_some() { + params_with_search + } else { + params + }, row_to_model, ) .map_err(ExternalError::DatabaseError)?; @@ -315,9 +386,9 @@ impl Repository { pub fn init(&mut self) -> Result<(), ExternalError> { self.conn.execute_batch(" - CREATE TABLE IF NOT EXISTS kr_connection(id INTEGER PRIMARY KEY, name TEXT UNIQUE, brokersList TEXT, securityType TEXT, saslMechanism TEXT, saslUsername TEXT, saslPassword TEXT); - CREATE TABLE IF NOT EXISTS kr_topic(connection_id INTEGER, name TEXT, cached INTEGER, PRIMARY KEY (connection_id, name), FOREIGN KEY (connection_id) REFERENCES kr_connection(id)); - ").map_err(ExternalError::DatabaseError) + CREATE TABLE IF NOT EXISTS kr_connection(id INTEGER PRIMARY KEY, name TEXT UNIQUE, brokersList TEXT, securityType TEXT, saslMechanism TEXT, saslUsername TEXT, saslPassword TEXT); + CREATE TABLE IF NOT EXISTS kr_topic(connection_id INTEGER, name TEXT, cached INTEGER, PRIMARY KEY (connection_id, name), FOREIGN KEY (connection_id) REFERENCES kr_connection(id)); + ").map_err(ExternalError::DatabaseError) } pub fn list_all_connections(&mut self) -> Result, ExternalError> { @@ -385,9 +456,9 @@ impl Repository { Ok(konn_to_update) => { let mut up_stmt = self.conn.prepare_cached("UPDATE kr_connection SET name = :name, brokersList = :brokers, securityType = :security, saslMechanism = :sasl, saslUsername = :sasl_u, saslPassword = :sasl_p WHERE id = :id")?; up_stmt - .execute(named_params! { ":id": &konn_to_update.id.unwrap(), ":name": &name, ":brokers": &brokers, ":security": security.to_string(), ":sasl": &sasl, ":sasl_u": &sasl_username, ":sasl_p": &sasl_password }) - .map_err(ExternalError::DatabaseError) - .map( |_| {KrustConnection { id: konn_to_update.id, name, brokers_list: brokers, security_type: security, sasl_mechanism: sasl, sasl_username, sasl_password }}) + .execute(named_params! { ":id": &konn_to_update.id.unwrap(), ":name": &name, ":brokers": &brokers, ":security": security.to_string(), ":sasl": &sasl, ":sasl_u": &sasl_username, ":sasl_p": &sasl_password }) + .map_err(ExternalError::DatabaseError) + .map( |_| {KrustConnection { id: konn_to_update.id, name, brokers_list: brokers, security_type: security, sasl_mechanism: sasl, sasl_username, sasl_password }}) } Err(_) => { let mut ins_stmt = self.conn.prepare_cached("INSERT INTO kr_connection (id, name, brokersList, securityType, saslMechanism, saslUsername, saslPassword) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id")?; @@ -429,9 +500,9 @@ impl Repository { let cached = topic.cached; let mut stmt_by_id = self.conn.prepare_cached( "INSERT INTO kr_topic(connection_id, name, cached) - VALUES (:cid, :topic, :cached) - ON CONFLICT(connection_id, name) - DO UPDATE SET cached=excluded.cached", + VALUES (:cid, :topic, :cached) + ON CONFLICT(connection_id, name) + DO UPDATE SET cached=excluded.cached", )?; let row_to_model = move |_| { Ok(KrustTopic { @@ -457,8 +528,8 @@ impl Repository { let name = topic.name.clone(); let mut stmt_by_id = self.conn.prepare_cached( "DELETE FROM kr_topic - WHERE connection_id = :cid - AND name = :topic", + WHERE connection_id = :cid + AND name = :topic", )?; stmt_by_id @@ -468,7 +539,7 @@ impl Repository { pub fn find_topic(&mut self, conn_id: usize, topic_name: &String) -> Option { let stmt = self.conn - .prepare_cached("SELECT connection_id, name, cached FROM kr_topic WHERE connection_id = :cid AND name = :topic"); + .prepare_cached("SELECT connection_id, name, cached FROM kr_topic WHERE connection_id = :cid AND name = :topic"); stmt.ok()? .query_row( named_params! {":cid": &conn_id, ":topic": &topic_name }, diff --git a/src/backend/worker.rs b/src/backend/worker.rs index 046f26b..5d3b502 100644 --- a/src/backend/worker.rs +++ b/src/backend/worker.rs @@ -33,6 +33,7 @@ pub struct MessagesRequest { pub page_operation: PageOp, pub page_size: u16, pub offset_partition: (usize, usize), + pub search: Option, } #[derive(Debug, Clone)] @@ -42,6 +43,7 @@ pub struct MessagesResponse { pub total: usize, pub messages: Vec, pub topic: Option, + pub search: Option, } pub struct MessagesCleanupRequest { @@ -92,7 +94,7 @@ impl MessagesWorker { _ = token.cancelled() => { info!("request {:?} cancelled", &req); // The token was cancelled - Ok(MessagesResponse { total: 0, messages: Vec::new(), topic: Some(req.topic), page_operation: req.page_operation, page_size: req.page_size}) + Ok(MessagesResponse { total: 0, messages: Vec::new(), topic: Some(req.topic), page_operation: req.page_operation, page_size: req.page_size, search: req.search}) } messages = self.get_messages_by_mode(&req) => { messages @@ -142,7 +144,7 @@ impl MessagesWorker { let total = match request.topic.cached { Some(_) => { if refresh { - let cached_total = mrepo.count_messages().unwrap_or_default(); + let cached_total = mrepo.count_messages(None).unwrap_or_default(); let total = kafka.topic_message_count(&topic.name).await - cached_total; let partitions = mrepo.find_offsets().ok(); kafka @@ -156,7 +158,7 @@ impl MessagesWorker { .await .unwrap(); } - mrepo.count_messages().unwrap_or_default() + mrepo.count_messages(request.search.clone()).unwrap_or_default() } None => { let total = kafka.topic_message_count(&topic.name).await; @@ -171,18 +173,29 @@ impl MessagesWorker { ) .await .unwrap(); + let total = if request.search.clone().is_some() { + mrepo.count_messages(request.search.clone()).unwrap_or_default() + } else { + total + }; total } }; let messages = match request.page_operation { PageOp::Next => match request.offset_partition { - (0, 0) => mrepo.find_messages(request.page_size).unwrap(), + (0, 0) => mrepo + .find_messages(request.clone().page_size, request.clone().search) + .unwrap(), offset_partition => mrepo - .find_next_messages(request.page_size, offset_partition) + .find_next_messages(request.page_size, offset_partition, request.clone().search) .unwrap(), }, PageOp::Prev => mrepo - .find_prev_messages(request.page_size, request.offset_partition) + .find_prev_messages( + request.page_size, + request.offset_partition, + request.clone().search, + ) .unwrap(), }; Ok(MessagesResponse { @@ -191,6 +204,7 @@ impl MessagesWorker { topic: Some(topic), page_operation: request.page_operation, page_size: request.page_size, + search: request.search.clone(), }) } @@ -202,13 +216,14 @@ impl MessagesWorker { let topic = &request.topic.name; // Run async background task let total = kafka.topic_message_count(topic).await; - let messages = kafka.list_messages_for_topic(topic, total, ).await?; + let messages = kafka.list_messages_for_topic(topic, total).await?; Ok(MessagesResponse { total, messages: messages, topic: Some(request.topic.clone()), page_operation: request.page_operation, page_size: request.page_size, + search: request.search.clone(), }) } } diff --git a/src/component/messages/lists.rs b/src/component/messages/lists.rs index 84d3b36..aa7ff09 100644 --- a/src/component/messages/lists.rs +++ b/src/component/messages/lists.rs @@ -113,7 +113,7 @@ impl MessageListItem { Self { offset: value.offset, partition: value.partition, - key: "".to_string(), + key: String::default(), value: value.value, timestamp: value.timestamp, headers: value.headers, diff --git a/src/component/messages/messages_tab.rs b/src/component/messages/messages_tab.rs index 04c7fc7..334d790 100644 --- a/src/component/messages/messages_tab.rs +++ b/src/component/messages/messages_tab.rs @@ -3,7 +3,9 @@ use chrono::{TimeZone, Utc}; use chrono_tz::America; use gtk::{gdk::DisplayManager, ColumnViewSorter}; -use relm4::{factory::{DynamicIndex, FactoryComponent}, typed_view::column::TypedColumnView, *}; +use relm4::{ + actions::{RelmAction, RelmActionGroup}, factory::{DynamicIndex, FactoryComponent}, typed_view::column::TypedColumnView, * +}; use relm4_components::simple_combo_box::SimpleComboBox; use sourceview::prelude::*; use sourceview5 as sourceview; @@ -29,6 +31,10 @@ use crate::{ Repository, DATE_TIME_FORMAT, }; +// page actions +relm4::new_action_group!(pub MessagesPageActionGroup, "messages_page"); +relm4::new_stateless_action!(pub MessagesSearchAction, MessagesPageActionGroup, "search"); + #[derive(Debug)] pub struct MessagesTabModel { token: CancellationToken, @@ -41,7 +47,7 @@ pub struct MessagesTabModel { page_size: u16, } -pub struct MessagesTabInit { +pub struct MessagesTabInit { pub topic: KrustTopic, pub connection: KrustConnection, } @@ -56,6 +62,8 @@ pub enum MessagesTabMsg { RefreshMessages, UpdateMessages(MessagesResponse), OpenMessage(u32), + SearchMessages, + LiveSearchMessages(String), Selection(u32), PageSizeChanged(usize), ToggleMode(bool), @@ -66,7 +74,7 @@ pub enum CommandMsg { Data(MessagesResponse), } -const AVAILABLE_PAGE_SIZES: [u16; 4] = [50, 100, 500, 1000]; +const AVAILABLE_PAGE_SIZES: [u16; 6] = [50, 100, 500, 1000, 2000, 5000]; #[relm4::factory(pub)] impl FactoryComponent for MessagesTabModel { @@ -138,17 +146,26 @@ impl FactoryComponent for MessagesTabModel { #[wrap(Some)] set_end_widget = >k::Box { set_orientation: gtk::Orientation::Horizontal, - set_halign: gtk::Align::Fill, + set_halign: gtk::Align::End, set_hexpand: true, - #[name(topics_search_entry)] + #[name(messages_search_entry)] gtk::SearchEntry { - set_hexpand: true, + set_hexpand: false, set_halign: gtk::Align::Fill, - + connect_search_changed[sender] => move |entry| { + sender.clone().input(MessagesTabMsg::LiveSearchMessages(entry.text().to_string())); + }, + connect_activate[sender] => move |_entry| { + sender.input(MessagesTabMsg::SearchMessages); + }, }, + #[name(messages_search_btn)] gtk::Button { set_icon_name: "edit-find-symbolic", set_margin_start: 5, + connect_clicked[sender] => move |_| { + sender.input(MessagesTabMsg::SearchMessages); + }, }, }, }, @@ -351,6 +368,7 @@ impl FactoryComponent for MessagesTabModel { messages_wrapper.append_column::(); messages_wrapper.append_column::(); messages_wrapper.append_column::(); + // Initialize the headers ListView wrapper let mut headers_wrapper = TypedColumnView::::new(); headers_wrapper.append_column::(); @@ -377,27 +395,37 @@ impl FactoryComponent for MessagesTabModel { let _headers_view = &model.headers_wrapper.view; let sender_for_selection = sender.clone(); messages_view - .model() - .unwrap() - .connect_selection_changed(move |selection_model, _, _| { - sender_for_selection.input(MessagesTabMsg::Selection(selection_model.n_items())); - }); + .model() + .unwrap() + .connect_selection_changed(move |selection_model, _, _| { + sender_for_selection.input(MessagesTabMsg::Selection(selection_model.n_items())); + }); let sender_for_activate = sender.clone(); messages_view.connect_activate(move |_view, idx| { sender_for_activate.input(MessagesTabMsg::OpenMessage(idx)); }); - messages_view.sorter().unwrap().connect_changed(move |sorter, change| { - let order = sorter.order(); - let csorter: &ColumnViewSorter = sorter.downcast_ref().unwrap(); - info!("sort order changed: {:?}:{:?}", change, order); - for i in 0..= csorter.n_sort_columns() { - let (cvc, sort) = csorter.nth_sort_column(i); - info!("column[{:?}]sort[{:?}]", cvc.map(|col| { col.title() }), sort); - } - }); + messages_view + .sorter() + .unwrap() + .connect_changed(move |sorter, change| { + let order = sorter.order(); + let csorter: &ColumnViewSorter = sorter.downcast_ref().unwrap(); + info!("sort order changed: {:?}:{:?}", change, order); + for i in 0..=csorter.n_sort_columns() { + let (cvc, sort) = csorter.nth_sort_column(i); + info!( + "column[{:?}]sort[{:?}]", + cvc.map(|col| { col.title() }), + sort + ); + } + }); - sender.input(MessagesTabMsg::Open(model.connection.clone().unwrap(), model.topic.clone().unwrap())); + sender.input(MessagesTabMsg::Open( + model.connection.clone().unwrap(), + model.topic.clone().unwrap(), + )); model } @@ -413,6 +441,20 @@ impl FactoryComponent for MessagesTabModel { } let language = sourceview::LanguageManager::default().language("json"); buffer.set_language(language.as_ref()); + + // Shortcuts + let mut actions = RelmActionGroup::::new(); + + let messages_search_entry = widgets.messages_search_entry.clone(); + let search_action = { + let messages_search_btn = widgets.messages_search_btn.clone(); + RelmAction::::new_stateless(move |_| { + messages_search_btn.emit_clicked(); + }) + }; + actions.add_action(search_action); + actions.register_for_widget(messages_search_entry); + } fn update_with_view( @@ -521,6 +563,20 @@ impl FactoryComponent for MessagesTabModel { self.page_size_combo.widget().queue_allocate(); sender.input(MessagesTabMsg::ToggleMode(toggled)); } + MessagesTabMsg::LiveSearchMessages(term) => { + match self.mode.clone() { + MessagesMode::Live => { + self.messages_wrapper.clear_filters(); + let search_term = term.clone(); + self.messages_wrapper + .add_filter(move |item| item.value.contains(search_term.as_str())); + } + MessagesMode::Cached { refresh: _ } => (), + }; + } + MessagesTabMsg::SearchMessages => { + sender.input(MessagesTabMsg::GetMessages); + } MessagesTabMsg::GetMessages => { STATUS_BROKER.send(StatusBarMsg::Start); on_loading(widgets, false); @@ -539,6 +595,7 @@ impl FactoryComponent for MessagesTabModel { } let page_size = self.page_size; let token = self.token.clone(); + let search = get_search_term(widgets); widgets.pag_current_entry.set_text("0"); sender.oneshot_command(async move { // Run async background task @@ -553,6 +610,7 @@ impl FactoryComponent for MessagesTabModel { page_operation: PageOp::Next, page_size, offset_partition: (0, 0), + search: search, }, ) .await @@ -586,6 +644,7 @@ impl FactoryComponent for MessagesTabModel { .parse::() .unwrap(), ); + let search = get_search_term(widgets); let token = self.token.clone(); info!( "getting next messages [page_size={}, last_offset={}, last_partition={}]", @@ -604,6 +663,7 @@ impl FactoryComponent for MessagesTabModel { page_operation: PageOp::Next, page_size, offset_partition: (offset, partition), + search: search, }, ) .await @@ -638,6 +698,7 @@ impl FactoryComponent for MessagesTabModel { .unwrap(), ); let token = self.token.clone(); + let search = get_search_term(widgets); sender.oneshot_command(async move { // Run async background task let messages_worker = MessagesWorker::new(); @@ -651,6 +712,7 @@ impl FactoryComponent for MessagesTabModel { page_operation: PageOp::Prev, page_size, offset_partition: (offset, partition), + search: search, }, ) .await @@ -752,18 +814,25 @@ impl FactoryComponent for MessagesTabModel { self.update_view(widgets, sender); } - fn update_cmd( - &mut self, - message: Self::CommandOutput, - sender: FactorySender, - ) { + fn update_cmd(&mut self, message: Self::CommandOutput, sender: FactorySender) { match message { CommandMsg::Data(messages) => sender.input(MessagesTabMsg::UpdateMessages(messages)), } } } -fn on_loading(widgets: &mut MessagesTabModelWidgets, enabled: bool,) { +fn get_search_term(widgets: &mut MessagesTabModelWidgets) -> Option { + let search: Option = widgets.messages_search_entry.text().try_into().ok(); + let search = search.clone().unwrap_or_default(); + let search_txt = search.trim(); + if search_txt.is_empty() { + None + } else { + Some(search_txt.to_string()) + } +} + +fn on_loading(widgets: &mut MessagesTabModelWidgets, enabled: bool) { widgets.btn_get_messages.set_sensitive(enabled); widgets.btn_cache_refresh.set_sensitive(enabled); widgets.btn_cache_toggle.set_sensitive(enabled); diff --git a/src/component/mod.rs b/src/component/mod.rs index 1e35c03..684b2c6 100644 --- a/src/component/mod.rs +++ b/src/component/mod.rs @@ -3,8 +3,8 @@ pub mod app; pub mod messages; - mod status_bar; pub(crate) mod connection_list; - mod connection_page; - mod topics_page; - mod settings_page; +mod connection_page; +mod settings_page; +mod status_bar; +mod topics_page; diff --git a/src/component/topics_page.rs b/src/component/topics_page.rs index e694bc8..c08c480 100644 --- a/src/component/topics_page.rs +++ b/src/component/topics_page.rs @@ -118,7 +118,7 @@ impl Component for TopicsPageModel { gtk::SearchEntry { connect_search_changed[sender] => move |entry| { sender.clone().input(TopicsPageMsg::Search(entry.text().to_string())); - } + }, }, }, }, diff --git a/src/lib.rs b/src/lib.rs index 15aab1d..53467a0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,6 +15,7 @@ mod backend; pub use component::app::AppModel; pub use component::app::AppMsg; pub use backend::repository::Repository; +pub use component::messages::messages_tab::MessagesSearchAction; pub const KRUST_QUALIFIER: &str = "io"; pub const KRUST_ORGANIZATION: &str = "miguelbaldi"; diff --git a/src/main.rs b/src/main.rs index 45c0253..0f01b79 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ use gtk::gdk; //#![windows_subsystem = "windows"] use gtk::prelude::ApplicationExt; use gtk::gio; +use krust::MessagesSearchAction; use tracing::*; use tracing_subscriber::filter; use tracing_subscriber::prelude::*; @@ -31,7 +32,7 @@ fn main() -> Result<(), ()> { gtk::init().expect("should initialize GTK"); let filter = filter::Targets::new() // Enable the `INFO` level for anything in `my_crate` - .with_target("relm4", Level::INFO) + .with_target("relm4", Level::WARN) // Enable the `DEBUG` level for a specific module. .with_target("krust", Level::DEBUG); tracing_subscriber::registry() @@ -63,6 +64,8 @@ fn main() -> Result<(), ()> { app.set_accelerators_for_action::(&["q"]); + setup_shortcuts(&app); + let app = RelmApp::from_app(app); app.set_global_css(include_str!("styles.css")); info!("running application"); @@ -71,3 +74,8 @@ fn main() -> Result<(), ()> { Ok(()) } + +pub fn setup_shortcuts(_app: >k::Application) { + info!("registering application shortcuts..."); + // app.set_accelerators_for_action::(&[""]); +} From f836538e490622d10ca3f8f5385d5b189b5844a9 Mon Sep 17 00:00:00 2001 From: Miguel Aranha Baldi Horlle Date: Sat, 20 Apr 2024 21:35:52 -0300 Subject: [PATCH 4/8] Messages cache fix. - Refactoring `refresh` message counter. --- src/backend/kafka.rs | 82 +++++++++++++++++++++---- src/backend/repository.rs | 3 + src/backend/worker.rs | 85 ++++++++++++++++---------- src/component/messages/messages_tab.rs | 1 + src/component/topics_page.rs | 1 + 5 files changed, 128 insertions(+), 44 deletions(-) diff --git a/src/backend/kafka.rs b/src/backend/kafka.rs index 5caa934..4d82eec 100644 --- a/src/backend/kafka.rs +++ b/src/backend/kafka.rs @@ -7,6 +7,8 @@ use rdkafka::message::Headers; use rdkafka::topic_partition_list::TopicPartitionList; use rdkafka::{Message, Offset}; +use std::borrow::Borrow; +use std::collections::HashMap; use std::time::{Duration, Instant}; use tracing::{debug, info, trace, warn}; @@ -134,13 +136,14 @@ impl KafkaBackend { name: topic.name().to_string(), cached: None, partitions, + total: None, }); } topics } - pub async fn topic_message_count(&self, topic: &String) -> usize { - info!("couting messages for topic {}", topic); + pub async fn fetch_partitions(&self, topic: &String) -> Vec { + info!("fetching partitions from topic {}", topic); let context = CustomContext; let consumer: LoggingConsumer = self.consumer(context).expect("Consumer creation failed"); @@ -150,7 +153,7 @@ impl KafkaBackend { .fetch_metadata(Some(topic.as_str()), TIMEOUT) .expect("Failed to fetch metadata"); - let mut message_count: usize = 0; + let mut partitions = vec![]; match metadata.topics().first() { Some(t) => { for partition in t.partitions() { @@ -163,13 +166,69 @@ impl KafkaBackend { high, high - low ); - message_count += usize::try_from(high).unwrap() - usize::try_from(low).unwrap(); + let part = Partition { + id: partition.id(), + offset_low: Some(low), + offset_high: Some(high), + }; + partitions.push(part); } } None => warn!(""), } + partitions + } + + pub async fn topic_message_count( + &self, + topic: &String, + current_partitions: Option>, + ) -> KrustTopic { + info!("couting messages for topic {}", topic); + + let mut message_count: usize = 0; + let partitions = &self.fetch_partitions(topic).await; + let mut result = current_partitions.clone().unwrap_or_default(); + let cpartitions = ¤t_partitions.unwrap_or_default().clone(); + + let part_map = cpartitions + .into_iter() + .map(|p| (p.id, p.clone())) + .collect::>(); + + for p in partitions { + if !cpartitions.is_empty() { + let low = match part_map.get(&p.id) { + Some(part) => part.offset_high.unwrap_or(p.offset_low.unwrap()), + None => { + result.push(Partition { + id: p.id, + offset_low: p.offset_low, + offset_high: None, + }); + p.offset_low.unwrap() + } + }; + message_count += usize::try_from(p.offset_high.unwrap_or_default()).unwrap() + - usize::try_from(low).unwrap(); + } else { + message_count += usize::try_from(p.offset_high.unwrap_or_default()).unwrap() + - usize::try_from(p.offset_low.unwrap_or_default()).unwrap(); + }; + } + info!("topic {} has {} messages", topic, message_count); - message_count + KrustTopic { + connection_id: None, + name: topic.clone(), + cached: None, + partitions: if !cpartitions.is_empty() { + result + } else { + partitions.clone() + }, + total: Some(message_count), + } } pub async fn cache_messages_for_topic( &self, @@ -194,10 +253,10 @@ impl KafkaBackend { let mut partition_list = TopicPartitionList::with_capacity(partitions.capacity()); for p in partitions.iter() { let offset = match fetch { - KafkaFetch::Newest => { - let latest_offset = p.offset_high.unwrap_or_default() + 1; - Offset::from_raw(latest_offset) - } + KafkaFetch::Newest => p + .offset_high + .map(|oh| Offset::from_raw(oh + 1)) + .unwrap_or(Offset::Beginning), KafkaFetch::Oldest => Offset::Beginning, }; partition_list @@ -230,7 +289,7 @@ impl KafkaBackend { } }; trace!("key: '{:?}', payload: '{}', topic: {}, partition: {}, offset: {}, timestamp: {:?}", - m.key(), payload, m.topic(), m.partition(), m.offset(), m.timestamp()); + m.key(), payload, m.topic(), m.partition(), m.offset(), m.timestamp()); let headers = if let Some(headers) = m.headers() { let mut header_list: Vec = vec![]; for header in headers.iter() { @@ -254,6 +313,7 @@ impl KafkaBackend { value: payload.to_string(), headers, }; + trace!("saving message {}", &message.offset); mrepo.save_message(&conn, &message)?; counter += 1; } @@ -297,7 +357,7 @@ impl KafkaBackend { } }; trace!("key: '{:?}', payload: '{}', topic: {}, partition: {}, offset: {}, timestamp: {:?}", - m.key(), payload, m.topic(), m.partition(), m.offset(), m.timestamp()); + m.key(), payload, m.topic(), m.partition(), m.offset(), m.timestamp()); let headers = if let Some(headers) = m.headers() { let mut header_list: Vec = vec![]; for header in headers.iter() { diff --git a/src/backend/repository.rs b/src/backend/repository.rs index 4bb1fdf..fae7358 100644 --- a/src/backend/repository.rs +++ b/src/backend/repository.rs @@ -49,6 +49,7 @@ pub struct KrustTopic { pub name: String, pub cached: Option, pub partitions: Vec, + pub total: Option, } impl Display for KrustTopic { @@ -510,6 +511,7 @@ impl Repository { name: topic.name.clone(), cached, partitions: vec![], + total: None, }) }; @@ -549,6 +551,7 @@ impl Repository { name: row.get(1)?, cached: row.get(2)?, partitions: vec![], + total: None, }) }, ) diff --git a/src/backend/worker.rs b/src/backend/worker.rs index 5d3b502..425b995 100644 --- a/src/backend/worker.rs +++ b/src/backend/worker.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use chrono::Utc; use tokio::select; use tokio_util::sync::CancellationToken; @@ -7,7 +9,7 @@ use crate::{config::ExternalError, Repository}; use super::{ kafka::{KafkaBackend, KafkaFetch}, - repository::{KrustConnection, KrustMessage, KrustTopic, MessagesRepository}, + repository::{KrustConnection, KrustMessage, KrustTopic, MessagesRepository, Partition}, }; #[derive(Debug, Clone, Copy, PartialEq, Default, strum::EnumString, strum::Display)] @@ -113,6 +115,9 @@ impl MessagesWorker { MessagesMode::Cached { refresh: _ } => self.get_messages_cached(request).await, } } + + + async fn get_messages_cached( self, request: &MessagesRequest, @@ -134,6 +139,7 @@ impl MessagesWorker { name: request.topic.name.clone(), cached, partitions: vec![], + total: None, }; let topic = repo.save_topic( topic.connection_id.expect("should have connection id"), @@ -144,37 +150,46 @@ impl MessagesWorker { let total = match request.topic.cached { Some(_) => { if refresh { - let cached_total = mrepo.count_messages(None).unwrap_or_default(); - let total = kafka.topic_message_count(&topic.name).await - cached_total; let partitions = mrepo.find_offsets().ok(); + let topic = kafka.topic_message_count(&topic.name, partitions.clone()).await; + let partitions = topic.partitions.clone(); + let total = topic.total.unwrap_or_default(); kafka - .cache_messages_for_topic( - topic_name, - total, - &mut mrepo, - partitions, - Some(KafkaFetch::Newest), - ) - .await - .unwrap(); - } - mrepo.count_messages(request.search.clone()).unwrap_or_default() - } - None => { - let total = kafka.topic_message_count(&topic.name).await; - mrepo.init().unwrap(); - kafka .cache_messages_for_topic( topic_name, total, &mut mrepo, - None, - Some(KafkaFetch::Oldest), + Some(partitions), + Some(KafkaFetch::Newest), ) .await .unwrap(); + } + mrepo + .count_messages(request.search.clone()) + .unwrap_or_default() + } + None => { + let total = kafka + .topic_message_count(&topic.name, None) + .await + .total + .unwrap_or_default(); + mrepo.init().unwrap(); + kafka + .cache_messages_for_topic( + topic_name, + total, + &mut mrepo, + None, + Some(KafkaFetch::Oldest), + ) + .await + .unwrap(); let total = if request.search.clone().is_some() { - mrepo.count_messages(request.search.clone()).unwrap_or_default() + mrepo + .count_messages(request.search.clone()) + .unwrap_or_default() } else { total }; @@ -184,19 +199,19 @@ impl MessagesWorker { let messages = match request.page_operation { PageOp::Next => match request.offset_partition { (0, 0) => mrepo - .find_messages(request.clone().page_size, request.clone().search) - .unwrap(), + .find_messages(request.clone().page_size, request.clone().search) + .unwrap(), offset_partition => mrepo - .find_next_messages(request.page_size, offset_partition, request.clone().search) - .unwrap(), + .find_next_messages(request.page_size, offset_partition, request.clone().search) + .unwrap(), }, PageOp::Prev => mrepo - .find_prev_messages( - request.page_size, - request.offset_partition, - request.clone().search, - ) - .unwrap(), + .find_prev_messages( + request.page_size, + request.offset_partition, + request.clone().search, + ) + .unwrap(), }; Ok(MessagesResponse { total, @@ -215,7 +230,11 @@ impl MessagesWorker { let kafka = KafkaBackend::new(&request.connection); let topic = &request.topic.name; // Run async background task - let total = kafka.topic_message_count(topic).await; + let total = kafka + .topic_message_count(topic, None) + .await + .total + .unwrap_or_default(); let messages = kafka.list_messages_for_topic(topic, total).await?; Ok(MessagesResponse { total, diff --git a/src/component/messages/messages_tab.rs b/src/component/messages/messages_tab.rs index 334d790..12221d5 100644 --- a/src/component/messages/messages_tab.rs +++ b/src/component/messages/messages_tab.rs @@ -482,6 +482,7 @@ impl FactoryComponent for MessagesTabModel { connection_id: cloned_topic.connection_id, cached: None, partitions: vec![], + total: None, }; let conn = self.connection.clone().unwrap(); MessagesWorker::new().cleanup_messages(&MessagesCleanupRequest { diff --git a/src/component/topics_page.rs b/src/component/topics_page.rs index c08c480..b2ebd55 100644 --- a/src/component/topics_page.rs +++ b/src/component/topics_page.rs @@ -197,6 +197,7 @@ impl Component for TopicsPageModel { name: item.borrow().name.clone(), cached: None, partitions: vec![], + total: None, }; sender .output(TopicsPageOutput::OpenMessagesPage( From 9f88c0a61859d5469201899686e2127c4acc2d58 Mon Sep 17 00:00:00 2001 From: Miguel Aranha Baldi Horlle Date: Sat, 20 Apr 2024 21:35:52 -0300 Subject: [PATCH 5/8] Messages cache fix. - HOTFIX `refresh`: removing high offset + 1 --- src/backend/kafka.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/backend/kafka.rs b/src/backend/kafka.rs index 4d82eec..7e00594 100644 --- a/src/backend/kafka.rs +++ b/src/backend/kafka.rs @@ -255,7 +255,7 @@ impl KafkaBackend { let offset = match fetch { KafkaFetch::Newest => p .offset_high - .map(|oh| Offset::from_raw(oh + 1)) + .map(|oh| Offset::from_raw(oh)) .unwrap_or(Offset::Beginning), KafkaFetch::Oldest => Offset::Beginning, }; @@ -313,8 +313,10 @@ impl KafkaBackend { value: payload.to_string(), headers, }; - trace!("saving message {}", &message.offset); - mrepo.save_message(&conn, &message)?; + match mrepo.save_message(&conn, &message) { + Ok(_) => trace!("message with offset {} saved", &message.offset), + Err(err) => warn!("unable to save message with offset {}: {}", &message.offset, err.to_string()), + }; counter += 1; } }; From c1d405b0150f17f6522c8b055dd1c8847419f961 Mon Sep 17 00:00:00 2001 From: Miguel Aranha Baldi Horlle Date: Wed, 24 Apr 2024 00:02:30 -0300 Subject: [PATCH 6/8] Messages page improvements. - Live: supporting fetch types: `Newest|Oldest`. - Live: supporting max messages per partition. - UI improvements. - Adding message key to the list and cache. - Messages copy: popover menu with copy commands, including CSV format. --- Cargo.lock | 22 ++ Cargo.toml | 1 + src/backend/kafka.rs | 201 ++++++++++++++----- src/backend/repository.rs | 63 +++--- src/backend/worker.rs | 29 +-- src/component/app.rs | 1 - src/component/messages/lists.rs | 35 +++- src/component/messages/messages_page.rs | 1 - src/component/messages/messages_tab.rs | 254 +++++++++++++++++++++--- src/main.rs | 3 +- 10 files changed, 491 insertions(+), 119 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 26ee1c6..0bb09cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -218,6 +218,27 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" +[[package]] +name = "csv" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac574ff4d437a7b5ad237ef331c17ccca63c46479e5b5453eb8e10bb99a759fe" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "csv-core" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efa2b3d7902f4b634a20cae3c9c4e6209dc4779feb6863329607560143efa70" +dependencies = [ + "memchr", +] + [[package]] name = "directories" version = "4.0.1" @@ -748,6 +769,7 @@ dependencies = [ "anyhow", "chrono", "chrono-tz", + "csv", "directories", "futures", "gtk4", diff --git a/Cargo.toml b/Cargo.toml index cfd0f40..14a15f7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,7 @@ chrono = { version = "0.4.37", features = ["serde"] } chrono-tz = { version = "0.9.0", features = [ "filter-by-regex" ] } strum = { version = "0.26.2", features = ["derive"] } rdkafka = { version = "0.36.2", features = [ "cmake-build", "gssapi", "ssl" ] } +csv = "1.3.0" [target.'cfg(target_os = "windows")'.dependencies] sasl2-sys = { version = "0.1.20",features = ["openssl-vendored"]} diff --git a/src/backend/kafka.rs b/src/backend/kafka.rs index 7e00594..9961cb5 100644 --- a/src/backend/kafka.rs +++ b/src/backend/kafka.rs @@ -7,7 +7,6 @@ use rdkafka::message::Headers; use rdkafka::topic_partition_list::TopicPartitionList; use rdkafka::{Message, Offset}; -use std::borrow::Borrow; use std::collections::HashMap; use std::time::{Duration, Instant}; use tracing::{debug, info, trace, warn}; @@ -41,13 +40,17 @@ type LoggingConsumer = StreamConsumer; // rdkafka: end -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, strum::EnumString, strum::Display)] pub enum KafkaFetch { #[default] Newest, Oldest, } +impl KafkaFetch { + pub const VALUES: [Self; 2] = [Self::Newest, Self::Oldest]; +} + #[derive(Debug, Clone)] pub struct KafkaBackend { pub config: KrustConnection, @@ -182,14 +185,21 @@ impl KafkaBackend { pub async fn topic_message_count( &self, topic: &String, + fetch: Option, + max_messages: Option, current_partitions: Option>, ) -> KrustTopic { - info!("couting messages for topic {}", topic); + info!( + "couting messages for topic {}, fetch {:?}, max messages {:?}", + topic, fetch, max_messages + ); let mut message_count: usize = 0; let partitions = &self.fetch_partitions(topic).await; let mut result = current_partitions.clone().unwrap_or_default(); let cpartitions = ¤t_partitions.unwrap_or_default().clone(); + let fetch = fetch.unwrap_or_default(); + let max_messages: i64 = max_messages.unwrap_or_default(); let part_map = cpartitions .into_iter() @@ -212,8 +222,56 @@ impl KafkaBackend { message_count += usize::try_from(p.offset_high.unwrap_or_default()).unwrap() - usize::try_from(low).unwrap(); } else { - message_count += usize::try_from(p.offset_high.unwrap_or_default()).unwrap() - - usize::try_from(p.offset_low.unwrap_or_default()).unwrap(); + let (low, high) = match fetch { + KafkaFetch::Newest => { + let low = p.offset_high.unwrap_or_default() - max_messages; + debug!( + "Newest::[low={},new_low={},high={},max={}]", + p.offset_low.unwrap_or_default(), + low, + p.offset_high.unwrap_or_default(), + max_messages + ); + if max_messages > 0 + && p.offset_high.unwrap_or_default() >= max_messages + && low >= p.offset_low.unwrap_or_default() + { + (low, p.offset_high.unwrap_or_default()) + } else { + ( + p.offset_low.unwrap_or_default(), + p.offset_high.unwrap_or_default(), + ) + } + } + KafkaFetch::Oldest => { + let high = p.offset_low.unwrap_or_default() + max_messages; + debug!( + "Oldest::[low={},high={},new_high={},max={}]", + p.offset_low.unwrap_or_default(), + p.offset_high.unwrap_or_default(), + high, + max_messages + ); + if max_messages > 0 + && p.offset_low.unwrap_or_default() < high + && high <= p.offset_high.unwrap_or_default() + { + (p.offset_low.unwrap_or_default(), high) + } else { + ( + p.offset_low.unwrap_or_default(), + p.offset_high.unwrap_or_default(), + ) + } + } + }; + result.push(Partition { + id: p.id, + offset_low: Some(low), + offset_high: Some(high), + }); + message_count += usize::try_from(high).unwrap() - usize::try_from(low).unwrap(); }; } @@ -222,7 +280,7 @@ impl KafkaBackend { connection_id: None, name: topic.clone(), cached: None, - partitions: if !cpartitions.is_empty() { + partitions: if !result.is_empty() { result } else { partitions.clone() @@ -288,8 +346,16 @@ impl KafkaBackend { "" } }; + let key = match m.key_view::() { + None => "", + Some(Ok(s)) => s, + Some(Err(e)) => { + warn!("Error while deserializing message key: {:?}", e); + "" + } + }; trace!("key: '{:?}', payload: '{}', topic: {}, partition: {}, offset: {}, timestamp: {:?}", - m.key(), payload, m.topic(), m.partition(), m.offset(), m.timestamp()); + key, payload, m.topic(), m.partition(), m.offset(), m.timestamp()); let headers = if let Some(headers) = m.headers() { let mut header_list: Vec = vec![]; for header in headers.iter() { @@ -309,13 +375,18 @@ impl KafkaBackend { topic: m.topic().to_string(), partition: m.partition(), offset: m.offset(), + key: key.to_string(), timestamp: m.timestamp().to_millis(), value: payload.to_string(), headers, }; match mrepo.save_message(&conn, &message) { Ok(_) => trace!("message with offset {} saved", &message.offset), - Err(err) => warn!("unable to save message with offset {}: {}", &message.offset, err.to_string()), + Err(err) => warn!( + "unable to save message with offset {}: {}", + &message.offset, + err.to_string() + ), }; counter += 1; } @@ -331,7 +402,8 @@ impl KafkaBackend { pub async fn list_messages_for_topic( &self, topic: &String, - total: usize, + fetch: Option, + max_messages: Option, ) -> Result, ExternalError> { let start_mark = Instant::now(); info!("starting listing messages for topic {}", topic); @@ -341,51 +413,86 @@ impl KafkaBackend { let mut counter = 0; - info!("consumer created"); + let topic = self + .topic_message_count(topic, fetch.clone(), max_messages, None) + .await; + let total = topic.total.clone().unwrap_or_default(); + let partitions = topic.partitions.clone(); + + let max_offset_map = partitions + .clone() + .into_iter() + .map(|p| (p.id, p.offset_high.clone().unwrap_or_default())) + .collect::>(); + + let mut partition_list = TopicPartitionList::with_capacity(partitions.capacity()); + for p in partitions.iter() { + let offset = Offset::from_raw(p.offset_low.unwrap_or_default()); + partition_list + .add_partition_offset(topic_name, p.id, offset) + .unwrap(); + } + info!("seeking partitions\n{:?}", partition_list); consumer - .subscribe(&[topic_name]) - .expect("Can't subscribe to specified topics"); + .assign(&partition_list) + .expect("Can't subscribe to partition list"); + let mut messages: Vec = Vec::with_capacity(total); while counter < total { match consumer.recv().await { Err(e) => warn!("Kafka error: {}", e), Ok(m) => { - let payload = match m.payload_view::() { - None => "", - Some(Ok(s)) => s, - Some(Err(e)) => { - warn!("Error while deserializing message payload: {:?}", e); - "" - } + let max_offset = match max_offset_map.get(&m.partition()) { + Some(max) => *max, + None => 0, }; - trace!("key: '{:?}', payload: '{}', topic: {}, partition: {}, offset: {}, timestamp: {:?}", - m.key(), payload, m.topic(), m.partition(), m.offset(), m.timestamp()); - let headers = if let Some(headers) = m.headers() { - let mut header_list: Vec = vec![]; - for header in headers.iter() { - let h = KrustHeader { - key: header.key.to_string(), - value: header - .value - .map(|v| String::from_utf8(v.to_vec()).unwrap_or_default()), - }; - header_list.push(h); - } - header_list - } else { - vec![] - }; - let message = KrustMessage { - topic: m.topic().to_string(), - partition: m.partition(), - offset: m.offset(), - timestamp: m.timestamp().to_millis(), - value: payload.to_string(), - headers, - }; - - messages.push(message); - counter += 1; + if m.offset() <= max_offset { + let payload = match m.payload_view::() { + None => "", + Some(Ok(s)) => s, + Some(Err(e)) => { + warn!("Error while deserializing message payload: {:?}", e); + "" + } + }; + let key = match m.key_view::() { + None => "", + Some(Ok(s)) => s, + Some(Err(e)) => { + warn!("Error while deserializing message key: {:?}", e); + "" + } + }; + trace!("key: '{:?}', payload: '{}', topic: {}, partition: {}, offset: {}, timestamp: {:?}", + key, payload, m.topic(), m.partition(), m.offset(), m.timestamp()); + let headers = if let Some(headers) = m.headers() { + let mut header_list: Vec = vec![]; + for header in headers.iter() { + let h = KrustHeader { + key: header.key.to_string(), + value: header + .value + .map(|v| String::from_utf8(v.to_vec()).unwrap_or_default()), + }; + header_list.push(h); + } + header_list + } else { + vec![] + }; + let message = KrustMessage { + topic: m.topic().to_string(), + partition: m.partition(), + offset: m.offset(), + key: key.to_string(), + timestamp: m.timestamp().to_millis(), + value: payload.to_string(), + headers, + }; + + messages.push(message); + counter += 1; + } } }; } diff --git a/src/backend/repository.rs b/src/backend/repository.rs index fae7358..c73663e 100644 --- a/src/backend/repository.rs +++ b/src/backend/repository.rs @@ -63,6 +63,7 @@ pub struct KrustMessage { pub topic: String, pub partition: i32, pub offset: i64, + pub key: String, pub value: String, pub timestamp: Option, pub headers: Vec, @@ -106,10 +107,19 @@ impl MessagesRepository { .unwrap(); conn } + pub fn get_init_connection(&mut self) -> Connection { + let conn = database_connection_with_name(&self.path, &self.database_name) + .expect("problem acquiring database connection"); + conn + } pub fn init(&mut self) -> Result<(), ExternalError> { - self.get_connection().execute_batch( - "CREATE TABLE IF NOT EXISTS kr_message (partition INTEGER, offset INTEGER, value TEXT, timestamp INTEGER, headers TEXT, PRIMARY KEY (partition, offset));" - ).map_err(ExternalError::DatabaseError) + let result = self.get_init_connection().execute_batch( + "CREATE TABLE IF NOT EXISTS kr_message (partition INTEGER, offset INTEGER, key TEXT, value TEXT, timestamp INTEGER, headers TEXT, PRIMARY KEY (partition, offset));" + ).map_err(ExternalError::DatabaseError); + let _ = self.get_init_connection().execute_batch( + "ALTER TABLE kr_message ADD COLUMN key TEXT;" + ).ok(); + result } pub fn destroy(&mut self) -> Result<(), ExternalError> { @@ -123,14 +133,14 @@ impl MessagesRepository { ) -> Result { //let conn = self.get_connection(); let mut stmt_by_id = conn.prepare_cached( - "INSERT INTO kr_message(partition, offset, value, timestamp, headers) - VALUES (:p, :o, :v, :t, :h)", + "INSERT INTO kr_message(partition, offset, key, value, timestamp, headers) + VALUES (:p, :o, :k, :v, :t, :h)", )?; let headers = ron::ser::to_string::>(message.headers.as_ref()).unwrap_or_default(); let maybe_message = stmt_by_id .execute( - named_params! { ":p": message.partition, ":o": message.offset, ":v": message.value, ":t": message.timestamp, ":h": headers}, + named_params! { ":p": message.partition, ":o": message.offset, ":k": message.key, ":v": message.value, ":t": message.timestamp, ":h": headers}, ) .map_err(ExternalError::DatabaseError); @@ -201,14 +211,14 @@ impl MessagesRepository { let conn = self.get_connection(); let mut stmt_query = match search { Some(_) => conn.prepare_cached( - "SELECT partition, offset, value, timestamp, headers + "SELECT partition, offset, key, value, timestamp, headers FROM kr_message WHERE value LIKE :search ORDER BY offset, partition LIMIT :ps", )?, None => conn.prepare_cached( - "SELECT partition, offset, value, timestamp, headers + "SELECT partition, offset, key, value, timestamp, headers FROM kr_message ORDER BY offset, partition LIMIT :ps", @@ -224,9 +234,10 @@ impl MessagesRepository { Ok(KrustMessage { partition: row.get(0)?, offset: row.get(1)?, - value: row.get(2)?, - timestamp: Some(row.get(3)?), - headers: string_to_headers(row.get(4)?)?, + key: row.get(2)?, + value: row.get(3)?, + timestamp: Some(row.get(4)?), + headers: string_to_headers(row.get(5)?)?, topic: topic_name.clone(), }) }; @@ -259,7 +270,7 @@ impl MessagesRepository { let conn = self.get_connection(); let mut stmt_query = match search { Some(_) => conn.prepare_cached( - "SELECT partition, offset, value, timestamp, headers + "SELECT partition, offset, key, value, timestamp, headers FROM kr_message WHERE (offset, partition) > (:o, :p) AND value LIKE :search @@ -267,7 +278,7 @@ impl MessagesRepository { LIMIT :ps", )?, None => conn.prepare_cached( - "SELECT partition, offset, value, timestamp, headers + "SELECT partition, offset, key, value, timestamp, headers FROM kr_message WHERE (offset, partition) > (:o, :p) ORDER BY offset, partition @@ -284,9 +295,10 @@ impl MessagesRepository { Ok(KrustMessage { partition: row.get(0)?, offset: row.get(1)?, - value: row.get(2)?, - timestamp: Some(row.get(3)?), - headers: string_to_headers(row.get(4)?)?, + key: row.get(2)?, + value: row.get(3)?, + timestamp: Some(row.get(4)?), + headers: string_to_headers(row.get(5)?)?, topic: topic_name.clone(), }) }; @@ -318,8 +330,8 @@ impl MessagesRepository { let conn = self.get_connection(); let mut stmt_query = match search { Some(_) => conn.prepare_cached( - "SELECT partition, offset, value, timestamp, headers FROM ( - SELECT partition, offset, value, timestamp, headers + "SELECT partition, offset, key, value, timestamp, headers FROM ( + SELECT partition, offset, key, value, timestamp, headers FROM kr_message WHERE (offset, partition) < (:o, :p) AND value LIKE :search @@ -328,8 +340,8 @@ impl MessagesRepository { ) ORDER BY offset ASC, partition ASC", )?, None => conn.prepare_cached( - "SELECT partition, offset, value, timestamp, headers FROM ( - SELECT partition, offset, value, timestamp, headers + "SELECT partition, offset, key, value, timestamp, headers FROM ( + SELECT partition, offset, key, value, timestamp, headers FROM kr_message WHERE (offset, partition) < (:o, :p) ORDER BY offset DESC, partition DESC @@ -347,9 +359,10 @@ impl MessagesRepository { Ok(KrustMessage { partition: row.get(0)?, offset: row.get(1)?, - value: row.get(2)?, - timestamp: Some(row.get(3)?), - headers: string_to_headers(row.get(4)?)?, + key: row.get(2)?, + value: row.get(3)?, + timestamp: Some(row.get(4)?), + headers: string_to_headers(row.get(5)?)?, topic: topic_name.clone(), }) }; @@ -577,17 +590,19 @@ impl Repository { topic, partition, offset, + key, value, timestamp, headers, } = message; - let mut insert_stmt = self.conn.prepare_cached("INSERT INTO kr_message (connection, topic, partition, offset, value, timestamp) VALUES (?, ?, ?, ?, ?, ?) RETURNING id")?; + let mut insert_stmt = self.conn.prepare_cached("INSERT INTO kr_message (connection, topic, partition, offset, key, value, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id")?; let result = insert_stmt .query_row(params![], |_row| { Ok(KrustMessage { topic, partition, offset, + key, value, timestamp, headers, diff --git a/src/backend/worker.rs b/src/backend/worker.rs index 425b995..6f0163b 100644 --- a/src/backend/worker.rs +++ b/src/backend/worker.rs @@ -1,5 +1,3 @@ -use std::collections::HashMap; - use chrono::Utc; use tokio::select; use tokio_util::sync::CancellationToken; @@ -9,7 +7,7 @@ use crate::{config::ExternalError, Repository}; use super::{ kafka::{KafkaBackend, KafkaFetch}, - repository::{KrustConnection, KrustMessage, KrustTopic, MessagesRepository, Partition}, + repository::{KrustConnection, KrustMessage, KrustTopic, MessagesRepository}, }; #[derive(Debug, Clone, Copy, PartialEq, Default, strum::EnumString, strum::Display)] @@ -36,6 +34,8 @@ pub struct MessagesRequest { pub page_size: u16, pub offset_partition: (usize, usize), pub search: Option, + pub fetch: KafkaFetch, + pub max_messages: i64, } #[derive(Debug, Clone)] @@ -116,8 +116,6 @@ impl MessagesWorker { } } - - async fn get_messages_cached( self, request: &MessagesRequest, @@ -151,7 +149,9 @@ impl MessagesWorker { Some(_) => { if refresh { let partitions = mrepo.find_offsets().ok(); - let topic = kafka.topic_message_count(&topic.name, partitions.clone()).await; + let topic = kafka + .topic_message_count(&topic.name, None, None, partitions.clone()) + .await; let partitions = topic.partitions.clone(); let total = topic.total.unwrap_or_default(); kafka @@ -171,7 +171,7 @@ impl MessagesWorker { } None => { let total = kafka - .topic_message_count(&topic.name, None) + .topic_message_count(&topic.name, None, None, None) .await .total .unwrap_or_default(); @@ -230,14 +230,15 @@ impl MessagesWorker { let kafka = KafkaBackend::new(&request.connection); let topic = &request.topic.name; // Run async background task - let total = kafka - .topic_message_count(topic, None) - .await - .total - .unwrap_or_default(); - let messages = kafka.list_messages_for_topic(topic, total).await?; + let messages = kafka + .list_messages_for_topic( + topic, + Some(request.fetch.clone()), + Some(request.max_messages), + ) + .await?; Ok(MessagesResponse { - total, + total: messages.len(), messages: messages, topic: Some(request.topic.clone()), page_operation: request.page_operation, diff --git a/src/component/app.rs b/src/component/app.rs index 2b9c078..2abb0e7 100644 --- a/src/component/app.rs +++ b/src/component/app.rs @@ -45,7 +45,6 @@ pub enum AppMsg { SavedSettings, } -#[derive(Debug)] pub struct AppModel { //state: State, _status_bar: Controller, diff --git a/src/component/messages/lists.rs b/src/component/messages/lists.rs index aa7ff09..53660da 100644 --- a/src/component/messages/lists.rs +++ b/src/component/messages/lists.rs @@ -113,7 +113,7 @@ impl MessageListItem { Self { offset: value.offset, partition: value.partition, - key: String::default(), + key: value.key, value: value.value, timestamp: value.timestamp, headers: value.headers, @@ -203,12 +203,12 @@ impl LabelColumn for MessageValueColumn { } fn format_cell_value(value: &Self::Value) -> String { - if value.len() >= 150 { + if value.len() >= 100 { format!( "{}...", value .replace('\n', " ") - .get(0..150) + .get(0..100) .unwrap_or("") ) } else { @@ -216,5 +216,34 @@ impl LabelColumn for MessageValueColumn { } } } +pub struct MessageKeyColumn; + +impl LabelColumn for MessageKeyColumn { + type Item = MessageListItem; + type Value = String; + + const COLUMN_NAME: &'static str = "Key"; + const ENABLE_RESIZE: bool = true; + const ENABLE_EXPAND: bool = true; + const ENABLE_SORT: bool = false; + + fn get_cell_value(item: &Self::Item) -> Self::Value { + item.key.clone() + } + + fn format_cell_value(value: &Self::Value) -> String { + if value.len() >= 40 { + format!( + "{}...", + value + .replace('\n', " ") + .get(0..40) + .unwrap_or("") + ) + } else { + format!("{}", value) + } + } +} // Table messages: end diff --git a/src/component/messages/messages_page.rs b/src/component/messages/messages_page.rs index af639f8..4557cc6 100644 --- a/src/component/messages/messages_page.rs +++ b/src/component/messages/messages_page.rs @@ -11,7 +11,6 @@ use sourceview5 as sourceview; use super::messages_tab::{MessagesTabInit, MessagesTabModel}; -#[derive(Debug)] pub struct MessagesPageModel { topic: Option, connection: Option, diff --git a/src/component/messages/messages_tab.rs b/src/component/messages/messages_tab.rs index 12221d5..da376a6 100644 --- a/src/component/messages/messages_tab.rs +++ b/src/component/messages/messages_tab.rs @@ -2,9 +2,16 @@ // See: https://gitlab.gnome.org/GNOME/gtk/-/issues/5644 use chrono::{TimeZone, Utc}; use chrono_tz::America; -use gtk::{gdk::DisplayManager, ColumnViewSorter}; +use csv::StringRecord; +use gtk::{ + gdk::{DisplayManager, Rectangle}, + ColumnViewSorter, +}; use relm4::{ - actions::{RelmAction, RelmActionGroup}, factory::{DynamicIndex, FactoryComponent}, typed_view::column::TypedColumnView, * + actions::{RelmAction, RelmActionGroup}, + factory::{DynamicIndex, FactoryComponent}, + typed_view::column::TypedColumnView, + *, }; use relm4_components::simple_combo_box::SimpleComboBox; use sourceview::prelude::*; @@ -14,6 +21,7 @@ use tracing::{debug, info, trace}; use crate::{ backend::{ + kafka::KafkaFetch, repository::{KrustConnection, KrustMessage, KrustTopic}, worker::{ MessagesCleanupRequest, MessagesMode, MessagesRequest, MessagesResponse, @@ -31,11 +39,17 @@ use crate::{ Repository, DATE_TIME_FORMAT, }; +use super::lists::MessageKeyColumn; + // page actions relm4::new_action_group!(pub MessagesPageActionGroup, "messages_page"); relm4::new_stateless_action!(pub MessagesSearchAction, MessagesPageActionGroup, "search"); -#[derive(Debug)] +relm4::new_action_group!(pub(super) MessagesListActionGroup, "messages-list"); +relm4::new_stateless_action!(pub(super) CopyMessagesAsCsv, MessagesListActionGroup, "copy-messages-as-csv"); +relm4::new_stateless_action!(pub(super) CopyMessagesValue, MessagesListActionGroup, "copy-messages-value"); +relm4::new_stateless_action!(pub(super) CopyMessagesKey, MessagesListActionGroup, "copy-messages-key"); + pub struct MessagesTabModel { token: CancellationToken, pub topic: Option, @@ -45,12 +59,22 @@ pub struct MessagesTabModel { headers_wrapper: TypedColumnView, page_size_combo: Controller>, page_size: u16, + fetch_type_combo: Controller>, + fetch_type: KafkaFetch, + max_messages: f64, + messages_menu_popover: gtk::PopoverMenu, } pub struct MessagesTabInit { pub topic: KrustTopic, pub connection: KrustConnection, } +#[derive(Debug)] +pub enum Copy { + AllAsCsv, + Value, + Key, +} #[derive(Debug)] pub enum MessagesTabMsg { @@ -66,7 +90,10 @@ pub enum MessagesTabMsg { LiveSearchMessages(String), Selection(u32), PageSizeChanged(usize), + FetchTypeChanged(usize), ToggleMode(bool), + DigitsOnly(f64), + CopyMessages(Copy), } #[derive(Debug)] @@ -84,6 +111,16 @@ impl FactoryComponent for MessagesTabModel { type CommandOutput = CommandMsg; type ParentWidget = adw::TabView; + menu! { + messages_menu: { + section! { + "_Copy as CSV" => CopyMessagesAsCsv, + "_Copy value" => CopyMessagesValue, + "_Copy key" => CopyMessagesKey, + } + } + } + view! { #[root] gtk::Paned { @@ -94,6 +131,9 @@ impl FactoryComponent for MessagesTabModel { set_orientation: gtk::Orientation::Vertical, set_hexpand: true, set_vexpand: true, + container_add = &self.messages_menu_popover.clone() { + set_menu_model: Some(&messages_menu), + }, gtk::CenterBox { set_orientation: gtk::Orientation::Horizontal, set_halign: gtk::Align::Fill, @@ -159,13 +199,8 @@ impl FactoryComponent for MessagesTabModel { sender.input(MessagesTabMsg::SearchMessages); }, }, - #[name(messages_search_btn)] - gtk::Button { - set_icon_name: "edit-find-symbolic", + self.fetch_type_combo.widget() -> >k::ComboBoxText { set_margin_start: 5, - connect_clicked[sender] => move |_| { - sender.input(MessagesTabMsg::SearchMessages); - }, }, }, }, @@ -180,7 +215,7 @@ impl FactoryComponent for MessagesTabModel { set_show_column_separators: true, set_single_click_activate: false, set_enable_rubberband: true, - } + }, }, }, #[wrap(Some)] @@ -345,19 +380,27 @@ impl FactoryComponent for MessagesTabModel { set_halign: gtk::Align::Start, set_hexpand: true, gtk::Label { - set_label: "Max messages", + set_label: "Max messages (per partition)", set_margin_start: 5, }, #[name(max_messages)] - gtk::Entry { + gtk::SpinButton { set_margin_start: 5, set_width_chars: 10, + set_numeric: true, + set_increments: (1000.0, 10000.0), + set_range: (1.0, 100000.0), + set_value: self.max_messages, + set_digits: 0, + connect_value_changed[sender] => move |sbtn| { + sender.input(MessagesTabMsg::DigitsOnly(sbtn.value())); + }, }, }, }, }, }, - } + }, } @@ -366,6 +409,7 @@ impl FactoryComponent for MessagesTabModel { let mut messages_wrapper = TypedColumnView::::new(); messages_wrapper.append_column::(); messages_wrapper.append_column::(); + messages_wrapper.append_column::(); messages_wrapper.append_column::(); messages_wrapper.append_column::(); @@ -381,6 +425,37 @@ impl FactoryComponent for MessagesTabModel { }) .forward(sender.input_sender(), MessagesTabMsg::PageSizeChanged); page_size_combo.widget().queue_allocate(); + let fetch_type_combo = SimpleComboBox::builder() + .launch(SimpleComboBox { + variants: KafkaFetch::VALUES.to_vec(), + active_index: Some(default_idx), + }) + .forward(sender.input_sender(), MessagesTabMsg::FetchTypeChanged); + + let messages_popover_menu = gtk::PopoverMenu::builder().build(); + let mut messages_actions = RelmActionGroup::::new(); + let messages_menu_sender = sender.input_sender().clone(); + let menu_copy_all_csv_action = RelmAction::::new_stateless(move |_| { + messages_menu_sender + .send(MessagesTabMsg::CopyMessages(Copy::AllAsCsv)) + .unwrap(); + }); + let messages_menu_sender = sender.input_sender().clone(); + let menu_copy_value_action = RelmAction::::new_stateless(move |_| { + messages_menu_sender + .send(MessagesTabMsg::CopyMessages(Copy::Value)) + .unwrap(); + }); + let messages_menu_sender = sender.input_sender().clone(); + let menu_copy_key_action = RelmAction::::new_stateless(move |_| { + messages_menu_sender + .send(MessagesTabMsg::CopyMessages(Copy::Key)) + .unwrap(); + }); + messages_actions.add_action(menu_copy_all_csv_action); + messages_actions.add_action(menu_copy_value_action); + messages_actions.add_action(menu_copy_key_action); + messages_actions.register_for_widget(&messages_popover_menu); let model = MessagesTabModel { token: CancellationToken::new(), mode: MessagesMode::Live, @@ -390,15 +465,19 @@ impl FactoryComponent for MessagesTabModel { headers_wrapper, page_size_combo, page_size: AVAILABLE_PAGE_SIZES[0], + fetch_type_combo, + fetch_type: KafkaFetch::default(), + max_messages: 1000.0, + messages_menu_popover: messages_popover_menu, }; let messages_view = &model.messages_wrapper.view; let _headers_view = &model.headers_wrapper.view; - let sender_for_selection = sender.clone(); + let _sender_for_selection = sender.clone(); messages_view .model() .unwrap() - .connect_selection_changed(move |selection_model, _, _| { - sender_for_selection.input(MessagesTabMsg::Selection(selection_model.n_items())); + .connect_selection_changed(move |_selection_model, _, _| { + //sender_for_selection.input(MessagesTabMsg::Selection(selection_model.n_items())); }); let sender_for_activate = sender.clone(); messages_view.connect_activate(move |_view, idx| { @@ -441,20 +520,38 @@ impl FactoryComponent for MessagesTabModel { } let language = sourceview::LanguageManager::default().language("json"); buffer.set_language(language.as_ref()); - + widgets.max_messages.set_increments(1000.0, 10000.0); // Shortcuts - let mut actions = RelmActionGroup::::new(); + // let mut actions = RelmActionGroup::::new(); - let messages_search_entry = widgets.messages_search_entry.clone(); - let search_action = { - let messages_search_btn = widgets.messages_search_btn.clone(); - RelmAction::::new_stateless(move |_| { - messages_search_btn.emit_clicked(); - }) - }; - actions.add_action(search_action); - actions.register_for_widget(messages_search_entry); + // let messages_search_entry = widgets.messages_search_entry.clone(); + // let search_action = { + // let messages_search_btn = widgets.messages_search_btn.clone(); + // RelmAction::::new_stateless(move |_| { + // messages_search_btn.emit_clicked(); + // }) + // }; + // actions.add_action(search_action); + // actions.register_for_widget(messages_search_entry); + + //self.messages_menu_popover.set_menu_model(widgets.menu) + // Create a click gesture + let gesture = gtk::GestureClick::new(); + // Set the gestures button to the right mouse button (=3) + gesture.set_button(gtk::gdk::ffi::GDK_BUTTON_SECONDARY as u32); + + // Assign your handler to an event of the gesture (e.g. the `pressed` event) + let messages_menu = self.messages_menu_popover.clone(); + gesture.connect_pressed(move |gesture, _n, x, y| { + gesture.set_state(gtk::EventSequenceState::Claimed); + let x = x as i32; + let y = y as i32; + info!("ColumnView: Right mouse button pressed [x={},y={}]", x, y); + messages_menu.set_pointing_to(Some(&Rectangle::new(x, y + 55, 1, 1))); + messages_menu.popup(); + }); + self.messages_wrapper.view.add_controller(gesture); } fn update_with_view( @@ -464,6 +561,10 @@ impl FactoryComponent for MessagesTabModel { sender: FactorySender, ) { match msg { + MessagesTabMsg::DigitsOnly(value) => { + self.max_messages = value; + info!("Max messages:{}", self.max_messages); + } MessagesTabMsg::ToggleMode(toggle) => { self.mode = if toggle { widgets.cached_controls.set_visible(true); @@ -501,6 +602,14 @@ impl FactoryComponent for MessagesTabModel { self.page_size = page_size; self.page_size_combo.widget().queue_allocate(); } + MessagesTabMsg::FetchTypeChanged(_idx) => { + let fetch_type = match self.fetch_type_combo.model().get_active_elem() { + Some(ps) => ps.clone(), + None => KafkaFetch::default(), + }; + self.fetch_type = fetch_type; + self.fetch_type_combo.widget().queue_allocate(); + } MessagesTabMsg::Selection(size) => { let mut copy_content = String::from("PARTITION;OFFSET;VALUE;TIMESTAMP"); let min_length = copy_content.len(); @@ -535,6 +644,22 @@ impl FactoryComponent for MessagesTabModel { .set_text(copy_content.as_str()); } } + MessagesTabMsg::CopyMessages(copy) => { + info!("copy selected messages"); + + let data = match copy { + Copy::AllAsCsv => copy_all_as_csv(self), + Copy::Value => copy_value(self), + Copy::Key => copy_key(self), + }; + if let Ok(data) = data { + DisplayManager::get() + .default_display() + .unwrap() + .clipboard() + .set_text(data.as_str()); + } + } MessagesTabMsg::Open(connection, topic) => { let conn_id = &connection.id.unwrap(); let topic_name = &topic.name.clone(); @@ -597,6 +722,8 @@ impl FactoryComponent for MessagesTabModel { let page_size = self.page_size; let token = self.token.clone(); let search = get_search_term(widgets); + let fetch = self.fetch_type.clone(); + let max_messages: i64 = self.max_messages as i64; widgets.pag_current_entry.set_text("0"); sender.oneshot_command(async move { // Run async background task @@ -612,6 +739,8 @@ impl FactoryComponent for MessagesTabModel { page_size, offset_partition: (0, 0), search: search, + fetch, + max_messages, }, ) .await @@ -647,6 +776,8 @@ impl FactoryComponent for MessagesTabModel { ); let search = get_search_term(widgets); let token = self.token.clone(); + let fetch = self.fetch_type.clone(); + let max_messages: i64 = self.max_messages as i64; info!( "getting next messages [page_size={}, last_offset={}, last_partition={}]", page_size, offset, partition @@ -665,6 +796,8 @@ impl FactoryComponent for MessagesTabModel { page_size, offset_partition: (offset, partition), search: search, + fetch, + max_messages, }, ) .await @@ -700,6 +833,8 @@ impl FactoryComponent for MessagesTabModel { ); let token = self.token.clone(); let search = get_search_term(widgets); + let fetch = self.fetch_type.clone(); + let max_messages: i64 = self.max_messages as i64; sender.oneshot_command(async move { // Run async background task let messages_worker = MessagesWorker::new(); @@ -714,6 +849,8 @@ impl FactoryComponent for MessagesTabModel { page_size, offset_partition: (offset, partition), search: search, + fetch, + max_messages, }, ) .await @@ -837,6 +974,7 @@ fn on_loading(widgets: &mut MessagesTabModelWidgets, enabled: bool) { widgets.btn_get_messages.set_sensitive(enabled); widgets.btn_cache_refresh.set_sensitive(enabled); widgets.btn_cache_toggle.set_sensitive(enabled); + widgets.max_messages.set_sensitive(enabled); } fn fill_pagination( @@ -900,3 +1038,65 @@ fn fill_pagination( } } } + +fn copy_all_as_csv(model: &mut MessagesTabModel) -> Result { + let mut wtr = csv::WriterBuilder::new() + .delimiter(b';') + .quote_style(csv::QuoteStyle::NonNumeric) + .from_writer(vec![]); + let _ = wtr.write_record(&["PARTITION", "OFFSET", "KEY", "VALUE", "TIMESTAMP"]); + for i in 0..model.messages_wrapper.selection_model.n_items() { + if model.messages_wrapper.selection_model.is_selected(i) { + let item = model.messages_wrapper.get_visible(i).unwrap(); + let partition = item.borrow().partition; + let offset = item.borrow().offset; + let key = item.borrow().key.clone(); + let value = item.borrow().value.clone(); + let clean_value = match serde_json::from_str::(value.as_str()) { + Ok(json) => json.to_string(), + Err(_) => value.replace('\n', ""), + }; + let timestamp = item.borrow().timestamp; + let record = StringRecord::from(vec![ + partition.to_string(), + offset.to_string(), + key, + clean_value, + timestamp.unwrap_or_default().to_string(), + ]); + let _ = wtr.write_record(&record); + } + } + let data = String::from_utf8(wtr.into_inner().unwrap_or_default()); + data +} +fn copy_value(model: &mut MessagesTabModel) -> Result { + let mut copy_content = String::default(); + for i in 0..model.messages_wrapper.selection_model.n_items() { + if model.messages_wrapper.selection_model.is_selected(i) { + let item = model.messages_wrapper.get_visible(i).unwrap(); + let value = item.borrow().value.clone(); + let clean_value = match serde_json::from_str::(value.as_str()) { + Ok(json) => json.to_string(), + Err(_) => value.replace('\n', ""), + }; + let copy_text = format!( + "{}\n", clean_value); + copy_content.push_str(©_text.as_str()); + } + } + Ok(copy_content) +} +fn copy_key(model: &mut MessagesTabModel) -> Result { + let mut copy_content = String::default(); + for i in 0..model.messages_wrapper.selection_model.n_items() { + if model.messages_wrapper.selection_model.is_selected(i) { + let item = model.messages_wrapper.get_visible(i).unwrap(); + let key = item.borrow().key.clone(); + let copy_text = format!( + "{}\n", key); + copy_content.push_str(©_text.as_str()); + } + } + Ok(copy_content) +} diff --git a/src/main.rs b/src/main.rs index 0f01b79..336f381 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,6 @@ use gtk::gdk; //#![windows_subsystem = "windows"] use gtk::prelude::ApplicationExt; use gtk::gio; -use krust::MessagesSearchAction; use tracing::*; use tracing_subscriber::filter; use tracing_subscriber::prelude::*; @@ -34,7 +33,7 @@ fn main() -> Result<(), ()> { // Enable the `INFO` level for anything in `my_crate` .with_target("relm4", Level::WARN) // Enable the `DEBUG` level for a specific module. - .with_target("krust", Level::DEBUG); + .with_target("krust", Level::TRACE); tracing_subscriber::registry() .with(HierarchicalLayer::new(2)) .with(EnvFilter::from_default_env()) From 5b6f0a66d7994b5f3731019b628214100864f57a Mon Sep 17 00:00:00 2001 From: Miguel Aranha Baldi Horlle Date: Thu, 25 Apr 2024 17:12:38 -0300 Subject: [PATCH 7/8] Topics and Messages improvements. - Messages UX: better tabs, copy/paste context menu (mouse right click). - Topics: introducing Favourites feature! Toggle button enable/disable favourites filter. Favourites topics appear on top. --- src/backend/kafka.rs | 6 +- src/backend/repository.rs | 102 +++++----- src/backend/worker.rs | 1 + src/component/app.rs | 2 +- src/component/messages/lists.rs | 2 +- src/component/messages/messages_page.rs | 195 ++++++++++++++++-- src/component/messages/messages_tab.rs | 171 ++++++++-------- src/component/topics_page.rs | 259 ++++++++++++++++++++---- src/config.rs | 6 +- src/styles.css | 55 +++++ win-prepare-and-build.sh | 1 + 11 files changed, 591 insertions(+), 209 deletions(-) diff --git a/src/backend/kafka.rs b/src/backend/kafka.rs index 9961cb5..278bef2 100644 --- a/src/backend/kafka.rs +++ b/src/backend/kafka.rs @@ -140,6 +140,7 @@ impl KafkaBackend { cached: None, partitions, total: None, + favourite: None, }); } topics @@ -286,6 +287,7 @@ impl KafkaBackend { partitions.clone() }, total: Some(message_count), + favourite: None, } } pub async fn cache_messages_for_topic( @@ -375,7 +377,7 @@ impl KafkaBackend { topic: m.topic().to_string(), partition: m.partition(), offset: m.offset(), - key: key.to_string(), + key: Some(key.to_string()), timestamp: m.timestamp().to_millis(), value: payload.to_string(), headers, @@ -484,7 +486,7 @@ impl KafkaBackend { topic: m.topic().to_string(), partition: m.partition(), offset: m.offset(), - key: key.to_string(), + key: Some(key.to_string()), timestamp: m.timestamp().to_millis(), value: payload.to_string(), headers, diff --git a/src/backend/repository.rs b/src/backend/repository.rs index c73663e..87fc032 100644 --- a/src/backend/repository.rs +++ b/src/backend/repository.rs @@ -6,6 +6,7 @@ use std::{fmt::Display, str::FromStr}; use rusqlite::{named_params, params, Connection, Row}; use serde::{Deserialize, Serialize}; use strum::EnumString; +use tracing::warn; use crate::config::{ database_connection, database_connection_with_name, destroy_database_with_name, ExternalError, @@ -50,6 +51,7 @@ pub struct KrustTopic { pub cached: Option, pub partitions: Vec, pub total: Option, + pub favourite: Option, } impl Display for KrustTopic { @@ -63,7 +65,7 @@ pub struct KrustMessage { pub topic: String, pub partition: i32, pub offset: i64, - pub key: String, + pub key: Option, pub value: String, pub timestamp: Option, pub headers: Vec, @@ -116,9 +118,10 @@ impl MessagesRepository { let result = self.get_init_connection().execute_batch( "CREATE TABLE IF NOT EXISTS kr_message (partition INTEGER, offset INTEGER, key TEXT, value TEXT, timestamp INTEGER, headers TEXT, PRIMARY KEY (partition, offset));" ).map_err(ExternalError::DatabaseError); - let _ = self.get_init_connection().execute_batch( - "ALTER TABLE kr_message ADD COLUMN key TEXT;" - ).ok(); + let _ = self + .get_init_connection() + .execute_batch("ALTER TABLE kr_message ADD COLUMN key TEXT;") + .ok(); result } @@ -399,10 +402,21 @@ impl Repository { } pub fn init(&mut self) -> Result<(), ExternalError> { - self.conn.execute_batch(" + let _result = self.conn.execute_batch(" CREATE TABLE IF NOT EXISTS kr_connection(id INTEGER PRIMARY KEY, name TEXT UNIQUE, brokersList TEXT, securityType TEXT, saslMechanism TEXT, saslUsername TEXT, saslPassword TEXT); CREATE TABLE IF NOT EXISTS kr_topic(connection_id INTEGER, name TEXT, cached INTEGER, PRIMARY KEY (connection_id, name), FOREIGN KEY (connection_id) REFERENCES kr_connection(id)); - ").map_err(ExternalError::DatabaseError) + ").map_err(ExternalError::DatabaseError)?; + let _result_add_topic_fav = self + .conn + .execute_batch( + " + ALTER TABLE kr_topic ADD COLUMN favourite INTEGER DEFAULT 0; + ", + ) + .map_err(ExternalError::DatabaseError).unwrap_or_else(|e| { + warn!("kr_topic.favourite: {:?}", e); + }); + Ok(()) } pub fn list_all_connections(&mut self) -> Result, ExternalError> { @@ -512,11 +526,12 @@ impl Repository { ) -> Result { let name = topic.name.clone(); let cached = topic.cached; + let favourite = topic.favourite; let mut stmt_by_id = self.conn.prepare_cached( - "INSERT INTO kr_topic(connection_id, name, cached) - VALUES (:cid, :topic, :cached) + "INSERT INTO kr_topic(connection_id, name, cached, favourite) + VALUES (:cid, :topic, :cached, :favourite) ON CONFLICT(connection_id, name) - DO UPDATE SET cached=excluded.cached", + DO UPDATE SET cached=excluded.cached, favourite=excluded.favourite", )?; let row_to_model = move |_| { Ok(KrustTopic { @@ -525,16 +540,18 @@ impl Repository { cached, partitions: vec![], total: None, + favourite, }) }; stmt_by_id .execute( - named_params! { ":cid": &conn_id, ":topic": &name.clone(), ":cached": &cached }, + named_params! { ":cid": &conn_id, ":topic": &name.clone(), ":cached": &cached, ":favourite": &favourite }, ) .map(row_to_model)? .map_err(ExternalError::DatabaseError) } + pub fn delete_topic( &mut self, conn_id: usize, @@ -554,7 +571,7 @@ impl Repository { pub fn find_topic(&mut self, conn_id: usize, topic_name: &String) -> Option { let stmt = self.conn - .prepare_cached("SELECT connection_id, name, cached FROM kr_topic WHERE connection_id = :cid AND name = :topic"); + .prepare_cached("SELECT connection_id, name, cached, favourite FROM kr_topic WHERE connection_id = :cid AND name = :topic"); stmt.ok()? .query_row( named_params! {":cid": &conn_id, ":topic": &topic_name }, @@ -565,50 +582,33 @@ impl Repository { cached: row.get(2)?, partitions: vec![], total: None, + favourite: row.get(3)?, }) }, ) .ok() } - - pub fn delete_all_messages_for_topic( - &mut self, - conn_id: usize, - topic_name: String, - ) -> Result { - let mut delete_stmt = self.conn.prepare_cached( - "DELETE from kr_message where connection = :conn_id AND topic = :topic", - )?; - let result = delete_stmt - .execute(named_params! {":conn_id": conn_id, ":topic": topic_name}) - .map_err(ExternalError::DatabaseError)?; - Ok(result) - } - - pub fn insert_message(&mut self, message: KrustMessage) -> Result { - let KrustMessage { - topic, - partition, - offset, - key, - value, - timestamp, - headers, - } = message; - let mut insert_stmt = self.conn.prepare_cached("INSERT INTO kr_message (connection, topic, partition, offset, key, value, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id")?; - let result = insert_stmt - .query_row(params![], |_row| { - Ok(KrustMessage { - topic, - partition, - offset, - key, - value, - timestamp, - headers, - }) - }) - .map_err(ExternalError::DatabaseError)?; - Ok(result) + pub fn find_topics_by_connection(&mut self, conn_id: usize) -> Result, ExternalError> { + let mut stmt = self.conn + .prepare_cached("SELECT connection_id, name, cached, favourite FROM kr_topic WHERE connection_id = :cid")?; + let rows = stmt + .query_and_then( + named_params! {":cid": &conn_id }, + |row| { + Ok::(KrustTopic { + connection_id: row.get(0)?, + name: row.get(1)?, + cached: row.get(2)?, + partitions: vec![], + total: None, + favourite: row.get(3)?, + }) + }, + ).map_err(ExternalError::DatabaseError)?; + let mut topics = vec![]; + for topic in rows { + topics.push(topic?); + } + Ok(topics) } } diff --git a/src/backend/worker.rs b/src/backend/worker.rs index 6f0163b..5210951 100644 --- a/src/backend/worker.rs +++ b/src/backend/worker.rs @@ -138,6 +138,7 @@ impl MessagesWorker { cached, partitions: vec![], total: None, + favourite: None, }; let topic = repo.save_topic( topic.connection_id.expect("should have connection id"), diff --git a/src/component/app.rs b/src/component/app.rs index 2abb0e7..187de95 100644 --- a/src/component/app.rs +++ b/src/component/app.rs @@ -164,7 +164,7 @@ impl Component for AppModel { set_name: "Topics", set_title: "Topics", }, - add_child = messages_page.widget() -> >k::Box {} -> { + add_child = messages_page.widget() -> &adw::TabOverview {} -> { set_name: "Messages", set_title: "Messages", }, diff --git a/src/component/messages/lists.rs b/src/component/messages/lists.rs index 53660da..b317d3e 100644 --- a/src/component/messages/lists.rs +++ b/src/component/messages/lists.rs @@ -113,7 +113,7 @@ impl MessageListItem { Self { offset: value.offset, partition: value.partition, - key: value.key, + key: value.key.unwrap_or_default(), value: value.value, timestamp: value.timestamp, headers: value.headers, diff --git a/src/component/messages/messages_page.rs b/src/component/messages/messages_page.rs index 4557cc6..02816a5 100644 --- a/src/component/messages/messages_page.rs +++ b/src/component/messages/messages_page.rs @@ -1,16 +1,22 @@ #![allow(deprecated)] + // See: https://gitlab.gnome.org/GNOME/gtk/-/issues/5644 use crate::{ backend::repository::{KrustConnection, KrustTopic}, Repository, }; use adw::TabPage; -use relm4::{factory::FactoryVecDeque, *}; +use relm4::{actions::RelmAction, factory::FactoryVecDeque, *}; use sourceview::prelude::*; use sourceview5 as sourceview; +use tracing::info; use super::messages_tab::{MessagesTabInit, MessagesTabModel}; +relm4::new_action_group!(pub(super) TopicTabActionGroup, "topic-tab"); +relm4::new_stateless_action!(pub(super) PinTabAction, TopicTabActionGroup, "toggle-pin"); +relm4::new_stateless_action!(pub(super) CloseTabAction, TopicTabActionGroup, "close"); + pub struct MessagesPageModel { topic: Option, connection: Option, @@ -21,6 +27,8 @@ pub struct MessagesPageModel { pub enum MessagesPageMsg { Open(KrustConnection, KrustTopic), PageAdded(TabPage, i32), + MenuPageClosed, + MenuPagePin, } #[relm4::component(pub)] @@ -30,17 +38,42 @@ impl Component for MessagesPageModel { type Output = (); type CommandOutput = (); + menu! { + tab_menu: { + section! { + "_Toggle pin" => PinTabAction, + "_Close" => CloseTabAction, + } + } + } + view! { #[root] - gtk::Box { - set_orientation: gtk::Orientation::Vertical, - append = &adw::TabBar { - set_autohide: false, - set_view: Some(&topics_viewer), + adw::TabOverview { + set_view: Some(&topics_viewer), + #[wrap(Some)] + set_child = >k::Box { + set_orientation: gtk::Orientation::Vertical, + append: topics_tabs = &adw::TabBar { + set_autohide: false, + set_expand_tabs: true, + set_view: Some(&topics_viewer), + #[wrap(Some)] + set_end_action_widget = >k::Box { + set_orientation: gtk::Orientation::Horizontal, + adw::TabButton { + set_view: Some(&topics_viewer), + set_action_name: Some("overview.open"), + }, + }, + }, + #[local_ref] + topics_viewer -> adw::TabView { + set_menu_model: Some(&tab_menu), + } }, - #[local_ref] - topics_viewer -> adw::TabView {} - } + }, + } fn init( @@ -53,12 +86,38 @@ impl Component for MessagesPageModel { .detach(); let topics_viewer: &adw::TabView = topics.widget(); - + topics_viewer.connect_setup_menu(|view, page| { + if let Some(page) = page { + view.set_selected_page(page); + } + }); + let tabs_sender = sender.clone(); topics_viewer.connect_page_attached(move |_tab_view, page, n| { - sender.input(MessagesPageMsg::PageAdded(page.clone(), n)); + tabs_sender.input(MessagesPageMsg::PageAdded(page.clone(), n)); }); + // let tabs_sender = sender.clone(); + // topics_viewer.connect_close_page(move |_tab_view, page| { + // //&topics_viewer.close_page_finish(&page, true); + // tabs_sender.input(MessagesPageMsg::PageClosed(page.clone())); + // true + // }); + + let widgets = view_output!(); + let mut topics_tabs_actions = relm4::actions::RelmActionGroup::::new(); + let tabs_sender = sender.input_sender().clone(); + let close_tab_action = RelmAction::::new_stateless(move |_| { + tabs_sender.send(MessagesPageMsg::MenuPageClosed).unwrap(); + }); + let tabs_sender = sender.input_sender().clone(); + let pin_tab_action = RelmAction::::new_stateless(move |_| { + tabs_sender.send(MessagesPageMsg::MenuPagePin).unwrap(); + }); + topics_tabs_actions.add_action(close_tab_action); + topics_tabs_actions.add_action(pin_tab_action); + topics_tabs_actions.register_for_widget(&widgets.topics_tabs); + let model = MessagesPageModel { topic: None, connection: None, @@ -77,17 +136,39 @@ impl Component for MessagesPageModel { ) { match msg { MessagesPageMsg::Open(connection, topic) => { - let conn_id = &connection.id.unwrap(); - let topic_name = &topic.name.clone(); - self.connection = Some(connection); - let mut repo = Repository::new(); - let maybe_topic = repo.find_topic(*conn_id, topic_name); - self.topic = maybe_topic.clone().or(Some(topic)); - let init = MessagesTabInit { - topic: self.topic.clone().unwrap(), - connection: self.connection.clone().unwrap(), - }; - let _index = self.topics.guard().push_front(init); + let mut has_page: Option<(usize, TabPage)> = None; + for i in 0..widgets.topics_viewer.n_pages() { + let tab = widgets.topics_viewer.nth_page(i); + let title = format!("[{}] {}", connection.name, topic.name); + if title == tab.title().to_string() { + has_page = Some((i as usize, tab.clone())); + break; + } + } + match has_page { + Some((pos, page)) => { + info!( + "page already exists [position={}, tab={}]", + pos, + page.title() + ); + widgets.topics_viewer.set_selected_page(&page); + } + None => { + info!("adding new page"); + let conn_id = &connection.id.unwrap(); + let topic_name = &topic.name.clone(); + self.connection = Some(connection); + let mut repo = Repository::new(); + let maybe_topic = repo.find_topic(*conn_id, topic_name); + self.topic = maybe_topic.clone().or(Some(topic)); + let init = MessagesTabInit { + topic: self.topic.clone().unwrap(), + connection: self.connection.clone().unwrap(), + }; + let _index = self.topics.guard().push_front(init); + } + } } MessagesPageMsg::PageAdded(page, index) => { let tab_model = self.topics.get(index.try_into().unwrap()).unwrap(); @@ -97,8 +178,78 @@ impl Component for MessagesPageModel { tab_model.topic.clone().unwrap().name ); page.set_title(title.as_str()); + page.set_live_thumbnail(true); widgets.topics_viewer.set_selected_page(&page); } + MessagesPageMsg::MenuPagePin => { + let page = widgets.topics_viewer.selected_page(); + if let Some(page) = page { + let pinned = !page.is_pinned(); + widgets.topics_viewer.set_page_pinned(&page, pinned); + } + } + MessagesPageMsg::MenuPageClosed => { + let page = widgets.topics_viewer.selected_page(); + if let Some(page) = page { + info!("closing messages page with name {}", page.title()); + let mut idx: Option = None; + let mut topics = self.topics.guard(); + for i in 0..topics.len() { + let tp = topics.get_mut(i); + if let Some(tp) = tp { + let title = format!( + "[{}] {}", + tp.connection.clone().unwrap().name.clone(), + tp.topic.clone().unwrap().name.clone() + ); + info!("PageClosed [{}][{}={}]", i, title, page.title()); + if title.eq(&page.title().to_string()) { + idx = Some(i); + break; + } + } + } + if let Some(idx) = idx { + let result = topics.remove(idx.try_into().unwrap()); + info!( + "page model with index {} and name {:?} removed", + idx, result + ); + } else { + info!("page model not found for removal"); + } + } + } + // MessagesPageMsg::PageClosed(page) => { + // info!("removing messages page with name {}", page.title()); + // widgets.topics_viewer.close_page_finish(&page, true); + // let mut idx: Option = None; + // let mut topics = self.topics.guard(); + // for i in 0..topics.len() { + // let tp = topics.get_mut(i); + // if let Some(tp) = tp { + // let title = format!( + // "[{}] {}", + // tp.connection.clone().unwrap().name.clone(), + // tp.topic.clone().unwrap().name.clone() + // ); + // info!("PageClosed [{}][{}={}]", i, title, page.title()); + // if title.eq(&page.title().to_string()) { + // idx = Some(i); + // break; + // } + // } + // } + // if let Some(idx) = idx { + // let result = topics.remove(idx.try_into().unwrap()); + // info!( + // "page model with index {} and name {:?} removed", + // idx, result + // ); + // } else { + // info!("page model not found for removal"); + // } + // } }; self.update_view(widgets, sender); diff --git a/src/component/messages/messages_tab.rs b/src/component/messages/messages_tab.rs index da376a6..19287d3 100644 --- a/src/component/messages/messages_tab.rs +++ b/src/component/messages/messages_tab.rs @@ -1,4 +1,6 @@ #![allow(deprecated)] +use std::borrow::Borrow; + // See: https://gitlab.gnome.org/GNOME/gtk/-/issues/5644 use chrono::{TimeZone, Utc}; use chrono_tz::America; @@ -50,6 +52,7 @@ relm4::new_stateless_action!(pub(super) CopyMessagesAsCsv, MessagesListActionGro relm4::new_stateless_action!(pub(super) CopyMessagesValue, MessagesListActionGroup, "copy-messages-value"); relm4::new_stateless_action!(pub(super) CopyMessagesKey, MessagesListActionGroup, "copy-messages-key"); +#[derive(Debug)] pub struct MessagesTabModel { token: CancellationToken, pub topic: Option, @@ -88,7 +91,6 @@ pub enum MessagesTabMsg { OpenMessage(u32), SearchMessages, LiveSearchMessages(String), - Selection(u32), PageSizeChanged(usize), FetchTypeChanged(usize), ToggleMode(bool), @@ -99,6 +101,7 @@ pub enum MessagesTabMsg { #[derive(Debug)] pub enum CommandMsg { Data(MessagesResponse), + CopyToClipboard(String), } const AVAILABLE_PAGE_SIZES: [u16; 6] = [50, 100, 500, 1000, 2000, 5000]; @@ -584,6 +587,7 @@ impl FactoryComponent for MessagesTabModel { cached: None, partitions: vec![], total: None, + favourite: None, }; let conn = self.connection.clone().unwrap(); MessagesWorker::new().cleanup_messages(&MessagesCleanupRequest { @@ -610,55 +614,43 @@ impl FactoryComponent for MessagesTabModel { self.fetch_type = fetch_type; self.fetch_type_combo.widget().queue_allocate(); } - MessagesTabMsg::Selection(size) => { - let mut copy_content = String::from("PARTITION;OFFSET;VALUE;TIMESTAMP"); - let min_length = copy_content.len(); - for i in 0..size { + MessagesTabMsg::CopyMessages(copy) => { + info!("copy selected messages"); + let topic = self.topic.clone().unwrap().name; + let mut selected_items = vec![]; + for i in 0..self.messages_wrapper.selection_model.n_items() { if self.messages_wrapper.selection_model.is_selected(i) { let item = self.messages_wrapper.get_visible(i).unwrap(); - let partition = item.borrow().partition; - let offset = item.borrow().offset; - let value = item.borrow().value.clone(); - let clean_value = - match serde_json::from_str::(value.as_str()) { - Ok(json) => json.to_string(), - Err(_) => value.replace('\n', ""), - }; - let timestamp = item.borrow().timestamp; - let copy_text = format!( - "\n{};{};{};{}", - partition, - offset, - clean_value, - timestamp.unwrap_or_default() - ); - copy_content.push_str(copy_text.as_str()); - info!("selected offset[{}]", copy_text); + selected_items.push(KrustMessage { + headers: item.borrow().headers.clone(), + topic: topic.clone(), + partition: item.borrow().partition, + offset: item.borrow().offset, + key: Some(item.borrow().key.clone()), + value: item.borrow().value.clone(), + timestamp: item.borrow().timestamp.clone(), + }); } } - if copy_content.len() > min_length { - DisplayManager::get() - .default_display() - .unwrap() - .clipboard() - .set_text(copy_content.as_str()); - } - } - MessagesTabMsg::CopyMessages(copy) => { - info!("copy selected messages"); - - let data = match copy { - Copy::AllAsCsv => copy_all_as_csv(self), - Copy::Value => copy_value(self), - Copy::Key => copy_key(self), - }; - if let Ok(data) = data { - DisplayManager::get() - .default_display() - .unwrap() - .clipboard() - .set_text(data.as_str()); - } + sender.spawn_oneshot_command(move || { + let data = match copy { + Copy::AllAsCsv => copy_all_as_csv(&selected_items), + Copy::Value => copy_value(&selected_items), + Copy::Key => copy_key(&selected_items), + }; + if let Ok(data) = data { + CommandMsg::CopyToClipboard(data) + } else { + CommandMsg::CopyToClipboard(String::default()) + } + }); + // if let Ok(data) = data { + // DisplayManager::get() + // .default_display() + // .unwrap() + // .clipboard() + // .set_text(data.as_str()); + // } } MessagesTabMsg::Open(connection, topic) => { let conn_id = &connection.id.unwrap(); @@ -955,6 +947,14 @@ impl FactoryComponent for MessagesTabModel { fn update_cmd(&mut self, message: Self::CommandOutput, sender: FactorySender) { match message { CommandMsg::Data(messages) => sender.input(MessagesTabMsg::UpdateMessages(messages)), + CommandMsg::CopyToClipboard(data) => { + info!("setting text to clipboard"); + DisplayManager::get() + .default_display() + .unwrap() + .clipboard() + .set_text(data.as_str()); + } } } } @@ -1039,64 +1039,55 @@ fn fill_pagination( } } -fn copy_all_as_csv(model: &mut MessagesTabModel) -> Result { +fn copy_all_as_csv( + selected_items: &Vec, +) -> Result { let mut wtr = csv::WriterBuilder::new() .delimiter(b';') .quote_style(csv::QuoteStyle::NonNumeric) .from_writer(vec![]); let _ = wtr.write_record(&["PARTITION", "OFFSET", "KEY", "VALUE", "TIMESTAMP"]); - for i in 0..model.messages_wrapper.selection_model.n_items() { - if model.messages_wrapper.selection_model.is_selected(i) { - let item = model.messages_wrapper.get_visible(i).unwrap(); - let partition = item.borrow().partition; - let offset = item.borrow().offset; - let key = item.borrow().key.clone(); - let value = item.borrow().value.clone(); - let clean_value = match serde_json::from_str::(value.as_str()) { - Ok(json) => json.to_string(), - Err(_) => value.replace('\n', ""), - }; - let timestamp = item.borrow().timestamp; - let record = StringRecord::from(vec![ - partition.to_string(), - offset.to_string(), - key, - clean_value, - timestamp.unwrap_or_default().to_string(), - ]); - let _ = wtr.write_record(&record); - } + for item in selected_items { + let partition = item.partition; + let offset = item.offset; + let key = item.key.clone(); + let value = item.value.clone(); + let clean_value = match serde_json::from_str::(value.as_str()) { + Ok(json) => json.to_string(), + Err(_) => value.replace('\n', ""), + }; + let timestamp = item.borrow().timestamp; + let record = StringRecord::from(vec![ + partition.to_string(), + offset.to_string(), + key.unwrap_or_default(), + clean_value, + timestamp.unwrap_or_default().to_string(), + ]); + let _ = wtr.write_record(&record); } let data = String::from_utf8(wtr.into_inner().unwrap_or_default()); data } -fn copy_value(model: &mut MessagesTabModel) -> Result { +fn copy_value(selected_items: &Vec) -> Result { let mut copy_content = String::default(); - for i in 0..model.messages_wrapper.selection_model.n_items() { - if model.messages_wrapper.selection_model.is_selected(i) { - let item = model.messages_wrapper.get_visible(i).unwrap(); - let value = item.borrow().value.clone(); - let clean_value = match serde_json::from_str::(value.as_str()) { - Ok(json) => json.to_string(), - Err(_) => value.replace('\n', ""), - }; - let copy_text = format!( - "{}\n", clean_value); - copy_content.push_str(©_text.as_str()); - } + for item in selected_items { + let value = item.value.clone(); + let clean_value = match serde_json::from_str::(value.as_str()) { + Ok(json) => json.to_string(), + Err(_) => value.replace('\n', ""), + }; + let copy_text = format!("{}\n", clean_value); + copy_content.push_str(©_text.as_str()); } Ok(copy_content) } -fn copy_key(model: &mut MessagesTabModel) -> Result { +fn copy_key(selected_items: &Vec) -> Result { let mut copy_content = String::default(); - for i in 0..model.messages_wrapper.selection_model.n_items() { - if model.messages_wrapper.selection_model.is_selected(i) { - let item = model.messages_wrapper.get_visible(i).unwrap(); - let key = item.borrow().key.clone(); - let copy_text = format!( - "{}\n", key); - copy_content.push_str(©_text.as_str()); - } + for item in selected_items { + let key = item.key.clone(); + let copy_text = format!("{}\n", key.unwrap_or_default()); + copy_content.push_str(©_text.as_str()); } Ok(copy_content) } diff --git a/src/component/topics_page.rs b/src/component/topics_page.rs index b2ebd55..622401b 100644 --- a/src/component/topics_page.rs +++ b/src/component/topics_page.rs @@ -1,28 +1,74 @@ +use std::{cell::RefCell, cmp::Ordering, collections::HashMap}; + use crate::{ backend::{ kafka::KafkaBackend, repository::{KrustConnection, KrustTopic}, }, component::status_bar::{StatusBarMsg, STATUS_BROKER}, + config::ExternalError, + Repository, }; -use gtk::prelude::*; +use gtk::{glib::SignalHandlerId, prelude::*}; use relm4::{ - typed_view::column::{LabelColumn, TypedColumnView}, + typed_view::column::{LabelColumn, RelmColumn, TypedColumnView}, *, }; +use tracing::{debug, info}; + +relm4::new_action_group!(pub(super) TopicListActionGroup, "topic-list"); +relm4::new_stateless_action!(pub(super) FavouriteAction, TopicListActionGroup, "toggle-favourite"); // Table: start -#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Debug)] pub struct TopicListItem { name: String, partition_count: usize, + favourite: bool, + sender: ComponentSender, + clicked_handler_id: RefCell>, } impl TopicListItem { - fn new(value: KrustTopic) -> Self { + fn new(value: KrustTopic, sender: ComponentSender) -> Self { Self { name: value.name, partition_count: value.partitions.len(), + favourite: value.favourite.unwrap_or(false), + sender, + clicked_handler_id: RefCell::new(None), + } + } +} + +impl Eq for TopicListItem {} + +impl Ord for TopicListItem { + fn cmp(&self, other: &Self) -> Ordering { + self.partial_cmp(other).unwrap() + } +} + +impl PartialEq for TopicListItem { + fn eq(&self, other: &Self) -> bool { + self.name == other.name + && self.partition_count == other.partition_count + && self.favourite == other.favourite + } +} + +impl PartialOrd for TopicListItem { + fn partial_cmp(&self, other: &Self) -> Option { + match PartialOrd::partial_cmp(&self.favourite, &other.favourite) { + Some(Ordering::Equal) => match PartialOrd::partial_cmp(&self.name, &other.name) { + Some(Ordering::Equal) => { + PartialOrd::partial_cmp(&self.partition_count, &other.partition_count) + } + cmp => cmp, + }, + Some(Ordering::Less) => Some(Ordering::Greater), + Some(Ordering::Greater) => Some(Ordering::Less), + cmp => cmp, } } } @@ -67,6 +113,41 @@ impl LabelColumn for NameColumn { } } +struct FavouriteColumn; + +impl RelmColumn for FavouriteColumn { + type Root = gtk::CheckButton; + type Widgets = (); + type Item = TopicListItem; + + const COLUMN_NAME: &'static str = "Favourite"; + const ENABLE_RESIZE: bool = false; + const ENABLE_EXPAND: bool = false; + + fn setup(_item: >k::ListItem) -> (Self::Root, Self::Widgets) { + let button = gtk::CheckButton::builder().css_name("btn-favourite").build(); + (button, ()) + } + + fn bind(item: &mut Self::Item, _: &mut Self::Widgets, button: &mut Self::Root) { + button.set_active(item.favourite); + let topic_name = item.name.clone(); + let sender = item.sender.clone(); + let signal_id = button.connect_toggled(move |b| { + info!("FavouriteColumn[{}][{}]", &topic_name, b.is_active()); + sender.input(TopicsPageMsg::FavouriteToggled { + topic_name: topic_name.clone(), + is_active: b.is_active(), + }); + }); + item.clicked_handler_id = RefCell::new(Some(signal_id)); + } + fn unbind(item: &mut Self::Item, _: &mut Self::Widgets, button: &mut Self::Root) { + if let Some(id) = item.clicked_handler_id.take() { + button.disconnect(id); + }; + } +} // Table: end #[derive(Debug)] @@ -82,6 +163,8 @@ pub enum TopicsPageMsg { List(KrustConnection), OpenTopic(u32), Search(String), + FavouriteToggled { topic_name: String, is_active: bool }, + ToggleFavouritesFilter(bool), } #[derive(Debug)] @@ -95,6 +178,24 @@ pub enum CommandMsg { ListFinished(Vec), } +impl TopicsPageModel { + fn fetch_persited_topics(&self) -> Result, ExternalError> { + let result = if let Some(conn) = self.current.clone() { + let mut repo = Repository::new(); + let topics = repo.find_topics_by_connection(conn.id.unwrap())?; + debug!("fetch_persited_topics::{:?}", topics); + let topics_map = topics + .into_iter() + .map(|t| (t.name.clone(), t.clone())) + .collect::>(); + topics_map + } else { + HashMap::new() + }; + Ok(result) + } +} + #[relm4::component(pub)] impl Component for TopicsPageModel { type Init = Option; @@ -103,37 +204,49 @@ impl Component for TopicsPageModel { type CommandOutput = CommandMsg; view! { - #[root] - gtk::Box { - set_orientation: gtk::Orientation::Vertical, - set_hexpand: true, - set_vexpand: true, - gtk::CenterBox { - set_orientation: gtk::Orientation::Horizontal, - set_margin_all: 10, - set_hexpand: true, - #[wrap(Some)] - set_start_widget = >k::Box { - #[name(topics_search_entry)] - gtk::SearchEntry { - connect_search_changed[sender] => move |entry| { - sender.clone().input(TopicsPageMsg::Search(entry.text().to_string())); - }, - }, - }, - }, - gtk::ScrolledWindow { - set_vexpand: true, - set_hexpand: true, - set_propagate_natural_width: true, - #[local_ref] - topics_view -> gtk::ColumnView { - set_vexpand: true, + #[root] + gtk::Box { + set_orientation: gtk::Orientation::Vertical, set_hexpand: true, - set_show_row_separators: true, - } + set_vexpand: true, + gtk::CenterBox { + set_orientation: gtk::Orientation::Horizontal, + set_margin_all: 10, + set_hexpand: true, + #[wrap(Some)] + set_start_widget = >k::Box { + set_orientation: gtk::Orientation::Horizontal, + #[name(topics_search_entry)] + gtk::SearchEntry { + connect_search_changed[sender] => move |entry| { + sender.clone().input(TopicsPageMsg::Search(entry.text().to_string())); + }, + }, + #[name(btn_cache_toggle)] + gtk::ToggleButton { + set_margin_start: 5, + set_label: "Favourites", + add_css_class: "krust-toggle", + connect_toggled[sender] => move |btn| { + sender.input(TopicsPageMsg::ToggleFavouritesFilter(btn.is_active())); + }, + }, + }, + }, + #[name(topics_scrolled_windows)] + gtk::ScrolledWindow { + set_vexpand: true, + set_hexpand: true, + set_propagate_natural_width: true, + set_vscrollbar_policy: gtk::PolicyType::Always, + #[local_ref] + topics_view -> gtk::ColumnView { + set_vexpand: true, + set_hexpand: true, + set_show_row_separators: true, + } + } } - } } fn init( @@ -143,9 +256,14 @@ impl Component for TopicsPageModel { ) -> ComponentParts { // Initialize the ListView wrapper let mut view_wrapper = TypedColumnView::::new(); + view_wrapper.append_column::(); view_wrapper.append_column::(); view_wrapper.append_column::(); + // Add a filter and disable it + view_wrapper.add_filter(|item| item.favourite ); + view_wrapper.set_filter_status(0, false); + let model = TopicsPageModel { current, topics_wrapper: view_wrapper, @@ -154,12 +272,13 @@ impl Component for TopicsPageModel { }; let topics_view = &model.topics_wrapper.view; - let snd = sender.clone(); + let snd: ComponentSender = sender.clone(); topics_view.connect_activate(move |_view, idx| { snd.input(TopicsPageMsg::OpenTopic(idx)); }); let widgets = view_output!(); + ComponentParts { model, widgets } } @@ -181,10 +300,18 @@ impl Component for TopicsPageModel { STATUS_BROKER.send(StatusBarMsg::Start); self.topics_wrapper.clear(); self.current = Some(conn.clone()); + let topics_map = self.fetch_persited_topics().unwrap(); + sender.oneshot_command(async move { let kafka = KafkaBackend::new(&conn); - let topics = kafka.list_topics().await; - + let mut topics = kafka.list_topics().await; + for topic in topics.iter_mut() { + if let Some(t) = topics_map.get(&topic.name) { + debug!("found topic: {:?}", t); + topic.favourite = t.favourite; + topic.cached = t.cached; + } + } CommandMsg::ListFinished(topics) }); } @@ -198,6 +325,7 @@ impl Component for TopicsPageModel { cached: None, partitions: vec![], total: None, + favourite: None, }; sender .output(TopicsPageOutput::OpenMessagesPage( @@ -206,24 +334,77 @@ impl Component for TopicsPageModel { )) .unwrap(); } + TopicsPageMsg::FavouriteToggled { + topic_name, + is_active, + } => { + info!("topic {} favourite toggled {}", topic_name, is_active); + let conn_id = self.current.clone().unwrap().id.unwrap(); + let mut repo = Repository::new(); + let mut topic = repo.find_topic(conn_id, &topic_name); + info!("persisted topic::{:?}", &topic); + if let Some(topic) = topic.as_mut() { + topic.favourite = Some(is_active); + repo.save_topic(conn_id, topic).unwrap(); + } else if is_active { + let topic = KrustTopic { + connection_id: Some(conn_id), + name: topic_name.clone(), + cached: None, + partitions: vec![], + total: None, + favourite: Some(is_active), + }; + repo.save_topic(conn_id, &topic).unwrap(); + } + //sender.input(TopicsPageMsg::List(self.current.clone().unwrap())); + } + TopicsPageMsg::ToggleFavouritesFilter(is_active) => { + if is_active { + self.topics_wrapper.clear_filters(); + self.topics_wrapper.add_filter(|item| item.favourite ); + } else { + self.topics_wrapper.clear_filters(); + } + } }; self.update_view(widgets, sender); } - fn update_cmd( + fn update_cmd_with_view( &mut self, + widgets: &mut Self::Widgets, message: Self::CommandOutput, - _sender: ComponentSender, + sender: ComponentSender, _: &Self::Root, ) { match message { CommandMsg::ListFinished(topics) => { self.topics_wrapper.clear(); for topic in topics.into_iter().filter(|t| !t.name.starts_with("__")) { + let snd = sender.clone(); self.topics_wrapper - .insert_sorted(TopicListItem::new(topic), |a, b| a.cmp(b)); + .insert_sorted(TopicListItem::new(topic, snd), |a, b| a.cmp(b)); } + let vadj = widgets.topics_scrolled_windows.vadjustment(); + info!( + "vertical scroll adjustment: upper={}, lower={}, page_size={}", + vadj.upper(), + vadj.lower(), + vadj.page_size() + ); + let scroll_result = widgets + .topics_scrolled_windows + .emit_scroll_child(gtk::ScrollType::Start, false); + let vadj = widgets.topics_scrolled_windows.vadjustment(); + info!( + "vertical scroll adjustment after: upper={}, lower={}, page_size={}::{}", + vadj.upper(), + vadj.lower(), + vadj.page_size(), + scroll_result + ); STATUS_BROKER.send(StatusBarMsg::StopWithInfo { text: Some("Topics loaded!".into()), }); diff --git a/src/config.rs b/src/config.rs index 0c3751b..8fb6537 100644 --- a/src/config.rs +++ b/src/config.rs @@ -61,7 +61,7 @@ impl State { pub fn write(&self) -> Result<(), ExternalError> { let path = state_path()?; - info!( + trace!( "persisting application state: {:?}, into path: {:?}", self, path ); @@ -123,7 +123,7 @@ fn state_path() -> Result { } pub fn ensure_path_dir(path: &PathBuf) -> Result { - info!("ensuring path: {:?}", path); + trace!("ensuring path: {:?}", path); fs::create_dir_all(path).map_err(|op| { ExternalError::ConfigurationError( format!("unable to create intermediate directories: {:?}", op), @@ -134,6 +134,6 @@ pub fn ensure_path_dir(path: &PathBuf) -> Result { pub fn ensure_app_config_dir() -> Result { let app_config_path = app_config_dir()?; - info!("app config path: {:?}", app_config_path); + trace!("app config path: {:?}", app_config_path); ensure_path_dir(&app_config_path) } diff --git a/src/styles.css b/src/styles.css index 8b401b1..cf06408 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,3 +1,11 @@ +/* +Logo/Theme colors: +Red Orange 4: #FF3F34 +Pickled Bluewood 2: #314459 +Mystic (light blue): #D8E9F0 + +*/ + .header-title { font-weight: bold; } @@ -14,3 +22,50 @@ button.krust-toggle:checked { .status-bar { background: #FDEBD8; } + +.tab-close-button {} + +columnview.column-separators listview row:nth-child(odd):not(:selected) { + background-color: #ebeaeafd; +} + +btn-favourite check { + -gtk-icon-source: -gtk-icontheme("non-starred-symbolic"); + background-clip: border-box; + background-image: none; + border-color: white; + color: inherit; + box-shadow: 0 0 0 0; + -gtk-icon-size: 20px; + padding: 0 0 0 0; +} + +btn-favourite check:checked { + -gtk-icon-source: -gtk-icontheme("starred-symbolic"); + background-clip: border-box; + background-image: none; + background-color: transparent; + border-color: white; + box-shadow: 0 0 0 0; + -gtk-icon-size: 20px; + padding: 0 0 0 0; + color: #FF3F34; + animation: spin 1s linear 1s initial; + animation-name: spin; + animation-duration: 5s; + animation-timing-function: ease-in-out; + animation-delay: 1s; + animation-iteration-count: initial; + animation-direction: normal; +} + +tab:selected { + background-color: #b7f39b; + /*background-color: #D8E9F0;*/ + font-weight: bolder; +} + +.tab-title label { + text-decoration-line: none; + +} diff --git a/win-prepare-and-build.sh b/win-prepare-and-build.sh index 0482343..b42c9e1 100755 --- a/win-prepare-and-build.sh +++ b/win-prepare-and-build.sh @@ -35,6 +35,7 @@ cp -fvr /tmp/mingw64/lib/girepository-1.0/ /usr/x86_64-w64-mingw32/sys-root/ming cd /mnt mkdir -p package/share/icons/hicolor/scalable/ +cp -rfv /usr/share/icons/Adwaita/ package/share/icons/ cp -rfv /tmp/mingw64/share/icons/hicolor/scalable/actions package/share/icons/hicolor/scalable/ cp -rfv /tmp/mingw64/share/gtksourceview-5 package/share/ cp -rfv /tmp/mingw64/lib/girepository-1.0/ package/lib/ From 359242edad38d9c9df847020aa3f47a57024c2e3 Mon Sep 17 00:00:00 2001 From: Miguel Aranha Baldi Horlle Date: Thu, 25 Apr 2024 17:12:38 -0300 Subject: [PATCH 8/8] Topics and Messages improvements. - Topics favourites: fix losing favourite in some occasions. --- src/backend/worker.rs | 2 +- src/component/messages/messages_tab.rs | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/backend/worker.rs b/src/backend/worker.rs index 5210951..2eac73d 100644 --- a/src/backend/worker.rs +++ b/src/backend/worker.rs @@ -138,7 +138,7 @@ impl MessagesWorker { cached, partitions: vec![], total: None, - favourite: None, + favourite: request.topic.favourite.clone(), }; let topic = repo.save_topic( topic.connection_id.expect("should have connection id"), diff --git a/src/component/messages/messages_tab.rs b/src/component/messages/messages_tab.rs index 19287d3..4aa83d0 100644 --- a/src/component/messages/messages_tab.rs +++ b/src/component/messages/messages_tab.rs @@ -587,7 +587,7 @@ impl FactoryComponent for MessagesTabModel { cached: None, partitions: vec![], total: None, - favourite: None, + favourite: cloned_topic.favourite.clone(), }; let conn = self.connection.clone().unwrap(); MessagesWorker::new().cleanup_messages(&MessagesCleanupRequest { @@ -659,7 +659,10 @@ impl FactoryComponent for MessagesTabModel { let mut repo = Repository::new(); let maybe_topic = repo.find_topic(*conn_id, topic_name); self.topic = maybe_topic.clone().or(Some(topic)); - let toggled = maybe_topic.is_some(); + let toggled = match &maybe_topic { + Some(t) => t.cached.is_some(), + None => false, + }; let cache_ts = maybe_topic .and_then(|t| { t.cached.map(|ts| {